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 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 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 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}