Skip to main content

podcast_api/
client.rs

1use super::{Api, ApiError, Error, Result};
2use reqwest::{Method, Url, header::HeaderValue};
3use serde_json::Value;
4use std::time::Duration;
5
6const DEFAULT_USER_AGENT: &str = concat!("podcast-api-rust ", env!("CARGO_PKG_VERSION"));
7
8/// Client for accessing Listen Notes API. Each instance owns its HTTP configuration.
9pub struct Client<'a> {
10    client: reqwest::Client,
11    api: Api<'a>,
12    user_agent: &'a str,
13    base_url: Url,
14}
15
16/// Response and request context for an API call.
17#[derive(Debug)]
18pub struct Response {
19    /// HTTP response, including status and response headers.
20    pub response: reqwest::Response,
21    /// HTTP request that resulted in this response. API key headers are sensitive.
22    pub request: reqwest::Request,
23}
24
25impl Response {
26    /// Consume the response and deserialize its JSON body.
27    pub async fn json(self) -> Result<Value> {
28        Ok(self.response.json().await?)
29    }
30}
31
32impl<'a> Client<'a> {
33    /// Create a production client with an API key, or a public mock client with `None`.
34    /// Uses a 30-second total timeout and a 10-second connection timeout.
35    pub fn new(api_key: Option<&'a str>) -> Self {
36        Self::new_custom(
37            Self::http_client_builder()
38                .build()
39                .expect("build Listen API HTTP client"),
40            api_key,
41            None,
42        )
43    }
44
45    /// Start with the SDK's timeout, no-redirect, and no-retry defaults.
46    /// Use this builder with [`Self::new_custom`] to customize proxies or timeouts.
47    pub fn http_client_builder() -> reqwest::ClientBuilder {
48        reqwest::Client::builder()
49            .timeout(Duration::from_secs(30))
50            .connect_timeout(Duration::from_secs(10))
51            .redirect(reqwest::redirect::Policy::none())
52            .retry(reqwest::retry::never())
53    }
54
55    /// Create a client with a supplied HTTP client and optional User-Agent.
56    /// The caller controls that HTTP client's timeout, proxy, redirect, and retry policies.
57    pub fn new_custom(client: reqwest::Client, api_key: Option<&'a str>, user_agent: Option<&'a str>) -> Self {
58        let api = api_key.map_or(Api::Mock, Api::Production);
59        let base_url = Url::parse(api.url()).expect("valid Listen API base URL");
60        Self {
61            client,
62            api,
63            user_agent: user_agent.unwrap_or(DEFAULT_USER_AGENT),
64            base_url,
65        }
66    }
67
68    /// Override the API base URL, for example for a local test server.
69    /// Requests send this client's credentials to the supplied server.
70    pub fn with_base_url(mut self, base_url: &str) -> Result<Self> {
71        let url = Url::parse(base_url).map_err(|_| Error::InvalidParameter("invalid base URL".into()))?;
72        if !matches!(url.scheme(), "http" | "https")
73            || url.host_str().is_none()
74            || !url.username().is_empty()
75            || url.password().is_some()
76            || url.query().is_some()
77            || url.fragment().is_some()
78        {
79            return Err(Error::InvalidParameter(
80                "base URL must be HTTP(S) without credentials, query, or fragment".into(),
81            ));
82        }
83        self.base_url = url;
84        Ok(self)
85    }
86
87    pub(crate) async fn request_api(
88        &self,
89        method: Method,
90        path: &str,
91        path_params: &[(&str, &str)],
92        query_names: &[&str],
93        parameters: &Value,
94    ) -> Result<Response> {
95        let parameters = parameters
96            .as_object()
97            .ok_or_else(|| Error::InvalidParameter("parameters must be a JSON object".into()))?;
98        let mut url = self.base_url.clone();
99        {
100            let mut segments = url
101                .path_segments_mut()
102                .map_err(|_| Error::InvalidParameter("invalid base URL".into()))?;
103            segments.pop_if_empty();
104            for segment in path.trim_start_matches('/').split('/') {
105                if let Some(name) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
106                    let value = path_params
107                        .iter()
108                        .find(|(key, _)| *key == name)
109                        .map(|(_, value)| *value)
110                        .ok_or_else(|| Error::InvalidParameter(format!("missing path parameter: {name}")))?;
111                    // URL parsers normalize dot segments; reject them instead of changing endpoints.
112                    if value.is_empty() || value == "." || value == ".." {
113                        return Err(Error::InvalidParameter(format!("invalid path parameter: {name}")));
114                    }
115                    segments.push(value);
116                } else {
117                    segments.push(segment);
118                }
119            }
120        }
121        let has_body = method == Method::POST || method == Method::PUT;
122        let mut query = Vec::new();
123        let mut body = Vec::new();
124        for (name, value) in parameters {
125            if value.is_null() || path_params.iter().any(|(key, _)| key == name) {
126                continue;
127            }
128            let encoded = value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string());
129            if !has_body || query_names.contains(&name.as_str()) {
130                query.push((name, encoded));
131            } else {
132                body.push((name, encoded));
133            }
134        }
135        let mut builder = self
136            .client
137            .request(method, url)
138            .query(&query)
139            .header("User-Agent", self.user_agent);
140        if let Api::Production(key) = self.api {
141            let mut header =
142                HeaderValue::from_str(key).map_err(|_| Error::InvalidParameter("invalid API key header".into()))?;
143            header.set_sensitive(true);
144            builder = builder.header("X-ListenAPI-Key", header);
145        }
146        if has_body {
147            builder = builder.form(&body);
148        }
149        let request = builder.build()?;
150        let outgoing = request
151            .try_clone()
152            .ok_or_else(|| Error::InvalidParameter("request body cannot be cloned".into()))?;
153        let response = self.client.execute(outgoing).await?;
154        let status = response.status();
155        if !status.is_success() {
156            let headers = response.headers().clone();
157            let body = response.text().await?;
158            return Err(Error::from_api(Box::new(ApiError { status, headers, body })));
159        }
160        Ok(Response { response, request })
161    }
162}