Skip to main content

ferriskey_sdk/
client.rs

1//! SDK client entrypoint and request preparation helpers.
2//!
3//! ## Design Philosophy
4//!
5//! This module implements a TypeState pattern for the SDK builder, ensuring
6//! that the transport layer is always configured before the SDK can be used.
7//! The `FerriskeySdk<T>` type is parameterized by the transport, making
8//! invalid states unrepresentable at compile time.
9//!
10//! ## Architecture
11//!
12//! ```text
13//! FerriskeySdkBuilder<Unconfigured>
14//!     │
15//!     ▼ .transport(transport)
16//! FerriskeySdkBuilder<Configured<T>>
17//!     │
18//!     ▼ .build()
19//! FerriskeySdk<T>
20//! ```
21//!
22//! ## tower::Service Integration
23//!
24//! The SDK accepts any `Transport` (which is a blanket impl over
25//! `tower::Service<SdkRequest>`), enabling middleware composition:
26//!
27//! ```no_run
28//! use ferriskey_sdk::{AuthStrategy, FerriskeySdk, HpxTransport, SdkConfig};
29//!
30//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
31//! let config = SdkConfig::new("https://api.example.com", AuthStrategy::None);
32//! let transport = HpxTransport::default();
33//!
34//! let sdk = FerriskeySdk::builder(config).transport(transport).build();
35//! # Ok(())
36//! # }
37//! ```
38
39use std::{collections::BTreeMap, future::Future, marker::PhantomData, pin::Pin};
40
41use serde::de::DeserializeOwned;
42use tower::{Service, ServiceExt};
43
44use crate::{
45    config::{AuthStrategy, SdkConfig},
46    encoding::{DecodedResponse, decode_response, encode_request},
47    error::SdkError,
48    generated::{self, GeneratedOperationDescriptor},
49    transport::{SdkRequest, SdkResponse, Transport},
50};
51
52// ---------------------------------------------------------------------------
53// TypeState markers for FerriskeySdkBuilder
54// ---------------------------------------------------------------------------
55
56/// TypeState: transport has not been configured yet.
57#[derive(Debug, Clone, Copy)]
58pub struct Unconfigured;
59
60/// TypeState: transport has been configured.
61#[derive(Debug, Clone, Copy)]
62pub struct Configured<T>(PhantomData<T>);
63
64// ---------------------------------------------------------------------------
65// OperationInput - Typed request input
66// ---------------------------------------------------------------------------
67
68/// Caller-provided request input for a generated FerrisKey operation.
69///
70/// ## Fluent Builder
71///
72/// Use [`OperationInput::builder()`] for a fluent API:
73///
74/// ```
75/// use ferriskey_sdk::OperationInput;
76///
77/// let input = OperationInput::builder()
78///     .path_param("id", "123")
79///     .query_param("filter", vec!["active".to_string()])
80///     .header("x-request-id", "abc")
81///     .body(br#"{"name": "test"}"#)
82///     .build();
83/// ```
84#[derive(Clone, Debug, Default, Eq, PartialEq)]
85pub struct OperationInput {
86    /// Optional raw request body.
87    pub body: Option<Vec<u8>>,
88    /// Additional headers to apply to the generated request.
89    pub headers: BTreeMap<String, String>,
90    /// Path parameters keyed by their template name.
91    pub path_params: BTreeMap<String, String>,
92    /// Query parameters keyed by name and preserving repeated values.
93    pub query_params: BTreeMap<String, Vec<String>>,
94}
95
96impl OperationInput {
97    /// Create a new empty operation input.
98    #[must_use]
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Create a fluent builder for operation input.
104    #[must_use]
105    pub fn builder() -> OperationInputBuilder {
106        OperationInputBuilder::default()
107    }
108}
109
110/// Fluent builder for [`OperationInput`].
111#[derive(Debug, Default)]
112pub struct OperationInputBuilder {
113    body: Option<Vec<u8>>,
114    headers: BTreeMap<String, String>,
115    path_params: BTreeMap<String, String>,
116    query_params: BTreeMap<String, Vec<String>>,
117}
118
119impl OperationInputBuilder {
120    /// Set the request body.
121    #[must_use]
122    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
123        self.body = Some(body.into());
124        self
125    }
126
127    /// Add a header.
128    #[must_use]
129    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
130        self.headers.insert(name.into(), value.into());
131        self
132    }
133
134    /// Add a path parameter.
135    #[must_use]
136    pub fn path_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
137        self.path_params.insert(name.into(), value.into());
138        self
139    }
140
141    /// Add a query parameter with a single value.
142    #[must_use]
143    pub fn query_param_single(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
144        self.query_params.insert(name.into(), vec![value.into()]);
145        self
146    }
147
148    /// Add a query parameter with multiple values.
149    #[must_use]
150    pub fn query_param(mut self, name: impl Into<String>, values: Vec<String>) -> Self {
151        self.query_params.insert(name.into(), values);
152        self
153    }
154
155    /// Build the operation input.
156    #[must_use]
157    pub fn build(self) -> OperationInput {
158        OperationInput {
159            body: self.body,
160            headers: self.headers,
161            path_params: self.path_params,
162            query_params: self.query_params,
163        }
164    }
165}
166
167// ---------------------------------------------------------------------------
168// OperationCall - Bound operation
169// ---------------------------------------------------------------------------
170
171/// Generated operation entrypoint bound to a specific SDK instance.
172///
173/// ## Associated Types
174///
175/// The transport type `T` flows through the entire call chain, ensuring
176/// type safety from SDK construction through request execution.
177#[derive(Clone, Copy, Debug)]
178pub struct OperationCall<'sdk, T: Transport + Clone> {
179    descriptor: &'static GeneratedOperationDescriptor,
180    sdk: &'sdk FerriskeySdk<T>,
181}
182
183impl<T: Transport + Clone> OperationCall<'_, T> {
184    /// Access the generated descriptor for this operation.
185    #[must_use]
186    pub const fn descriptor(&self) -> &'static GeneratedOperationDescriptor {
187        self.descriptor
188    }
189
190    /// Build a canonical SDK request for this generated operation.
191    pub fn to_request(&self, input: OperationInput) -> Result<SdkRequest, SdkError> {
192        encode_request(self.descriptor, input)
193    }
194
195    /// Execute this operation through the SDK transport.
196    pub fn execute(
197        &self,
198        input: OperationInput,
199    ) -> Pin<Box<dyn Future<Output = Result<SdkResponse, SdkError>> + Send + '_>>
200    where
201        <T as Service<SdkRequest>>::Future: Send,
202    {
203        Box::pin(async move {
204            let request = self.to_request(input)?;
205            self.sdk.execute(request).await
206        })
207    }
208
209    /// Execute this operation and decode the documented response payload.
210    pub fn execute_decoded(
211        &self,
212        input: OperationInput,
213    ) -> Pin<Box<dyn Future<Output = Result<DecodedResponse, SdkError>> + Send + '_>>
214    where
215        <T as Service<SdkRequest>>::Future: Send,
216    {
217        Box::pin(async move {
218            let response = self.execute(input).await?;
219            decode_response(self.descriptor, response)
220        })
221    }
222}
223
224// ---------------------------------------------------------------------------
225// TagClient - Tag-scoped view
226// ---------------------------------------------------------------------------
227
228/// Tag-scoped SDK view over the generated operation registry.
229///
230/// ## Extension Trait Pattern
231///
232/// Tag-specific convenience methods can be added via extension traits
233/// without modifying the core `TagClient` type.
234#[derive(Clone, Copy, Debug)]
235pub struct TagClient<'sdk, T: Transport + Clone> {
236    sdk: &'sdk FerriskeySdk<T>,
237    tag: &'static str,
238}
239
240impl<T: Transport + Clone> TagClient<'_, T> {
241    /// Access the tag name associated with this client.
242    #[must_use]
243    pub const fn tag(&self) -> &'static str {
244        self.tag
245    }
246
247    /// Iterate over the generated descriptors assigned to this tag.
248    pub fn descriptors(&self) -> impl Iterator<Item = &'static GeneratedOperationDescriptor> + '_ {
249        generated::OPERATION_DESCRIPTORS.iter().filter(move |descriptor| descriptor.tag == self.tag)
250    }
251
252    /// Resolve an operation within this tag-scoped view.
253    #[must_use]
254    pub fn operation(&self, operation_id: &str) -> Option<OperationCall<'_, T>> {
255        self.descriptors()
256            .find(|descriptor| descriptor.operation_id == operation_id)
257            .map(|descriptor| OperationCall { descriptor, sdk: self.sdk })
258    }
259}
260
261// ---------------------------------------------------------------------------
262// FerriskeySdk - Main SDK type
263// ---------------------------------------------------------------------------
264
265/// FerrisKey SDK entrypoint parameterized by a transport implementation.
266///
267/// ## Type-Driven Design
268///
269/// The generic parameter `T: Transport` ensures that:
270/// 1. The transport type is known at compile time
271/// 2. Invalid transport configurations are caught before runtime
272/// 3. The compiler can optimize based on the concrete transport type
273///
274/// ## Builder Pattern
275///
276/// Use [`FerriskeySdk::builder()`] for a fluent, type-safe construction:
277///
278/// ```no_run
279/// use ferriskey_sdk::{AuthStrategy, FerriskeySdk, HpxTransport, SdkConfig};
280///
281/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
282/// let config = SdkConfig::new("https://api.example.com", AuthStrategy::None);
283/// let sdk = FerriskeySdk::builder(config).transport(HpxTransport::default()).build();
284/// # Ok(())
285/// # }
286/// ```
287#[derive(Clone, Debug)]
288pub struct FerriskeySdk<T: Transport + Clone> {
289    config: SdkConfig,
290    transport: T,
291}
292
293impl<T: Transport + Clone> FerriskeySdk<T> {
294    /// Construct a new SDK instance directly.
295    ///
296    /// Prefer using [`Self::builder()`] for a more fluent API.
297    #[must_use]
298    pub const fn new(config: SdkConfig, transport: T) -> Self {
299        Self { config, transport }
300    }
301
302    /// Create a typed builder with the required configuration.
303    ///
304    /// The builder ensures the transport is set before calling `.build()`.
305    #[must_use]
306    pub const fn builder(config: SdkConfig) -> FerriskeySdkBuilder<T, Unconfigured> {
307        FerriskeySdkBuilder { config, transport: None, _state: PhantomData }
308    }
309
310    /// Access the SDK configuration.
311    #[must_use]
312    pub const fn config(&self) -> &SdkConfig {
313        &self.config
314    }
315
316    /// Access the underlying transport.
317    #[must_use]
318    pub const fn transport(&self) -> &T {
319        &self.transport
320    }
321
322    /// Access the full generated operation registry.
323    #[must_use]
324    pub const fn operations(&self) -> &'static [GeneratedOperationDescriptor] {
325        generated::OPERATION_DESCRIPTORS
326    }
327
328    /// Access a tag-scoped SDK view.
329    #[must_use]
330    pub const fn tag(&self, tag: &'static str) -> TagClient<'_, T> {
331        TagClient { sdk: self, tag }
332    }
333
334    /// Resolve a generated operation by its operation ID.
335    #[must_use]
336    pub fn operation(&self, operation_id: &str) -> Option<OperationCall<'_, T>> {
337        generated::OPERATION_DESCRIPTORS
338            .iter()
339            .find(|descriptor| descriptor.operation_id == operation_id)
340            .map(|descriptor| OperationCall { descriptor, sdk: self })
341    }
342
343    /// Execute a generated operation through the canonical SDK request path.
344    pub fn execute_operation(
345        &self,
346        operation_id: &str,
347        input: OperationInput,
348    ) -> Pin<Box<dyn Future<Output = Result<SdkResponse, SdkError>> + Send + '_>>
349    where
350        <T as Service<SdkRequest>>::Future: Send,
351    {
352        let resolved_operation = self.operation(operation_id);
353        let requested_operation_id = operation_id.to_string();
354
355        Box::pin(async move {
356            let Some(operation) = resolved_operation else {
357                return Err(SdkError::UnknownOperation { operation_id: requested_operation_id });
358            };
359
360            operation.execute(input).await
361        })
362    }
363
364    /// Prepare a request by resolving its URL and applying auth.
365    ///
366    /// ## Design Decision: Result Type
367    ///
368    /// Returns `Result<SdkRequest, SdkError>` rather than panicking,
369    /// enabling callers to handle configuration errors gracefully.
370    pub fn prepare_request(&self, mut request: SdkRequest) -> Result<SdkRequest, SdkError> {
371        request.path = resolve_url(self.config.base_url(), &request.path)?;
372
373        if request.requires_auth {
374            match self.config.auth() {
375                AuthStrategy::Bearer(token) => {
376                    request.headers.insert("authorization".to_string(), format!("Bearer {token}"));
377                }
378                AuthStrategy::None => return Err(SdkError::MissingAuth),
379            }
380        }
381
382        Ok(request)
383    }
384
385    /// Execute a request through the configured transport.
386    ///
387    /// Uses `tower::ServiceExt::oneshot` for clean single-request execution.
388    pub fn execute(
389        &self,
390        request: SdkRequest,
391    ) -> Pin<Box<dyn Future<Output = Result<SdkResponse, SdkError>> + Send + '_>>
392    where
393        <T as Service<SdkRequest>>::Future: Send,
394    {
395        // We need to clone transport for the async block since oneshot consumes self
396        let transport = self.transport.clone();
397
398        Box::pin(async move {
399            let prepared_request = self.prepare_request(request)?;
400
401            // Use tower's oneshot for clean execution
402            transport.oneshot(prepared_request).await.map_err(SdkError::Transport)
403        })
404    }
405
406    /// Execute a request and decode a JSON response for the expected status.
407    pub fn execute_json<Output>(
408        &self,
409        request: SdkRequest,
410        expected_status: u16,
411    ) -> Pin<Box<dyn Future<Output = Result<Output, SdkError>> + Send + '_>>
412    where
413        Output: DeserializeOwned + Send + 'static,
414        <T as Service<SdkRequest>>::Future: Send,
415    {
416        Box::pin(async move {
417            let response = self.execute(request).await?;
418
419            if response.status != expected_status {
420                return Err(SdkError::UnexpectedStatus {
421                    expected: expected_status,
422                    actual: response.status,
423                });
424            }
425
426            serde_json::from_slice(&response.body).map_err(SdkError::Decode)
427        })
428    }
429}
430
431// ---------------------------------------------------------------------------
432// FerriskeySdkBuilder - Type-safe builder
433// ---------------------------------------------------------------------------
434
435/// Typed builder for [`FerriskeySdk`] with compile-time validation.
436///
437/// ## Type-State Pattern
438///
439/// The builder uses phantom type parameters to track whether the transport
440/// has been configured. Calling `.build()` before setting the transport
441/// is a compile-time error.
442#[derive(Debug)]
443pub struct FerriskeySdkBuilder<T: Transport + Clone, S> {
444    config: SdkConfig,
445    transport: Option<T>,
446    _state: PhantomData<S>,
447}
448
449impl<T: Transport + Clone> FerriskeySdkBuilder<T, Unconfigured> {
450    /// Set the transport. Transitions to `Configured` state.
451    #[must_use]
452    pub fn transport(mut self, transport: T) -> FerriskeySdkBuilder<T, Configured<T>> {
453        self.transport = Some(transport);
454        FerriskeySdkBuilder { config: self.config, transport: self.transport, _state: PhantomData }
455    }
456}
457
458impl<T: Transport + Clone> FerriskeySdkBuilder<T, Configured<T>> {
459    /// Build the SDK instance. Available only when transport is configured.
460    ///
461    /// # Panics
462    ///
463    /// Panics if the transport was somehow not set (should be impossible
464    /// due to type-state guarantees).
465    #[must_use]
466    #[expect(clippy::expect_used)]
467    pub fn build(self) -> FerriskeySdk<T> {
468        FerriskeySdk {
469            config: self.config,
470            transport: self.transport.expect("transport must be set in Configured state"),
471        }
472    }
473}
474
475// ---------------------------------------------------------------------------
476// Extension traits for fluent API
477// ---------------------------------------------------------------------------
478
479/// Extension trait for convenient SDK construction.
480pub trait SdkExt: Sized {
481    /// The transport type for this SDK.
482    type Transport: Transport + Clone;
483
484    /// Create an SDK with a fluent one-liner.
485    ///
486    /// ```no_run
487    /// use ferriskey_sdk::{AuthStrategy, FerriskeySdk, HpxTransport, SdkConfig, SdkExt};
488    ///
489    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
490    /// let config = SdkConfig::new("https://api.example.com", AuthStrategy::None);
491    /// let sdk = FerriskeySdk::with_transport(config, HpxTransport::default());
492    /// # Ok(())
493    /// # }
494    /// ```
495    fn with_transport(
496        config: SdkConfig,
497        transport: Self::Transport,
498    ) -> FerriskeySdk<Self::Transport>;
499}
500
501impl<T: Transport + Clone> SdkExt for FerriskeySdk<T> {
502    type Transport = T;
503
504    fn with_transport(config: SdkConfig, transport: T) -> Self {
505        Self::new(config, transport)
506    }
507}
508
509// ---------------------------------------------------------------------------
510// URL resolution
511// ---------------------------------------------------------------------------
512
513/// Resolve a URL from base and path components.
514fn resolve_url(base_url: &str, path: &str) -> Result<String, SdkError> {
515    if path.starts_with("http://") || path.starts_with("https://") {
516        return Ok(path.to_string());
517    }
518
519    let trimmed_base = base_url.trim_end_matches('/');
520    let trimmed_path = path.trim_start_matches('/');
521
522    if trimmed_base.is_empty() || trimmed_path.is_empty() {
523        return Err(SdkError::InvalidUrl { base_url: base_url.to_string(), path: path.to_string() });
524    }
525
526    Ok(format!("{trimmed_base}/{trimmed_path}"))
527}