wf_market/client/
mod.rs

1//! HTTP client for the warframe.market API.
2//!
3//! This module provides the [`Client`] type which is the main entry point
4//! for interacting with the warframe.market API.
5//!
6//! # Type States
7//!
8//! The client uses a type-state pattern to provide compile-time safety:
9//!
10//! - [`Client<Unauthenticated>`]: Can only access public endpoints
11//! - [`Client<Authenticated>`]: Can access all endpoints including user-specific ones
12//!
13//! # Example
14//!
15//! ```no_run
16//! use wf_market::{Client, Credentials};
17//!
18//! async fn example() -> wf_market::Result<()> {
19//!     // Create an unauthenticated client
20//!     let client = Client::builder().build().await?;
21//!
22//!     // Fetch public data
23//!     let items = client.fetch_items().await?;
24//!
25//!     // Login to access authenticated endpoints
26//!     let creds = Credentials::new("email", "password", Credentials::generate_device_id());
27//!     let client = client.login(creds).await?;
28//!
29//!     // Now we can access user-specific endpoints
30//!     let my_orders = client.my_orders().await?;
31//!
32//!     Ok(())
33//! }
34//! ```
35
36mod auth;
37mod builder;
38
39pub use builder::*;
40
41use std::marker::PhantomData;
42use std::sync::Arc;
43
44use crate::error::{Error, Result};
45use crate::internal::ApiRateLimiter;
46use crate::models::{Credentials, Item, ItemIndex, Language, Platform};
47
48// Sealed trait pattern for auth states
49mod private {
50    pub trait Sealed {}
51    impl Sealed for super::Unauthenticated {}
52    impl Sealed for super::Authenticated {}
53}
54
55/// Marker trait for authentication states.
56pub trait AuthState: private::Sealed {}
57
58/// Unauthenticated state - can only access public endpoints.
59#[derive(Debug, Clone, Copy)]
60pub struct Unauthenticated;
61impl AuthState for Unauthenticated {}
62
63/// Authenticated state - can access all endpoints.
64#[derive(Debug, Clone, Copy)]
65pub struct Authenticated;
66impl AuthState for Authenticated {}
67
68/// Client configuration.
69#[derive(Debug, Clone)]
70pub struct ClientConfig {
71    /// Gaming platform (default: PC)
72    pub platform: Platform,
73    /// Language for responses (default: English)
74    pub language: Language,
75    /// Enable cross-play orders (default: true)
76    pub crossplay: bool,
77    /// Rate limit (requests per second, default: 3)
78    pub rate_limit: u32,
79}
80
81impl Default for ClientConfig {
82    fn default() -> Self {
83        Self {
84            platform: Platform::Pc,
85            language: Language::English,
86            crossplay: true,
87            rate_limit: 3,
88        }
89    }
90}
91
92/// HTTP client for the warframe.market API.
93///
94/// The client is parameterized by an authentication state:
95///
96/// - `Client<Unauthenticated>`: Can only access public endpoints
97/// - `Client<Authenticated>`: Can access all endpoints
98///
99/// Use [`Client::builder()`] to create a new client.
100pub struct Client<S: AuthState = Unauthenticated> {
101    pub(crate) http: reqwest::Client,
102    pub(crate) config: ClientConfig,
103    pub(crate) limiter: Arc<ApiRateLimiter>,
104    pub(crate) credentials: Option<Credentials>,
105    pub(crate) items: Arc<ItemIndex>,
106    pub(crate) _state: PhantomData<S>,
107}
108
109impl Client<Unauthenticated> {
110    /// Create a new client builder.
111    ///
112    /// # Example
113    ///
114    /// ```ignore
115    /// use wf_market::{Client, Platform, Language};
116    ///
117    /// async fn example() -> wf_market::Result<()> {
118    ///     let client = Client::builder()
119    ///         .platform(Platform::Pc)
120    ///         .language(Language::English)
121    ///         .build()
122    ///         .await?;
123    ///     Ok(())
124    /// }
125    /// ```
126    pub fn builder() -> ClientBuilder {
127        ClientBuilder::default()
128    }
129
130    /// Create a client and login in one step.
131    ///
132    /// This is a convenience method equivalent to:
133    /// ```no_run
134    /// # use wf_market::{Client, Credentials};
135    /// # async fn example() -> wf_market::Result<()> {
136    /// # let credentials = Credentials::new("", "", "");
137    /// let client = Client::builder().build().await?.login(credentials).await?;
138    /// # Ok(())
139    /// # }
140    /// ```
141    ///
142    /// # Example
143    ///
144    /// ```no_run
145    /// use wf_market::{Client, Credentials};
146    ///
147    /// async fn example() -> wf_market::Result<()> {
148    ///     let creds = Credentials::new("email", "password", Credentials::generate_device_id());
149    ///     let client = Client::from_credentials(creds).await?;
150    ///     Ok(())
151    /// }
152    /// ```
153    pub async fn from_credentials(credentials: Credentials) -> Result<Client<Authenticated>> {
154        Self::builder().build().await?.login(credentials).await
155    }
156
157    /// Create a client with custom config and login in one step.
158    ///
159    /// # Example
160    ///
161    /// ```no_run
162    /// use wf_market::{Client, ClientConfig, Credentials, Platform};
163    ///
164    /// async fn example() -> wf_market::Result<()> {
165    ///     let config = ClientConfig {
166    ///         platform: Platform::Ps4,
167    ///         ..Default::default()
168    ///     };
169    ///     let creds = Credentials::new("email", "password", Credentials::generate_device_id());
170    ///     let client = Client::from_credentials_with_config(creds, config).await?;
171    ///     Ok(())
172    /// }
173    /// ```
174    pub async fn from_credentials_with_config(
175        credentials: Credentials,
176        config: ClientConfig,
177    ) -> Result<Client<Authenticated>> {
178        Self::builder()
179            .config(config)
180            .build()
181            .await?
182            .login(credentials)
183            .await
184    }
185
186    /// Validate credentials without fully logging in.
187    ///
188    /// This is useful for checking if a saved token is still valid
189    /// before creating a full authenticated client.
190    ///
191    /// # Example
192    ///
193    /// ```no_run
194    /// use wf_market::{Client, Credentials};
195    ///
196    /// async fn example() -> wf_market::Result<()> {
197    ///     let saved = Credentials::from_token("email", "device-id", "saved-token");
198    ///
199    ///     if Client::validate_credentials(&saved).await? {
200    ///         let client = Client::from_credentials(saved).await?;
201    ///         println!("Session restored!");
202    ///     } else {
203    ///         println!("Token expired, please login again");
204    ///     }
205    ///     Ok(())
206    /// }
207    /// ```
208    pub async fn validate_credentials(credentials: &Credentials) -> Result<bool> {
209        use crate::internal::{BASE_URL, build_authenticated_client};
210
211        if let Some(token) = credentials.token() {
212            let http = build_authenticated_client(Platform::Pc, Language::English, true, token)
213                .map_err(Error::Network)?;
214
215            match http.get(format!("{}/me", BASE_URL)).send().await {
216                Ok(resp) if resp.status().is_success() => Ok(true),
217                Ok(resp) if resp.status() == reqwest::StatusCode::UNAUTHORIZED => Ok(false),
218                Ok(resp) => Err(Error::api(
219                    resp.status(),
220                    format!("Unexpected response: {}", resp.status()),
221                )),
222                Err(e) => Err(Error::Network(e)),
223            }
224        } else {
225            // Password-based credentials can't be validated without logging in
226            // Return true since we'll find out during login if they're invalid
227            Ok(true)
228        }
229    }
230
231    /// Create an unauthenticated client (internal use).
232    pub(crate) fn new_unauthenticated(
233        http: reqwest::Client,
234        config: ClientConfig,
235        limiter: Arc<ApiRateLimiter>,
236        items: Arc<ItemIndex>,
237    ) -> Self {
238        Self {
239            http,
240            config,
241            limiter,
242            credentials: None,
243            items,
244            _state: PhantomData,
245        }
246    }
247}
248
249impl<S: AuthState> Client<S> {
250    /// Get the client configuration.
251    pub fn config(&self) -> &ClientConfig {
252        &self.config
253    }
254
255    /// Get the platform this client is configured for.
256    pub fn platform(&self) -> Platform {
257        self.config.platform
258    }
259
260    /// Get the language this client is configured for.
261    pub fn language(&self) -> Language {
262        self.config.language
263    }
264
265    /// Get the item index.
266    ///
267    /// The item index provides O(1) lookups for items by ID or slug.
268    /// It is automatically populated when the client is constructed.
269    ///
270    /// # Example
271    ///
272    /// ```ignore
273    /// let client = Client::builder().build().await?;
274    ///
275    /// // Iterate over all items
276    /// for item in client.items().iter() {
277    ///     println!("{}: {}", item.slug, item.name());
278    /// }
279    ///
280    /// // Lookup by slug
281    /// if let Some(item) = client.items().get_by_slug("serration") {
282    ///     println!("Found: {}", item.name());
283    /// }
284    /// ```
285    pub fn items(&self) -> &ItemIndex {
286        &self.items
287    }
288
289    /// Get an item by its unique ID.
290    ///
291    /// This is a convenience method equivalent to `client.items().get_by_id(id)`.
292    pub fn get_item_by_id(&self, id: &str) -> Option<&Item> {
293        self.items.get_by_id(id)
294    }
295
296    /// Get an item by its URL-friendly slug.
297    ///
298    /// This is a convenience method equivalent to `client.items().get_by_slug(slug)`.
299    pub fn get_item_by_slug(&self, slug: &str) -> Option<&Item> {
300        self.items.get_by_slug(slug)
301    }
302
303    /// Get a shared reference to the item index.
304    ///
305    /// This is useful for passing to functions that need the index.
306    pub(crate) fn items_arc(&self) -> Arc<ItemIndex> {
307        Arc::clone(&self.items)
308    }
309
310    /// Wait for rate limiter before making a request.
311    pub(crate) async fn wait_for_rate_limit(&self) {
312        self.limiter.until_ready().await;
313    }
314}
315
316impl Client<Authenticated> {
317    /// Get the current credentials (with token) for persistence.
318    ///
319    /// The returned credentials can be serialized and stored, then
320    /// used with [`Credentials::from_token()`] to restore the session.
321    ///
322    /// # Example
323    ///
324    /// ```no_run
325    /// use wf_market::{Client, Credentials};
326    ///
327    /// async fn save_session(client: &Client<wf_market::client::Authenticated>) -> std::io::Result<()> {
328    ///     let creds = client.credentials();
329    ///     let json = serde_json::to_string(creds)?;
330    ///     std::fs::write("session.json", json)?;
331    ///     Ok(())
332    /// }
333    /// ```
334    ///
335    /// # Panics
336    ///
337    /// This method uses `expect()` internally but should never panic because
338    /// `Client<Authenticated>` can only be constructed with valid credentials.
339    /// If this panics, it indicates an internal invariant violation.
340    pub fn credentials(&self) -> &Credentials {
341        self.credentials
342            .as_ref()
343            .expect("Authenticated client must have credentials")
344    }
345
346    /// Export credentials for saving (convenience method).
347    ///
348    /// This clones the credentials so they can be serialized and stored.
349    pub fn export_session(&self) -> Credentials {
350        self.credentials().clone()
351    }
352
353    /// Get the authentication token.
354    ///
355    /// # Panics
356    ///
357    /// This method uses `expect()` internally but should never panic because
358    /// `Client<Authenticated>` can only be constructed with valid credentials
359    /// that include a token. If this panics, it indicates an internal invariant violation.
360    pub fn token(&self) -> &str {
361        self.credentials()
362            .token()
363            .expect("Authenticated client must have token")
364    }
365
366    /// Get the device ID.
367    pub fn device_id(&self) -> &str {
368        &self.credentials().device_id
369    }
370
371    /// Create an authenticated client (internal use).
372    pub(crate) fn new_authenticated(
373        http: reqwest::Client,
374        config: ClientConfig,
375        limiter: Arc<ApiRateLimiter>,
376        credentials: Credentials,
377        items: Arc<ItemIndex>,
378    ) -> Self {
379        Self {
380            http,
381            config,
382            limiter,
383            credentials: Some(credentials),
384            items,
385            _state: PhantomData,
386        }
387    }
388}
389
390// Clone implementations
391impl Clone for Client<Unauthenticated> {
392    fn clone(&self) -> Self {
393        Self {
394            http: self.http.clone(),
395            config: self.config.clone(),
396            limiter: self.limiter.clone(),
397            credentials: None,
398            items: self.items.clone(),
399            _state: PhantomData,
400        }
401    }
402}
403
404impl Clone for Client<Authenticated> {
405    fn clone(&self) -> Self {
406        Self {
407            http: self.http.clone(),
408            config: self.config.clone(),
409            limiter: self.limiter.clone(),
410            credentials: self.credentials.clone(),
411            items: self.items.clone(),
412            _state: PhantomData,
413        }
414    }
415}