Skip to main content

polyoxide_core/
request.rs

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