From b1f97b13da80182bc219daea6bbc559a7529861b Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Fri, 8 Apr 2022 11:13:42 +0200 Subject: [PATCH] start working on new api design --- Cargo.toml | 2 + examples/invoice.rs | 4 + src/api/invoice.rs | 279 ++++++++++++++++++++++++++++++++++ src/api/mod.rs | 2 + src/api/orders.rs | 191 +++++++++++++++++++++++ src/client.rs | 226 +++++++++++++++++++++++++++ src/{ => data}/common.rs | 6 +- src/{ => data}/invoice.rs | 283 +--------------------------------- src/data/mod.rs | 3 + src/{ => data}/orders.rs | 262 +++++--------------------------- src/endpoint.rs | 31 ++++ src/errors.rs | 2 +- src/lib.rs | 312 ++++---------------------------------- 13 files changed, 811 insertions(+), 792 deletions(-) create mode 100644 src/api/invoice.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/orders.rs rename src/{ => data}/common.rs (97%) rename src/{ => data}/invoice.rs (75%) create mode 100644 src/data/mod.rs rename src/{ => data}/orders.rs (77%) create mode 100644 src/endpoint.rs diff --git a/Cargo.toml b/Cargo.toml index fc1ecea..d2f2c68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ jsonwebtoken = "8.0.1" base64 = "0.13.0" log = "0.4.16" bytes = "1.1.0" +derive_builder = "0.11.1" +serde_qs = "0.9.1" [dev-dependencies] tokio = { version = "1.17.0", features = ["macros", "rt-multi-thread"] } diff --git a/examples/invoice.rs b/examples/invoice.rs index e5d9af7..159eea1 100644 --- a/examples/invoice.rs +++ b/examples/invoice.rs @@ -1,3 +1,4 @@ +/* use paypal_rs::{common::*, errors::*, invoice::*, Client, HeaderParams}; #[tokio::main] @@ -70,3 +71,6 @@ async fn main() -> Result<(), ResponseError> { Ok(()) } +*/ + +fn main() {} \ No newline at end of file diff --git a/src/api/invoice.rs b/src/api/invoice.rs new file mode 100644 index 0000000..03e1fe1 --- /dev/null +++ b/src/api/invoice.rs @@ -0,0 +1,279 @@ +//! Use the Invoicing API to create, send, and manage invoices. +//! You can also use the API or webhooks to track invoice payments. When you send an invoice to a customer, +//! the invoice moves from draft to payable state. PayPal then emails the customer a link to the invoice on the PayPal website. +//! Customers with a PayPal account can log in and pay the invoice with PayPal. Alternatively, +//! customers can pay as a guest with a debit card or credit card. For more information, see the Invoicing Overview and the Invoicing Integration Guide. +//! +//! Reference: https://developer.paypal.com/docs/api/invoicing/v2/ + + +/* + +impl super::Client { + /// Generates the next invoice number that is available to the merchant. + /// + /// The next invoice number uses the prefix and suffix from the last invoice number and increments the number by one. + /// + /// For example, the next invoice number after `INVOICE-1234` is `INVOICE-1235`. + pub async fn generate_invoice_number( + &mut self, + header_params: crate::HeaderParams, + ) -> Result { + let build = self + .setup_headers( + self.client + .post(format!("{}/v2/invoicing/generate-next-invoice-number", self.endpoint()).as_str()), + header_params, + ) + .await; + + let res = build.send().await?; + + if res.status().is_success() { + let x = res.json::>().await?; + Ok(x.get("invoice_number").expect("to have a invoice number").clone()) + } else { + Err(res.json::().await?.into()) + } + } + + /// Creates a draft invoice. To move the invoice from a draft to payable state, you must send the invoice. + /// Include invoice details including merchant information. The invoice object must include an items array. + pub async fn create_draft_invoice( + &mut self, + invoice: InvoicePayload, + header_params: HeaderParams, + ) -> Result { + let build = self + .setup_headers( + self.client + .post(format!("{}/v2/invoicing/invoices", self.endpoint()).as_str()), + header_params, + ) + .await; + + let res = build.json(&invoice).send().await?; + + if res.status().is_success() { + //println!("{:#?}", res.text().await?); + let inv = res.json::().await?; + Ok(inv) + } else { + Err(res.json::().await?.into()) + } + } + + /// Get an invoice by ID. + pub async fn get_invoice( + &mut self, + invoice_id: &str, + header_params: HeaderParams, + ) -> Result { + let build = self + .setup_headers( + self.client + .post(format!("{}/v2/invoicing/invoices/{}", self.endpoint(), invoice_id).as_str()), + header_params, + ) + .await; + + let res = build.send().await?; + + if res.status().is_success() { + let x = res.json::().await?; + Ok(x) + } else { + Err(res.json::().await?.into()) + } + } + + /// List invoices + /// Page size has the following limits: [1, 100]. + pub async fn list_invoices( + &mut self, + page: i32, + page_size: i32, + header_params: HeaderParams, + ) -> Result { + let build = self + .setup_headers( + self.client.get( + format!( + "{}/v2/invoicing/invoices?page={}&page_size={}&total_required=true", + self.endpoint(), + page, + page_size + ) + .as_str(), + ), + header_params, + ) + .await; + + let res = build.send().await?; + + if res.status().is_success() { + let x = res.json::().await?; + Ok(x) + } else { + Err(res.json::().await?.into()) + } + } + + /// Delete a invoice + pub async fn delete_invoice(&mut self, invoice_id: &str, header_params: HeaderParams) -> Result<(), ResponseError> { + let build = self + .setup_headers( + self.client + .delete(format!("{}/v2/invoicing/invoices/{}", self.endpoint(), invoice_id).as_str()), + header_params, + ) + .await; + + let res = build.send().await?; + + if res.status().is_success() { + Ok(()) + } else { + Err(res.json::().await?.into()) + } + } + + /// Update a invoice + pub async fn update_invoice( + &mut self, + invoice: Invoice, + send_to_recipient: bool, + send_to_invoicer: bool, + header_params: HeaderParams, + ) -> Result<(), ResponseError> { + let build = self + .setup_headers( + self.client.put( + format!( + "{}/v2/invoicing/invoices/{}?send_to_recipient={}&send_to_invoicer={}", + self.endpoint(), + invoice.id, + send_to_recipient, + send_to_invoicer + ) + .as_str(), + ), + header_params, + ) + .await; + + let res = build.send().await?; + + if res.status().is_success() { + Ok(()) + } else { + Err(res.json::().await?.into()) + } + } + + /// Cancel a invoice + pub async fn cancel_invoice( + &mut self, + invoice_id: &str, + reason: CancelReason, + header_params: HeaderParams, + ) -> Result<(), ResponseError> { + let build = self + .setup_headers( + self.client + .post(format!("{}/v2/invoicing/invoices/{}/cancel", self.endpoint(), invoice_id,).as_str()), + header_params, + ) + .await; + + let res = build.json(&reason).send().await?; + + if res.status().is_success() { + Ok(()) + } else { + Err(res.json::().await?.into()) + } + } + + /// Generate a QR code + pub async fn generate_qr_code( + &mut self, + invoice_id: &str, + params: QRCodeParams, + header_params: HeaderParams, + ) -> Result { + let build = self + .setup_headers( + self.client.post( + format!( + "{}/v2/invoicing/invoices/{}/generate-qr-code", + self.endpoint(), + invoice_id + ) + .as_str(), + ), + header_params, + ) + .await; + + let res = build.json(¶ms).send().await?; + + if res.status().is_success() { + let b = res.bytes().await?; + Ok(b) + } else { + Err(res.json::().await?.into()) + } + } + + /// Records a payment for the invoice. If no payment is due, the invoice is marked as PAID. Otherwise, the invoice is marked as PARTIALLY PAID. + pub async fn record_invoice_payment( + &mut self, + invoice_id: &str, + payload: RecordPaymentPayload, + header_params: crate::HeaderParams, + ) -> Result { + let build = self + .setup_headers( + self.client + .post(format!("{}/v2/invoicing/invoices/{}/payments", self.endpoint(), invoice_id).as_str()), + header_params, + ) + .await; + + let res = build.json(&payload).send().await?; + + if res.status().is_success() { + let x = res.json::>().await?; + Ok(x.get("payment_id").unwrap().to_owned()) + } else { + Err(res.json::().await?.into()) + } + } + + // TODO: https://developer.paypal.com/docs/api/invoicing/v2/#invoices_payments-delete +} + +#[cfg(test)] +mod tests { + use crate::{Client, HeaderParams}; + + async fn create_client() -> Client { + dotenv::dotenv().ok(); + let clientid = std::env::var("PAYPAL_CLIENTID").unwrap(); + let secret = std::env::var("PAYPAL_SECRET").unwrap(); + + Client::new(clientid, secret, true) + } + + #[tokio::test] + async fn test_invoice() -> anyhow::Result<()> { + let mut client = create_client().await; + + let _list = client.list_invoices(1, 10, HeaderParams::default()).await?; + Ok(()) + } +} + +*/ \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..bc9b094 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod orders; +pub mod invoice; \ No newline at end of file diff --git a/src/api/orders.rs b/src/api/orders.rs new file mode 100644 index 0000000..1a107f6 --- /dev/null +++ b/src/api/orders.rs @@ -0,0 +1,191 @@ +use std::borrow::Cow; + +use crate::{ + data::orders::{Order, OrderPayload, PaymentSourceResponse}, + endpoint::Endpoint, +}; + +#[derive(Debug)] +pub struct CreateOrder { + order: OrderPayload, +} + +impl CreateOrder { + pub fn new(order: OrderPayload) -> Self { + Self { order } + } +} + +impl Endpoint for CreateOrder { + type Query = (); + + type Body = OrderPayload; + + type Response = Order; + + fn relative_path(&self) -> Cow { + Cow::Borrowed("/v2/checkout/orders") + } + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } + + fn body(&self) -> Option<&Self::Body> { + Some(&self.order) + } +} + +// TODO: Update order. + +#[derive(Debug)] +pub struct ShowOrderDetails { + order_id: String, +} + +impl ShowOrderDetails { + pub fn new(order_id: &str) -> Self { + Self { + order_id: order_id.to_string(), + } + } +} + +impl Endpoint for ShowOrderDetails { + type Query = (); + + type Body = (); + + type Response = Order; + + fn relative_path(&self) -> Cow { + Cow::Owned(format!("/v2/checkout/orders/{}", self.order_id)) + } + + fn method(&self) -> reqwest::Method { + reqwest::Method::GET + } +} + +#[derive(Debug)] +pub struct CaptureOrder { + order_id: String, + // TODO: payment source? https://developer.paypal.com/docs/api/orders/v2/#orders_capture +} + +impl CaptureOrder { + pub fn new(order_id: &str) -> Self { + Self { + order_id: order_id.to_string(), + } + } +} + +impl Endpoint for CaptureOrder { + type Query = (); + + type Body = (); + + type Response = Order; + + fn relative_path(&self) -> Cow { + Cow::Owned(format!("/v2/checkout/orders/{}/capture", self.order_id)) + } + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } +} + +#[derive(Debug)] +pub struct AuthorizeOrder { + order_id: String, + // TODO: payment source? https://developer.paypal.com/docs/api/orders/v2/#orders_authorize +} + +impl AuthorizeOrder { + pub fn new(order_id: &str) -> Self { + Self { + order_id: order_id.to_string(), + } + } +} + +impl Endpoint for AuthorizeOrder { + type Query = (); + + type Body = (); + + type Response = Order; + + fn relative_path(&self) -> Cow { + Cow::Owned(format!("/v2/checkout/orders/{}/authorize", self.order_id)) + } + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } +} + +#[cfg(test)] +mod tests { + use crate::data::common::Currency; + use crate::HeaderParams; + use crate::{ + api::orders::{CreateOrder, ShowOrderDetails}, + data::orders::*, + tests::create_client, + }; + + #[tokio::test] + async fn test_order() -> anyhow::Result<()> { + let mut client = create_client().await; + client.get_access_token().await?; + + let order = OrderPayloadBuilder::default() + .intent(Intent::Authorize) + .purchase_units(vec![PurchaseUnit::new(Amount::new(Currency::EUR, "10.0"))]) + .build()?; + + let ref_id = format!( + "TEST-{:?}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + + let create_order = CreateOrder::new(order); + + let order_created = client + .execute_ext( + create_order, + HeaderParams { + request_id: Some(ref_id.clone()), + ..Default::default() + }, + ) + .await?; + + assert_ne!(order_created.id, ""); + assert_eq!(order_created.status, OrderStatus::Created); + assert_eq!(order_created.links.len(), 4); + + let show_order = ShowOrderDetails::new(&order_created.id); + + let show_order_result = client + .execute_ext( + show_order, + HeaderParams { + request_id: Some(ref_id.clone()), + ..Default::default() + }, + ) + .await?; + + assert_eq!(order_created.id, show_order_result.id); + assert_eq!(order_created.status, show_order_result.status); + + Ok(()) + } +} diff --git a/src/client.rs b/src/client.rs index e69de29..fa0b574 100644 --- a/src/client.rs +++ b/src/client.rs @@ -0,0 +1,226 @@ +use reqwest::header::{self, HeaderMap}; +use serde::Deserialize; +use std::time::Duration; +use std::time::Instant; + +use crate::{ + endpoint::Endpoint, + errors::{PaypalError, ResponseError}, + AuthAssertionClaims, HeaderParams, Prefer, LIVE_ENDPOINT, SANDBOX_ENDPOINT, +}; + +/// Represents the access token returned by the OAuth2 authentication. +/// +/// https://developer.paypal.com/docs/api/get-an-access-token-postman/ +#[derive(Debug, Deserialize)] +pub struct AccessToken { + /// The OAuth2 scopes. + pub scope: String, + /// The access token. + pub access_token: String, + /// The token type. + pub token_type: String, + /// The app id. + pub app_id: String, + /// Seconds until it expires. + pub expires_in: u64, + /// The nonce. + pub nonce: String, +} + +/// Stores OAuth2 information. +#[derive(Debug)] +pub struct Auth { + /// Your client id. + pub client_id: String, + /// The secret. + pub secret: String, + /// The access token returned by oauth2 authentication. + pub access_token: Option, + /// Used to check when the token expires. + pub expires: Option<(Instant, Duration)>, +} + +/// Represents a client used to interact with the paypal api. +#[derive(Debug)] +pub struct Client { + /// Internal http client + pub(crate) client: reqwest::Client, + /// Whether you are or not in a sandbox enviroment. + pub sandbox: bool, + /// Api Auth information + pub auth: Auth, +} + +impl Client { + /// Returns a new client, you must get_access_token afterwards to interact with the api. + /// + /// # Examples + /// + /// ``` + /// use paypal_rs::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// # dotenv::dotenv().ok(); + /// let clientid = std::env::var("PAYPAL_CLIENTID").unwrap(); + /// let secret = std::env::var("PAYPAL_SECRET").unwrap(); + /// + /// let mut client = Client::new( + /// clientid, + /// secret, + /// true, + /// ); + /// client.get_access_token().await.unwrap(); + /// } + /// ``` + pub fn new(client_id: String, secret: String, sandbox: bool) -> Client { + Client { + client: reqwest::Client::new(), + sandbox, + auth: Auth { + client_id, + secret, + access_token: None, + expires: None, + }, + } + } + + fn endpoint(&self) -> &str { + if self.sandbox { + SANDBOX_ENDPOINT + } else { + LIVE_ENDPOINT + } + } + + /// Sets up the request headers as required on https://developer.paypal.com/docs/api/reference/api-requests/#http-request-headers + async fn setup_headers( + &self, + builder: reqwest::RequestBuilder, + header_params: HeaderParams, + ) -> Result { + let mut headers = HeaderMap::new(); + + headers.append(header::ACCEPT, "application/json".parse().unwrap()); + + if let Some(token) = &self.auth.access_token { + headers.append( + header::AUTHORIZATION, + format!("Bearer {}", token.access_token).parse().unwrap(), + ); + } + + if let Some(merchant_payer_id) = header_params.merchant_payer_id { + let claims = AuthAssertionClaims { + iss: self.auth.client_id.clone(), + payer_id: merchant_payer_id, + }; + let jwt_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); + let token = jsonwebtoken::encode( + &jwt_header, + &claims, + &jsonwebtoken::EncodingKey::from_secret(self.auth.secret.as_ref()), + ) + .unwrap(); + let encoded_token = base64::encode(token); + headers.append("PayPal-Auth-Assertion", encoded_token.parse().unwrap()); + } + + if let Some(client_metadata_id) = header_params.client_metadata_id { + headers.append("PayPal-Client-Metadata-Id", client_metadata_id.parse().unwrap()); + } + + if let Some(partner_attribution_id) = header_params.partner_attribution_id { + headers.append("PayPal-Partner-Attribution-Id", partner_attribution_id.parse().unwrap()); + } + + if let Some(request_id) = header_params.request_id { + headers.append("PayPal-Request-Id", request_id.parse().unwrap()); + } + + match header_params.prefer { + Prefer::Minimal => headers.append("Prefer", "return=minimal".parse().unwrap()), + Prefer::Representation => headers.append("Prefer", "return=representation".parse().unwrap()), + }; + + if let Some(content_type) = header_params.content_type { + headers.append(header::CONTENT_TYPE, content_type.parse().unwrap()); + } + + Ok(builder.headers(headers)) + } + + /// Gets a access token used in all the api calls. + pub async fn get_access_token(&mut self) -> Result<(), ResponseError> { + if !self.access_token_expired() { + return Ok(()); + } + let res = self + .client + .post(format!("{}/v1/oauth2/token", self.endpoint()).as_str()) + .basic_auth(&self.auth.client_id, Some(&self.auth.secret)) + .header("Content-Type", "x-www-form-urlencoded") + .header("Accept", "application/json") + .body("grant_type=client_credentials") + .send() + .await + .map_err(ResponseError::HttpError)?; + + if res.status().is_success() { + let token = res.json::().await.map_err(ResponseError::HttpError)?; + self.auth.expires = Some((Instant::now(), Duration::new(token.expires_in, 0))); + self.auth.access_token = Some(token); + Ok(()) + } else { + Err(ResponseError::ApiError( + res.json::().await.map_err(ResponseError::HttpError)?, + )) + } + } + + /// Checks if the access token expired. + pub fn access_token_expired(&self) -> bool { + if let Some(expires) = self.auth.expires { + expires.0.elapsed() >= expires.1 + } else { + true + } + } + + pub async fn execute_ext(&self, endpoint: E, headers: HeaderParams) -> Result + where + E: Endpoint, + { + let mut url = endpoint.full_path(self.sandbox); + + if let Some(query) = endpoint.query() { + let query_string = serde_qs::to_string(query).expect("serialize the query correctly"); + url.push_str(&query_string); + } + + let mut request = self.client.request(endpoint.method(), url); + request = self.setup_headers(request, headers).await?; + + if let Some(body) = endpoint.body() { + request = request.json(body); + } + + let res = request.send().await?; + + if res.status().is_success() { + let response_body = res.json::().await?; + Ok(response_body) + } else { + Err(ResponseError::ApiError(res.json::().await?)) + } + } + + pub async fn execute(&self, endpoint: E) -> Result + where + E: Endpoint, + { + self.execute_ext(endpoint, HeaderParams::default()).await + } +} diff --git a/src/common.rs b/src/data/common.rs similarity index 97% rename from src/common.rs rename to src/data/common.rs index ae58bb6..48ad819 100644 --- a/src/common.rs +++ b/src/data/common.rs @@ -8,7 +8,7 @@ use std::str::FromStr; /// The phone type. /// /// https://developer.paypal.com/docs/api/orders/v2/#definition-phone_with_type -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[allow(missing_docs)] pub enum PhoneType { @@ -21,7 +21,7 @@ pub enum PhoneType { /// The non-portable additional address details #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct AddressDetails { /// The street number. pub street_number: Option, @@ -40,7 +40,7 @@ pub struct AddressDetails { /// The address of the payer. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct Address { /// The first line of the address. For example, number or street. For example, 173 Drury Lane. /// Required for data entry and compliance and risk checks. Must contain the full address. diff --git a/src/invoice.rs b/src/data/invoice.rs similarity index 75% rename from src/invoice.rs rename to src/data/invoice.rs index 13dc4b6..cac9d2b 100644 --- a/src/invoice.rs +++ b/src/data/invoice.rs @@ -1,18 +1,6 @@ -//! Use the Invoicing API to create, send, and manage invoices. -//! You can also use the API or webhooks to track invoice payments. When you send an invoice to a customer, -//! the invoice moves from draft to payable state. PayPal then emails the customer a link to the invoice on the PayPal website. -//! Customers with a PayPal account can log in and pay the invoice with PayPal. Alternatively, -//! customers can pay as a guest with a debit card or credit card. For more information, see the Invoicing Overview and the Invoicing Integration Guide. -//! -//! Reference: https://developer.paypal.com/docs/api/invoicing/v2/ - -use crate::common::*; -use crate::errors::{PaypalError, ResponseError}; -use crate::HeaderParams; -use bytes::Bytes; +use crate::{data::common::*, data::common::LinkDescription}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; -use std::collections::HashMap; /// Paypal File reference #[derive(Debug, Serialize, Deserialize)] @@ -641,271 +629,4 @@ pub struct RecordPaymentPayload { amount: Amount, shipping_info: Option, -} - -impl super::Client { - /// Generates the next invoice number that is available to the merchant. - /// - /// The next invoice number uses the prefix and suffix from the last invoice number and increments the number by one. - /// - /// For example, the next invoice number after `INVOICE-1234` is `INVOICE-1235`. - pub async fn generate_invoice_number( - &mut self, - header_params: crate::HeaderParams, - ) -> Result { - let build = self - .setup_headers( - self.client - .post(format!("{}/v2/invoicing/generate-next-invoice-number", self.endpoint()).as_str()), - header_params, - ) - .await; - - let res = build.send().await?; - - if res.status().is_success() { - let x = res.json::>().await?; - Ok(x.get("invoice_number").expect("to have a invoice number").clone()) - } else { - Err(res.json::().await?.into()) - } - } - - /// Creates a draft invoice. To move the invoice from a draft to payable state, you must send the invoice. - /// Include invoice details including merchant information. The invoice object must include an items array. - pub async fn create_draft_invoice( - &mut self, - invoice: InvoicePayload, - header_params: HeaderParams, - ) -> Result { - let build = self - .setup_headers( - self.client - .post(format!("{}/v2/invoicing/invoices", self.endpoint()).as_str()), - header_params, - ) - .await; - - let res = build.json(&invoice).send().await?; - - if res.status().is_success() { - //println!("{:#?}", res.text().await?); - let inv = res.json::().await?; - Ok(inv) - } else { - Err(res.json::().await?.into()) - } - } - - /// Get an invoice by ID. - pub async fn get_invoice( - &mut self, - invoice_id: &str, - header_params: HeaderParams, - ) -> Result { - let build = self - .setup_headers( - self.client - .post(format!("{}/v2/invoicing/invoices/{}", self.endpoint(), invoice_id).as_str()), - header_params, - ) - .await; - - let res = build.send().await?; - - if res.status().is_success() { - let x = res.json::().await?; - Ok(x) - } else { - Err(res.json::().await?.into()) - } - } - - /// List invoices - /// Page size has the following limits: [1, 100]. - pub async fn list_invoices( - &mut self, - page: i32, - page_size: i32, - header_params: HeaderParams, - ) -> Result { - let build = self - .setup_headers( - self.client.get( - format!( - "{}/v2/invoicing/invoices?page={}&page_size={}&total_required=true", - self.endpoint(), - page, - page_size - ) - .as_str(), - ), - header_params, - ) - .await; - - let res = build.send().await?; - - if res.status().is_success() { - let x = res.json::().await?; - Ok(x) - } else { - Err(res.json::().await?.into()) - } - } - - /// Delete a invoice - pub async fn delete_invoice(&mut self, invoice_id: &str, header_params: HeaderParams) -> Result<(), ResponseError> { - let build = self - .setup_headers( - self.client - .delete(format!("{}/v2/invoicing/invoices/{}", self.endpoint(), invoice_id).as_str()), - header_params, - ) - .await; - - let res = build.send().await?; - - if res.status().is_success() { - Ok(()) - } else { - Err(res.json::().await?.into()) - } - } - - /// Update a invoice - pub async fn update_invoice( - &mut self, - invoice: Invoice, - send_to_recipient: bool, - send_to_invoicer: bool, - header_params: HeaderParams, - ) -> Result<(), ResponseError> { - let build = self - .setup_headers( - self.client.put( - format!( - "{}/v2/invoicing/invoices/{}?send_to_recipient={}&send_to_invoicer={}", - self.endpoint(), - invoice.id, - send_to_recipient, - send_to_invoicer - ) - .as_str(), - ), - header_params, - ) - .await; - - let res = build.send().await?; - - if res.status().is_success() { - Ok(()) - } else { - Err(res.json::().await?.into()) - } - } - - /// Cancel a invoice - pub async fn cancel_invoice( - &mut self, - invoice_id: &str, - reason: CancelReason, - header_params: HeaderParams, - ) -> Result<(), ResponseError> { - let build = self - .setup_headers( - self.client - .post(format!("{}/v2/invoicing/invoices/{}/cancel", self.endpoint(), invoice_id,).as_str()), - header_params, - ) - .await; - - let res = build.json(&reason).send().await?; - - if res.status().is_success() { - Ok(()) - } else { - Err(res.json::().await?.into()) - } - } - - /// Generate a QR code - pub async fn generate_qr_code( - &mut self, - invoice_id: &str, - params: QRCodeParams, - header_params: HeaderParams, - ) -> Result { - let build = self - .setup_headers( - self.client.post( - format!( - "{}/v2/invoicing/invoices/{}/generate-qr-code", - self.endpoint(), - invoice_id - ) - .as_str(), - ), - header_params, - ) - .await; - - let res = build.json(¶ms).send().await?; - - if res.status().is_success() { - let b = res.bytes().await?; - Ok(b) - } else { - Err(res.json::().await?.into()) - } - } - - /// Records a payment for the invoice. If no payment is due, the invoice is marked as PAID. Otherwise, the invoice is marked as PARTIALLY PAID. - pub async fn record_invoice_payment( - &mut self, - invoice_id: &str, - payload: RecordPaymentPayload, - header_params: crate::HeaderParams, - ) -> Result { - let build = self - .setup_headers( - self.client - .post(format!("{}/v2/invoicing/invoices/{}/payments", self.endpoint(), invoice_id).as_str()), - header_params, - ) - .await; - - let res = build.json(&payload).send().await?; - - if res.status().is_success() { - let x = res.json::>().await?; - Ok(x.get("payment_id").unwrap().to_owned()) - } else { - Err(res.json::().await?.into()) - } - } - - // TODO: https://developer.paypal.com/docs/api/invoicing/v2/#invoices_payments-delete -} - -#[cfg(test)] -mod tests { - use crate::{Client, HeaderParams}; - - async fn create_client() -> Client { - dotenv::dotenv().ok(); - let clientid = std::env::var("PAYPAL_CLIENTID").unwrap(); - let secret = std::env::var("PAYPAL_SECRET").unwrap(); - - Client::new(clientid, secret, true) - } - - #[tokio::test] - async fn test_invoice() -> anyhow::Result<()> { - let mut client = create_client().await; - - let _list = client.list_invoices(1, 10, HeaderParams::default()).await?; - Ok(()) - } -} +} \ No newline at end of file diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..cab894e --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,3 @@ +pub mod common; +pub mod orders; +pub mod invoice; \ No newline at end of file diff --git a/src/orders.rs b/src/data/orders.rs similarity index 77% rename from src/orders.rs rename to src/data/orders.rs index 30843b3..3070074 100644 --- a/src/orders.rs +++ b/src/data/orders.rs @@ -1,17 +1,12 @@ -//! An order represents a payment between two or more parties. -//! -//! Use the Orders API to create, update, retrieve, authorize, and capture orders. -//! -//! Reference: https://developer.paypal.com/docs/api/orders/v2/ - -use crate::common::*; +use super::common::*; use crate::errors::{PaypalError, ResponseError}; use crate::HeaderParams; +use derive_builder::Builder; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; /// The intent to either capture payment immediately or authorize a payment for an order after order creation. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Intent { /// The merchant intends to capture payment immediately after the customer makes a payment. @@ -33,7 +28,7 @@ impl Default for Intent { /// Represents a payer name. /// /// https://developer.paypal.com/docs/api/orders/v2/#definition-payer.name -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] pub struct PayerName { /// When the party is a person, the party's given, or first, name. pub given_name: String, @@ -43,7 +38,7 @@ pub struct PayerName { } /// The phone number, in its canonical international E.164 numbering plan format. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] pub struct PhoneNumber { /// The national number, in its canonical international E.164 numbering plan format. /// The combined length of the country calling code (CC) and the national number must not be greater than 15 digits. @@ -54,7 +49,7 @@ pub struct PhoneNumber { /// The phone number of the customer. Available only when you enable the /// Contact Telephone Number option in the Profile & Settings for the merchant's PayPal account. #[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Phone { /// The phone type. pub phone_type: Option, @@ -63,7 +58,7 @@ pub struct Phone { } /// The customer's tax ID type. Supported for the PayPal payment method only. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[allow(non_camel_case_types)] pub enum TaxIdType { @@ -74,7 +69,7 @@ pub enum TaxIdType { } /// The tax information of the payer. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct TaxInfo { /// The customer's tax ID. Supported for the PayPal payment method only. /// Typically, the tax ID is 11 characters long for individuals and 14 characters long for businesses. @@ -87,7 +82,7 @@ pub struct TaxInfo { /// /// https://developer.paypal.com/docs/api/orders/v2/#definition-payer #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct Payer { /// The name of the payer. pub name: Option, @@ -108,7 +103,7 @@ pub struct Payer { /// Breakdown provides details such as total item amount, total tax amount, shipping, handling, insurance, and discounts, if any. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct Breakdown { /// The subtotal for all items. Required if the request includes purchase_units[].items[].unit_amount. /// Must equal the sum of (items[].unit_amount * items[].quantity) for all items. @@ -129,7 +124,7 @@ pub struct Breakdown { /// Represents an amount of money. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct Amount { /// The [three-character ISO-4217 currency code](https://developer.paypal.com/docs/integration/direct/rest/currency-codes/) that identifies the currency. pub currency_code: Currency, @@ -156,7 +151,7 @@ impl Amount { /// The merchant who receives payment for this transaction. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct Payee { /// The email address of merchant. pub email_address: Option, @@ -166,7 +161,7 @@ pub struct Payee { /// Fees, commissions, tips, or donations #[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct PlatformFee { /// The fee for this transaction. pub amount: Money, @@ -176,7 +171,7 @@ pub struct PlatformFee { } /// The funds that are held on behalf of the merchant -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] pub enum DisbursementMode { /// The funds are released to the merchant immediately. Instant, @@ -194,7 +189,7 @@ impl Default for DisbursementMode { /// Any additional payment instructions for PayPal Commerce Platform customers. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct PaymentInstruction { /// An array of various fees, commissions, tips, or donations. pub platform_fees: Option>, @@ -203,7 +198,7 @@ pub struct PaymentInstruction { } /// The item category type. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ItemCategoryType { /// Goods that are stored, delivered, and used in their electronic format. @@ -222,7 +217,7 @@ impl Default for ItemCategoryType { /// The name and address of the person to whom to ship the items. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct ShippingDetail { /// The name of the person to whom to ship the items. Supports only the full_name property. pub name: Option, @@ -232,7 +227,7 @@ pub struct ShippingDetail { /// Represents an item. #[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Item { /// The item name or title. pub name: String, @@ -252,7 +247,7 @@ pub struct Item { } /// The status of the payment authorization. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum AuthorizationStatus { /// The authorized payment is created. No captured payments have been made for this authorized payment. @@ -274,7 +269,7 @@ pub enum AuthorizationStatus { } /// Authorization status reason. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum AuthorizationStatusDetailsReason { /// Authorization is pending manual review. @@ -282,14 +277,14 @@ pub enum AuthorizationStatusDetailsReason { } /// Details about the status of the authorization. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] pub struct AuthorizationStatusDetails { /// The reason why the authorized status is PENDING. pub reason: AuthorizationStatusDetailsReason, } /// A payment authorization. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] pub struct AuthorizationWithData { /// The status for the authorized payment. pub status: AuthorizationStatus, @@ -298,7 +293,7 @@ pub struct AuthorizationWithData { } /// The capture status. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum CaptureStatus { /// The funds for this captured payment were credited to the payee's PayPal account. @@ -314,7 +309,7 @@ pub enum CaptureStatus { } /// Capture status reason. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum CaptureStatusDetailsReason { /// The payer initiated a dispute for this captured payment with PayPal. @@ -346,7 +341,7 @@ pub enum CaptureStatusDetailsReason { } /// Details about the captured payment status. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] pub struct CaptureStatusDetails { /// The reason why the captured payment status is PENDING or DENIED. pub reason: CaptureStatusDetailsReason, @@ -354,7 +349,7 @@ pub struct CaptureStatusDetails { /// A captured payment. #[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] pub struct Capture { /// The status of the captured payment. pub status: CaptureStatus, @@ -363,7 +358,7 @@ pub struct Capture { } /// The status of the refund -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RefundStatus { /// The refund was cancelled. @@ -375,7 +370,7 @@ pub enum RefundStatus { } /// Refund status reason. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RefundStatusDetailsReason { /// The customer's account is funded through an eCheck, which has not yet cleared. @@ -383,14 +378,14 @@ pub enum RefundStatusDetailsReason { } /// Details about the status of the refund. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Copy, Clone)] pub struct RefundStatusDetails { /// The reason why the refund has the PENDING or FAILED status. pub reason: RefundStatusDetailsReason, } /// A refund -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Copy, Clone)] pub struct Refund { /// The status of the refund. pub status: RefundStatus, @@ -399,7 +394,7 @@ pub struct Refund { } /// The comprehensive history of payments for the purchase unit. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct PaymentCollection { /// An array of authorized payments for a purchase unit. A purchase unit can have zero or more authorized payments. #[serde(default)] @@ -414,7 +409,7 @@ pub struct PaymentCollection { /// Represents either a full or partial order that the payer intends to purchase from the payee. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct PurchaseUnit { /// The API caller-provided external ID for the purchase unit. Required for multiple purchase units when you must update the order through PATCH. /// If you omit this value and the order contains only one purchase unit, PayPal sets this value to default. @@ -469,7 +464,7 @@ impl PurchaseUnit { } /// The type of landing page to show on the PayPal site for customer checkout. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum LandingPage { /// When the customer clicks PayPal Checkout, the customer is redirected to a page to log in to PayPal and approve the payment. @@ -490,7 +485,7 @@ impl Default for LandingPage { } /// The shipping preference -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ShippingPreference { /// Use the customer-provided shipping address on the PayPal site. @@ -508,7 +503,7 @@ impl Default for ShippingPreference { } /// Configures a Continue or Pay Now checkout flow. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum UserAction { /// After you redirect the customer to the PayPal payment page, a Continue button appears. Use this option when @@ -528,7 +523,7 @@ impl Default for UserAction { } /// The merchant-preferred payment sources. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum PayeePreferred { /// Accepts any type of payment from the customer. @@ -547,7 +542,7 @@ impl Default for PayeePreferred { /// A payment method. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct PaymentMethod { /// The customer-selected payment method on the merchant site. pub payer_selected: Option, @@ -557,7 +552,7 @@ pub struct PaymentMethod { /// Customize the payer experience during the approval process for the payment with PayPal. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct ApplicationContext { /// The label that overrides the business name in the PayPal account on the PayPal site. pub brand_name: Option, @@ -581,7 +576,8 @@ pub struct ApplicationContext { /// A order payload to be used when creating an order. #[skip_serializing_none] -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Clone, Builder)] +#[builder(setter(strip_option), default)] pub struct OrderPayload { /// The intent to either capture payment immediately or authorize a payment for an order after order creation. pub intent: Intent, @@ -721,180 +717,4 @@ pub struct Order { pub status: OrderStatus, /// An array of request-related HATEOAS links. To complete payer approval, use the approve link to redirect the payer. pub links: Vec, -} - -impl super::Client { - /// Creates an order. Supports orders with only one purchase unit. - pub async fn create_order( - &mut self, - order: OrderPayload, - header_params: HeaderParams, - ) -> Result { - let builder = { - self.setup_headers( - self.client.post(&format!("{}/v2/checkout/orders", self.endpoint())), - header_params, - ) - .await - }; - let res = builder.json(&order).send().await?; - - if res.status().is_success() { - let order = res.json::().await?; - Ok(order) - } else { - Err(ResponseError::ApiError(res.json::().await?)) - } - } - - /// Used internally for order requests that have no body. - async fn build_endpoint_order( - &mut self, - order_id: &str, - endpoint: &str, - post: bool, - header_params: crate::HeaderParams, - ) -> Result { - let format = format!("{}/v2/checkout/orders/{}/{}", self.endpoint(), order_id, endpoint); - - let builder = self - .setup_headers( - match post { - true => self.client.post(&format), - false => self.client.get(&format), - }, - header_params, - ) - .await; - - let res = builder.send().await?; - - if res.status().is_success() { - let order = res.json::().await?; - Ok(order) - } else { - Err(ResponseError::ApiError(res.json::().await?)) - } - } - - /// Updates an order with the CREATED or APPROVED status. - /// You cannot update an order with the COMPLETED status. - /// - /// Only replacing the existing purchase units and intent is supported right now. - /// - /// Note: You can only update the intent from Authorize to Capture - /// - /// More info on what you can change: https://developer.paypal.com/docs/api/orders/v2/#orders_patch - pub async fn update_order( - &mut self, - id: &str, - intent: Option, - purchase_units: Option>, - ) -> Result<(), ResponseError> { - let mut intent_json = String::new(); - let units_json = String::new(); - - if let Some(p_units) = purchase_units { - let mut units_json = String::new(); - - for (i, unit) in p_units.iter().enumerate() { - let unit_str = serde_json::to_string(&unit).expect("error serializing purchase unit"); - let mut unit_json = format!( - r#" - {{ - "op": "replace", - "path": "/purchase_units/@reference_id='{reference_id}'", - "value": {unit} - }} - "#, - reference_id = unit.reference_id.clone().unwrap_or_else(|| String::from("default")), - unit = unit_str - ); - - if i < p_units.len() - 1 { - unit_json += ","; - } - - units_json.push_str(&unit_json); - } - } - - if let Some(x) = intent { - let intent_str = match x { - Intent::Authorize => String::from("AUTHORIZE"), - Intent::Capture => String::from("CAPTURE"), - }; - - intent_json = format!( - r#" - {{ - "op": "replace", - "path": "/intent", - "value": "{intent}" - }} - "#, - intent = intent_str - ); - } - - let final_json = { - if !intent_json.is_empty() && !units_json.is_empty() { - format!("[{},{}]", intent_json, units_json) - } else { - format!("[{}{}]", intent_json, units_json) - } - }; - - let builder = { - self.setup_headers( - self.client - .patch(&format!("{}/v2/checkout/orders/{}", self.endpoint(), id)), - crate::HeaderParams { - content_type: Some(String::from("application/json")), - ..Default::default() - }, - ) - .await - }; - - let res = builder.body(final_json.clone()).send().await?; - - if res.status().is_success() { - Ok(()) - } else { - Err(ResponseError::ApiError(res.json::().await?)) - } - } - - /// Shows details for an order, by ID. - pub async fn show_order_details(&mut self, order_id: &str) -> Result { - self.build_endpoint_order(order_id, "", false, HeaderParams::default()) - .await - } - - /// Captures payment for an order. To successfully capture payment for an order, - /// the buyer must first approve the order or a valid payment_source must be provided in the request. - /// A buyer can approve the order upon being redirected to the rel:approve URL that was returned in the HATEOAS links in the create order response. - pub async fn capture_order( - &mut self, - order_id: &str, - header_params: crate::HeaderParams, - ) -> Result { - self.build_endpoint_order(order_id, "capture", true, header_params) - .await - } - - /// Authorizes payment for an order. To successfully authorize payment for an order, - /// the buyer must first approve the order or a valid payment_source must be provided in the request. - /// A buyer can approve the order upon being redirected to the rel:approve URL that was returned in the HATEOAS links in the create order response. - pub async fn authorize_order( - &mut self, - order_id: &str, - header_params: HeaderParams, - ) -> Result { - self.build_endpoint_order(order_id, "authorize", true, header_params) - .await - } -} - -// TODO: Add strong typed support for order errors in body: https://developer.paypal.com/docs/api/orders/v2/#errors +} \ No newline at end of file diff --git a/src/endpoint.rs b/src/endpoint.rs new file mode 100644 index 0000000..d152045 --- /dev/null +++ b/src/endpoint.rs @@ -0,0 +1,31 @@ +use std::borrow::Cow; +use serde::{Serialize, de::DeserializeOwned}; +use crate::{SANDBOX_ENDPOINT, LIVE_ENDPOINT}; + +pub trait Endpoint { + type Query: Serialize; + type Body: Serialize; + type Response: DeserializeOwned; + + // The endpoint relative path. Must start with a `/` + fn relative_path(&self) -> Cow; + + // The request method. + fn method(&self) -> reqwest::Method; + + fn query(&self) -> Option<&Self::Query> { + None + } + + fn body(&self) -> Option<&Self::Body> { + None + } + + fn full_path(&self, is_sandbox: bool) -> String { + if is_sandbox { + format!("{}{}", SANDBOX_ENDPOINT, self.relative_path()) + } else { + format!("{}{}", LIVE_ENDPOINT, self.relative_path()) + } + } +} \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs index dd9ba98..724fdfa 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,5 +1,5 @@ //! Errors created by this crate. -use crate::common::LinkDescription; +use crate::data::common::LinkDescription; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::error::Error; diff --git a/src/lib.rs b/src/lib.rs index 3e783d0..241caa0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,10 +19,9 @@ //! ```rust //! use paypal_rs::{ //! Client, -//! HeaderParams, -//! Prefer, -//! orders::{OrderPayload, Intent, PurchaseUnit, Amount}, -//! common::Currency, +//! api::orders::*, +//! data::orders::*, +//! data::common::Currency, //! }; //! //! #[tokio::main] @@ -35,18 +34,15 @@ //! //! client.get_access_token().await.unwrap(); //! -//! let order_payload = OrderPayload::new( -//! Intent::Authorize, -//! vec![PurchaseUnit::new(Amount::new(Currency::EUR, "10.0"))], -//! ); +//! let order = OrderPayloadBuilder::default() +//! .intent(Intent::Authorize) +//! .purchase_units(vec![PurchaseUnit::new(Amount::new(Currency::EUR, "10.0"))]) +//! .build().unwrap(); //! -//! let order = client -//! .create_order( -//! order_payload, -//! HeaderParams::default(), -//! ) -//! .await -//! .unwrap(); +//! let create_order = CreateOrder::new(order); +//! +//! let _order_created = client +//! .execute(create_order).await.unwrap(); //! } //! ``` //! @@ -78,69 +74,24 @@ //! - [ ] Webhooks Management API - 0.14.0 //! - [ ] Payment Experience Web Profiles API - 1.0.0 -#![deny(missing_docs)] +//#![deny(missing_docs)] -pub mod common; +pub mod api; pub mod countries; +pub mod data; +pub mod endpoint; pub mod errors; -pub mod invoice; -pub mod orders; +pub mod client; +pub use client::*; -use errors::{PaypalError, ResponseError}; -use reqwest::header; -use reqwest::header::HeaderMap; -use serde::{Deserialize, Serialize}; +use derive_builder::Builder; +use serde::Serialize; use serde_with::skip_serializing_none; -use std::{borrow::Cow, time::{Duration, Instant}}; /// The paypal api endpoint used on a live application. pub const LIVE_ENDPOINT: &str = "https://api-m.paypal.com"; /// The paypal api endpoint used on when testing. pub const SANDBOX_ENDPOINT: &str = "https://api-m.sandbox.paypal.com"; - -/// Represents the access token returned by the OAuth2 authentication. -/// -/// https://developer.paypal.com/docs/api/get-an-access-token-postman/ -#[derive(Debug, Deserialize)] -pub struct AccessToken { - /// The OAuth2 scopes. - pub scope: String, - /// The access token. - pub access_token: String, - /// The token type. - pub token_type: String, - /// The app id. - pub app_id: String, - /// Seconds until it expires. - pub expires_in: u64, - /// The nonce. - pub nonce: String, -} - -/// Stores OAuth2 information. -#[derive(Debug)] -pub struct Auth { - /// Your client id. - pub client_id: String, - /// The secret. - pub secret: String, - /// The access token returned by oauth2 authentication. - pub access_token: Option, - /// Used to check when the token expires. - pub expires: Option<(Instant, Duration)>, -} - -/// Represents a client used to interact with the paypal api. -#[derive(Debug)] -pub struct Client { - /// Internal http client - pub(crate) client: reqwest::Client, - /// Whether you are or not in a sandbox enviroment. - pub sandbox: bool, - /// Api Auth information - pub auth: Auth, -} - /// Represents the query used in most GET api requests. /// /// Reference: https://developer.paypal.com/docs/api/reference/api-requests/#query-parameters @@ -151,7 +102,7 @@ pub struct Client { /// let query = Query { count: Some(40), ..Default::default() }; /// ``` #[skip_serializing_none] -#[derive(Debug, Default, Serialize)] +#[derive(Debug, Default, Serialize, Builder)] pub struct Query { /// The number of items to list in the response. pub count: Option, @@ -181,7 +132,7 @@ pub struct Query { } /// The preferred server response upon successful completion of the request. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum Prefer { /// The server returns a minimal response to optimize communication between the API caller and the server. /// A minimal response includes the id, status and HATEOAS links. @@ -199,7 +150,7 @@ impl Default for Prefer { /// Represents the optional header values used on paypal requests. /// /// https://developer.paypal.com/docs/api/reference/api-requests/#paypal-auth-assertion -#[derive(Debug, Default)] +#[derive(Debug, Default, Builder, Clone)] pub struct HeaderParams { /// The merchant payer id used on PayPal-Auth-Assertion pub merchant_payer_id: Option, @@ -225,184 +176,15 @@ struct AuthAssertionClaims { pub payer_id: String, } -impl Client { - /// Returns a new client, you must get_access_token afterwards to interact with the api. - /// - /// # Examples - /// - /// ``` - /// use paypal_rs::Client; - /// - /// #[tokio::main] - /// async fn main() { - /// # dotenv::dotenv().ok(); - /// let clientid = std::env::var("PAYPAL_CLIENTID").unwrap(); - /// let secret = std::env::var("PAYPAL_SECRET").unwrap(); - /// - /// let mut client = Client::new( - /// clientid, - /// secret, - /// true, - /// ); - /// client.get_access_token().await.unwrap(); - /// } - /// ``` - pub fn new(client_id: String, secret: String, sandbox: bool) -> Client { - Client { - client: reqwest::Client::new(), - sandbox, - auth: Auth { - client_id, - secret, - access_token: None, - expires: None, - }, - } - } - - fn endpoint(&self) -> &str { - if self.sandbox { - SANDBOX_ENDPOINT - } else { - LIVE_ENDPOINT - } - } - - /// Sets up the request headers as required on https://developer.paypal.com/docs/api/reference/api-requests/#http-request-headers - async fn setup_headers( - &mut self, - builder: reqwest::RequestBuilder, - header_params: HeaderParams, - ) -> reqwest::RequestBuilder { - // Check if the token hasn't expired here, since it's called before any other call. - if let Err(e) = self.get_access_token().await { - log::warn!(target: "paypal-rs", "error getting access token: {:?}", e); - } - - let mut headers = HeaderMap::new(); - - headers.append(header::ACCEPT, "application/json".parse().unwrap()); - - if let Some(token) = &self.auth.access_token { - headers.append( - header::AUTHORIZATION, - format!("Bearer {}", token.access_token).parse().unwrap(), - ); - } - - if let Some(merchant_payer_id) = header_params.merchant_payer_id { - let claims = AuthAssertionClaims { - iss: self.auth.client_id.clone(), - payer_id: merchant_payer_id, - }; - let jwt_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); - let token = jsonwebtoken::encode( - &jwt_header, - &claims, - &jsonwebtoken::EncodingKey::from_secret(self.auth.secret.as_ref()), - ) - .unwrap(); - let encoded_token = base64::encode(token); - headers.append("PayPal-Auth-Assertion", encoded_token.parse().unwrap()); - } - - if let Some(client_metadata_id) = header_params.client_metadata_id { - headers.append("PayPal-Client-Metadata-Id", client_metadata_id.parse().unwrap()); - } - - if let Some(partner_attribution_id) = header_params.partner_attribution_id { - headers.append("PayPal-Partner-Attribution-Id", partner_attribution_id.parse().unwrap()); - } - - if let Some(request_id) = header_params.request_id { - headers.append("PayPal-Request-Id", request_id.parse().unwrap()); - } - - match header_params.prefer { - Prefer::Minimal => headers.append("Prefer", "return=minimal".parse().unwrap()), - Prefer::Representation => headers.append("Prefer", "return=representation".parse().unwrap()), - }; - - if let Some(content_type) = header_params.content_type { - headers.append(header::CONTENT_TYPE, content_type.parse().unwrap()); - } - - builder.headers(headers) - } - - /// Gets a access token used in all the api calls. - pub async fn get_access_token(&mut self) -> Result<(), ResponseError> { - if !self.access_token_expired() { - return Ok(()); - } - let res = self - .client - .post(format!("{}/v1/oauth2/token", self.endpoint()).as_str()) - .basic_auth(&self.auth.client_id, Some(&self.auth.secret)) - .header("Content-Type", "x-www-form-urlencoded") - .header("Accept", "application/json") - .body("grant_type=client_credentials") - .send() - .await - .map_err(ResponseError::HttpError)?; - - if res.status().is_success() { - let token = res.json::().await.map_err(ResponseError::HttpError)?; - self.auth.expires = Some((Instant::now(), Duration::new(token.expires_in, 0))); - self.auth.access_token = Some(token); - Ok(()) - } else { - Err(ResponseError::ApiError( - res.json::().await.map_err(ResponseError::HttpError)?, - )) - } - } - - /// Checks if the access token expired. - pub fn access_token_expired(&self) -> bool { - if let Some(expires) = self.auth.expires { - expires.0.elapsed() >= expires.1 - } else { - true - } - } -} - -pub(crate) trait FromResponse: Sized { - type Response; - - fn from_response(res: Self::Response) -> Self; -} - -pub(crate) trait Endpoint { - type Query: Serialize; - type Body: Serialize; - type Response: FromResponse; - - fn path(&self) -> Cow; - - fn method(&self) -> reqwest::Method { - reqwest::Method::GET - } - - fn query(&self) -> Option<&Self::Query> { - None - } - - fn body(&self) -> Option<&Self::Body> { - None - } -} - #[cfg(test)] mod tests { - use crate::common::Currency; - use crate::countries::Country; - use crate::{orders::*, Client, HeaderParams}; + use crate::{countries::Country}; + use crate::data::common::Currency; + use crate::{Client}; use std::env; use std::str::FromStr; - async fn create_client() -> Client { + pub async fn create_client() -> Client { dotenv::dotenv().ok(); let clientid = env::var("PAYPAL_CLIENTID").unwrap(); let secret = env::var("PAYPAL_SECRET").unwrap(); @@ -410,48 +192,6 @@ mod tests { Client::new(clientid, secret, true) } - #[tokio::test] - async fn test_order() { - let mut client = create_client().await; - - let order = OrderPayload::new( - Intent::Authorize, - vec![PurchaseUnit::new(Amount::new(Currency::EUR, "10.0"))], - ); - - let ref_id = format!( - "TEST-{:?}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - ); - - let order_created = client - .create_order( - order, - HeaderParams { - request_id: Some(ref_id.clone()), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_ne!(order_created.id, ""); - assert_eq!(order_created.status, OrderStatus::Created); - assert_eq!(order_created.links.len(), 4); - - client - .update_order( - &order_created.id, - Some(Intent::Capture), - Some(order_created.purchase_units.expect("to exist")), - ) - .await - .unwrap(); - } - #[test] fn test_currency() { assert_eq!(Currency::EUR.to_string(), "EUR");