1use std::{collections::BTreeMap, fmt};
8
9use reqwest::header::CONTENT_TYPE;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use url::Url;
13
14use crate::{
15 ClientBuilder, CloudConvertClient, Error, Result,
16 config::{OAuthAccessToken, OAuthClientSecret, OAuthRefreshToken},
17};
18
19const OAUTH_AUTHORIZE_URL: &str = "https://cloudconvert.com/oauth/authorize";
20const OAUTH_TOKEN_URL: &str = "https://cloudconvert.com/oauth/token";
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24#[non_exhaustive]
25pub enum OAuthScope {
26 UserRead,
27 UserWrite,
28 TaskRead,
29 TaskWrite,
30 WebhookRead,
31 WebhookWrite,
32 Other(String),
33}
34
35impl OAuthScope {
36 pub fn as_str(&self) -> &str {
37 match self {
38 Self::UserRead => "user.read",
39 Self::UserWrite => "user.write",
40 Self::TaskRead => "task.read",
41 Self::TaskWrite => "task.write",
42 Self::WebhookRead => "webhook.read",
43 Self::WebhookWrite => "webhook.write",
44 Self::Other(value) => value.as_str(),
45 }
46 }
47}
48
49impl From<&str> for OAuthScope {
50 fn from(value: &str) -> Self {
51 match value {
52 "user.read" => Self::UserRead,
53 "user.write" => Self::UserWrite,
54 "task.read" => Self::TaskRead,
55 "task.write" => Self::TaskWrite,
56 "webhook.read" => Self::WebhookRead,
57 "webhook.write" => Self::WebhookWrite,
58 _ => Self::Other(value.to_string()),
59 }
60 }
61}
62
63impl From<String> for OAuthScope {
64 fn from(value: String) -> Self {
65 Self::from(value.as_str())
66 }
67}
68
69impl Serialize for OAuthScope {
70 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
71 where
72 S: serde::Serializer,
73 {
74 serializer.serialize_str(self.as_str())
75 }
76}
77
78#[derive(Clone)]
80pub struct OAuthClient {
81 client_id: String,
82 client_secret: OAuthClientSecret,
83 authorize_url: Url,
84 token_url: Url,
85 http: reqwest::Client,
86}
87
88impl OAuthClient {
89 pub fn new(client_id: impl Into<String>, client_secret: OAuthClientSecret) -> Result<Self> {
90 Self::with_http_client(client_id, client_secret, reqwest::Client::new())
91 }
92
93 pub fn with_http_client(
94 client_id: impl Into<String>,
95 client_secret: OAuthClientSecret,
96 http: reqwest::Client,
97 ) -> Result<Self> {
98 Ok(Self {
99 client_id: client_id.into(),
100 client_secret,
101 authorize_url: Url::parse(OAUTH_AUTHORIZE_URL)?,
102 token_url: Url::parse(OAUTH_TOKEN_URL)?,
103 http,
104 })
105 }
106
107 pub fn with_endpoints(mut self, authorize_url: Url, token_url: Url) -> Self {
108 self.authorize_url = authorize_url;
109 self.token_url = token_url;
110 self
111 }
112
113 pub fn client_id(&self) -> &str {
114 self.client_id.as_str()
115 }
116
117 pub fn authorize_url(&self) -> &Url {
118 &self.authorize_url
119 }
120
121 pub fn token_url(&self) -> &Url {
122 &self.token_url
123 }
124
125 pub fn authorization_code_url(
127 &self,
128 redirect_uri: impl AsRef<str>,
129 scopes: impl IntoIterator<Item = OAuthScope>,
130 ) -> Result<Url> {
131 self.authorization_url("code", redirect_uri.as_ref(), scopes, None)
132 }
133
134 pub fn authorization_code_url_with_state(
135 &self,
136 redirect_uri: impl AsRef<str>,
137 scopes: impl IntoIterator<Item = OAuthScope>,
138 state: impl Into<String>,
139 ) -> Result<Url> {
140 self.authorization_url("code", redirect_uri.as_ref(), scopes, Some(state.into()))
141 }
142
143 pub fn implicit_url(
144 &self,
145 redirect_uri: impl AsRef<str>,
146 scopes: impl IntoIterator<Item = OAuthScope>,
147 ) -> Result<Url> {
148 self.authorization_url("token", redirect_uri.as_ref(), scopes, None)
149 }
150
151 pub fn implicit_url_with_state(
152 &self,
153 redirect_uri: impl AsRef<str>,
154 scopes: impl IntoIterator<Item = OAuthScope>,
155 state: impl Into<String>,
156 ) -> Result<Url> {
157 self.authorization_url("token", redirect_uri.as_ref(), scopes, Some(state.into()))
158 }
159
160 pub async fn exchange_code(
162 &self,
163 code: impl Into<String>,
164 redirect_uri: impl Into<String>,
165 ) -> Result<OAuthTokenResponse> {
166 self.send_token_request(vec![
167 ("grant_type", "authorization_code".to_string()),
168 ("code", code.into()),
169 ("redirect_uri", redirect_uri.into()),
170 ("client_id", self.client_id.clone()),
171 ("client_secret", self.client_secret.expose().to_string()),
172 ])
173 .await
174 }
175
176 pub async fn refresh_access_token(
178 &self,
179 refresh_token: &OAuthRefreshToken,
180 ) -> Result<OAuthTokenResponse> {
181 self.send_token_request(vec![
182 ("grant_type", "refresh_token".to_string()),
183 ("refresh_token", refresh_token.expose().to_string()),
184 ("client_id", self.client_id.clone()),
185 ("client_secret", self.client_secret.expose().to_string()),
186 ])
187 .await
188 }
189
190 fn authorization_url(
191 &self,
192 response_type: &str,
193 redirect_uri: &str,
194 scopes: impl IntoIterator<Item = OAuthScope>,
195 state: Option<String>,
196 ) -> Result<Url> {
197 let mut url = self.authorize_url.clone();
198 let scope = scopes
199 .into_iter()
200 .map(|scope| scope.as_str().to_string())
201 .collect::<Vec<_>>()
202 .join(" ");
203
204 {
205 let mut query = url.query_pairs_mut();
206 query
207 .append_pair("response_type", response_type)
208 .append_pair("client_id", &self.client_id)
209 .append_pair("redirect_uri", redirect_uri);
210 if !scope.is_empty() {
211 query.append_pair("scope", &scope);
212 }
213 if let Some(state) = state {
214 query.append_pair("state", &state);
215 }
216 }
217
218 Ok(url)
219 }
220
221 async fn send_token_request(
222 &self,
223 form: Vec<(&'static str, String)>,
224 ) -> Result<OAuthTokenResponse> {
225 let body = form_body(&form);
226 let response = self
227 .http
228 .post(self.token_url.clone())
229 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
230 .body(body)
231 .send()
232 .await?;
233
234 if !response.status().is_success() {
235 return Err(oauth_api_error(response).await);
236 }
237
238 let raw = response.json::<RawOAuthTokenResponse>().await?;
239 Ok(raw.into())
240 }
241}
242
243impl fmt::Debug for OAuthClient {
244 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245 formatter
246 .debug_struct("OAuthClient")
247 .field("client_id", &self.client_id)
248 .field("client_secret", &self.client_secret)
249 .field("authorize_url", &self.authorize_url)
250 .field("token_url", &self.token_url)
251 .field("http", &"reqwest::Client")
252 .finish()
253 }
254}
255
256#[derive(Clone)]
258#[non_exhaustive]
259pub struct OAuthTokenResponse {
260 pub access_token: OAuthAccessToken,
261 pub refresh_token: Option<OAuthRefreshToken>,
262 pub token_type: Option<String>,
263 pub expires_in: Option<u64>,
264 pub scope: Option<String>,
265 pub extra: BTreeMap<String, Value>,
266}
267
268impl OAuthTokenResponse {
269 pub fn access_token(&self) -> &OAuthAccessToken {
270 &self.access_token
271 }
272
273 pub fn refresh_token(&self) -> Option<&OAuthRefreshToken> {
274 self.refresh_token.as_ref()
275 }
276
277 pub fn into_access_token(self) -> OAuthAccessToken {
278 self.access_token
279 }
280
281 pub fn client_builder(&self) -> ClientBuilder {
283 CloudConvertClient::builder_with_access_token(self.access_token.clone())
284 }
285
286 pub fn into_client_builder(self) -> ClientBuilder {
287 CloudConvertClient::builder_with_access_token(self.access_token)
288 }
289}
290
291impl fmt::Debug for OAuthTokenResponse {
292 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293 formatter
294 .debug_struct("OAuthTokenResponse")
295 .field("access_token", &self.access_token)
296 .field("refresh_token", &self.refresh_token)
297 .field("token_type", &self.token_type)
298 .field("expires_in", &self.expires_in)
299 .field("scope", &self.scope)
300 .field("extra", &self.extra)
301 .finish()
302 }
303}
304
305#[derive(Deserialize)]
306struct RawOAuthTokenResponse {
307 access_token: String,
308 #[serde(default)]
309 refresh_token: Option<String>,
310 #[serde(default)]
311 token_type: Option<String>,
312 #[serde(default)]
313 expires_in: Option<u64>,
314 #[serde(default)]
315 scope: Option<String>,
316 #[serde(flatten)]
317 extra: BTreeMap<String, Value>,
318}
319
320impl From<RawOAuthTokenResponse> for OAuthTokenResponse {
321 fn from(value: RawOAuthTokenResponse) -> Self {
322 Self {
323 access_token: OAuthAccessToken::new(value.access_token),
324 refresh_token: value.refresh_token.map(OAuthRefreshToken::new),
325 token_type: value.token_type,
326 expires_in: value.expires_in,
327 scope: value.scope,
328 extra: value.extra,
329 }
330 }
331}
332
333fn form_body(form: &[(&'static str, String)]) -> String {
334 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
335 for (key, value) in form {
336 serializer.append_pair(key, value);
337 }
338 serializer.finish()
339}
340
341async fn oauth_api_error(response: reqwest::Response) -> Error {
342 let status = response.status().as_u16();
343 let body = response.text().await.unwrap_or_default();
344 let parsed = serde_json::from_str::<Value>(&body).ok();
345 let message = parsed
346 .as_ref()
347 .and_then(|body| {
348 body.get("error_description")
349 .or_else(|| body.get("message"))
350 .and_then(Value::as_str)
351 })
352 .filter(|message| !message.is_empty())
353 .unwrap_or("OAuth token request failed")
354 .to_string();
355 let code = parsed
356 .as_ref()
357 .and_then(|body| {
358 body.get("error")
359 .or_else(|| body.get("code"))
360 .and_then(Value::as_str)
361 })
362 .map(ToString::to_string);
363
364 Error::Api {
365 status,
366 message,
367 code,
368 errors: parsed.map(Box::new),
369 rate_limit: None,
370 }
371}