use crate::error::{AppError, AppResult}; use crate::ip_service::IpServiceProvider::IDENTME; use http::StatusCode; use reqwest::{Client, Url}; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; use std::net::IpAddr; use std::str::FromStr; #[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(tag = "type")] pub enum IpServiceProvider { IDENTME(IdentMe), } impl IpService for IpServiceProvider { async fn resolve(&self, client: &Client) -> AppResult { match self { IDENTME(ident_me) => ident_me.resolve(client).await, } } } impl Default for IpServiceProvider { fn default() -> Self { IDENTME(IdentMe::default()) } } pub trait IpService { async fn resolve(&self, client: &Client) -> AppResult; } // impl IpService { // pub(crate) async fn resolve(config: &impl IpServiceConfiguration) -> Result> { // let response = reqwest::get(config.get_service_url()).await.unwrap(); // Ok(IpAddr::from_str(&response.text().await.unwrap())?) // } // } pub trait IpServiceConfiguration { fn get_service_url(&self) -> Url; } #[serde_as] #[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct IdentMe { #[serde_as(as = "DisplayFromStr")] url: Url, } impl IdentMe { fn convert_string_to_ip_address(input: String, url: &Url) -> AppResult { IpAddr::from_str(&input).map_err(|e| AppError::InvalidResponse { url: url.clone(), reason: format!( "Failed to parse service response [{}] into valid ip address: {}", input, e ), }) } fn request_failed(&self, e: reqwest::Error) -> AppError { AppError::RequestFailed { url: self.url.clone(), source: e, } } fn invalid_status_code(&self, status: StatusCode) -> AppError { AppError::InvalidResponse { url: self.url.clone(), reason: format!("Status code: {}", status), } } fn missing_text_response(&self, e: reqwest::Error) -> AppError { AppError::InvalidResponse { url: self.url.clone(), reason: format!("Unable to get text from service response: {}", e), } } } impl Default for IdentMe { fn default() -> Self { Self { url: Url::parse("https://v4.ident.me/").unwrap(), } } } impl IpService for IdentMe { async fn resolve(&self, client: &Client) -> AppResult { let response = client .get(self.url.clone()) .send() .await .map_err(|e| self.request_failed(e))?; if !response.status().is_success() { return Err(self.invalid_status_code(response.status())); }; let response_text = response.text().await; match response_text { Ok(text) => Self::convert_string_to_ip_address(text, &self.url), Err(e) => Err(self.missing_text_response(e)), } } } impl IpServiceConfiguration for IdentMe { fn get_service_url(&self) -> Url { self.url.clone() } } #[cfg(test)] mod tests { use super::*; use crate::{assert_error, assert_invalid_response, assert_request_failed}; use reqwest::ClientBuilder; use std::error::Error; use std::time::Duration; use wiremock::matchers::method; use wiremock::{Mock, MockServer, Respond, ResponseTemplate}; trait HasUrl { fn url(&self) -> Url; } impl HasUrl for MockServer { fn url(&self) -> Url { Url::from_str(self.uri().as_str()).unwrap() } } async fn setup_ipv4_service(response: impl Respond + 'static) -> MockServer { let service = MockServer::start().await; Mock::given(method("GET")) .respond_with(response) .mount(&service) .await; service } fn plaintext_response(result: String) -> impl Respond + 'static { ResponseTemplate::new(200) .insert_header("Content-Type", "text/plain; charset=utf-8") .set_body_string(result) } fn empty_response() -> impl Respond + 'static { ResponseTemplate::new(204) } fn delayed_response(delay: u64) -> impl Respond + 'static { ResponseTemplate::new(204).set_delay(Duration::from_millis(delay)) } fn server_error() -> impl Respond + 'static { ResponseTemplate::new(500) } #[tokio::test] async fn successful_ipv4_address_resolution() -> Result<(), Box> { let expected = IpAddr::from_str("17.5.7.8")?; let input = plaintext_response(expected.to_string()); let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); assert_eq!(actual?, expected); Ok(()) } #[tokio::test] async fn failed_no_reachable_server() -> Result<(), Box> { let client = ClientBuilder::new().build()?; let url = Url::from_str("http://localhost:8765/path")?; let service = IdentMe { url: url.clone() }; let actual = IDENTME(service).resolve(&client).await; assert_request_failed!(actual, url); Ok(()) } #[tokio::test] async fn failed_status_code() -> Result<(), Box> { let input = server_error(); let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); assert_invalid_response!( actual, ip_service.url(), "Status code: 500 Internal Server Error" ); Ok(()) } #[tokio::test] async fn failed_empty_response() -> Result<(), Box> { let input = empty_response(); let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); assert_invalid_response!( actual, ip_service.url(), "Failed to parse service response [] into valid ip address" ); Ok(()) } #[tokio::test] async fn failed_not_an_ip_address() -> Result<(), Box> { let input = plaintext_response("not-an-ip-address".to_string()); let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); assert_invalid_response!( actual, ip_service.url(), "Failed to parse service response [not-an-ip-address] into valid ip address" ); Ok(()) } #[tokio::test] async fn failed_timeout() -> Result<(), Box> { let input = delayed_response(200); let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); assert_request_failed!(actual, ip_service.url()); Ok(()) } async fn run_identme( payload: impl Respond + 'static, ) -> Result<(MockServer, AppResult), Box> { let ip_service = setup_ipv4_service(payload).await; let client = ClientBuilder::new() .timeout(Duration::from_millis(100)) .build()?; let service = IdentMe { url: ip_service.uri().parse()?, }; let actual = IDENTME(service).resolve(&client).await; Ok((ip_service, actual)) } }