ferriskey_sdk/transport.rs
1//! Transport seam and HTTP request/response types for the FerrisKey SDK.
2//!
3//! ## Design Philosophy
4//!
5//! This module leverages `tower::Service` as the foundational abstraction for transport,
6//! enabling composition of middleware layers (retry, timeout, rate-limiting) without
7//! custom framework abstractions. The `Transport` trait is implemented as a blanket
8//! implementation over any `tower::Service<SdkRequest>`, providing maximum flexibility.
9
10use std::{
11 collections::BTreeMap,
12 future::Future,
13 pin::Pin,
14 task::{Context, Poll},
15};
16
17use tower::{Service, ServiceExt};
18
19use crate::error::TransportError;
20
21/// Canonical SDK request passed to a transport implementation.
22///
23/// ## Type Safety
24///
25/// The builder pattern ensures required fields are set at compile time.
26/// Use [`SdkRequest::builder()`] to construct requests with guaranteed validity.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct SdkRequest {
29 /// Raw request body bytes.
30 pub body: Option<Vec<u8>>,
31 /// Whether the request requires bearer authentication.
32 pub requires_auth: bool,
33 /// Header values to attach to the outgoing request.
34 pub headers: BTreeMap<String, String>,
35 /// HTTP method to execute.
36 pub method: String,
37 /// Absolute or relative path for the request.
38 pub path: String,
39}
40
41impl SdkRequest {
42 /// Create a typed builder for constructing requests.
43 ///
44 /// # Examples
45 ///
46 /// ```
47 /// use ferriskey_sdk::SdkRequest;
48 ///
49 /// let request = SdkRequest::builder("GET", "/api/users")
50 /// .header("accept", "application/json")
51 /// .auth_required(true)
52 /// .build();
53 /// ```
54 #[must_use]
55 pub fn builder(
56 method: impl Into<String>,
57 path: impl Into<String>,
58 ) -> SdkRequestBuilder<MethodSet, PathSet> {
59 SdkRequestBuilder {
60 method: method.into(),
61 path: path.into(),
62 body: None,
63 requires_auth: false,
64 headers: BTreeMap::new(),
65 _state: std::marker::PhantomData,
66 }
67 }
68
69 /// Construct a request with an HTTP method and request path.
70 /// Prefer using [`Self::builder()`] for more complex requests.
71 #[must_use]
72 pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
73 Self {
74 body: None,
75 requires_auth: false,
76 headers: BTreeMap::new(),
77 method: method.into(),
78 path: path.into(),
79 }
80 }
81}
82
83// ---------------------------------------------------------------------------
84// TypeState markers for SdkRequestBuilder
85// ---------------------------------------------------------------------------
86
87/// TypeState marker: HTTP method has been set.
88#[derive(Debug, Clone, Copy)]
89pub struct MethodSet;
90
91/// TypeState marker: path has been set.
92#[derive(Debug, Clone, Copy)]
93pub struct PathSet;
94
95/// Typed builder for [`SdkRequest`] with compile-time field validation.
96///
97/// ## Type-State Pattern
98///
99/// The builder uses phantom type parameters to track which required fields
100/// have been set. This prevents calling `.build()` before all required
101/// fields are provided—caught at compile time, not runtime.
102#[derive(Debug)]
103pub struct SdkRequestBuilder<M, P> {
104 method: String,
105 path: String,
106 body: Option<Vec<u8>>,
107 requires_auth: bool,
108 headers: BTreeMap<String, String>,
109 _state: std::marker::PhantomData<(M, P)>,
110}
111
112impl SdkRequestBuilder<MethodSet, PathSet> {
113 /// Build the request. Available only when both method and path are set.
114 #[must_use]
115 pub fn build(self) -> SdkRequest {
116 SdkRequest {
117 method: self.method,
118 path: self.path,
119 body: self.body,
120 requires_auth: self.requires_auth,
121 headers: self.headers,
122 }
123 }
124}
125
126impl<M, P> SdkRequestBuilder<M, P> {
127 /// Set the request body.
128 #[must_use]
129 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
130 self.body = Some(body.into());
131 self
132 }
133
134 /// Mark the request as requiring authentication.
135 #[must_use]
136 pub const fn auth_required(mut self, required: bool) -> Self {
137 self.requires_auth = required;
138 self
139 }
140
141 /// Add a header to the request.
142 #[must_use]
143 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
144 self.headers.insert(name.into(), value.into());
145 self
146 }
147
148 /// Add multiple headers to the request.
149 #[must_use]
150 pub fn headers(mut self, headers: BTreeMap<String, String>) -> Self {
151 self.headers.extend(headers);
152 self
153 }
154}
155
156/// Canonical SDK response returned by a transport implementation.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct SdkResponse {
159 /// Response body bytes.
160 pub body: Vec<u8>,
161 /// Response headers represented as UTF-8 strings when possible.
162 pub headers: BTreeMap<String, String>,
163 /// HTTP status code.
164 pub status: u16,
165}
166
167/// Transport contract using `tower::Service` as the foundation.
168///
169/// ## Design Decision: Blanket Implementation
170///
171/// Rather than defining a custom `Transport` trait, we implement a blanket
172/// `Transport` impl for any type that implements `Service<SdkRequest>`.
173/// This allows seamless integration with the tower ecosystem:
174/// - `tower::retry::Retry` for automatic retries
175/// - `tower::timeout::Timeout` for request timeouts
176/// - `tower::limit::rate::RateLimit` for rate limiting
177/// - Custom middleware via `tower::Layer`
178///
179/// ## Example: Composing Middleware
180///
181/// ```no_run
182/// use std::time::Duration;
183///
184/// use ferriskey_sdk::HpxTransport;
185/// use tower::ServiceBuilder;
186///
187/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
188/// let transport =
189/// ServiceBuilder::new().timeout(Duration::from_secs(30)).service(HpxTransport::default());
190/// # Ok(())
191/// # }
192/// ```
193pub trait Transport:
194 Service<SdkRequest, Response = SdkResponse, Error = TransportError> + Send + Sync
195{
196}
197
198/// Blanket implementation: any Service<SdkRequest> with the right associated types is a Transport.
199impl<T> Transport for T where
200 T: Service<SdkRequest, Response = SdkResponse, Error = TransportError> + Send + Sync
201{
202}
203
204/// Extension trait providing convenience methods for Transport implementors.
205///
206/// ## Why an Extension Trait?
207///
208/// Extension traits allow adding methods to all `Transport` implementors
209/// without modifying the core trait. This follows the Open/Closed Principle
210/// and avoids trait method bloat.
211pub trait TransportExt: Transport {
212 /// Execute a request and return the response, consuming `self` for one-shot use.
213 ///
214 /// This is a convenience wrapper around `tower::ServiceExt::oneshot`.
215 fn execute(
216 &mut self,
217 request: SdkRequest,
218 ) -> impl Future<Output = Result<SdkResponse, TransportError>> + Send;
219}
220
221impl<T> TransportExt for T
222where
223 T: Transport + Clone,
224 <T as Service<SdkRequest>>::Future: Send,
225{
226 async fn execute(&mut self, request: SdkRequest) -> Result<SdkResponse, TransportError> {
227 // Use tower's oneshot for clean single-request execution
228 let transport = self.clone();
229 transport.oneshot(request).await
230 }
231}
232
233/// Primary HTTP transport adapter backed by `hpx`.
234///
235/// ## Usage
236///
237/// `HpxTransport` implements `tower::Service` directly, making it composable
238/// with any tower middleware layer.
239#[derive(Clone)]
240pub struct HpxTransport {
241 client: hpx::Client,
242}
243
244impl std::fmt::Debug for HpxTransport {
245 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 formatter.debug_struct("HpxTransport").finish_non_exhaustive()
247 }
248}
249
250impl Default for HpxTransport {
251 fn default() -> Self {
252 Self::new(hpx::Client::new())
253 }
254}
255
256impl HpxTransport {
257 /// Build a transport from an `hpx` client instance.
258 #[must_use]
259 pub const fn new(client: hpx::Client) -> Self {
260 Self { client }
261 }
262}
263
264/// Implement `tower::Service` for `HpxTransport`.
265///
266/// This makes HpxTransport composable with any tower middleware.
267impl Service<SdkRequest> for HpxTransport {
268 type Response = SdkResponse;
269 type Error = TransportError;
270 type Future = Pin<Box<dyn Future<Output = Result<SdkResponse, TransportError>> + Send>>;
271
272 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
273 // hpx::Client is always ready
274 Poll::Ready(Ok(()))
275 }
276
277 fn call(&mut self, request: SdkRequest) -> Self::Future {
278 let client = self.client.clone();
279 Box::pin(async move {
280 let method = hpx::Method::from_bytes(request.method.as_bytes())
281 .map_err(|_| TransportError::InvalidMethod { method: request.method.clone() })?;
282 let mut builder = client.request(method, request.path);
283
284 for (name, value) in request.headers {
285 builder = builder.header(name, value);
286 }
287
288 if let Some(body) = request.body {
289 builder = builder.body(body);
290 }
291
292 let response = builder.send().await?;
293 let status = response.status().as_u16();
294 let headers = response
295 .headers()
296 .iter()
297 .filter_map(|(name, value)| {
298 value.to_str().ok().map(|value| (name.to_string(), value.to_string()))
299 })
300 .collect::<BTreeMap<_, _>>();
301 let body = response.bytes().await?.to_vec();
302
303 Ok(SdkResponse { body, headers, status })
304 })
305 }
306}