Skip to main content

ferriskey_sdk/
lib.rs

1//! FerrisKey Rust SDK
2//!
3//! ## Design Philosophy
4//!
5//! This SDK is built with a focus on:
6//!
7//! 1. **Zero Custom Macros**: All abstractions use native Rust generics, traits, and type-state
8//!    patterns. No procedural macros are introduced.
9//!
10//! 2. **Type-Driven Design**: Invalid states are unrepresentable at compile time. The builder
11//!    pattern uses phantom types to track configuration state.
12//!
13//! 3. **tower::Service Integration**: The transport layer is built on `tower::Service`, enabling
14//!    seamless composition of middleware (retry, timeout, rate-limiting).
15//!
16//! 4. **Extension Traits**: Functionality is organized via extension traits, allowing opt-in
17//!    features without bloating the core API.
18//!
19//! ## Quick Start
20//!
21//! ```no_run
22//! use ferriskey_sdk::prelude::*;
23//!
24//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
25//! // Configure the SDK
26//! let config = SdkConfig::builder("https://api.ferriskey.com")
27//!     .auth(AuthStrategy::Bearer("your-token".into()))
28//!     .build();
29//!
30//! // Create SDK with transport
31//! let sdk = FerriskeySdk::builder(config).transport(HpxTransport::default()).build();
32//!
33//! // Execute operations
34//! let input = OperationInput::builder().path_param("realm", "master").build();
35//!
36//! let response = sdk.execute_operation("getRealm", input).await?;
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! ## Middleware Composition
42//!
43//! ```no_run
44//! use ferriskey_sdk::{AuthStrategy, FerriskeySdk, HpxTransport, SdkConfig};
45//!
46//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
47//! let config = SdkConfig::builder("https://api.ferriskey.com")
48//!     .auth(AuthStrategy::Bearer("your-token".into()))
49//!     .build();
50//!
51//! let transport = HpxTransport::default();
52//!
53//! let sdk = FerriskeySdk::builder(config).transport(transport).build();
54//! # Ok(())
55//! # }
56//! ```
57
58pub mod cli;
59pub mod client;
60pub mod config;
61/// Build-time contract normalization and registry helpers.
62pub mod contract;
63mod encoding;
64pub mod error;
65pub mod generated;
66pub mod transport;
67
68// Re-export core types for ergonomic imports
69pub use client::{
70    Configured, FerriskeySdk, FerriskeySdkBuilder, OperationCall, OperationInput,
71    OperationInputBuilder, SdkExt, TagClient, Unconfigured,
72};
73pub use config::{AuthStrategy, AuthStrategyExt, BaseUrlSet, SdkConfig, SdkConfigBuilder};
74pub use encoding::DecodedResponse;
75pub use error::{SdkError, TransportError};
76pub use generated::{OPERATION_COUNT, OPERATION_DESCRIPTORS, PATH_COUNT, SCHEMA_COUNT, TAG_NAMES};
77// Re-export tower for middleware composition
78pub use tower;
79pub use transport::{
80    HpxTransport, MethodSet, PathSet, SdkRequest, SdkRequestBuilder, SdkResponse, Transport,
81    TransportExt,
82};
83
84/// A line item in the shopping cart.
85///
86/// ## Immutability
87///
88/// `CartItem` is immutable once created. Use the builder pattern for
89/// complex item construction.
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct CartItem {
92    /// Human-readable item name.
93    pub name: String,
94    /// Price per unit in cents.
95    pub price_cents: u32,
96    /// Selected quantity.
97    pub quantity: u32,
98}
99
100impl CartItem {
101    /// Create a new cart item.
102    #[must_use]
103    pub fn new(name: impl Into<String>, price_cents: u32, quantity: u32) -> Self {
104        Self { name: name.into(), price_cents, quantity }
105    }
106
107    /// Calculate the total price for this line item.
108    #[must_use]
109    pub const fn total_cents(&self) -> u32 {
110        self.price_cents * self.quantity
111    }
112}
113
114/// A shopping cart.
115///
116/// ## Type-State Pattern for Cart Lifecycle
117///
118/// The cart can be extended with type-state markers to enforce valid
119/// transitions (e.g., empty → populated → checked out).
120#[derive(Clone, Debug, Default, Eq, PartialEq)]
121pub struct Cart {
122    /// Items currently in the cart.
123    pub items: Vec<CartItem>,
124}
125
126impl Cart {
127    /// Create an empty cart.
128    #[must_use]
129    pub const fn new() -> Self {
130        Self { items: Vec::new() }
131    }
132
133    /// Add an item to the cart.
134    pub fn add_item(&mut self, item: CartItem) {
135        self.items.push(item);
136    }
137
138    /// Calculate the total cart value in cents.
139    #[must_use]
140    pub fn total_cents(&self) -> u32 {
141        self.items.iter().map(CartItem::total_cents).sum()
142    }
143
144    /// Check if the cart is empty.
145    #[must_use]
146    pub const fn is_empty(&self) -> bool {
147        self.items.is_empty()
148    }
149}
150
151/// An order created from checkout.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct Order {
154    /// Items included in the order.
155    pub items: Vec<CartItem>,
156    /// Total order value in cents.
157    pub total_cents: u32,
158}
159
160impl Order {
161    /// Create a new order from items.
162    #[must_use]
163    pub fn from_items(items: Vec<CartItem>) -> Self {
164        let total_cents = items.iter().map(CartItem::total_cents).sum();
165        Self { items, total_cents }
166    }
167}
168
169/// The result of checking out a cart.
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct CheckoutResult {
172    /// The newly created order.
173    pub order: Order,
174    /// The emptied cart after checkout.
175    pub cart: Cart,
176}
177
178/// Builds a temporary greeting string for the CLI scaffold.
179#[must_use]
180pub fn greeting(name: &str) -> String {
181    format!("Hello, {name}!")
182}
183
184/// Creates an order from the provided items and clears the cart.
185///
186/// ## Functional Style
187///
188/// This function is pure—it takes items and returns a result without
189/// side effects. The cart is not mutated; a new empty cart is returned.
190#[must_use]
191pub fn checkout_cart(items: &[CartItem]) -> CheckoutResult {
192    CheckoutResult { order: Order::from_items(items.to_vec()), cart: Cart::new() }
193}
194
195/// Re-exports commonly used items.
196///
197/// ## Design Decision: Prelude Module
198///
199/// A prelude module provides a single import point for the most commonly
200/// used types, reducing import boilerplate in user code.
201pub mod prelude {
202    pub use crate::{
203        // Config & Auth
204        AuthStrategy,
205        AuthStrategyExt,
206        Cart,
207        CartItem,
208        CheckoutResult,
209        // Client
210        FerriskeySdk,
211        // Transport
212        HpxTransport,
213        OperationInput,
214        OperationInputBuilder,
215        Order,
216        SdkConfig,
217        // Errors
218        SdkError,
219        SdkRequest,
220        SdkResponse,
221        Transport,
222        TransportError,
223        TransportExt,
224        // Functions
225        checkout_cart,
226        greeting,
227    };
228}
229
230#[cfg(test)]
231mod tests {
232    use std::{
233        collections::BTreeMap,
234        future::Future,
235        pin::Pin,
236        sync::{Arc, Mutex},
237    };
238
239    use proptest::prelude::*;
240    use serde::Deserialize;
241    use tower::Service;
242
243    use crate::{
244        AuthStrategy, CartItem, FerriskeySdk, SdkConfig, SdkError, SdkRequest, SdkResponse,
245        TransportError, checkout_cart, greeting,
246    };
247
248    #[derive(Clone, Debug)]
249    struct MockTransport {
250        captured_requests: Arc<Mutex<Vec<SdkRequest>>>,
251        response: SdkResponse,
252    }
253
254    impl MockTransport {
255        fn new(response: SdkResponse) -> Self {
256            Self { captured_requests: Arc::new(Mutex::new(Vec::new())), response }
257        }
258
259        fn captured_requests(&self) -> Vec<SdkRequest> {
260            self.captured_requests
261                .lock()
262                .expect("captured requests mutex should not be poisoned")
263                .clone()
264        }
265    }
266
267    /// Implement tower::Service for MockTransport
268    /// This demonstrates that any Service<SdkRequest> is automatically a Transport
269    impl Service<SdkRequest> for MockTransport {
270        type Response = SdkResponse;
271        type Error = TransportError;
272        type Future = Pin<Box<dyn Future<Output = Result<SdkResponse, TransportError>> + Send>>;
273
274        fn poll_ready(
275            &mut self,
276            _cx: &mut std::task::Context<'_>,
277        ) -> std::task::Poll<Result<(), Self::Error>> {
278            std::task::Poll::Ready(Ok(()))
279        }
280
281        fn call(&mut self, request: SdkRequest) -> Self::Future {
282            let captured_requests = Arc::clone(&self.captured_requests);
283            let response = self.response.clone();
284
285            Box::pin(async move {
286                captured_requests
287                    .lock()
288                    .expect("captured requests mutex should not be poisoned")
289                    .push(request);
290                Ok(response)
291            })
292        }
293    }
294
295    fn successful_response(body: impl Into<Vec<u8>>) -> SdkResponse {
296        SdkResponse { body: body.into(), headers: BTreeMap::new(), status: 200 }
297    }
298
299    fn cart_item_strategy() -> impl Strategy<Value = CartItem> {
300        ("[A-Za-z][A-Za-z0-9 ]{0,15}", 0_u16..10_000, 0_u16..100).prop_map(
301            |(name, price_cents, quantity)| {
302                CartItem::new(name, u32::from(price_cents), u32::from(quantity))
303            },
304        )
305    }
306
307    #[test]
308    fn greeting_builds_message() {
309        assert_eq!(greeting("Rust"), "Hello, Rust!");
310    }
311
312    #[test]
313    fn checkout_cart_creates_an_order_and_clears_the_cart() {
314        let result = checkout_cart(&[CartItem::new("Tea", 450, 2), CartItem::new("Cake", 350, 1)]);
315
316        assert_eq!(result.order.total_cents, 1250);
317        assert!(result.cart.items.is_empty());
318    }
319
320    #[test]
321    fn cart_item_total_calculation() {
322        let item = CartItem::new("Widget", 1000, 3);
323        assert_eq!(item.total_cents(), 3000);
324    }
325
326    #[tokio::test]
327    async fn transport_and_auth_core_injects_bearer_header() {
328        let transport = MockTransport::new(successful_response(br#"{"ok":true}"#.to_vec()));
329        let sdk = FerriskeySdk::new(
330            SdkConfig::new(
331                "https://api.ferriskey.test",
332                AuthStrategy::Bearer("secret-token".to_string()),
333            ),
334            transport.clone(),
335        );
336        let mut request = SdkRequest::new("GET", "/realms/test");
337        request.requires_auth = true;
338
339        let response = sdk.execute(request).await.expect("request should succeed");
340        let captured_requests = transport.captured_requests();
341
342        assert_eq!(response.status, 200);
343        assert_eq!(captured_requests.len(), 1);
344        assert_eq!(
345            captured_requests[0].headers.get("authorization"),
346            Some(&"Bearer secret-token".to_string()),
347        );
348        assert_eq!(captured_requests[0].path, "https://api.ferriskey.test/realms/test");
349    }
350
351    #[tokio::test]
352    async fn transport_and_auth_core_rejects_missing_auth() {
353        let transport = MockTransport::new(successful_response(Vec::new()));
354        let sdk = FerriskeySdk::new(
355            SdkConfig::new("https://api.ferriskey.test", AuthStrategy::None),
356            transport.clone(),
357        );
358        let mut request = SdkRequest::new("GET", "/realms/secured");
359        request.requires_auth = true;
360
361        let error = sdk.execute(request).await.expect_err("missing auth should fail");
362
363        assert!(matches!(error, SdkError::MissingAuth));
364        assert!(transport.captured_requests().is_empty());
365    }
366
367    #[derive(Debug, Deserialize, PartialEq)]
368    struct ExamplePayload {
369        ok: bool,
370    }
371
372    #[tokio::test]
373    async fn transport_and_auth_core_reports_status_body_mismatch() {
374        let transport = MockTransport::new(successful_response(br#"not-json"#.to_vec()));
375        let sdk = FerriskeySdk::new(
376            SdkConfig::new("https://api.ferriskey.test", AuthStrategy::None),
377            transport,
378        );
379        let request = SdkRequest::new("GET", "/realms/test");
380
381        let error = sdk
382            .execute_json::<ExamplePayload>(request, 200)
383            .await
384            .expect_err("invalid JSON should fail decoding");
385
386        assert!(matches!(error, SdkError::Decode(_)));
387    }
388
389    proptest! {
390        #[test]
391        fn checkout_cart_preserves_generated_items(
392            items in proptest::collection::vec(cart_item_strategy(), 0..16),
393        ) {
394            let result = checkout_cart(&items);
395
396            prop_assert_eq!(result.order.items, items);
397            prop_assert!(result.cart.items.is_empty());
398        }
399
400        #[test]
401        fn checkout_cart_total_matches_generated_line_items(
402            items in proptest::collection::vec(cart_item_strategy(), 0..16),
403        ) {
404            let expected_total: u32 = items.iter().map(CartItem::total_cents).sum();
405            let result = checkout_cart(&items);
406
407            prop_assert_eq!(result.order.total_cents, expected_total);
408        }
409    }
410}