use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use reqwest::Client;
use tower::retry::Retry;
use tower::Service;
use crate::error::{BbmError, Result};
use crate::plan::Plan;
use crate::provider::Provider;
use crate::report::AnnualReportSummary;
use crate::retry::RetryPolicy;
use crate::speed::Speed;
const DEFAULT_BASE_URL: &str = "https://breitbandmessung.de";
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Debug, Clone)]
struct ApiRequest {
url: String,
}
#[derive(Clone)]
struct ApiService {
http: Client,
}
impl Service<ApiRequest> for ApiService {
type Response = reqwest::Response;
type Error = BbmError;
type Future =
Pin<Box<dyn Future<Output = std::result::Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: ApiRequest) -> Self::Future {
let client = self.http.clone();
Box::pin(async move {
let response = client
.get(&req.url)
.header("Accept", "application/json")
.send()
.await?
.error_for_status()?;
Ok(response)
})
}
}
pub struct BbmClient {
http: Client,
base_url: String,
retry_policy: RetryPolicy,
}
impl BbmClient {
pub fn new() -> Self {
Self::with_base_url(DEFAULT_BASE_URL)
}
pub fn with_base_url(base_url: &str) -> Self {
Self {
http: Self::build_http(DEFAULT_TIMEOUT),
base_url: base_url.trim_end_matches('/').to_owned(),
retry_policy: RetryPolicy::default(),
}
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.http = Self::build_http(timeout);
self
}
fn build_http(timeout: std::time::Duration) -> Client {
Client::builder()
.timeout(timeout)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT.min(timeout))
.build()
.expect("failed to build HTTP client")
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let svc = ApiService {
http: self.http.clone(),
};
let mut retry_svc = Retry::new(self.retry_policy.clone(), svc);
let request = ApiRequest { url };
let response = retry_svc.call(request).await?;
let status = response.status();
if !status.is_success() {
return Err(BbmError::Api(format!("{path} returned HTTP {status}")));
}
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
if !content_type.contains("application/json") {
let body = response.text().await.unwrap_or_default();
let preview: String = body.chars().take(200).collect();
return Err(BbmError::Api(format!(
"{path} returned non-JSON response (Content-Type: {content_type}): {preview}"
)));
}
let bytes = response.bytes().await?;
let value = serde_json::from_slice(&bytes)?;
Ok(value)
}
pub async fn get_providers(&self) -> Result<Vec<Provider>> {
self.get_json("/api/provider").await
}
pub async fn get_provider_by_id(&self, id: i64) -> Result<Provider> {
self.get_json(&format!("/api/provider/{id}")).await
}
pub async fn get_plans_by_provider_id(&self, provider_id: i64) -> Result<Vec<Plan>> {
self.get_json(&format!("/api/plans_desktop/{provider_id}"))
.await
}
pub async fn get_speeds(&self) -> Result<Vec<Speed>> {
self.get_json("/api/speed").await
}
pub async fn get_speeds_by_provider_id(&self, id: i64) -> Result<Vec<Speed>> {
self.get_json(&format!("/api/speed/{id}")).await
}
pub async fn get_statistics(&self) -> Result<serde_json::Value> {
self.get_json("/api/statistics").await
}
pub fn annual_reports() -> Vec<AnnualReportSummary> {
crate::report::annual_reports()
}
}
impl Default for BbmClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{http_response, StubServer};
#[tokio::test]
async fn non_json_body_with_multibyte_char_at_cutoff_does_not_panic() {
let body = format!("{}ä", "a".repeat(199));
assert!(!body.is_char_boundary(200), "test fixture is wrong");
let server = StubServer::serve_raw(http_response(200, "text/html", &body)).await;
let client = BbmClient::with_base_url(&server.base_url);
let err = client
.get_providers()
.await
.expect_err("non-JSON response must be an error");
assert!(
err.to_string().contains("non-JSON"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn server_errors_are_retried() {
let server =
StubServer::serve_raw(http_response(503, "application/json", r#"{"err":1}"#)).await;
let client = BbmClient::with_base_url(&server.base_url);
let err = client
.get_providers()
.await
.expect_err("503 must surface as an error");
assert_eq!(
server.hits(),
4,
"expected 4 attempts (1 initial + 3 retries), got {}: {err}",
server.hits()
);
}
#[tokio::test]
async fn request_to_silent_server_times_out() {
let server = crate::testutil::serve_never_responds().await;
let client = BbmClient::with_base_url(&server.base_url)
.with_timeout(std::time::Duration::from_millis(200));
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(10), client.get_providers()).await;
let inner = outcome.expect("client did not enforce its own timeout");
assert!(inner.is_err(), "a silent server must surface as an error");
}
#[tokio::test]
async fn client_errors_are_not_retried() {
let server =
StubServer::serve_raw(http_response(404, "application/json", r#"{"err":1}"#)).await;
let client = BbmClient::with_base_url(&server.base_url);
let _ = client.get_providers().await.expect_err("404 must error");
assert_eq!(server.hits(), 1, "404 must not be retried");
}
}