mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-05-06 07:17:27 +00:00
feat: http client and eco support (#175)
* feat: initial http and eco support * http: Replace reqwest with ureq and add HTTPS support ureq markets itself as a lightweight blocking HTTP client which might be a good choice for rust-gamedig at the moment. However the main reason for changing to ureq is that it allows setting a "resolver" function which overrides the IP address to connect to. This is useful because it allows us to pass a URL with the desired hostname without the HTTP library doing an extra DNS lookup (this allows HTTPS to work when we specify the exact IP and port to connect to external to the URL). Other changes in this commit are: - Feature gated things that depend on serde: this means that the eco game won't be available if the library is compiled without serde - Added the TLS feature to enable TLS support in the HTTP library - Added HTTPSettings to set the protocol (HTTP/HTTPS) and the hostname - Setting a user-agent string on HTTP requests (allows the server to see what program is being used to query them) - Store the address as a parsed Url so we don't re-parse it on every request - Add a method to POST JSON data and parse response - Renamed the request() method to get_json() in anticipation of a future method that will send a GET request and handle the raw bytes instead of using serde - Improved documentation * eco: Add generic impls * eco: fixes * http: Add headers to HttpSettings and rename from HTTPSettings * eco: Add extra request settings * http: Add support for querying raw bytes * http: Add unit-tests * http: Rename HttpProtocol * crate: Make serde dependency non-optional The serde feature now only enable serde derivations for our types that don't need it for the library to function. * http: Add helper for creating HttpClients to query APIs Adds the from_url helper that should make working with master server web APIs easier. * Add/Update badge * crate: Require games feature for eco example * docs: Update changelog --------- Co-authored-by: Douile <douile@douile.com>
This commit is contained in:
parent
2a65c39cb6
commit
310b62664c
13 changed files with 800 additions and 10 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
10
crates/lib/examples/test_eco.rs
Normal file
10
crates/lib/examples/test_eco.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ pub enum GDErrorKind {
|
|||
JsonParse,
|
||||
/// Couldn't parse a value.
|
||||
TypeParse,
|
||||
/// Couldn't find the host specified.
|
||||
HostLookup,
|
||||
}
|
||||
|
||||
impl GDErrorKind {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
|
|
|
|||
8
crates/lib/src/games/eco/mod.rs
Normal file
8
crates/lib/src/games/eco/mod.rs
Normal file
|
|
@ -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::*;
|
||||
37
crates/lib/src/games/eco/protocol.rs
Normal file
37
crates/lib/src/games/eco/protocol.rs
Normal file
|
|
@ -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<u16>) -> GDResult<Response> { query_with_timeout(address, port, &None) }
|
||||
|
||||
/// Query a eco server.
|
||||
#[inline]
|
||||
pub fn query_with_timeout(
|
||||
address: &IpAddr,
|
||||
port: Option<u16>,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
) -> GDResult<Response> {
|
||||
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<u16>,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
extra_settings: Option<EcoRequestSettings>,
|
||||
) -> GDResult<Response> {
|
||||
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::<Root>("/frontpage")?;
|
||||
|
||||
Ok(response.into())
|
||||
}
|
||||
241
crates/lib/src/games/eco/types.rs
Normal file
241
crates/lib/src/games/eco/types.rs
Normal file
|
|
@ -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<String>,
|
||||
#[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<String, String>,
|
||||
#[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<Player>,
|
||||
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<String, String>,
|
||||
pub relay_address: String,
|
||||
pub access: String,
|
||||
pub connect: String,
|
||||
}
|
||||
|
||||
impl From<Root> 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<bool> { Some(self.has_password) }
|
||||
|
||||
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> { 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<String>,
|
||||
}
|
||||
|
||||
impl From<ExtraRequestSettings> for EcoRequestSettings {
|
||||
fn from(value: ExtraRequestSettings) -> Self {
|
||||
EcoRequestSettings {
|
||||
hostname: value.hostname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EcoRequestSettings> for HttpSettings<String> {
|
||||
fn from(value: EcoRequestSettings) -> Self {
|
||||
HttpSettings {
|
||||
protocol: HttpProtocol::Http,
|
||||
hostname: value.hostname,
|
||||
headers: Vec::with_capacity(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
447
crates/lib/src/http.rs
Normal file
447
crates/lib/src/http.rs
Normal file
|
|
@ -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<S: Into<String>> {
|
||||
/// 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<S>,
|
||||
/// Choose HTTP headers to send with requests.
|
||||
pub headers: Vec<(S, S)>,
|
||||
}
|
||||
|
||||
impl<S: Into<String>> HttpSettings<S> {
|
||||
/// Set the HTTP protocol (defaults to HTTP).
|
||||
pub const fn protocol(mut self, protocol: HttpProtocol) -> HttpSettings<S> {
|
||||
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<S> {
|
||||
self.hostname = Some(hostname);
|
||||
self
|
||||
}
|
||||
|
||||
/// Overwrite all the current HTTP headers with new headers.
|
||||
pub fn headers(mut self, headers: Vec<(S, S)>) -> HttpSettings<S> {
|
||||
self.headers = headers;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set one HTTP header value.
|
||||
pub fn header(mut self, name: S, value: S) -> HttpSettings<S> {
|
||||
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<S: Into<String>>(
|
||||
address: &SocketAddr,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
http_settings: HttpSettings<S>,
|
||||
) -> GDResult<Self>
|
||||
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<U: TryInto<Url>>(
|
||||
url: U,
|
||||
timeout_settings: &Option<TimeoutSettings>,
|
||||
headers: Option<Vec<(&str, &str)>>,
|
||||
) -> GDResult<HttpClient>
|
||||
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<Vec<u8>> { self.request("GET", path) }
|
||||
|
||||
/// Send a HTTP GET request and parse the JSON resonse.
|
||||
pub fn get_json<T: DeserializeOwned>(&mut self, path: &str) -> GDResult<T> { self.request_json("GET", path) }
|
||||
|
||||
/// Send a HTTP Post request with JSON data and parse a JSON response.
|
||||
pub fn post_json<T: DeserializeOwned, S: Serialize>(&mut self, path: &str, data: S) -> GDResult<T> {
|
||||
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<Vec<u8>> {
|
||||
// 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::<usize>()
|
||||
.map_err(|e| ProtocolFormat.context(e))?
|
||||
.min(MAX_RESPONSE_LENGTH)
|
||||
} else {
|
||||
5012 // Sensible default allocation
|
||||
};
|
||||
|
||||
let mut buffer: Vec<u8> = 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<T: DeserializeOwned>(&mut self, method: &str, path: &str) -> GDResult<T> {
|
||||
// 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::<T>()
|
||||
.map_err(|e| ProtocolFormat.context(e))
|
||||
}
|
||||
|
||||
/// Send a HTTP request with JSON data and parse the JSON response.
|
||||
#[inline]
|
||||
fn request_with_json_data<T: DeserializeOwned, S: Serialize>(
|
||||
&mut self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
data: S,
|
||||
) -> GDResult<T> {
|
||||
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::<T>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,11 +28,14 @@
|
|||
//! # Crate features:
|
||||
//! Enabled by default: `games`, `game_defs`, `services`
|
||||
//!
|
||||
//! `serde` - enables json serialization/deserialization for all response types.
|
||||
//! <br> `games` - include games support. <br>
|
||||
//! `serde` - enables serde serialization/deserialization for many gamedig types
|
||||
//! using serde derive. <br>
|
||||
//! `games` - include games support. <br>
|
||||
//! `services` - include services support. <br>
|
||||
//! `game_defs` - Include game definitions for programmatic access (enabled by
|
||||
//! default).
|
||||
//! `game_defs` - include game definitions for programmatic access (enabled by
|
||||
//! default). <br>
|
||||
//! `clap` - enable clap derivations for gamedig settings types. <br>
|
||||
//! `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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue