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 serde::{Deserialize, Serialize};
145/// # #[derive(Deserialize)] struct Item { id: u32, name: String }
146/// # #[derive(Serialize)] struct NewItem { name: String }
147/// # async fn example() -> Result<(), reqwest::Error> {
148/// // Async-only client — safe inside #[tokio::main]
149/// let client = Client::new_async(Target::Localhost(8080));
150///
151/// let items: Vec<Item> = client.get("/items").send().await?;
152/// let created: Item = client.post("/items").json(NewItem { name: "w".into() }).send().await?;
153/// # Ok(())
154/// # }
155/// ```
156#[derive(Clone)]
157pub struct Client {
158    target: Target,
159    async_client: Option<AsyncClient>,
160    sync_client: Option<BlockingClient>,
161}
162
163impl Client {
164    /// Creates an **async-only** client. Safe to call from any context, including inside
165    /// `#[tokio::main]`. Calling `.send_sync()` on builders from this client will panic.
166    pub fn new_async(target: Target) -> Self {
167        log::debug!("Creating async-only client");
168        Self { target, async_client: Some(AsyncClient::new()), sync_client: None }
169    }
170
171    /// Creates a **sync-only** client. **Must not** be called from within an async context
172    /// (inside `#[tokio::main]` or similar) — doing so panics. Calling `.send()` on builders
173    /// from this client will panic with a message pointing to [`Client::new_async`].
174    ///
175    /// # Panics
176    ///
177    /// Panics at construction time if called inside a tokio runtime (same restriction as
178    /// `reqwest::blocking::Client`). Prefer [`Client::new_async`] for async contexts.
179    pub fn new_sync(target: Target) -> Self {
180        log::debug!("Creating sync-only client");
181        Self { target, async_client: None, sync_client: Some(BlockingClient::new()) }
182    }
183
184    /// Creates a client supporting **both** async and blocking sends.
185    ///
186    /// # Panics
187    ///
188    /// **Panics immediately if called from within an async context** (e.g. inside
189    /// `#[tokio::main]`, `tokio::spawn`, or any `.await` call chain). This happens because
190    /// `reqwest::blocking::Client` creates its own internal tokio runtime, and Rust/tokio
191    /// forbids nesting two runtimes in the same thread.
192    ///
193    /// If you are in an async context, use [`Client::new_async`] instead.
194    /// If you only need blocking calls, use [`Client::new_sync`] **before** entering any runtime.
195    ///
196    /// # Example
197    /// ```rust,no_run
198    /// // Correct — called from synchronous main before any async runtime starts
199    /// fn main() {
200    ///     let client = Client::new(Target::Localhost(8080));
201    ///     // ... use client.send_sync() and client.send() via manual runtime
202    /// }
203    ///
204    /// // WRONG — will panic at runtime:
205    /// // #[tokio::main]
206    /// // async fn main() { let client = Client::new(...); }  // panics!
207    /// ```
208    pub fn new(target: Target) -> Self {
209        // Detect async context early: tokio sets a thread-local when a runtime is active.
210        // try_current() succeeds only if we are already inside a tokio runtime — exactly
211        // the forbidden case for BlockingClient, so we panic with an actionable message.
212        if tokio::runtime::Handle::try_current().is_ok() {
213            panic!(
214                "Client::new() called inside an async context (tokio runtime detected). \
215                 BlockingClient cannot be created inside an existing runtime.\n\
216                 → Use Client::new_async(target) if you only need .send() (async).\n\
217                 → Use Client::new_sync(target) called before entering any async runtime if you only need .send_sync()."
218            );
219        }
220        log::debug!("Creating dual async+sync client");
221        Self {
222            target,
223            async_client: Some(AsyncClient::new()),
224            sync_client: Some(BlockingClient::new()),
225        }
226    }
227
228    fn async_client(&self) -> &AsyncClient {
229        self.async_client.as_ref()
230            .expect("Client was created with new_sync() — call new_async() or new() to use async sends")
231    }
232
233    fn sync_client(&self) -> &BlockingClient {
234        self.sync_client.as_ref()
235            .expect("Client was created with new_async() — call new_sync() or new() to use sync sends")
236    }
237
238    /// Returns the base URL derived from the configured [`Target`].
239    pub fn base_url(&self) -> String {
240        match &self.target {
241            Target::Localhost(port) => format!("http://localhost:{}", port),
242            Target::Remote(url) => url.clone(),
243        }
244    }
245
246    fn builder(&self, method: HttpMethod, endpoint: impl Into<String>) -> RequestBuilder<'_> {
247        RequestBuilder::new(self, method, endpoint)
248    }
249
250    /// Starts a `GET` request builder for `endpoint`.
251    pub fn get(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
252        self.builder(HttpMethod::Get, endpoint)
253    }
254
255    /// Starts a `POST` request builder for `endpoint`.
256    pub fn post(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
257        self.builder(HttpMethod::Post, endpoint)
258    }
259
260    /// Starts a `PUT` request builder for `endpoint`.
261    pub fn put(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
262        self.builder(HttpMethod::Put, endpoint)
263    }
264
265    /// Starts a `DELETE` request builder for `endpoint`.
266    pub fn delete(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
267        self.builder(HttpMethod::Delete, endpoint)
268    }
269
270    /// Starts a `PATCH` request builder for `endpoint`.
271    pub fn patch(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
272        self.builder(HttpMethod::Patch, endpoint)
273    }
274
275    /// Starts a `HEAD` request builder for `endpoint`.
276    pub fn head(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
277        self.builder(HttpMethod::Head, endpoint)
278    }
279
280    /// Starts an `OPTIONS` request builder for `endpoint`.
281    pub fn options(&self, endpoint: impl Into<String>) -> RequestBuilder<'_> {
282        self.builder(HttpMethod::Options, endpoint)
283    }
284}
285
286/// A request builder with no body or query parameters attached.
287///
288/// Obtained from any [`Client`] method constructor. Attach a JSON body via
289/// [`json`](RequestBuilder::json) or query parameters via [`query`](RequestBuilder::query),
290/// or finalise directly with [`send`](RequestBuilder::send) /
291/// [`send_sync`](RequestBuilder::send_sync).
292pub struct RequestBuilder<'a> {
293    client: &'a Client,
294    method: HttpMethod,
295    endpoint: String,
296}
297
298impl<'a> RequestBuilder<'a> {
299    fn new(client: &'a Client, method: HttpMethod, endpoint: impl Into<String>) -> Self {
300        let endpoint = endpoint.into();
301        log::debug!("Building {:?} request for endpoint '{}'", method, endpoint);
302        Self { client, method, endpoint }
303    }
304
305    /// Attaches a JSON-serialisable body, transitioning to [`JsonRequestBuilder`].
306    ///
307    /// # Example
308    /// ```rust,no_run
309    /// # use serde::{Deserialize, Serialize};
310    /// # #[derive(Serialize)] struct NewItem { name: String }
311    /// # #[derive(Deserialize)] struct Item { id: u32, name: String }
312    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
313    /// let item: Item = client
314    ///     .post("/items")
315    ///     .json(NewItem { name: "widget".to_string() })
316    ///     .send()
317    ///     .await?;
318    /// # Ok(())
319    /// # }
320    /// ```
321    pub fn json<T: Serialize>(self, body: T) -> JsonRequestBuilder<'a, T> {
322        log::trace!("Attaching JSON body to {:?} request for '{}'", self.method, self.endpoint);
323        JsonRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, body }
324    }
325
326    /// Attaches query parameters that serialise into the URL query string, transitioning to
327    /// [`QueryRequestBuilder`].
328    ///
329    /// # Example
330    /// ```rust,no_run
331    /// # use serde::{Deserialize, Serialize};
332    /// # #[derive(Serialize)] struct SearchParams { q: String, page: u32 }
333    /// # #[derive(Deserialize)] struct SearchResult { items: Vec<String> }
334    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
335    /// let results: SearchResult = client
336    ///     .get("/search")
337    ///     .query(SearchParams { q: "rust".to_string(), page: 1 })
338    ///     .send()
339    ///     .await?;
340    /// # Ok(())
341    /// # }
342    /// ```
343    pub fn query<T: Serialize>(self, params: T) -> QueryRequestBuilder<'a, T> {
344        log::trace!("Attaching query params to {:?} request for '{}'", self.method, self.endpoint);
345        QueryRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, params }
346    }
347
348    /// Sends the request asynchronously and deserialises the response body as `R`.
349    ///
350    /// # Example
351    /// ```rust,no_run
352    /// # use serde::Deserialize;
353    /// # #[derive(Deserialize)] struct User { id: u32, name: String }
354    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
355    /// let user: User = client.get("/users/1").send().await?;
356    /// # Ok(())
357    /// # }
358    /// ```
359    pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
360        let url = build_url(&self.client.base_url(), &self.endpoint);
361        log::info!("Sending async {:?} to '{}'", self.method, url);
362        let resp = self.method.apply_async(&self.client.async_client(), &url)
363            .send().await?;
364        log::debug!("Response status: {}", resp.status());
365        resp.json::<R>().await
366    }
367
368    /// Sends the request synchronously and deserialises the response body as `R`.
369    ///
370    /// # Example
371    /// ```rust,no_run
372    /// # use serde::Deserialize;
373    /// # #[derive(Deserialize)] struct User { id: u32, name: String }
374    /// # fn example(client: &Client) -> Result<(), reqwest::Error> {
375    /// let user: User = client.get("/users/1").send_sync()?;
376    /// # Ok(())
377    /// # }
378    /// ```
379    pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
380        let url = build_url(&self.client.base_url(), &self.endpoint);
381        log::info!("Sending sync {:?} to '{}'", self.method, url);
382        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
383            .send()?;
384        log::debug!("Response status: {}", resp.status());
385        resp.json::<R>()
386    }
387
388    /// Attaches a VEIL-sealed body, transitioning to [`EncryptedBodyRequestBuilder`].
389    ///
390    /// The body is VEIL-sealed with the given [`SerializationKey`] and sent as
391    /// `application/octet-stream`; the response is opened with the same key.
392    /// For plain-JSON routes use `.json(body)` instead.
393    ///
394    /// # Example
395    /// ```rust,no_run
396    /// # use bincode::{Encode, Decode};
397    /// # #[derive(Encode)] struct Req { value: i32 }
398    /// # #[derive(Decode)] struct Resp { result: i32 }
399    /// # async fn example(client: &Client) -> Result<(), toolkit_zero::socket::client::ClientError> {
400    /// use toolkit_zero::socket::SerializationKey;
401    /// let resp: Resp = client
402    ///     .post("/compute")
403    ///     .encryption(Req { value: 42 }, SerializationKey::Default)
404    ///     .send()
405    ///     .await?;
406    /// # Ok(())
407    /// # }
408    /// ```
409    pub fn encryption<T: bincode::Encode>(self, body: T, key: SerializationKey) -> EncryptedBodyRequestBuilder<'a, T> {
410        log::trace!("Attaching encrypted body to {:?} request for '{}'", self.method, self.endpoint);
411        EncryptedBodyRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, body, key }
412    }
413
414    /// Attaches VEIL-sealed query parameters, transitioning to [`EncryptedQueryRequestBuilder`].
415    ///
416    /// The params are VEIL-sealed and sent as `?data=<base64url>`; the response is opened
417    /// with the same key. For plain-JSON query routes use `.query(params)` instead.
418    ///
419    /// # Example
420    /// ```rust,no_run
421    /// # use bincode::{Encode, Decode};
422    /// # #[derive(Encode)] struct Filter { page: u32 }
423    /// # #[derive(Decode)] struct Page { items: Vec<String> }
424    /// # async fn example(client: &Client) -> Result<(), toolkit_zero::socket::client::ClientError> {
425    /// use toolkit_zero::socket::SerializationKey;
426    /// let page: Page = client
427    ///     .get("/items")
428    ///     .encrypted_query(Filter { page: 1 }, SerializationKey::Default)
429    ///     .send()
430    ///     .await?;
431    /// # Ok(())
432    /// # }
433    /// ```
434    pub fn encrypted_query<T: bincode::Encode>(self, params: T, key: SerializationKey) -> EncryptedQueryRequestBuilder<'a, T> {
435        log::trace!("Attaching encrypted query to {:?} request for '{}'", self.method, self.endpoint);
436        EncryptedQueryRequestBuilder { client: self.client, method: self.method, endpoint: self.endpoint, params, key }
437    }
438}
439
440/// A request builder that will send a JSON-serialised body.
441///
442/// Obtained from [`RequestBuilder::json`]. Finalise with [`send`](JsonRequestBuilder::send)
443/// (async) or [`send_sync`](JsonRequestBuilder::send_sync) (sync).
444///
445/// # Example
446/// ```rust,no_run
447/// # use serde::{Deserialize, Serialize};
448/// # #[derive(Serialize)] struct UpdateItem { name: String }
449/// # #[derive(Deserialize)] struct Item { id: u32, name: String }
450/// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
451/// // Async PUT
452/// let updated: Item = client
453///     .put("/items/42")
454///     .json(UpdateItem { name: "new name".to_string() })
455///     .send()
456///     .await?;
457///
458/// // Sync PATCH
459/// let patched: Item = client
460///     .patch("/items/42")
461///     .json(UpdateItem { name: "new name".to_string() })
462///     .send_sync()?;
463/// # Ok(())
464/// # }
465/// ```
466pub struct JsonRequestBuilder<'a, T> {
467    client: &'a Client,
468    method: HttpMethod,
469    endpoint: String,
470    body: T,
471}
472
473impl<'a, T: Serialize> JsonRequestBuilder<'a, T> {
474    /// Sends the request asynchronously with the JSON body and deserialises the response as `R`.
475    ///
476    /// # Example
477    /// ```rust,no_run
478    /// # use serde::{Deserialize, Serialize};
479    /// # #[derive(Serialize)] struct Payload { value: i32 }
480    /// # #[derive(Deserialize)] struct Ack { received: bool }
481    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
482    /// let ack: Ack = client
483    ///     .post("/process")
484    ///     .json(Payload { value: 42 })
485    ///     .send()
486    ///     .await?;
487    /// # Ok(())
488    /// # }
489    /// ```
490    pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
491        let url = build_url(&self.client.base_url(), &self.endpoint);
492        log::info!("Sending async {:?} with JSON body to '{}'", self.method, url);
493        let resp = self.method.apply_async(&self.client.async_client(), &url)
494            .json(&self.body)
495            .send().await?;
496        log::debug!("Response status: {}", resp.status());
497        resp.json::<R>().await
498    }
499
500    /// Sends the request synchronously with the JSON body and deserialises the response as `R`.
501    ///
502    /// # Example
503    /// ```rust,no_run
504    /// # use serde::{Deserialize, Serialize};
505    /// # #[derive(Serialize)] struct Payload { value: i32 }
506    /// # #[derive(Deserialize)] struct Ack { received: bool }
507    /// # fn example(client: &Client) -> Result<(), reqwest::Error> {
508    /// let ack: Ack = client
509    ///     .post("/process")
510    ///     .json(Payload { value: 42 })
511    ///     .send_sync()?;
512    /// # Ok(())
513    /// # }
514    /// ```
515    pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
516        let url = build_url(&self.client.base_url(), &self.endpoint);
517        log::info!("Sending sync {:?} with JSON body to '{}'", self.method, url);
518        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
519            .json(&self.body)
520            .send()?;
521        log::debug!("Response status: {}", resp.status());
522        resp.json::<R>()
523    }
524}
525
526/// A request builder that will append serialisable query parameters to the URL.
527///
528/// Obtained from [`RequestBuilder::query`]. Finalise with [`send`](QueryRequestBuilder::send)
529/// (async) or [`send_sync`](QueryRequestBuilder::send_sync) (sync).
530///
531/// # Example
532/// ```rust,no_run
533/// # use serde::{Deserialize, Serialize};
534/// # #[derive(Serialize)] struct Filters { status: String, limit: u32 }
535/// # #[derive(Deserialize)] struct Item { id: u32, name: String }
536/// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
537/// // Async GET with query params
538/// let items: Vec<Item> = client
539///     .get("/items")
540///     .query(Filters { status: "active".to_string(), limit: 20 })
541///     .send()
542///     .await?;
543///
544/// // Sync variant
545/// let items: Vec<Item> = client
546///     .get("/items")
547///     .query(Filters { status: "active".to_string(), limit: 20 })
548///     .send_sync()?;
549/// # Ok(())
550/// # }
551/// ```
552pub struct QueryRequestBuilder<'a, T> {
553    client: &'a Client,
554    method: HttpMethod,
555    endpoint: String,
556    params: T,
557}
558
559impl<'a, T: Serialize> QueryRequestBuilder<'a, T> {
560    /// Sends the request asynchronously with query parameters and deserialises the response as `R`.
561    ///
562    /// # Example
563    /// ```rust,no_run
564    /// # use serde::{Deserialize, Serialize};
565    /// # #[derive(Serialize)] struct Params { page: u32 }
566    /// # #[derive(Deserialize)] struct Page { items: Vec<String> }
567    /// # async fn example(client: &Client) -> Result<(), reqwest::Error> {
568    /// let page: Page = client
569    ///     .get("/feed")
570    ///     .query(Params { page: 2 })
571    ///     .send()
572    ///     .await?;
573    /// # Ok(())
574    /// # }
575    /// ```
576    pub async fn send<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
577        let url = build_url(&self.client.base_url(), &self.endpoint);
578        log::info!("Sending async {:?} with query params to '{}'", self.method, url);
579        let resp = self.method.apply_async(&self.client.async_client(), &url)
580            .query(&self.params)
581            .send().await?;
582        log::debug!("Response status: {}", resp.status());
583        resp.json::<R>().await
584    }
585
586    /// Sends the request synchronously with query parameters and deserialises the response as `R`.
587    ///
588    /// # Example
589    /// ```rust,no_run
590    /// # use serde::{Deserialize, Serialize};
591    /// # #[derive(Serialize)] struct Params { page: u32 }
592    /// # #[derive(Deserialize)] struct Page { items: Vec<String> }
593    /// # fn example(client: &Client) -> Result<(), reqwest::Error> {
594    /// let page: Page = client
595    ///     .get("/feed")
596    ///     .query(Params { page: 2 })
597    ///     .send_sync()?;
598    /// # Ok(())
599    /// # }
600    /// ```
601    pub fn send_sync<R: DeserializeOwned>(self) -> Result<R, reqwest::Error> {
602        let url = build_url(&self.client.base_url(), &self.endpoint);
603        log::info!("Sending sync {:?} with query params to '{}'", self.method, url);
604        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
605            .query(&self.params)
606            .send()?;
607        log::debug!("Response status: {}", resp.status());
608        resp.json::<R>()
609    }
610}
611
612// ─── ClientError ─────────────────────────────────────────────────────────────
613
614/// Error returned by [`EncryptedBodyRequestBuilder`] and [`EncryptedQueryRequestBuilder`].
615///
616/// Wraps either a transport-level [`reqwest::Error`] or a VEIL cipher failure.
617#[derive(Debug)]
618pub enum ClientError {
619    /// The underlying HTTP transport failed (connection refused, timeout, etc.).
620    Transport(reqwest::Error),
621    /// VEIL sealing or opening failed (wrong key, corrupted bytes, etc.).
622    Serialization(SerializationError),
623}
624
625impl std::fmt::Display for ClientError {
626    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        match self {
628            Self::Transport(e)      => write!(f, "transport error: {e}"),
629            Self::Serialization(e)  => write!(f, "serialization error: {e}"),
630        }
631    }
632}
633
634impl std::error::Error for ClientError {}
635
636impl From<reqwest::Error> for ClientError {
637    fn from(e: reqwest::Error) -> Self { Self::Transport(e) }
638}
639
640impl From<SerializationError> for ClientError {
641    fn from(e: SerializationError) -> Self { Self::Serialization(e) }
642}
643
644// ─── EncryptedBodyRequestBuilder ─────────────────────────────────────────────
645
646/// A request builder that VEIL-seals the body before sending.
647///
648/// Obtained from [`RequestBuilder::encryption`]. Finalise with
649/// [`send`](EncryptedBodyRequestBuilder::send) (async) or
650/// [`send_sync`](EncryptedBodyRequestBuilder::send_sync) (sync).
651///
652/// The expected response is also VEIL-sealed and is opened transparently.
653pub struct EncryptedBodyRequestBuilder<'a, T> {
654    client: &'a Client,
655    method: HttpMethod,
656    endpoint: String,
657    body: T,
658    key: SerializationKey,
659}
660
661impl<'a, T: bincode::Encode> EncryptedBodyRequestBuilder<'a, T> {
662    /// Sends the request asynchronously.
663    ///
664    /// Before the request leaves, the body is VEIL-sealed using the [`SerializationKey`]
665    /// supplied to [`.encryption()`](RequestBuilder::encryption).  The server receives a
666    /// raw `application/octet-stream` payload.  When the response arrives, its bytes are
667    /// VEIL-opened with the same key to produce `R`.  If either sealing or opening fails
668    /// the error is wrapped in [`ClientError::Serialization`].
669    pub async fn send<R>(self) -> Result<R, ClientError>
670    where
671        R: bincode::Decode<()>,
672    {
673        let url = build_url(&self.client.base_url(), &self.endpoint);
674        log::info!("Sending async {:?} with encrypted body to '{}'", self.method, url);
675        let sealed = crate::serialization::seal(&self.body, self.key.veil_key())?;
676        let resp = self.method.apply_async(&self.client.async_client(), &url)
677            .header("content-type", "application/octet-stream")
678            .body(sealed)
679            .send().await?;
680        log::debug!("Response status: {}", resp.status());
681        let bytes = resp.bytes().await?;
682        Ok(crate::serialization::open::<R>(&bytes, self.key.veil_key())?)
683    }
684
685    /// Sends the request synchronously.
686    ///
687    /// The body is VEIL-sealed with the configured [`SerializationKey`] before the wire
688    /// send.  The response bytes, once received, are VEIL-opened with the same key to
689    /// produce `R`.  Any cipher failure is wrapped in [`ClientError::Serialization`].
690    pub fn send_sync<R>(self) -> Result<R, ClientError>
691    where
692        R: bincode::Decode<()>,
693    {
694        let url = build_url(&self.client.base_url(), &self.endpoint);
695        log::info!("Sending sync {:?} with encrypted body to '{}'", self.method, url);
696        let sealed = crate::serialization::seal(&self.body, self.key.veil_key())?;
697        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
698            .header("content-type", "application/octet-stream")
699            .body(sealed)
700            .send()?;
701        log::debug!("Response status: {}", resp.status());
702        let bytes = resp.bytes()?;
703        Ok(crate::serialization::open::<R>(&bytes, self.key.veil_key())?)
704    }
705}
706
707// ─── EncryptedQueryRequestBuilder ────────────────────────────────────────────
708
709/// A request builder that VEIL-seals query params and sends them as `?data=<base64url>`.
710///
711/// Obtained from [`RequestBuilder::encrypted_query`]. Finalise with
712/// [`send`](EncryptedQueryRequestBuilder::send) (async) or
713/// [`send_sync`](EncryptedQueryRequestBuilder::send_sync) (sync).
714///
715/// The expected response is also VEIL-sealed and is opened transparently.
716pub struct EncryptedQueryRequestBuilder<'a, T> {
717    client: &'a Client,
718    method: HttpMethod,
719    endpoint: String,
720    params: T,
721    key: SerializationKey,
722}
723
724impl<'a, T: bincode::Encode> EncryptedQueryRequestBuilder<'a, T> {
725    /// Sends the request asynchronously.
726    ///
727    /// The params are VEIL-sealed with the configured [`SerializationKey`] and
728    /// base64url-encoded, then appended to the URL as `?data=<base64url>`.  When the
729    /// response arrives, its bytes are VEIL-opened with the same key to produce `R`.
730    /// Any cipher failure is wrapped in [`ClientError::Serialization`].
731    pub async fn send<R>(self) -> Result<R, ClientError>
732    where
733        R: bincode::Decode<()>,
734    {
735        let url = build_url(&self.client.base_url(), &self.endpoint);
736        log::info!("Sending async {:?} with encrypted query to '{}'", self.method, url);
737        let sealed = crate::serialization::seal(&self.params, self.key.veil_key())?;
738        let b64 = URL_SAFE_NO_PAD.encode(&sealed);
739        let resp = self.method.apply_async(&self.client.async_client(), &url)
740            .query(&[("data", &b64)])
741            .send().await?;
742        log::debug!("Response status: {}", resp.status());
743        let bytes = resp.bytes().await?;
744        Ok(crate::serialization::open::<R>(&bytes, self.key.veil_key())?)
745    }
746
747    /// Sends the request synchronously.
748    ///
749    /// Same behaviour as [`send`](Self::send) — params are VEIL-sealed and base64url-encoded
750    /// as `?data=<value>`, and the sealed response bytes are VEIL-opened to `R` — but the
751    /// network call blocks the current thread.  Any cipher failure is wrapped in
752    /// [`ClientError::Serialization`].
753    pub fn send_sync<R>(self) -> Result<R, ClientError>
754    where
755        R: bincode::Decode<()>,
756    {
757        let url = build_url(&self.client.base_url(), &self.endpoint);
758        log::info!("Sending sync {:?} with encrypted query to '{}'", self.method, url);
759        let sealed = crate::serialization::seal(&self.params, self.key.veil_key())?;
760        let b64 = URL_SAFE_NO_PAD.encode(&sealed);
761        let resp = self.method.apply_sync(&self.client.sync_client(), &url)
762            .query(&[("data", &b64)])
763            .send()?;
764        log::debug!("Response status: {}", resp.status());
765        let bytes = resp.bytes()?;
766        Ok(crate::serialization::open::<R>(&bytes, self.key.veil_key())?)
767    }
768}