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}