1use crate::error::{DiscogsError, Result};
2use rand::{Rng, distr::Alphanumeric};
3use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
4use std::collections::BTreeMap;
5use std::time::{SystemTime, UNIX_EPOCH};
6use url::form_urlencoded;
7
8const API_BASE: &str = "https://api.discogs.com";
9
10#[derive(Debug, Clone)]
11pub struct DiscogsOAuthClient {
12 consumer_key: String,
13 consumer_secret: String,
14 user_agent: String,
15 http: reqwest::Client,
16}
17
18#[derive(Debug, Clone)]
19pub struct RequestToken {
20 pub token: String,
21 pub token_secret: String,
22 pub callback_confirmed: bool,
23 pub authorize_url: String,
24}
25
26#[derive(Debug, Clone)]
27pub struct AccessToken {
28 pub access_token: String,
29 pub access_token_secret: String,
30}
31
32impl DiscogsOAuthClient {
33 pub fn new(
34 consumer_key: impl Into<String>,
35 consumer_secret: impl Into<String>,
36 user_agent: impl Into<String>,
37 ) -> Result<Self> {
38 Ok(Self {
39 consumer_key: consumer_key.into(),
40 consumer_secret: consumer_secret.into(),
41 user_agent: user_agent.into(),
42 http: reqwest::Client::builder().build()?,
43 })
44 }
45
46 pub async fn request_token(&self, callback_url: &str) -> Result<RequestToken> {
47 let nonce = oauth_nonce();
48 let timestamp = oauth_timestamp_seconds();
49 let callback_encoded: String =
50 form_urlencoded::byte_serialize(callback_url.as_bytes()).collect();
51
52 let header_value = format!(
53 "OAuth oauth_consumer_key=\"{}\", oauth_nonce=\"{}\", oauth_signature=\"{}&\", oauth_signature_method=\"PLAINTEXT\", oauth_timestamp=\"{}\", oauth_callback=\"{}\"",
54 self.consumer_key,
55 self.nonce_safe(&nonce),
56 self.consumer_secret,
57 timestamp,
58 callback_encoded
59 );
60
61 let response = self
62 .http
63 .get(format!("{API_BASE}/oauth/request_token"))
64 .header(USER_AGENT, &self.user_agent)
65 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
66 .header(AUTHORIZATION, header_value)
67 .send()
68 .await?;
69
70 if !response.status().is_success() {
71 let status = response.status();
72 let message = response
73 .text()
74 .await
75 .unwrap_or_else(|_| "unknown error".to_string());
76 return Err(DiscogsError::Http { status, message });
77 }
78
79 let text = response.text().await?;
80 let values = parse_oauth_form(&text);
82
83 let token = values
84 .get("oauth_token")
85 .cloned()
86 .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
87 let token_secret = values
88 .get("oauth_token_secret")
89 .cloned()
90 .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
91 let callback_confirmed = values
92 .get("oauth_callback_confirmed")
93 .map(|v| v == "true")
94 .unwrap_or(false);
95
96 Ok(RequestToken {
97 authorize_url: format!("https://discogs.com/oauth/authorize?oauth_token={token}"),
98 token,
99 token_secret,
100 callback_confirmed,
101 })
102 }
103
104 pub async fn access_token(
105 &self,
106 request_token: &str,
107 request_token_secret: &str,
108 verifier: &str,
109 ) -> Result<AccessToken> {
110 let nonce = oauth_nonce();
111 let timestamp = oauth_timestamp_seconds();
112
113 let header_value = format!(
114 "OAuth oauth_consumer_key=\"{}\", oauth_nonce=\"{}\", oauth_token=\"{}\", oauth_signature=\"{}&{}\", oauth_signature_method=\"PLAINTEXT\", oauth_timestamp=\"{}\", oauth_verifier=\"{}\"",
115 self.consumer_key,
116 self.nonce_safe(&nonce),
117 request_token,
118 self.consumer_secret,
119 request_token_secret,
120 timestamp,
121 verifier
122 );
123
124 let response = self
125 .http
126 .post(format!("{API_BASE}/oauth/access_token"))
127 .header(USER_AGENT, &self.user_agent)
128 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
129 .header(AUTHORIZATION, header_value)
130 .send()
131 .await?;
132
133 if !response.status().is_success() {
134 let status = response.status();
135 let message = response
136 .text()
137 .await
138 .unwrap_or_else(|_| "unknown error".to_string());
139 return Err(DiscogsError::Http { status, message });
140 }
141
142 let text = response.text().await?;
143 let values = parse_oauth_form(&text);
145 let access_token = values
146 .get("oauth_token")
147 .cloned()
148 .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
149 let access_token_secret = values
150 .get("oauth_token_secret")
151 .cloned()
152 .ok_or_else(|| DiscogsError::InvalidOAuthResponse(text.clone()))?;
153
154 Ok(AccessToken {
155 access_token,
156 access_token_secret,
157 })
158 }
159
160 fn nonce_safe(&self, nonce: &str) -> String {
161 nonce
162 .chars()
163 .filter(|c| c.is_ascii_alphanumeric())
164 .collect()
165 }
166}
167
168pub fn build_oauth_header(
169 consumer_key: &str,
170 consumer_secret: &str,
171 access_token: &str,
172 access_token_secret: &str,
173) -> String {
174 let nonce = oauth_nonce();
175 let timestamp = oauth_timestamp_seconds();
176
177 format!(
178 "OAuth oauth_consumer_key=\"{consumer_key}\", oauth_token=\"{access_token}\", oauth_signature_method=\"PLAINTEXT\", oauth_signature=\"{consumer_secret}&{access_token_secret}\", oauth_timestamp=\"{timestamp}\", oauth_nonce=\"{nonce}\", oauth_token_secret=\"{access_token_secret}\", oauth_version=\"1.0\""
179 )
180}
181
182fn oauth_nonce() -> String {
183 rand::rng()
184 .sample_iter(&Alphanumeric)
185 .take(64)
186 .map(char::from)
187 .collect()
188}
189
190fn oauth_timestamp_seconds() -> u64 {
191 SystemTime::now()
192 .duration_since(UNIX_EPOCH)
193 .map(|d| d.as_secs())
194 .unwrap_or(0)
195}
196
197fn parse_oauth_form(raw: &str) -> BTreeMap<String, String> {
198 form_urlencoded::parse(raw.as_bytes())
199 .into_owned()
200 .collect()
201}