From 3e5aa28345bb009c12b5a55f2e7174957bf4ed9a Mon Sep 17 00:00:00 2001 From: Gus Power Date: Thu, 22 May 2025 16:21:05 +0100 Subject: started on arbitrary http endpoint configuration --- src/error.rs | 4 ++- src/http.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ip_service.rs | 11 ++++++- src/main.rs | 1 + src/test_macros.rs | 11 +++++++ 5 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/http.rs diff --git a/src/error.rs b/src/error.rs index 429aa58..1ce9482 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,7 @@ pub enum AppError { IoError(io::Error), RequestFailed { url: Url, source: ReqwestError }, InvalidResponse { url: Url, reason: String }, + InvalidHttpHeader(String), UnableToGetHomeDirectory(GetHomeError), } @@ -33,7 +34,8 @@ impl fmt::Display for AppError { Self::RequestFailed { url, .. } => write!(f, "Request to {} failed", url), Self::InvalidResponse { url, reason } => { write!(f, "Invalid response from {}: {}", url, reason) - } + }, + Self::InvalidHttpHeader(message) => write!(f, "Invalid HTTP header configuration: {}", message), Self::UnableToGetHomeDirectory(err) => { write!(f, "Failed to get home directory: {}", err) } diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..db5e3a6 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,97 @@ +use serde_with::DisplayFromStr; +use http::{HeaderMap, HeaderName, HeaderValue, Method}; +use reqwest::{Client, Request, Url}; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use crate::error::{AppError, AppResult}; + +#[serde_as] +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct RequestConfig { + #[serde_as(as = "DisplayFromStr")] + #[serde(default = "RequestConfig::default_method")] + method: Method, + #[serde_as(as = "DisplayFromStr")] + url: Url, + #[serde(default)] + headers: Vec<(String, String)>, +} + +impl RequestConfig { + + pub fn new(method: Method, url: Url, headers: Vec<(String, String)>) -> Self { + Self { method, url, headers } + } + + pub fn get(url: Url, headers: Vec<(String, String)>) -> Self { + Self::new(Method::GET, url, headers) + } + + pub fn build(&self, client: &Client) -> AppResult { + let mut builder = client.request(self.method.clone(), self.url.clone()); + if !self.headers.is_empty() { + builder = builder.headers(self.header_map()?); + } + Ok(builder.build()?) + } + + fn default_method() -> Method { + Method::GET + } + + fn header_map(&self) -> AppResult { + let mut header_map = HeaderMap::new(); + for (name, value) in &self.headers { + let name: HeaderName = name.try_into().map_err(|e| { + AppError::InvalidHttpHeader(format!("[{}] is not a valid HTTP header name ({})", name, e)) + })?; + let value: HeaderValue = value.parse().map_err(|e| { + AppError::InvalidHttpHeader(format!("[{}] is not a valid HTTP header value ({})", value, e)) + })?; + header_map.insert(name, value); + } + + Ok(header_map) + } + +} + +#[cfg(test)] +mod tests { + use crate::AppError; + use crate::{assert_error, assert_invalid_http_header, test}; + use reqwest::ClientBuilder; + use super::*; + + test! { + fn simple_get_request() { + let url = Url::parse("https://example.com")?; + let actual = build_get_request(&url, vec![])?; + assert_eq!(actual.method(), Method::GET); + assert_eq!(actual.url(), &url); + } + } + + test! { + fn invalid_header_name() { + let url = Url::parse("https://example.com")?; + let invalid_headers = vec![("Content\x00Type".to_string(), "application/json".to_string())]; + assert_invalid_http_header!(build_get_request(&url, invalid_headers), "[Content\x00Type] is not a valid HTTP header name"); + } + } + + test! { + fn invalid_header_value() { + let url = Url::parse("https://example.com")?; + let invalid_headers = vec![("Content-Type".to_string(), "application/json\x00".to_string())]; + assert_invalid_http_header!(build_get_request(&url, invalid_headers), "[application/json\x00] is not a valid HTTP header value"); + } + } + + fn build_get_request(url: &Url, headers: Vec<(String, String)>) -> AppResult { + let config = RequestConfig::get(url.clone(), headers); + let client = ClientBuilder::new().build()?; + config.build(&client) + } + +} diff --git a/src/ip_service.rs b/src/ip_service.rs index 3d869db..abe75f1 100644 --- a/src/ip_service.rs +++ b/src/ip_service.rs @@ -1,5 +1,5 @@ use crate::error::{AppError, AppResult}; -use crate::ip_service::IpServiceProvider::{IdentMe, Noop}; +use crate::ip_service::IpServiceProvider::{Http, IdentMe, Noop}; use http::StatusCode; use reqwest::{Client, Url}; use serde::{Deserialize, Serialize}; @@ -11,15 +11,21 @@ use std::str::FromStr; #[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(tag = "type")] pub enum IpServiceProvider { + #[serde(rename = "HTTP")] + Http(HttpConfig), #[serde(rename = "IDENTME")] IdentMe(IdentMeConfig), #[serde(rename = "NOOP")] Noop, } +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct HttpConfig; + impl IpService for IpServiceProvider { async fn resolve(&self, client: &Client) -> AppResult { match self { + Http(_http) => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), IdentMe(ident_me) => ident_me.resolve(client).await, Noop => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), } @@ -84,6 +90,9 @@ impl Default for IdentMeConfig { } } +// http service +// url - method - headers - response content-type (text, json) - response parser (text, json -> json path?) + impl IpService for IdentMeConfig { async fn resolve(&self, client: &Client) -> AppResult { let response = client diff --git a/src/main.rs b/src/main.rs index 66fbdee..2843eb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod ip_service; #[cfg(test)] mod test_macros; +mod http; #[derive(Parser, Debug)] #[command(ignore_errors(true), version, about, long_about = None)] diff --git a/src/test_macros.rs b/src/test_macros.rs index e1998cd..7887647 100644 --- a/src/test_macros.rs +++ b/src/test_macros.rs @@ -84,3 +84,14 @@ macro_rules! assert_invalid_response { }); }; } + +#[macro_export] +macro_rules! assert_invalid_http_header { + ($result:expr, $reason_prefix:expr) => { + assert_error!($result, AppError::InvalidHttpHeader(reason) => { + assert!(reason.starts_with($reason_prefix), + "Expected reason to start with '{}', but got '{}'", + $reason_prefix, reason); + }); + }; +} -- cgit v1.2.3