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
82// Written by hand: `#[derive(Clone)]` would require `T: Clone` and `E: Clone`
83// through `PhantomData<(T, E)>`, but both are only type markers. Cloning a
84// request copies its client handle, path and query, which is what lets a
85// paginated walk re-send identical filters on every page.
86impl<T, E> Clone for Request<T, E> {
87    fn clone(&self) -> Self {
88        Self {
89            http_client: self.http_client.clone(),
90            path: self.path.clone(),
91            query: self.query.clone(),
92            _marker: PhantomData,
93        }
94    }
95}
96
97impl<T, E> QueryBuilder for Request<T, E> {
98    fn add_query(&mut self, key: String, value: String) {
99        self.query.push((key, value));
100    }
101}
102
103impl<T: DeserializeOwned, E: RequestError> Request<T, E> {
104    /// Execute the request and deserialize response
105    pub async fn send(self) -> Result<T, E> {
106        let response = self.send_raw().await?;
107
108        // Get text for debugging
109        let text = response
110            .text()
111            .await
112            .map_err(|e| E::from(ApiError::from(e)))?;
113
114        // Deserialize and provide better error context
115        serde_json::from_str(&text).map_err(|e| {
116            tracing::error!("Deserialization failed: {}", e);
117            tracing::error!("Failed to deserialize: {}", crate::truncate_for_log(&text));
118            E::from(ApiError::from(e))
119        })
120    }
121
122    /// Execute the request and return raw response
123    pub async fn send_raw(self) -> Result<Response, E> {
124        let url = self
125            .http_client
126            .base_url
127            .join(&self.path)
128            .map_err(|e| E::from(ApiError::from(e)))?;
129
130        let http_client = self.http_client;
131        let query = self.query;
132        let path = self.path;
133        let mut attempt = 0u32;
134
135        loop {
136            let _permit = http_client.acquire_concurrency().await;
137            http_client.acquire_rate_limit(&path, None).await;
138
139            let mut request = http_client.client.get(url.clone());
140
141            if !query.is_empty() {
142                request = request.query(&query);
143            }
144
145            let response = request
146                .send()
147                .await
148                .map_err(|e| E::from(ApiError::from(e)))?;
149            let status = response.status();
150            let retry_after = retry_after_header(&response);
151
152            // Before `should_retry`, and unconditionally: a 429 has to become
153            // backpressure for every request on this limiter even when *this*
154            // request is out of attempts and about to give up.
155            http_client.note_rate_limited(status, retry_after.as_deref());
156
157            if let Some(backoff) = http_client.should_retry(status, attempt, retry_after.as_deref())
158            {
159                attempt += 1;
160                tracing::warn!(
161                    "Retriable status {} on {}, retry {} after {}ms",
162                    status,
163                    path,
164                    attempt,
165                    backoff.as_millis()
166                );
167                drop(_permit);
168                tokio::time::sleep(backoff).await;
169                continue;
170            }
171
172            tracing::debug!("Response status: {}", status);
173
174            if !status.is_success() {
175                let error = E::from_response(response).await;
176                tracing::error!("Request failed: {:?}", error);
177                return Err(error);
178            }
179
180            return Ok(response);
181        }
182    }
183}
184
185/// Type marker for deserializable responses
186pub struct TypedRequest<T> {
187    pub(crate) _marker: PhantomData<T>,
188}
189
190impl<T> TypedRequest<T> {
191    /// Create a new typed request marker.
192    pub fn new() -> Self {
193        Self {
194            _marker: PhantomData,
195        }
196    }
197}
198
199impl<T> Default for TypedRequest<T> {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::HttpClientBuilder;
209
210    // ── QueryBuilder via Request<T, E> ──────────────────────────
211
212    /// Helper to build a Request and extract its query pairs for assertions.
213    fn make_request() -> Request<(), ApiError> {
214        let http = HttpClientBuilder::new("https://example.com")
215            .build()
216            .unwrap();
217        Request::new(http, "/test")
218    }
219
220    #[test]
221    fn test_query_adds_key_value() {
222        let req = make_request().query("limit", 10);
223        assert_eq!(req.query, vec![("limit".into(), "10".into())]);
224    }
225
226    #[test]
227    fn test_query_chaining_preserves_order() {
228        let req = make_request()
229            .query("limit", 10)
230            .query("offset", "abc")
231            .query("active", true);
232        assert_eq!(
233            req.query,
234            vec![
235                ("limit".into(), "10".into()),
236                ("offset".into(), "abc".into()),
237                ("active".into(), "true".into()),
238            ]
239        );
240    }
241
242    #[test]
243    fn test_query_opt_some_adds_parameter() {
244        let req = make_request().query_opt("tag", Some("politics"));
245        assert_eq!(req.query, vec![("tag".into(), "politics".into())]);
246    }
247
248    #[test]
249    fn test_query_opt_none_skips_parameter() {
250        let req = make_request().query_opt("tag", None::<&str>);
251        assert!(req.query.is_empty());
252    }
253
254    #[test]
255    fn test_query_opt_interleaved_with_query() {
256        let req = make_request()
257            .query("limit", 25)
258            .query_opt("cursor", None::<String>)
259            .query("active", true)
260            .query_opt("slug", Some("will-x-happen"));
261
262        assert_eq!(
263            req.query,
264            vec![
265                ("limit".into(), "25".into()),
266                ("active".into(), "true".into()),
267                ("slug".into(), "will-x-happen".into()),
268            ]
269        );
270    }
271
272    #[test]
273    fn test_query_many_adds_repeated_key() {
274        let req = make_request().query_many("id", vec!["abc", "def", "ghi"]);
275        assert_eq!(
276            req.query,
277            vec![
278                ("id".into(), "abc".into()),
279                ("id".into(), "def".into()),
280                ("id".into(), "ghi".into()),
281            ]
282        );
283    }
284
285    #[test]
286    fn test_query_many_empty_iterator() {
287        let req = make_request().query_many("id", Vec::<String>::new());
288        assert!(req.query.is_empty());
289    }
290
291    #[test]
292    fn test_query_many_opt_some_adds_values() {
293        let ids = vec![1u64, 2, 3];
294        let req = make_request().query_many_opt("id", Some(ids));
295        assert_eq!(
296            req.query,
297            vec![
298                ("id".into(), "1".into()),
299                ("id".into(), "2".into()),
300                ("id".into(), "3".into()),
301            ]
302        );
303    }
304
305    #[test]
306    fn test_query_many_opt_none_skips() {
307        let req = make_request().query_many_opt("id", None::<Vec<String>>);
308        assert!(req.query.is_empty());
309    }
310
311    #[test]
312    fn test_query_duplicate_keys_allowed() {
313        let req = make_request()
314            .query("sort", "price")
315            .query("sort", "volume");
316        assert_eq!(
317            req.query,
318            vec![
319                ("sort".into(), "price".into()),
320                ("sort".into(), "volume".into()),
321            ]
322        );
323    }
324
325    // ── Request::new ────────────────────────────────────────────
326
327    #[test]
328    fn test_request_new_stores_path() {
329        let req = make_request();
330        assert_eq!(req.path, "/test");
331        assert!(req.query.is_empty());
332    }
333
334    #[test]
335    fn test_request_new_with_string_path() {
336        let http = HttpClientBuilder::new("https://example.com")
337            .build()
338            .unwrap();
339        let req: Request<(), ApiError> = Request::new(http, String::from("/events"));
340        assert_eq!(req.path, "/events");
341    }
342
343    // ── Clone ───────────────────────────────────────────────────
344
345    #[test]
346    fn test_request_clone_does_not_require_clone_markers() {
347        // Neither marker implements `Clone`; a derived impl would not compile here.
348        struct NotClone;
349        struct NotCloneError;
350
351        let http = HttpClientBuilder::new("https://example.com")
352            .build()
353            .unwrap();
354        let original: Request<NotClone, NotCloneError> =
355            Request::new(http, "/feed").query("user", "0xabc");
356        let extended = original.clone().query("cursor", "c1");
357
358        assert_eq!(original.path, "/feed");
359        assert_eq!(original.query, vec![("user".into(), "0xabc".into())]);
360        assert_eq!(extended.path, "/feed");
361        assert_eq!(
362            extended.query,
363            vec![
364                ("user".into(), "0xabc".into()),
365                ("cursor".into(), "c1".into()),
366            ]
367        );
368    }
369
370    // ── TypedRequest ────────────────────────────────────────────
371
372    #[test]
373    fn test_typed_request_new_and_default() {
374        let _t1: TypedRequest<String> = TypedRequest::new();
375        let _t2: TypedRequest<String> = TypedRequest::default();
376        // Both should compile and create distinct instances — no state to verify
377    }
378}