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}