Skip to main content

discogs_rs/
client.rs

1use crate::auth::{Auth, AuthLevel, OutputFormat};
2use crate::endpoints::{
3    collection::CollectionApi, database::DatabaseApi, inventory::InventoryApi,
4    marketplace::MarketplaceApi, user::UserApi, user_list::ListApi, wantlist::WantlistApi,
5};
6use crate::error::{DiscogsError, Result};
7use crate::models::{AboutResponse, ApiResponse, Identity, RateLimit};
8use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
9use reqwest::{Method, Response, StatusCode};
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12use std::sync::Arc;
13use std::time::Duration;
14
15#[derive(Debug, Clone)]
16pub struct RetryConfig {
17    pub max_retries: u32,
18    pub base_delay: Duration,
19    pub backoff_factor: f64,
20}
21
22impl Default for RetryConfig {
23    fn default() -> Self {
24        Self {
25            max_retries: 0,
26            base_delay: Duration::from_millis(2_000),
27            backoff_factor: 2.7,
28        }
29    }
30}
31
32#[derive(Debug, Clone)]
33struct ClientConfig {
34    base_url: String,
35    user_agent: String,
36    output_format: OutputFormat,
37    auth: Auth,
38    retry: RetryConfig,
39}
40
41#[derive(Clone)]
42pub struct DiscogsClient {
43    config: Arc<ClientConfig>,
44    http: reqwest::Client,
45}
46
47pub struct DiscogsClientBuilder {
48    config: ClientConfig,
49    timeout: Duration,
50}
51
52impl DiscogsClientBuilder {
53    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
54        self.config.base_url = base_url.into();
55        self
56    }
57
58    pub fn auth(mut self, auth: Auth) -> Self {
59        self.config.auth = auth;
60        self
61    }
62
63    pub fn user_token(mut self, token: impl Into<String>) -> Self {
64        self.config.auth = Auth::UserToken {
65            token: token.into(),
66        };
67        self
68    }
69
70    pub fn output_format(mut self, output_format: OutputFormat) -> Self {
71        self.config.output_format = output_format;
72        self
73    }
74
75    pub fn retry(mut self, retry: RetryConfig) -> Self {
76        self.config.retry = retry;
77        self
78    }
79
80    pub fn timeout(mut self, timeout: Duration) -> Self {
81        self.timeout = timeout;
82        self
83    }
84
85    pub fn build(self) -> Result<DiscogsClient> {
86        let http = reqwest::Client::builder().timeout(self.timeout).build()?;
87        Ok(DiscogsClient {
88            config: Arc::new(self.config),
89            http,
90        })
91    }
92}
93
94impl DiscogsClient {
95    pub fn builder(user_agent: impl Into<String>) -> DiscogsClientBuilder {
96        DiscogsClientBuilder {
97            config: ClientConfig {
98                base_url: "https://api.discogs.com".to_string(),
99                user_agent: user_agent.into(),
100                output_format: OutputFormat::Discogs,
101                auth: Auth::None,
102                retry: RetryConfig::default(),
103            },
104            timeout: Duration::from_secs(30),
105        }
106    }
107
108    pub fn with_default_user_agent() -> DiscogsClientBuilder {
109        let ua = format!(
110            "discogs-rs/{} +https://github.com/your-org/discogs-rs",
111            env!("CARGO_PKG_VERSION")
112        );
113        Self::builder(ua)
114    }
115
116    pub fn with_user_token(
117        user_agent: impl Into<String>,
118        token: impl Into<String>,
119    ) -> Result<DiscogsClient> {
120        Self::builder(user_agent).user_token(token).build()
121    }
122
123    pub fn with_default_user_agent_and_user_token(
124        token: impl Into<String>,
125    ) -> Result<DiscogsClient> {
126        Self::with_default_user_agent().user_token(token).build()
127    }
128
129    pub fn auth_level(&self) -> AuthLevel {
130        self.config.auth.level()
131    }
132
133    pub async fn about(&self) -> Result<ApiResponse<AboutResponse>> {
134        self.request_json::<AboutResponse, (), ()>(Method::GET, "/", None, None, AuthLevel::None)
135            .await
136    }
137
138    pub async fn get_identity(&self) -> Result<ApiResponse<Identity>> {
139        self.request_json::<Identity, (), ()>(
140            Method::GET,
141            "/oauth/identity",
142            None,
143            None,
144            AuthLevel::User,
145        )
146        .await
147    }
148
149    pub fn database(&self) -> DatabaseApi<'_> {
150        DatabaseApi::new(self)
151    }
152
153    pub fn marketplace(&self) -> MarketplaceApi<'_> {
154        MarketplaceApi::new(self)
155    }
156
157    pub fn inventory(&self) -> InventoryApi<'_> {
158        InventoryApi::new(self)
159    }
160
161    pub fn user(&self) -> UserApi<'_> {
162        UserApi::new(self)
163    }
164
165    pub fn collection(&self) -> CollectionApi<'_> {
166        CollectionApi::new(self)
167    }
168
169    pub fn wantlist(&self) -> WantlistApi<'_> {
170        WantlistApi::new(self)
171    }
172
173    pub fn list(&self) -> ListApi<'_> {
174        ListApi::new(self)
175    }
176
177    pub(crate) async fn request_json<T, Q, B>(
178        &self,
179        method: Method,
180        path: &str,
181        query: Option<&Q>,
182        body: Option<&B>,
183        required_auth: AuthLevel,
184    ) -> Result<ApiResponse<T>>
185    where
186        T: DeserializeOwned,
187        Q: Serialize + ?Sized,
188        B: Serialize + ?Sized,
189    {
190        let response = self
191            .send_with_retry(method, path, query, body, required_auth)
192            .await?;
193
194        let rate_limit = parse_rate_limit(&response);
195        let data = response.json::<T>().await?;
196        Ok(ApiResponse { data, rate_limit })
197    }
198
199    pub(crate) async fn request_empty<Q, B>(
200        &self,
201        method: Method,
202        path: &str,
203        query: Option<&Q>,
204        body: Option<&B>,
205        required_auth: AuthLevel,
206    ) -> Result<ApiResponse<()>>
207    where
208        Q: Serialize + ?Sized,
209        B: Serialize + ?Sized,
210    {
211        let response = self
212            .send_with_retry(method, path, query, body, required_auth)
213            .await?;
214
215        let rate_limit = parse_rate_limit(&response);
216        Ok(ApiResponse {
217            data: (),
218            rate_limit,
219        })
220    }
221
222    pub(crate) async fn request_bytes<Q, B>(
223        &self,
224        method: Method,
225        path: &str,
226        query: Option<&Q>,
227        body: Option<&B>,
228        required_auth: AuthLevel,
229    ) -> Result<ApiResponse<bytes::Bytes>>
230    where
231        Q: Serialize + ?Sized,
232        B: Serialize + ?Sized,
233    {
234        let response = self
235            .send_with_retry(method, path, query, body, required_auth)
236            .await?;
237
238        let rate_limit = parse_rate_limit(&response);
239        let data = response.bytes().await?;
240        Ok(ApiResponse { data, rate_limit })
241    }
242
243    async fn send_with_retry<Q, B>(
244        &self,
245        method: Method,
246        path: &str,
247        query: Option<&Q>,
248        body: Option<&B>,
249        required_auth: AuthLevel,
250    ) -> Result<Response>
251    where
252        Q: Serialize + ?Sized,
253        B: Serialize + ?Sized,
254    {
255        // Enforce auth level before sending any request to avoid unnecessary network round trips.
256        self.ensure_auth(required_auth)?;
257
258        let mut attempt: u32 = 0;
259        loop {
260            let mut request = self
261                .http
262                .request(method.clone(), self.absolute_url(path))
263                .header(USER_AGENT, &self.config.user_agent)
264                .header(ACCEPT, self.config.output_format.accept_header_value());
265
266            if let Some(auth_header) = self.config.auth.authorization_header() {
267                request = request.header(AUTHORIZATION, auth_header);
268            }
269
270            if let Some(query) = query {
271                request = request.query(query);
272            }
273            if let Some(body) = body {
274                request = request.json(body);
275            }
276
277            let response = request.send().await?;
278            let status = response.status();
279
280            // Discogs can return 429 under burst traffic; retry with configurable exponential backoff.
281            if status == StatusCode::TOO_MANY_REQUESTS && attempt < self.config.retry.max_retries {
282                let delay = retry_delay(&self.config.retry, attempt);
283                tokio::time::sleep(delay).await;
284                attempt += 1;
285                continue;
286            }
287
288            if status.is_success() {
289                return Ok(response);
290            }
291
292            return Err(http_error(response).await);
293        }
294    }
295
296    fn ensure_auth(&self, required: AuthLevel) -> Result<()> {
297        let current = self.config.auth.level();
298        if current < required {
299            return Err(DiscogsError::AuthRequired { required, current });
300        }
301        Ok(())
302    }
303
304    fn absolute_url(&self, path: &str) -> String {
305        if path.starts_with("http://") || path.starts_with("https://") {
306            return path.to_string();
307        }
308
309        let base = self.config.base_url.trim_end_matches('/');
310        let path = if path.starts_with('/') {
311            path.to_string()
312        } else {
313            format!("/{path}")
314        };
315        format!("{base}{path}")
316    }
317}
318
319fn retry_delay(config: &RetryConfig, attempt: u32) -> Duration {
320    let base_ms = config.base_delay.as_millis() as f64;
321    let factor = config.backoff_factor.powi(attempt as i32);
322    let delay_ms = (base_ms * factor).round() as u64;
323    Duration::from_millis(delay_ms.max(1))
324}
325
326fn parse_rate_limit(response: &Response) -> Option<RateLimit> {
327    let headers = response.headers();
328    // Rate-limit headers may be absent on some responses, so parsing is intentionally optional.
329    let limit = headers
330        .get("x-discogs-ratelimit")?
331        .to_str()
332        .ok()?
333        .parse()
334        .ok()?;
335    let used = headers
336        .get("x-discogs-ratelimit-used")?
337        .to_str()
338        .ok()?
339        .parse()
340        .ok()?;
341    let remaining = headers
342        .get("x-discogs-ratelimit-remaining")?
343        .to_str()
344        .ok()?
345        .parse()
346        .ok()?;
347
348    Some(RateLimit {
349        limit,
350        used,
351        remaining,
352    })
353}
354
355async fn http_error(response: Response) -> DiscogsError {
356    let status = response.status();
357    let message = match response.json::<serde_json::Value>().await {
358        Ok(json) => json
359            .get("message")
360            .and_then(|value| value.as_str())
361            .map(ToOwned::to_owned)
362            .unwrap_or_else(|| json.to_string()),
363        Err(_) => "unknown error".to_string(),
364    };
365
366    DiscogsError::Http { status, message }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::{DiscogsClient, RetryConfig, retry_delay};
372    use crate::auth::AuthLevel;
373    use std::time::Duration;
374
375    #[test]
376    fn retry_delay_grows_exponentially() {
377        let config = RetryConfig {
378            max_retries: 3,
379            base_delay: Duration::from_millis(100),
380            backoff_factor: 2.0,
381        };
382
383        assert_eq!(retry_delay(&config, 0), Duration::from_millis(100));
384        assert_eq!(retry_delay(&config, 1), Duration::from_millis(200));
385        assert_eq!(retry_delay(&config, 2), Duration::from_millis(400));
386    }
387
388    #[test]
389    fn builder_user_token_sets_user_auth_level() {
390        let client = DiscogsClient::builder("test-agent")
391            .user_token("user-token")
392            .build()
393            .expect("build client");
394
395        assert_eq!(client.auth_level(), AuthLevel::User);
396    }
397
398    #[test]
399    fn convenience_user_token_constructor_sets_user_auth_level() {
400        let client = DiscogsClient::with_user_token("test-agent", "user-token")
401            .expect("build client with user token");
402
403        assert_eq!(client.auth_level(), AuthLevel::User);
404    }
405}