Skip to main content

polyoxide_core/
request.rs

1use std::marker::PhantomData;
2
3use reqwest::Response;
4use serde::de::DeserializeOwned;
5
6use crate::client::{retry_after_header, HttpClient};
7use crate::ApiError;
8
9/// Query parameter builder
10pub trait QueryBuilder: Sized {
11    /// Append a query parameter in place. Implementor hook for the builder methods below.
12    fn add_query(&mut self, key: String, value: String);
13
14    /// Append a query parameter and return `self` for chaining.
15    fn query(mut self, key: impl Into<String>, value: impl ToString) -> Self {
16        self.add_query(key.into(), value.to_string());
17        self
18    }
19
20    /// Add optional query parameter (only if Some)
21    fn query_opt(mut self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
22        if let Some(v) = value {
23            self.add_query(key.into(), v.to_string());
24        }
25        self
26    }
27
28    /// Add multiple query parameters with the same key
29    fn query_many<I, V>(self, key: impl Into<String>, values: I) -> Self
30    where
31        I: IntoIterator<Item = V>,
32        V: ToString,
33    {
34        let key = key.into();
35        let mut result = self;
36        for value in values {
37            result.add_query(key.clone(), value.to_string());
38        }
39        result
40    }
41
42    /// Add multiple optional query parameters with the same key
43    fn query_many_opt<I, V>(self, key: impl Into<String>, values: Option<I>) -> Self
44    where
45        I: IntoIterator<Item = V>,
46        V: ToString,
47    {
48        if let Some(values) = values {
49            self.query_many(key, values)
50        } else {
51            self
52        }
53    }
54}
55
56/// Trait for error types that can be created from API responses
57pub trait RequestError: From<ApiError> + std::fmt::Debug {
58    /// Create error from HTTP response
59    fn from_response(response: Response) -> impl std::future::Future<Output = Self> + Send;
60}
61
62/// Generic request builder for simple GET-only APIs (Gamma, Data)
63pub struct Request<T, E> {
64    pub(crate) http_client: HttpClient,
65    pub(crate) path: String,
66    pub(crate) query: Vec<(String, String)>,
67    pub(crate) _marker: PhantomData<(T, E)>,
68}
69
70impl<T, E> Request<T, E> {
71    /// Create a new request
72    pub fn new(http_client: HttpClient, path: impl Into<String>) -> Self {
73        Self {
74            http_client,
75            path: path.into(),
76            query: Vec::new(),
77            _marker: PhantomData,
78        }
79    }
80}
81
82impl<T, E> QueryBuilder for Request<T, E> {
83    fn add_query(&mut self, key: String, value: String) {
84        self.query.push((key, value));
85    }
86}
87
88impl<T: DeserializeOwned, E: RequestError> Request<T, E> {
89    /// Execute the request and deserialize response
90    pub async fn send(self) -> Result<T, E> {
91        let response = self.send_raw().await?;
92
93        // Get text for debugging
94        let text = response
95            .text()
96            .await
97            .map_err(|e| E::from(ApiError::from(e)))?;
98
99        // Deserialize and provide better error context
100        serde_json::from_str(&text).map_err(|e| {
101            tracing::error!("Deserialization failed: {}", e);
102            tracing::error!("Failed to deserialize: {}", crate::truncate_for_log(&text));
103            E::from(ApiError::from(e))
104        })
105    }
106
107    /// Execute the request and return raw response
108    pub async fn send_raw(self) -> Result<Response, E> {
109        let url = self
110            .http_client
111            .base_url
112            .join(&self.path)
113            .map_err(|e| E::from(ApiError::from(e)))?;
114
115        let http_client = self.http_client;
116        let query = self.query;
117        let path = self.path;
118        let mut attempt = 0u32;
119
120        loop {
121            let _permit = http_client.acquire_concurrency().await;
122            http_client.acquire_rate_limit(&path, None).await;
123
124            let mut request = http_client.client.get(url.clone());
125
126            if !query.is_empty() {
127                request = request.query(&query);
128            }
129
130            let response = request
131                .send()
132                .await
133                .map_err(|e| E::from(ApiError::from(e)))?;
134            let status = response.status();
135            let retry_after = retry_after_header(&response);
136
137            // Before `should_retry`, and unconditionally: a 429 has to become
138            // backpressure for every request on this limiter even when *this*
139            // request is out of attempts and about to give up.
140            http_client.note_rate_limited(status, retry_after.as_deref());
141
142            if let Some(backoff) = http_client.should_retry(status, attempt, retry_after.as_deref())
143            {
144                attempt += 1;
145                tracing::warn!(
146                    "Retriable status {} on {}, retry {} after {}ms",
147                    status,
148                    path,
149                    attempt,
150                    backoff.as_millis()
151                );
152                drop(_permit);
153                tokio::time::sleep(backoff).await;
154                continue;
155            }
156
157            tracing::debug!("Response status: {}", status);
158
159            if !status.is_success() {
160                let error = E::from_response(response).await;
161                tracing::error!("Request failed: {:?}", error);
162                return Err(error);
163            }
164
165            return Ok(response);
166        }
167    }
168}
169
170/// Type marker for deserializable responses
171pub struct TypedRequest<T> {
172    pub(crate) _marker: PhantomData<T>,
173}
174
175impl<T> TypedRequest<T> {
176    /// Create a new typed request marker.
177    pub fn new() -> Self {
178        Self {
179            _marker: PhantomData,
180        }
181    }
182}
183
184impl<T> Default for TypedRequest<T> {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::HttpClientBuilder;
194
195    // ── QueryBuilder via Request<T, E> ──────────────────────────
196
197    /// Helper to build a Request and extract its query pairs for assertions.
198    fn make_request() -> Request<(), ApiError> {
199        let http = HttpClientBuilder::new("https://example.com")
200            .build()
201            .unwrap();
202        Request::new(http, "/test")
203    }
204
205    #[test]
206    fn test_query_adds_key_value() {
207        let req = make_request().query("limit", 10);
208        assert_eq!(req.query, vec![("limit".into(), "10".into())]);
209    }
210
211    #[test]
212    fn test_query_chaining_preserves_order() {
213        let req = make_request()
214            .query("limit", 10)
215            .query("offset", "abc")
216            .query("active", true);
217        assert_eq!(
218            req.query,
219            vec![
220                ("limit".into(), "10".into()),
221                ("offset".into(), "abc".into()),
222                ("active".into(), "true".into()),
223            ]
224        );
225    }
226
227    #[test]
228    fn test_query_opt_some_adds_parameter() {
229        let req = make_request().query_opt("tag", Some("politics"));
230        assert_eq!(req.query, vec![("tag".into(), "politics".into())]);
231    }
232
233    #[test]
234    fn test_query_opt_none_skips_parameter() {
235        let req = make_request().query_opt("tag", None::<&str>);
236        assert!(req.query.is_empty());
237    }
238
239    #[test]
240    fn test_query_opt_interleaved_with_query() {
241        let req = make_request()
242            .query("limit", 25)
243            .query_opt("cursor", None::<String>)
244            .query("active", true)
245            .query_opt("slug", Some("will-x-happen"));
246
247        assert_eq!(
248            req.query,
249            vec![
250                ("limit".into(), "25".into()),
251                ("active".into(), "true".into()),
252                ("slug".into(), "will-x-happen".into()),
253            ]
254        );
255    }
256
257    #[test]
258    fn test_query_many_adds_repeated_key() {
259        let req = make_request().query_many("id", vec!["abc", "def", "ghi"]);
260        assert_eq!(
261            req.query,
262            vec![
263                ("id".into(), "abc".into()),
264                ("id".into(), "def".into()),
265                ("id".into(), "ghi".into()),
266            ]
267        );
268    }
269
270    #[test]
271    fn test_query_many_empty_iterator() {
272        let req = make_request().query_many("id", Vec::<String>::new());
273        assert!(req.query.is_empty());
274    }
275
276    #[test]
277    fn test_query_many_opt_some_adds_values() {
278        let ids = vec![1u64, 2, 3];
279        let req = make_request().query_many_opt("id", Some(ids));
280        assert_eq!(
281            req.query,
282            vec![
283                ("id".into(), "1".into()),
284                ("id".into(), "2".into()),
285                ("id".into(), "3".into()),
286            ]
287        );
288    }
289
290    #[test]
291    fn test_query_many_opt_none_skips() {
292        let req = make_request().query_many_opt("id", None::<Vec<String>>);
293        assert!(req.query.is_empty());
294    }
295
296    #[test]
297    fn test_query_duplicate_keys_allowed() {
298        let req = make_request()
299            .query("sort", "price")
300            .query("sort", "volume");
301        assert_eq!(
302            req.query,
303            vec![
304                ("sort".into(), "price".into()),
305                ("sort".into(), "volume".into()),
306            ]
307        );
308    }
309
310    // ── Request::new ────────────────────────────────────────────
311
312    #[test]
313    fn test_request_new_stores_path() {
314        let req = make_request();
315        assert_eq!(req.path, "/test");
316        assert!(req.query.is_empty());
317    }
318
319    #[test]
320    fn test_request_new_with_string_path() {
321        let http = HttpClientBuilder::new("https://example.com")
322            .build()
323            .unwrap();
324        let req: Request<(), ApiError> = Request::new(http, String::from("/events"));
325        assert_eq!(req.path, "/events");
326    }
327
328    // ── TypedRequest ────────────────────────────────────────────
329
330    #[test]
331    fn test_typed_request_new_and_default() {
332        let _t1: TypedRequest<String> = TypedRequest::new();
333        let _t2: TypedRequest<String> = TypedRequest::default();
334        // Both should compile and create distinct instances — no state to verify
335    }
336}