Skip to main content

toolkit_zero/socket/
client.rs

1
2//! Typed, fluent HTTP client.
3//!
4//! This module provides a builder-oriented API for issuing HTTP requests
5//! against a configurable [`Target`] — a `localhost` port or an arbitrary
6//! remote base URL — and deserialising the response body into a concrete Rust
7//! type via [`serde`].
8//!
9//! The entry point is [`Client`]. Call any method constructor
10//! ([`get`](Client::get), [`post`](Client::post), etc.) to obtain a
11//! [`RequestBuilder`]. Optionally attach a JSON body via
12//! [`json`](RequestBuilder::json) or URL query parameters via
13//! [`query`](RequestBuilder::query), then finalise with
14//! [`send`](RequestBuilder::send) (async) or
15//! [`send_sync`](RequestBuilder::send_sync) (blocking).
16//! All seven standard HTTP methods are supported.
17//!
18//! # Builder chains at a glance
19//!
20//! | Chain | Sends |
21//! |---|---|
22//! | `client.method(endpoint).send().await` | plain request |
23//! | `.json(value).send().await` | `Content-Type: application/json` body |
24//! | `.query(params).send().await` | serialised query string |
25//!
26//! # Example
27//!
28//! ```rust,no_run
29//! use toolkit_zero::socket::client::{Client, Target};
30//! use serde::{Deserialize, Serialize};
31//!
32//! #[derive(Deserialize, Serialize)] struct Item { id: u32, name: String }
33//! #[derive(Serialize)] struct NewItem { name: String }
34//! #[derive(Serialize)] struct Filter { page: u32 }
35//!
36//! # async fn run() -> Result<(), reqwest::Error> {
37//! let client = Client::new_async(Target::Localhost(8080));
38//!
39//! // Plain async GET
40//! let items: Vec<Item> = client.get("/items").send().await?;
41//!
42//! // POST with a JSON body
43//! let created: Item = client
44//!     .post("/items")
45//!     .json(NewItem { name: "widget".into() })
46//!     .send()
47//!     .await?;
48//!
49//! // GET with query parameters
50//! let page: Vec<Item> = client
51//!     .get("/items")
52//!     .query(Filter { page: 2 })
53//!     .send()
54//!     .await?;
55//!
56//! // Synchronous DELETE
57//! let _: Item = client.delete("/items/1").send_sync()?;
58//! # Ok(())
59//! # }
60//! ```
61
62use reqwest::{Client as AsyncClient, blocking::Client as BlockingClient};
63use serde::{Serialize, de::DeserializeOwned};
64use base64::Engine as _;
65use base64::engine::general_purpose::URL_SAFE_NO_PAD;
66use crate::socket::SerializationKey;
67use crate::serialization::SerializationError;
68
69pub use toolkit_zero_macros::request;
70
71fn build_url(base: &str, endpoint: &str) -> String {
72    let ep = endpoint.trim_start_matches('/');
73    if ep.is_empty() {
74        base.trim_end_matches('/').to_owned()
75    } else {
76        format!("{}/{}", base.trim_end_matches('/'), ep)
77    }
78}
79
80#[derive(Clone, Copy, Debug)]
81enum HttpMethod {
82    Get,
83    Post,
84    Put,
85    Delete,
86    Patch,
87    Head,
88    Options,
89}
90
91impl HttpMethod {
92    fn apply_async(&self, client: &AsyncClient, url: &str) -> reqwest::RequestBuilder {
93        match self {
94            HttpMethod::Get     => client.get(url),
95            HttpMethod::Post    => client.post(url),
96            HttpMethod::Put     => client.put(url),
97            HttpMethod::Delete  => client.delete(url),
98            HttpMethod::Patch   => client.patch(url),
99            HttpMethod::Head    => client.head(url),
100            HttpMethod::Options => client.request(reqwest::Method::OPTIONS, url),
101        }
102    }
103
104    fn apply_sync(&self, client: &BlockingClient, url: &str) -> reqwest::blocking::RequestBuilder {
105        match self {
106            HttpMethod::Get     => client.get(url),
107            HttpMethod::Post    => client.post(url),
108            HttpMethod::Put     => client.put(url),
109            HttpMethod::Delete  => client.delete(url),
110            HttpMethod::Patch   => client.patch(url),
111            HttpMethod::Head    => client.head(url),
112            HttpMethod::Options => client.request(reqwest::Method::OPTIONS, url),
113        }
114    }
115}
116
117/// The target server for a [`Client`].
118#[derive(Clone)]
119pub enum Target {
120    /// A locally running server. Provide the port number.
121    Localhost(u16),
122    /// A remote server. Provide the full base URL (e.g. `"https://example.com"`).
123    Remote(String),
124}
125
126/// HTTP client for making typed requests against a [`Target`] server.
127///
128/// A `Client` is created in one of three modes depending on which send variants you need:
129///
130/// | Constructor | `send()` (async) | `send_sync()` (blocking) | Safe in async context |
131/// |---|---|---|---|
132/// | [`Client::new_async`] | ✓ | ✗ | ✓ |
133/// | [`Client::new_sync`] | ✗ | ✓ | ✓ |
134/// | [`Client::new`] | ✓ | ✓ | ✗ — panics if called inside a tokio runtime |
135///
136/// `reqwest::blocking::Client` internally creates its own single-threaded tokio runtime. If
137/// you call `Client::new()` (or `Client::new_sync()`) from within an existing async context
138/// (e.g. inside `#[tokio::main]`) it will panic. Use `Client::new_async()` when your program
139/// is async-first and only call `Client::new_sync()` / `Client::new()` before entering any
140/// async runtime.
141///
142/// # Example
143/// ```rust,no_run
144/// # use toolkit_zero::socket::client::{Client, Target};
145/// # use serde::{Deserialize, Serialize};
146/// # #[derive(Deserialize)] struct Item { id: u32, name: String }
147/// # #[derive(Serialize)] struct NewItem { name: String }
148/// # async fn example() -> Result<(), reqwest::Error> {
149/// // Async-only client — safe inside #[tokio::main]
150/// let client = Client::new_async(Target::Localhost(8080));
151///
152/// let items: Vec<Item> = client.get("/items").send().await?;
153/// let created: Item = client.post("/items").json(NewItem { name: "w".into() }).send().await?;
154/// # Ok(())
155/// # }
156/// ```
157#[derive(Clone)]
158pub struct Client {
159    target: Target,
160    async_client: Option<AsyncClient>,
161    sync_client: Option<BlockingClient>,
162}
163
164impl Client {
165    /// Creates an **async-only** client. Safe to call from any context, including inside
166    /// `#[tokio::main]`. Calling `.send_sync()` on builders from this client will panic.
167    pub fn new_async(target: Target) -> Self {
168        log::debug!("Creating async-only client");
169        Self { target, async_client: Some(AsyncClient::new()), sync_client: None }
170    }
171
172    /// Creates a **sync-only** client. **Must not** be called from within an async context
173    /// (inside `#[tokio::main]` or similar) — doing so panics. Calling `.send()` on builders
174    /// from this client will panic with a message pointing to [`Client::new_async`].
175    ///
176    /// # Panics
177    ///
178    /// Panics at construction time if called inside a tokio runtime (same restriction as
179    /// `reqwest::blocking::Client`). Prefer [`Client::new_async`] for async contexts.
180    pub fn new_sync(target: Target) -> Self {
181        log::debug!("Creating sync-only client");
182        Self { target, async_client: None, sync_client: Some(BlockingClient::new()) }
183    }
184
185    /// Creates a client supporting **both** async and blocking sends.
186    ///
187    /// # Panics
188    ///
189    /// **Panics immediately if called from within an async context** (e.g. inside
190    /// `#[tokio::main]`, `tokio::spawn`, or any `.await` call chain). This happens because
191    /// `reqwest::blocking::Client` creates its own internal tokio runtime, and Rust/tokio
192    /// forbids nesting two runtimes in the same thread.
193    ///
194    /// If you are in an async context, use [`Client::new_async`] instead.
195    /// If you only need blocking calls, use [`Client::new_sync`] **before** entering any runtime.
196    ///
197    /// # Example
198    /// ```rust,no_run
199    /// # use toolkit_zero::socket::client::{Client, Target};
200    /// // Correct — called from synchronous main before any async runtime starts
201    /// fn main() {
202    ///     let client = Client::new(Target::Localhost(8080));
203    ///     // ... use client.send_sync() and client.send() via manual runtime
204    /// }
205    ///
206    /// // WRONG — will panic at runtime:
207    /// // #[tokio::main]
208    /// // async fn main() { let client = Client::new(...); }  // panics!
209    /// ```
210    pub fn new(target: Target) -> Self {
211        // Detect async context early: tokio sets a thread-local when a runtime is active.
212        // try_current() succeeds only if we are already inside a tokio runtime — exactly
213        // the forbidden case for BlockingClient, so we panic with an actionable message.
214        if tokio::runtime::Handle::try_current().is_ok() {
215            panic!(
216                "Client::new() called inside an async context (tokio runtime detected). \
217                 BlockingClient cannot be created inside an existing runtime.\n\
218                 → Use Client::new_async(target) if you only need .send() (async).\n\
219                 → Use Client::new_sync(target) called before entering any async runtime if you only need .send_sync()."
220            );
221        }
222        log::debug!("Creating dual async+sync client");
223        Self {
224            target,
225            async_client: Some(AsyncClient::new()),
226            sync_client: Some(BlockingClient::new()),
227        }
228    }
229
230    fn async_client(&self) -> &AsyncClient {
231        self.async_client.as_ref()
232            .expect("Client was created with new_sync() — call new_async() or new() to use async sends")
233    }
234
235    fn sync_client(&self) -> &BlockingClient {
236        self.sync_client.as_ref()
237            .expect("Client was created with new_async() — call new_sync() or new() to use sync sends")
238    }
239
240    /// Returns the base URL derived from the configured [`Target`].
241    pub fn base_url(&self) -> String {
242        match &self.target {
243            Target::Localhost(port) => format!("http://localhost:{}", port),
244            Target::Remote(url) => url.clone(),
245        }
246    }
247
248    fn builder(&self, method: HttpMethod, endpoint: impl Into<String>) -> RequestBuilder<'_> {
249        RequestBuilder::new(self, method, endpoint)
250    }
251
252    /// Starts a `GET` request builder for `endpoint`.
253    pub fn get(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
254        self.builder(HttpMethod::Get, endpoint)
255    }
256
257    /// Starts a `POST` request builder for `endpoint`.
258    pub fn post(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
259        self.builder(HttpMethod::Post, endpoint)
260    }
261
262    /// Starts a `PUT` request builder for `endpoint`.
263    pub fn put(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
264        self.builder(HttpMethod::Put, endpoint)
265    }
266
267    /// Starts a `DELETE` request builder for `endpoint`.
268    pub fn delete(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
269        self.builder(HttpMethod::Delete, endpoint)
270    }
271
272    /// Starts a `PATCH` request builder for `endpoint`.
273    pub fn patch(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
274        self.builder(HttpMethod::Patch, endpoint)
275    }
276
277    /// Starts a `HEAD` request builder for `endpoint`.
278    pub fn head(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
279        self.builder(HttpMethod::Head, endpoint)
280    }
281
282    /// Starts an `OPTIONS` request builder for `endpoint`.
283    pub fn options(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
284        self.builder(HttpMethod::Options, endpoint)
285    }
286}
287
288/// A request builder with no body or query parameters attached.
289///
290/// Obtained from any [`Client`] method constructor. Attach a JSON body via
291/// [`json`](RequestBuilder::json) or query parameters via [`query`](RequestBuilder::query),
292/// or finalise directly with [`send`](RequestBuilder::send) /
293/// [`send_sync`](RequestBuilder::send_sync).
294pub struct RequestBuilder<'a> {
295    client: &'a Client,
296    method: HttpMethod,
297    endpoint: String,
298}
299
300impl<'a> RequestBuilder<'a> {
301    fn new(client: &'a Client, method: HttpMethod, endpoint: impl Into<String>) -> Self {
302        let endpoint = endpoint.into();
303        log::debug!("Building {:?} request for endpoint '{}'", method, endpoint);
304        Self { client, method, endpoint }
305    }
306
307    /// Attaches a JSON-serialisable body, transitioning to [`JsonRequestBuilder`].
308    ///
309    /// # Example
310    /// ```rust,no_run
311    /// # use toolkit_zero::socket::client::Client;
312    /// # use serde::{Deserialize, Serialize};
313    /// # #[derive(Serialize)] struct NewItem { name: String }
314    /// # #[derive(Deserialize)] struct Item { id: u32, name: String }
315    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
316    /// let item: Item = client
317    ///     .post("/items")
318    ///     .json(NewItem { name: "widget".to_string() })
319    ///     .send()
320    ///     .await?;
321    /// # Ok(())
322    /// # }
323    /// ```
324    pub fn json<T: Serialize>(self, body: T) -> JsonRequestBuilder<'a, T> {
325        log::trace!("Attaching JSON body to {:?} request for '{}'", self.method, self.endpoint);
326        JsonRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, body }
327    }
328
329    /// Attaches query parameters that serialise into the URL query string, transitioning to
330    /// [`QueryRequestBuilder`].
331    ///
332    /// # Example
333    /// ```rust,no_run
334    /// # use toolkit_zero::socket::client::Client;
335    /// # use serde::{Deserialize, Serialize};
336    /// # #[derive(Serialize)] struct SearchParams { q: String, page: u32 }
337    /// # #[derive(Deserialize)] struct SearchResult { items: Vec<String> }
338    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
339    /// let results: SearchResult = client
340    ///     .get("/search")
341    ///     .query(SearchParams { q: "rust".to_string(), page: 1 })
342    ///     .send()
343    ///     .await?;
344    /// # Ok(())
345    /// # }
346    /// ```
347    pub fn query<T: Serialize>(self, params: T) -> QueryRequestBuilder<'a, T> {
348        log::trace!("Attaching query params to {:?} request for '{}'", self.method, self.endpoint);
349        QueryRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, params }
350    }
351
352    /// Sends the request asynchronously and deserialises the response body as `R`.
353    ///
354    /// # Example
355    /// ```rust,no_run
356    /// # use toolkit_zero::socket::client::Client;
357    /// # use serde::Deserialize;
358    /// # #[derive(Deserialize)] struct User { id: u32, name: String }
359    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
360    /// let user: User = client.get("/users/1").send().await?;
361    /// # Ok(())
362    /// # }
363    /// ```
364    pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
365        let url = build_url(&self.client.base_url(), &self.endpoint);
366        log::info!("Sending async {:?} to '{}'", self.method, url);
367        let resp = self.method.apply_async(&self.client.async_client(), &url)
368            .send().await?;
369        log::debug!("Response status: {}", resp.status());
370        resp.json::<R>().await
371    }
372
373    /// Sends the request synchronously and deserialises the response body as `R`.
374    ///
375    /// # Example
376    /// ```rust,no_run
377    /// # use toolkit_zero::socket::client::Client;
378    /// # use serde::Deserialize;
379    /// # #[derive(Deserialize)] struct User { id: u32, name: String }
380    /// # fn example(client: &Client) -> Result<(), reqwest::Error> {
381    /// let user: User = client.get("/users/1").send_sync()?;
382    /// # Ok(())
383    /// # }
384    /// ```
385    pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
386        let url = build_url(&self.client.base_url(), &self.endpoint);
387        log::info!("Sending sync {:?} to '{}'", self.method, url);
388        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
389            .send()?;
390        log::debug!("Response status: {}", resp.status());
391        resp.json::<R>()
392    }
393
394    /// Attaches an authenticated-encrypted body (ChaCha20-Poly1305), transitioning to
395    /// [`EncryptedBodyRequestBuilder`].
396    ///
397    /// The body is sealed with the given [`SerializationKey`] and sent as
398    /// `application/octet-stream`; the response is opened with the same key.
399    /// For plain-JSON routes use `.json(body)` instead.
400    ///
401    /// # Example
402    /// ```rust,no_run
403    /// # use toolkit_zero::socket::client::Client;
404    /// # use bincode::{Encode, Decode};
405    /// # #[derive(Encode)] struct Req { value: i32 }
406    /// # #[derive(Decode)] struct Resp { result: i32 }
407    /// # async fn example(client: &Client) -> Result<(), toolkit_zero::socket::client::ClientError> {
408    /// use toolkit_zero::socket::SerializationKey;
409    /// let resp: Resp = client
410    ///     .post("/compute")
411    ///     .encryption(Req { value: 42 }, SerializationKey::Default)
412    ///     .send()
413    ///     .await?;
414    /// # Ok(())
415    /// # }
416    /// ```
417    pub fn encryption<T: bincode::Encode>(self, body: T, key: SerializationKey) -> EncryptedBodyRequestBuilder<'a, T> {
418        log::trace!("Attaching encrypted body to {:?} request for '{}'", self.method, self.endpoint);
419        EncryptedBodyRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, body, key }
420    }
421
422    /// Attaches authenticated-encrypted query parameters (ChaCha20-Poly1305), transitioning
423    /// to [`EncryptedQueryRequestBuilder`].
424    ///
425    /// The params are sealed and sent as `?data=<base64url>`; the response is opened
426    /// with the same key. For plain query-string routes use `.query(params)` instead.
427    ///
428    /// # Example
429    /// ```rust,no_run
430    /// # use toolkit_zero::socket::client::Client;
431    /// # use bincode::{Encode, Decode};
432    /// # #[derive(Encode)] struct Filter { page: u32 }
433    /// # #[derive(Decode)] struct Page { items: Vec<String> }
434    /// # async fn example(client: &Client) -> Result<(), toolkit_zero::socket::client::ClientError> {
435    /// use toolkit_zero::socket::SerializationKey;
436    /// let page: Page = client
437    ///     .get("/items")
438    ///     .encrypted_query(Filter { page: 1 }, SerializationKey::Default)
439    ///     .send()
440    ///     .await?;
441    /// # Ok(())
442    /// # }
443    /// ```
444    pub fn encrypted_query<T: bincode::Encode>(self, params: T, key: SerializationKey) -> EncryptedQueryRequestBuilder<'a, T> {
445        log::trace!("Attaching encrypted query to {:?} request for '{}'", self.method, self.endpoint);
446        EncryptedQueryRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, params, key }
447    }
448}
449
450/// A request builder that will send a JSON-serialised body.
451///
452/// Obtained from [`RequestBuilder::json`]. Finalise with [`send`](JsonRequestBuilder::send)
453/// (async) or [`send_sync`](JsonRequestBuilder::send_sync) (sync).
454///
455/// # Example
456/// ```rust,no_run
457/// # use toolkit_zero::socket::client::Client;
458/// # use serde::{Deserialize, Serialize};
459/// # #[derive(Serialize)] struct UpdateItem { name: String }
460/// # #[derive(Deserialize)] struct Item { id: u32, name: String }
461/// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
462/// // Async PUT
463/// let updated: Item = client
464///     .put("/items/42")
465///     .json(UpdateItem { name: "new name".to_string() })
466///     .send()
467///     .await?;
468///
469/// // Sync PATCH
470/// let patched: Item = client
471///     .patch("/items/42")
472///     .json(UpdateItem { name: "new name".to_string() })
473///     .send_sync()?;
474/// # Ok(())
475/// # }
476/// ```
477pub struct JsonRequestBuilder<'a, T> {
478    client: &'a Client,
479    method: HttpMethod,
480    endpoint: String,
481    body: T,
482}
483
484impl<'a, T: Serialize> JsonRequestBuilder<'a, T> {
485    /// Sends the request asynchronously with the JSON body and deserialises the response as `R`.
486    ///
487    /// # Example
488    /// ```rust,no_run
489    /// # use toolkit_zero::socket::client::Client;
490    /// # use serde::{Deserialize, Serialize};
491    /// # #[derive(Serialize)] struct Payload { value: i32 }
492    /// # #[derive(Deserialize)] struct Ack { received: bool }
493    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
494    /// let ack: Ack = client
495    ///     .post("/process")
496    ///     .json(Payload { value: 42 })
497    ///     .send()
498    ///     .await?;
499    /// # Ok(())
500    /// # }
501    /// ```
502    pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
503        let url = build_url(&self.client.base_url(), &self.endpoint);
504        log::info!("Sending async {:?} with JSON body to '{}'", self.method, url);
505        let resp = self.method.apply_async(&self.client.async_client(), &url)
506            .json(&self.body)
507            .send().await?;
508        log::debug!("Response status: {}", resp.status());
509        resp.json::<R>().await
510    }
511
512    /// Sends the request synchronously with the JSON body and deserialises the response as `R`.
513    ///
514    /// # Example
515    /// ```rust,no_run
516    /// # use toolkit_zero::socket::client::Client;
517    /// # use serde::{Deserialize, Serialize};
518    /// # #[derive(Serialize)] struct Payload { value: i32 }
519    /// # #[derive(Deserialize)] struct Ack { received: bool }
520    /// # fn example(client: &Client) -> Result<(), reqwest::Error> {
521    /// let ack: Ack = client
522    ///     .post("/process")
523    ///     .json(Payload { value: 42 })
524    ///     .send_sync()?;
525    /// # Ok(())
526    /// # }
527    /// ```
528    pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
529        let url = build_url(&self.client.base_url(), &self.endpoint);
530        log::info!("Sending sync {:?} with JSON body to '{}'", self.method, url);
531        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
532            .json(&self.body)
533            .send()?;
534        log::debug!("Response status: {}", resp.status());
535        resp.json::<R>()
536    }
537}
538
539/// A request builder that will append serialisable query parameters to the URL.
540///
541/// Obtained from [`RequestBuilder::query`]. Finalise with [`send`](QueryRequestBuilder::send)
542/// (async) or [`send_sync`](QueryRequestBuilder::send_sync) (sync).
543///
544/// # Example
545/// ```rust,no_run
546/// # use toolkit_zero::socket::client::Client;
547/// # use serde::{Deserialize, Serialize};
548/// # #[derive(Serialize)] struct Filters { status: String, limit: u32 }
549/// # #[derive(Deserialize)] struct Item { id: u32, name: String }
550/// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
551/// // Async GET with query params
552/// let items: Vec<Item> = client
553///     .get("/items")
554///     .query(Filters { status: "active".to_string(), limit: 20 })
555///     .send()
556///     .await?;
557///
558/// // Sync variant
559/// let items: Vec<Item> = client
560///     .get("/items")
561///     .query(Filters { status: "active".to_string(), limit: 20 })
562///     .send_sync()?;
563/// # Ok(())
564/// # }
565/// ```
566pub struct QueryRequestBuilder<'a, T> {
567    client: &'a Client,
568    method: HttpMethod,
569    endpoint: String,
570    params: T,
571}
572
573impl<'a, T: Serialize> QueryRequestBuilder<'a, T> {
574    /// Sends the request asynchronously with query parameters and deserialises the response as `R`.
575    ///
576    /// # Example
577    /// ```rust,no_run
578    /// # use toolkit_zero::socket::client::Client;
579    /// # use serde::{Deserialize, Serialize};
580    /// # #[derive(Serialize)] struct Params { page: u32 }
581    /// # #[derive(Deserialize)] struct Page { items: Vec<String> }
582    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
583    /// let page: Page = client
584    ///     .get("/feed")
585    ///     .query(Params { page: 2 })
586    ///     .send()
587    ///     .await?;
588    /// # Ok(())
589    /// # }
590    /// ```
591    pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
592        let url = build_url(&self.client.base_url(), &self.endpoint);
593        log::info!("Sending async {:?} with query params to '{}'", self.method, url);
594        let resp = self.method.apply_async(&self.client.async_client(), &url)
595            .query(&self.params)
596            .send().await?;
597        log::debug!("Response status: {}", resp.status());
598        resp.json::<R>().await
599    }
600
601    /// Sends the request synchronously with query parameters and deserialises the response as `R`.
602    ///
603    /// # Example
604    /// ```rust,no_run
605    /// # use toolkit_zero::socket::client::Client;
606    /// # use serde::{Deserialize, Serialize};
607    /// # #[derive(Serialize)] struct Params { page: u32 }
608    /// # #[derive(Deserialize)] struct Page { items: Vec<String> }
609    /// # fn example(client: &Client) -> Result<(), reqwest::Error> {
610    /// let page: Page = client
611    ///     .get("/feed")
612    ///     .query(Params { page: 2 })
613    ///     .send_sync()?;
614    /// # Ok(())
615    /// # }
616    /// ```
617    pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
618        let url = build_url(&self.client.base_url(), &self.endpoint);
619        log::info!("Sending sync {:?} with query params to '{}'", self.method, url);
620        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
621            .query(&self.params)
622            .send()?;
623        log::debug!("Response status: {}", resp.status());
624        resp.json::<R>()
625    }
626}
627
628// ─── ClientError ─────────────────────────────────────────────────────────────
629
630/// Error returned by [`EncryptedBodyRequestBuilder`] and [`EncryptedQueryRequestBuilder`].
631///
632/// Wraps either a transport-level [`reqwest::Error`] or a cipher failure.
633#[derive(Debug)]
634pub enum ClientError {
635    /// The underlying HTTP transport failed (connection refused, timeout, etc.).
636    Transport(reqwest::Error),
637    /// Sealing or opening failed (wrong key, corrupted bytes, etc.).
638    Serialization(SerializationError),
639}
640
641impl std::fmt::Display for ClientError {
642    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643        match self {
644            Self::Transport(e)      => write!(f, "transport error: {e}"),
645            Self::Serialization(e)  => write!(f, "serialization error: {e}"),
646        }
647    }
648}
649
650impl std::error::Error for ClientError {}
651
652impl From<reqwest::Error> for ClientError {
653    fn from(e: reqwest::Error) -> Self { Self::Transport(e) }
654}
655
656impl From<SerializationError> for ClientError {
657    fn from(e: SerializationError) -> Self { Self::Serialization(e) }
658}
659
660// ─── EncryptedBodyRequestBuilder ─────────────────────────────────────────────
661
662/// A request builder that seals the body (ChaCha20-Poly1305) before sending.
663///
664/// Obtained from [`RequestBuilder::encryption`]. Finalise with
665/// [`send`](EncryptedBodyRequestBuilder::send) (async) or
666/// [`send_sync`](EncryptedBodyRequestBuilder::send_sync) (sync).
667///
668/// The expected response is also sealed and is opened transparently.
669pub struct EncryptedBodyRequestBuilder<'a, T> {
670    client: &'a Client,
671    method: HttpMethod,
672    endpoint: String,
673    body: T,
674    key: SerializationKey,
675}
676
677impl<'a, T: bincode::Encode> EncryptedBodyRequestBuilder<'a, T> {
678    /// Sends the request asynchronously.
679    ///
680    /// Before the request leaves, the body is sealed using the [`SerializationKey`]
681    /// supplied to [`.encryption()`](RequestBuilder::encryption).  The server receives a
682    /// raw `application/octet-stream` payload.  When the response arrives, its bytes are
683    /// opened with the same key to produce `R`.  If either sealing or opening fails
684    /// the error is wrapped in [`ClientError::Serialization`].
685    pub async fn send<R>(self) -> Result<R, ClientError>
686    where
687        R: bincode::Decode<()>,
688    {
689        let url = build_url(&self.client.base_url(), &self.endpoint);
690        log::info!("Sending async {:?} with encrypted body to '{}'", self.method, url);
691        let sealed = crate::serialization::seal(&self.body, self.key.veil_key())?;
692        let resp = self.method.apply_async(&self.client.async_client(), &url)
693            .header("content-type", "application/octet-stream")
694            .body(sealed)
695            .send().await?;
696        log::debug!("Response status: {}", resp.status());
697        let bytes = resp.bytes().await?;
698        Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
699    }
700
701    /// Sends the request synchronously.
702    ///
703    /// The body is sealed with the configured [`SerializationKey`] before the wire
704    /// send.  The response bytes, once received, are opened with the same key to
705    /// produce `R`.  Any cipher failure is wrapped in [`ClientError::Serialization`].
706    pub fn send_sync<R>(self) -> Result<R, ClientError>
707    where
708        R: bincode::Decode<()>,
709    {
710        let url = build_url(&self.client.base_url(), &self.endpoint);
711        log::info!("Sending sync {:?} with encrypted body to '{}'", self.method, url);
712        let sealed = crate::serialization::seal(&self.body, self.key.veil_key())?;
713        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
714            .header("content-type", "application/octet-stream")
715            .body(sealed)
716            .send()?;
717        log::debug!("Response status: {}", resp.status());
718        let bytes = resp.bytes()?;
719        Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
720    }
721}
722
723// ─── EncryptedQueryRequestBuilder ────────────────────────────────────────────
724
725/// A request builder that seals query params (ChaCha20-Poly1305) and sends them as
726/// `?data=<base64url>`.
727///
728/// Obtained from [`RequestBuilder::encrypted_query`]. Finalise with
729/// [`send`](EncryptedQueryRequestBuilder::send) (async) or
730/// [`send_sync`](EncryptedQueryRequestBuilder::send_sync) (sync).
731///
732/// The expected response is also sealed and is opened transparently.
733pub struct EncryptedQueryRequestBuilder<'a, T> {
734    client: &'a Client,
735    method: HttpMethod,
736    endpoint: String,
737    params: T,
738    key: SerializationKey,
739}
740
741impl<'a, T: bincode::Encode> EncryptedQueryRequestBuilder<'a, T> {
742    /// Sends the request asynchronously.
743    ///
744    /// The params are sealed with the configured [`SerializationKey`] and
745    /// base64url-encoded, then appended to the URL as `?data=<base64url>`.  When the
746    /// response arrives, its bytes are opened with the same key to produce `R`.
747    /// Any cipher failure is wrapped in [`ClientError::Serialization`].
748    pub async fn send<R>(self) -> Result<R, ClientError>
749    where
750        R: bincode::Decode<()>,
751    {
752        let url = build_url(&self.client.base_url(), &self.endpoint);
753        log::info!("Sending async {:?} with encrypted query to '{}'", self.method, url);
754        let sealed = crate::serialization::seal(&self.params, self.key.veil_key())?;
755        let b64 = URL_SAFE_NO_PAD.encode(&sealed);
756        let resp = self.method.apply_async(&self.client.async_client(), &url)
757            .query(&[("data", &b64)])
758            .send().await?;
759        log::debug!("Response status: {}", resp.status());
760        let bytes = resp.bytes().await?;
761        Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
762    }
763
764    /// Sends the request synchronously.
765    ///
766    /// Same behaviour as [`send`](Self::send) — params are sealed and base64url-encoded
767    /// as `?data=<value>`, and the sealed response bytes are opened to `R` — but the
768    /// network call blocks the current thread.  Any cipher failure is wrapped in
769    /// [`ClientError::Serialization`].
770    pub fn send_sync<R>(self) -> Result<R, ClientError>
771    where
772        R: bincode::Decode<()>,
773    {
774        let url = build_url(&self.client.base_url(), &self.endpoint);
775        log::info!("Sending sync {:?} with encrypted query to '{}'", self.method, url);
776        let sealed = crate::serialization::seal(&self.params, self.key.veil_key())?;
777        let b64 = URL_SAFE_NO_PAD.encode(&sealed);
778        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
779            .query(&[("data", &b64)])
780            .send()?;
781        log::debug!("Response status: {}", resp.status());
782        let bytes = resp.bytes()?;
783        Ok(crate::serialization::open::<R, _>(&bytes, self.key.veil_key())?)
784    }
785}
786
787// ─── ClientBuilder ────────────────────────────────────────────────────────────
788
789/// Fluent builder for [`Client`] with optional timeout and other configuration.
790///
791/// Use this instead of the bare `Client::new_*` constructors when you need to
792/// configure a request timeout.
793///
794/// # Example
795///
796/// ```rust,no_run
797/// use std::time::Duration;
798/// use toolkit_zero::socket::client::{ClientBuilder, Target};
799///
800/// // Async client with a 10-second timeout
801/// let client = ClientBuilder::new(Target::Localhost(8080))
802///     .timeout(Duration::from_secs(10))
803///     .build_async();
804///
805/// // Sync client with a 30-second timeout
806/// let client = ClientBuilder::new(Target::Remote("https://api.example.com".to_string()))
807///     .timeout(Duration::from_secs(30))
808///     .build_sync();
809/// ```
810pub struct ClientBuilder {
811    target:  Target,
812    timeout: Option<std::time::Duration>,
813}
814
815impl ClientBuilder {
816    /// Create a new builder for the given [`Target`].
817    pub fn new(target: Target) -> Self {
818        Self { target, timeout: None }
819    }
820
821    /// Set a request timeout.
822    ///
823    /// Both the async and blocking reqwest clients will respect this duration.
824    /// Requests that do not complete within the timeout are cancelled and return
825    /// a [`reqwest::Error`] with `is_timeout()` = `true`.
826    pub fn timeout(mut self, duration: std::time::Duration) -> Self {
827        self.timeout = Some(duration);
828        self
829    }
830
831    /// Build an **async-only** [`Client`]. Safe to call from any context,
832    /// including inside `#[tokio::main]`.
833    pub fn build_async(self) -> Client {
834        log::debug!("Building async-only client (timeout={:?})", self.timeout);
835        let mut builder = AsyncClient::builder();
836        if let Some(t) = self.timeout {
837            builder = builder.timeout(t);
838        }
839        Client {
840            target:       self.target,
841            async_client: Some(builder.build().expect("failed to build reqwest async client")),
842            sync_client:  None,
843        }
844    }
845
846    /// Build a **sync-only** [`Client`].
847    ///
848    /// # Panics
849    ///
850    /// Panics if called from within an async context (same restriction as
851    /// `reqwest::blocking::Client`). See [`Client::new_sync`] for details.
852    pub fn build_sync(self) -> Client {
853        log::debug!("Building sync-only client (timeout={:?})", self.timeout);
854        let mut builder = BlockingClient::builder();
855        if let Some(t) = self.timeout {
856            builder = builder.timeout(t);
857        }
858        Client {
859            target:       self.target,
860            async_client: None,
861            sync_client:  Some(builder.build().expect("failed to build reqwest blocking client")),
862        }
863    }
864
865    /// Build a client that supports **both** async and blocking sends.
866    ///
867    /// # Panics
868    ///
869    /// Panics if called from within an async context. See [`Client::new`] for details.
870    pub fn build(self) -> Client {
871        if tokio::runtime::Handle::try_current().is_ok() {
872            panic!(
873                "ClientBuilder::build() called inside an async context. \
874                 Use ClientBuilder::build_async() for async-only clients."
875            );
876        }
877        log::debug!("Building dual async+sync client (timeout={:?})", self.timeout);
878        let mut async_builder   = AsyncClient::builder();
879        let mut sync_builder    = BlockingClient::builder();
880        if let Some(t) = self.timeout {
881            async_builder = async_builder.timeout(t);
882            sync_builder  = sync_builder.timeout(t);
883        }
884        Client {
885            target:       self.target,
886            async_client: Some(async_builder.build().expect("failed to build reqwest async client")),
887            sync_client:  Some(sync_builder.build().expect("failed to build reqwest blocking client")),
888        }
889    }
890}