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