Skip to main content

polyoxide_core/
request.rs

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