paypal_rust/client/
paypal.rs

1use std::sync::Arc;
2
3use base64::{engine::general_purpose, Engine as _};
4use http_types::Url;
5use reqwest::header::AUTHORIZATION;
6use reqwest::RequestBuilder;
7use reqwest_middleware;
8use reqwest_retry::policies::ExponentialBackoff;
9use reqwest_retry::RetryTransientMiddleware;
10use serde::{Deserialize, Serialize};
11use tokio::sync::RwLock;
12
13use crate::client::app_info::AppInfo;
14use crate::client::auth::{AuthData, AuthResponse, AuthStrategy, Authenticate};
15use crate::client::endpoint::Endpoint;
16use crate::client::error::{PayPalError, ValidationError};
17use crate::client::request;
18use crate::client::request::QueryParams;
19
20pub static USER_AGENT: &str = concat!("PayPal/v2 Rust Bindings/", env!("CARGO_PKG_VERSION"));
21
22#[derive(Clone)]
23pub struct Client {
24    pub default_headers: request::HttpRequestHeaders,
25    pub auth_data: Arc<RwLock<AuthData>>,
26
27    user_agent: String,
28    client_secret: String,
29    username: String,
30    environment: Environment,
31    base_url: Url,
32    http: reqwest::Client,
33}
34
35impl Client {
36    /// Initialize a new PayPal client. To authenticate, use the `authenticate` method.
37    ///
38    /// # Errors
39    /// Errors if the environment URL cannot be parsed. This should never happen, if it does,
40    /// please open an issue.
41    pub fn new(
42        username: String,
43        client_secret: String,
44        environment: Environment,
45    ) -> Result<Self, Box<PayPalError>> {
46        let authorization =
47            get_basic_auth_for_user_service(username.as_str(), client_secret.as_str());
48
49        let base_url = match environment {
50            Environment::Sandbox => request::RequestUrl::Sandbox,
51            Environment::Live => request::RequestUrl::Live,
52        }
53        .as_url()
54        .map_err(|_e| PayPalError::LibraryError("Could not parse environment Url".to_string()))?;
55
56        Ok(Self {
57            environment,
58            client_secret,
59            username,
60            default_headers: request::HttpRequestHeaders::new(authorization),
61            base_url,
62            http: reqwest::Client::new(),
63            user_agent: USER_AGENT.into(),
64            auth_data: Arc::new(RwLock::new(AuthData::default())),
65        })
66    }
67
68    /// Composes an URL from the base URL and the provided path.
69    ///
70    /// # Arguments
71    ///  * `request_path` - The path to append to the base URL.
72    pub fn compose_url(&self, request_path: &str) -> Url {
73        let mut url = self.base_url.clone();
74        url.set_path(request_path);
75        url
76    }
77
78    /// Composes an URL with query parameters.
79    ///
80    /// # Arguments
81    /// * `request_path` - The path to append to the base URL.
82    /// * `query` - The query parameters to append to the URL.
83    ///
84    /// # Errors
85    /// Errors if the query parameters cannot be serialized. This should never happen, if it does,
86    /// please open an issue.
87    pub fn compose_url_with_query(
88        &self,
89        request_path: &str,
90        query: &QueryParams,
91    ) -> Result<Url, serde_qs::Error> {
92        let mut url = self.compose_url(request_path);
93        let params = serde_qs::to_string(query)?;
94
95        if params.is_empty() {
96            return Ok(url);
97        }
98
99        url.set_query(Some(&params));
100        Ok(url)
101    }
102
103    #[must_use]
104    pub fn with_app_info(mut self, app_info: &AppInfo) -> Self {
105        self.user_agent = format!("{} {}", self.user_agent, app_info.to_string());
106        self
107    }
108
109    /// Performs a GET request.
110    ///
111    /// # Arguments
112    /// * `endpoint` - The endpoint to call.
113    ///
114    /// # Returns
115    /// The response body serialized into the provided type.
116    ///
117    /// # Errors
118    /// Errors if the request fails or the response body cannot be deserialized.
119    pub async fn get<T: Endpoint>(&self, endpoint: &T) -> Result<T::ResponseBody, PayPalError> {
120        let mut req = self.http.get(endpoint.request_url(self.environment));
121        req = self.set_request_headers(req, &endpoint.headers());
122
123        let response = self.execute(endpoint, req).await?;
124
125        Ok(response)
126    }
127
128    /// Performs a POST request.
129    /// # Arguments
130    /// * `endpoint` - The endpoint to call.
131    ///
132    /// # Returns
133    /// The response body serialized into the provided type.
134    ///
135    /// # Errors
136    /// Errors if the request fails or the response body cannot be deserialized.
137    pub async fn post<T: Endpoint>(&self, endpoint: &T) -> Result<T::ResponseBody, PayPalError> {
138        let body = serde_json::to_string(&endpoint.request_body())?;
139        let mut req = self.http.post(endpoint.request_url(self.environment));
140
141        req = self.set_request_headers(req, &endpoint.headers());
142        let response = self.execute(endpoint, req.body(body)).await?;
143
144        Ok(response)
145    }
146
147    /// Performs a PATCH request.
148    ///
149    /// # Arguments
150    /// * `endpoint` - The endpoint to call.
151    ///
152    /// # Returns
153    /// The response body serialized into the provided type.
154    ///
155    /// # Errors
156    /// Errors if the request fails or the response body cannot be deserialized.
157    pub async fn patch<T: Endpoint>(&self, endpoint: &T) -> Result<(), PayPalError> {
158        let body = serde_json::to_string(&endpoint.request_body())?;
159        let mut req = self.http.patch(endpoint.request_url(self.environment));
160
161        req = self.set_request_headers(req, &endpoint.headers());
162        self.execute(endpoint, req.body(body)).await?;
163
164        Ok(())
165    }
166
167    /// Sets the request headers for a request.
168    ///
169    /// # Arguments
170    /// * `request_builder` - The request builder to set the headers on.
171    /// * `headers` - The headers to set.
172    ///
173    /// # Returns
174    /// The request builder with the headers set.
175    pub fn set_request_headers(
176        &self,
177        mut request_builder: RequestBuilder,
178        headers: &request::HttpRequestHeaders,
179    ) -> RequestBuilder {
180        for (key, value) in headers.to_vec() {
181            request_builder = request_builder.header(key, value);
182        }
183
184        request_builder
185    }
186
187    /// Executes a request.
188    ///
189    /// # Arguments
190    /// * `endpoint` - The endpoint to call.
191    /// * `request` - The request to execute (builder).
192    ///
193    /// # Returns
194    /// The response body serialized into the provided type.
195    async fn execute<T: Endpoint>(
196        &self,
197        endpoint: &T,
198        mut request: RequestBuilder,
199    ) -> Result<T::ResponseBody, PayPalError> {
200        if endpoint.auth_strategy() == AuthStrategy::TokenRefresh
201            && self.auth_data.read().await.about_to_expire()
202        {
203            self.authenticate().await?;
204        }
205
206        request = request.header(
207            AUTHORIZATION,
208            format!("Bearer {}", self.auth_data.read().await.access_token),
209        );
210
211        let response = request.send().await?;
212
213        if !response.status().is_success() {
214            return Err(PayPalError::from(response.json::<ValidationError>().await?));
215        }
216
217        serde_json::from_str::<T::ResponseBody>(&response.text().await?).or_else(|error| {
218            // Endpoints that return an empty response body can safely be deserialized into
219            // an empty struct.
220            if error.is_eof() {
221                Ok(serde_json::from_str::<T::ResponseBody>("{}")?)
222            } else {
223                Err(error.into())
224            }
225        })
226    }
227
228    /// Authenticates the client with PayPal. This gets called automatically when the auth strategy
229    /// is set to `TokenRefresh` and the access token is about to expire.
230    ///
231    /// It's recommended to call this method manually when initializing the client.
232    ///
233    /// # Errors
234    /// Errors if the request fails or the response body cannot be deserialized.
235    pub async fn authenticate(&self) -> Result<(), PayPalError> {
236        let endpoint = Authenticate::new(get_basic_auth_for_user_service(
237            self.username.as_str(),
238            self.client_secret.as_str(),
239        ));
240
241        let mut request = self
242            .http
243            .post(endpoint.request_url(self.environment))
244            .body(serde_urlencoded::to_string(endpoint.request_body())?);
245
246        let mut retries = 0;
247        if let Some(retry_count) = &endpoint.request_strategy().get_retry_count() {
248            retries = (*retry_count).get();
249        }
250
251        request = self.set_request_headers(request, &endpoint.headers());
252        request = request.header(
253            AUTHORIZATION,
254            get_basic_auth_for_user_service(&self.username, &self.client_secret),
255        );
256
257        let retry_client = reqwest_middleware::ClientBuilder::new(self.http.clone())
258            .with(RetryTransientMiddleware::new_with_policy(
259                ExponentialBackoff::builder().build_with_max_retries(retries),
260            ))
261            .build();
262
263        let retry_request = retry_client.execute(request.build()?).await?;
264        let parsed_response = serde_json::from_str::<AuthResponse>(&retry_request.text().await?)?;
265
266        self.auth_data.write().await.update(parsed_response);
267        Ok(())
268    }
269}
270
271fn get_basic_auth_for_user_service(username: &str, client_secret: &str) -> String {
272    format!(
273        "Basic {}",
274        general_purpose::STANDARD.encode(format!("{username}:{client_secret}"))
275    )
276}
277
278#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
279pub enum Environment {
280    Sandbox,
281    Live,
282}
283
284impl Environment {
285    pub const fn as_str(&self) -> &'static str {
286        match self {
287            Self::Sandbox => "sandbox",
288            Self::Live => "live",
289        }
290    }
291}
292
293impl AsRef<str> for Environment {
294    fn as_ref(&self) -> &str {
295        self.as_str()
296    }
297}
298
299impl std::fmt::Display for Environment {
300    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
301        self.as_str().fmt(formatter)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use std::str::FromStr;
308
309    use http_types::Url;
310
311    use super::{Client, Environment, QueryParams};
312
313    #[test]
314    fn test_environment() {
315        assert_eq!(Environment::Sandbox.as_str(), "sandbox");
316        assert_eq!(Environment::Live.as_str(), "live");
317    }
318
319    #[test]
320    fn test_compose_url() {
321        let client = Client::new(
322            "username".to_string(),
323            "password".to_string(),
324            Environment::Sandbox,
325        )
326        .unwrap();
327        let url = client.compose_url("test");
328        assert_eq!(
329            url,
330            Url::from_str("https://api-m.sandbox.paypal.com/test").unwrap()
331        );
332
333        let client = Client::new(
334            "username".to_string(),
335            "password".to_string(),
336            Environment::Live,
337        )
338        .unwrap();
339        let url = client.compose_url("test");
340        assert_eq!(url, Url::from_str("https://api-m.paypal.com/test").unwrap());
341    }
342
343    #[test]
344    fn test_compose_url_with_query() {
345        let client = Client::new(
346            "username".to_string(),
347            "password".to_string(),
348            Environment::Sandbox,
349        )
350        .unwrap();
351        let query: QueryParams = QueryParams::new()
352            .page(1)
353            .page_size(10)
354            .total_count_required(true);
355
356        let url = client.compose_url_with_query("test", &query).unwrap();
357
358        assert_eq!(
359            url,
360            Url::from_str("https://api-m.sandbox.paypal.com/test?page=1&page_size=10&total_count_required=true").unwrap()
361        );
362    }
363}