Skip to main content

polyoxide_core/
request.rs

1use std::marker::PhantomData;
2use std::sync::Arc;
3
4use reqwest::header::{HeaderValue, CONTENT_TYPE};
5use reqwest::{Method, Response};
6use serde::de::DeserializeOwned;
7use serde::Serialize;
8
9use crate::client::HttpClient;
10use crate::hooks::{Cost, DynAuthenticator, RequestParts};
11use crate::ApiError;
12
13/// Query parameter builder
14pub trait QueryBuilder: Sized {
15    /// Append a query parameter in place. Implementor hook for the builder methods below.
16    fn add_query(&mut self, key: String, value: String);
17
18    /// Append a query parameter and return `self` for chaining.
19    fn query(mut self, key: impl Into<String>, value: impl ToString) -> Self {
20        self.add_query(key.into(), value.to_string());
21        self
22    }
23
24    /// Add optional query parameter (only if Some)
25    fn query_opt(mut self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
26        if let Some(v) = value {
27            self.add_query(key.into(), v.to_string());
28        }
29        self
30    }
31
32    /// Add multiple query parameters with the same key
33    fn query_many<I, V>(self, key: impl Into<String>, values: I) -> Self
34    where
35        I: IntoIterator<Item = V>,
36        V: ToString,
37    {
38        let key = key.into();
39        let mut result = self;
40        for value in values {
41            result.add_query(key.clone(), value.to_string());
42        }
43        result
44    }
45
46    /// Add multiple optional query parameters with the same key
47    fn query_many_opt<I, V>(self, key: impl Into<String>, values: Option<I>) -> Self
48    where
49        I: IntoIterator<Item = V>,
50        V: ToString,
51    {
52        if let Some(values) = values {
53            self.query_many(key, values)
54        } else {
55            self
56        }
57    }
58}
59
60/// An error a [`Request`] can return: any type core's [`ApiError`] converts
61/// into.
62///
63/// The conversion is the type's one decode function. An unsuccessful response
64/// reaches it as [`ApiError::Response`], carrying the status, headers and body,
65/// so a venue reads its own body shape there, and a `?` on core's error
66/// anywhere decodes the same way.
67pub trait RequestError: From<ApiError> + std::fmt::Debug {}
68
69impl<E: From<ApiError> + std::fmt::Debug> RequestError for E {}
70
71/// The one request builder: a method, a path and its query, an optional JSON
72/// body, the [`Authenticator`](crate::Authenticator) that signs it and the
73/// [`Cost`]s it charges the client's throttle.
74///
75/// [`send`](Self::send) runs it on [`HttpClient::send`], decodes the body as
76/// `T`, and turns an unsuccessful response into `E`.
77pub struct Request<T, E> {
78    pub(crate) http_client: HttpClient,
79    pub(crate) method: Method,
80    pub(crate) path: String,
81    pub(crate) query: Vec<(String, String)>,
82    pub(crate) body: Option<String>,
83    pub(crate) authenticator: Option<Arc<DynAuthenticator<'static>>>,
84    pub(crate) costs: Vec<Cost>,
85    pub(crate) _marker: PhantomData<(T, E)>,
86}
87
88impl<T, E> Request<T, E> {
89    /// Create a new `GET` request
90    pub fn new(http_client: HttpClient, path: impl Into<String>) -> Self {
91        Self {
92            http_client,
93            method: Method::GET,
94            path: path.into(),
95            query: Vec::new(),
96            body: None,
97            authenticator: None,
98            costs: Vec::new(),
99            _marker: PhantomData,
100        }
101    }
102
103    /// Send with `method` instead of `GET`.
104    pub fn method(mut self, method: Method) -> Self {
105        self.method = method;
106        self
107    }
108
109    /// Sign every attempt with `auth`.
110    pub fn authenticator(mut self, auth: Arc<DynAuthenticator<'static>>) -> Self {
111        self.authenticator = Some(auth);
112        self
113    }
114
115    /// Charge the client's throttle `cost` for every attempt, as well as
116    /// whatever its request-counting layers charge.
117    pub fn with_cost(mut self, cost: Cost) -> Self {
118        self.costs.push(cost);
119        self
120    }
121}
122
123impl<T, E: From<ApiError>> Request<T, E> {
124    /// Send `body` as JSON, with `Content-Type: application/json`.
125    ///
126    /// The body is serialised once, here, through [`serde_json::Value`], so
127    /// every attempt sends, and an authenticator signs, the same bytes. A
128    /// `Value` sorts an object's keys, so those bytes need not follow the
129    /// struct's field order.
130    ///
131    /// # Errors
132    ///
133    /// [`ApiError::Serialization`] when `body` does not serialise.
134    pub fn body<B: Serialize + ?Sized>(mut self, body: &B) -> Result<Self, E> {
135        let value = serde_json::to_value(body).map_err(ApiError::from)?;
136        self.body = Some(value.to_string());
137        Ok(self)
138    }
139}
140
141// Written by hand: `#[derive(Clone)]` would require `T: Clone` and `E: Clone`
142// through `PhantomData<(T, E)>`, but both are only type markers. Cloning a
143// request copies its client handle, method, path, query, body, authenticator
144// and costs, which is what lets a paginated walk re-send identical filters on
145// every page.
146impl<T, E> Clone for Request<T, E> {
147    fn clone(&self) -> Self {
148        Self {
149            http_client: self.http_client.clone(),
150            method: self.method.clone(),
151            path: self.path.clone(),
152            query: self.query.clone(),
153            body: self.body.clone(),
154            authenticator: self.authenticator.clone(),
155            costs: self.costs.clone(),
156            _marker: PhantomData,
157        }
158    }
159}
160
161impl<T, E> QueryBuilder for Request<T, E> {
162    fn add_query(&mut self, key: String, value: String) {
163        self.query.push((key, value));
164    }
165}
166
167impl<T: DeserializeOwned, E: RequestError> Request<T, E> {
168    /// Execute the request and deserialize response
169    pub async fn send(self) -> Result<T, E> {
170        let path = self.path.clone();
171        let response = self.send_raw().await?;
172
173        let text = response
174            .text()
175            .await
176            .map_err(|e| E::from(ApiError::from(e)))?;
177
178        crate::decode_json(&path, &text).map_err(|e| E::from(ApiError::from(e)))
179    }
180
181    /// Execute the request and return raw response
182    ///
183    /// It runs on [`HttpClient::send`], so it is throttled, gated, signed,
184    /// retried and held by the client's hooks. A response that is not a 2xx
185    /// is never returned: it reaches `E` as [`ApiError::Response`].
186    pub async fn send_raw(self) -> Result<Response, E> {
187        let mut parts = RequestParts::new(self.method, self.path);
188        parts.query = self.query;
189        if let Some(body) = self.body {
190            parts
191                .headers
192                .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
193            parts.body = Some(body);
194        }
195        let failure = match self
196            .http_client
197            .send(parts, &self.costs, self.authenticator.as_deref())
198            .await
199        {
200            Ok(response) if response.status().is_success() => {
201                tracing::debug!("Response status: {}", response.status());
202                return Ok(response);
203            }
204            // Only a policy that is `Done` with a failed response gets here:
205            // core's and Polymarket's fail it, and the loop returns the error.
206            Ok(response) => ApiError::from_response(response).await,
207            Err(err) => err,
208        };
209        let answered = matches!(failure, ApiError::Response(_));
210        let error = E::from(failure);
211        if answered {
212            tracing::error!("Request failed: {:?}", error);
213        }
214        Err(error)
215    }
216}
217
218/// Type marker for deserializable responses
219pub struct TypedRequest<T> {
220    pub(crate) _marker: PhantomData<T>,
221}
222
223impl<T> TypedRequest<T> {
224    /// Create a new typed request marker.
225    pub fn new() -> Self {
226        Self {
227            _marker: PhantomData,
228        }
229    }
230}
231
232impl<T> Default for TypedRequest<T> {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::HttpClientBuilder;
242
243    // ── QueryBuilder via Request<T, E> ──────────────────────────
244
245    /// Helper to build a Request and extract its query pairs for assertions.
246    fn make_request() -> Request<(), ApiError> {
247        let http = HttpClientBuilder::new("https://example.com")
248            .build()
249            .unwrap();
250        Request::new(http, "/test")
251    }
252
253    #[test]
254    fn test_query_adds_key_value() {
255        let req = make_request().query("limit", 10);
256        assert_eq!(req.query, vec![("limit".into(), "10".into())]);
257    }
258
259    #[test]
260    fn test_query_chaining_preserves_order() {
261        let req = make_request()
262            .query("limit", 10)
263            .query("offset", "abc")
264            .query("active", true);
265        assert_eq!(
266            req.query,
267            vec![
268                ("limit".into(), "10".into()),
269                ("offset".into(), "abc".into()),
270                ("active".into(), "true".into()),
271            ]
272        );
273    }
274
275    #[test]
276    fn test_query_opt_some_adds_parameter() {
277        let req = make_request().query_opt("tag", Some("politics"));
278        assert_eq!(req.query, vec![("tag".into(), "politics".into())]);
279    }
280
281    #[test]
282    fn test_query_opt_none_skips_parameter() {
283        let req = make_request().query_opt("tag", None::<&str>);
284        assert!(req.query.is_empty());
285    }
286
287    #[test]
288    fn test_query_opt_interleaved_with_query() {
289        let req = make_request()
290            .query("limit", 25)
291            .query_opt("cursor", None::<String>)
292            .query("active", true)
293            .query_opt("slug", Some("will-x-happen"));
294
295        assert_eq!(
296            req.query,
297            vec![
298                ("limit".into(), "25".into()),
299                ("active".into(), "true".into()),
300                ("slug".into(), "will-x-happen".into()),
301            ]
302        );
303    }
304
305    #[test]
306    fn test_query_many_adds_repeated_key() {
307        let req = make_request().query_many("id", vec!["abc", "def", "ghi"]);
308        assert_eq!(
309            req.query,
310            vec![
311                ("id".into(), "abc".into()),
312                ("id".into(), "def".into()),
313                ("id".into(), "ghi".into()),
314            ]
315        );
316    }
317
318    #[test]
319    fn test_query_many_empty_iterator() {
320        let req = make_request().query_many("id", Vec::<String>::new());
321        assert!(req.query.is_empty());
322    }
323
324    #[test]
325    fn test_query_many_opt_some_adds_values() {
326        let ids = vec![1u64, 2, 3];
327        let req = make_request().query_many_opt("id", Some(ids));
328        assert_eq!(
329            req.query,
330            vec![
331                ("id".into(), "1".into()),
332                ("id".into(), "2".into()),
333                ("id".into(), "3".into()),
334            ]
335        );
336    }
337
338    #[test]
339    fn test_query_many_opt_none_skips() {
340        let req = make_request().query_many_opt("id", None::<Vec<String>>);
341        assert!(req.query.is_empty());
342    }
343
344    #[test]
345    fn test_query_duplicate_keys_allowed() {
346        let req = make_request()
347            .query("sort", "price")
348            .query("sort", "volume");
349        assert_eq!(
350            req.query,
351            vec![
352                ("sort".into(), "price".into()),
353                ("sort".into(), "volume".into()),
354            ]
355        );
356    }
357
358    // ── Request::new ────────────────────────────────────────────
359
360    #[test]
361    fn test_request_new_stores_path() {
362        let req = make_request();
363        assert_eq!(req.path, "/test");
364        assert!(req.query.is_empty());
365    }
366
367    #[test]
368    fn test_request_new_with_string_path() {
369        let http = HttpClientBuilder::new("https://example.com")
370            .build()
371            .unwrap();
372        let req: Request<(), ApiError> = Request::new(http, String::from("/events"));
373        assert_eq!(req.path, "/events");
374    }
375
376    // ── Clone ───────────────────────────────────────────────────
377
378    #[test]
379    fn test_request_clone_does_not_require_clone_markers() {
380        // Neither marker implements `Clone`; a derived impl would not compile here.
381        struct NotClone;
382        struct NotCloneError;
383
384        let http = HttpClientBuilder::new("https://example.com")
385            .build()
386            .unwrap();
387        let original: Request<NotClone, NotCloneError> =
388            Request::new(http, "/feed").query("user", "0xabc");
389        let extended = original.clone().query("cursor", "c1");
390
391        assert_eq!(original.path, "/feed");
392        assert_eq!(original.query, vec![("user".into(), "0xabc".into())]);
393        assert_eq!(extended.path, "/feed");
394        assert_eq!(
395            extended.query,
396            vec![
397                ("user".into(), "0xabc".into()),
398                ("cursor".into(), "c1".into()),
399            ]
400        );
401    }
402
403    // ── TypedRequest ────────────────────────────────────────────
404
405    #[test]
406    fn test_typed_request_new_and_default() {
407        let _t1: TypedRequest<String> = TypedRequest::new();
408        let _t2: TypedRequest<String> = TypedRequest::default();
409        // Both should compile and create distinct instances — no state to verify
410    }
411}