From 75e7eae2ea297e7ed4773af92cd4318c30fced24 Mon Sep 17 00:00:00 2001 From: Collins Muriuki Date: Mon, 29 Jan 2024 01:46:13 +0300 Subject: [PATCH 1/3] chore!: use derive builder for account balance api --- src/client.rs | 12 +- src/constants.rs | 2 +- src/services/account_balance.rs | 193 +++++++++-------------- src/services/mod.rs | 4 +- tests/mpesa-rust/account_balance_test.rs | 115 ++++---------- 5 files changed, 109 insertions(+), 217 deletions(-) diff --git a/src/client.rs b/src/client.rs index 5721f5e11..9f8211c16 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,13 +12,7 @@ use serde::Serialize; use crate::auth::AUTH; use crate::environment::ApiEnvironment; -use crate::services::{ - AccountBalanceBuilder, B2bBuilder, B2cBuilder, BulkInvoiceBuilder, C2bRegisterBuilder, - C2bSimulateBuilder, CancelInvoiceBuilder, DynamicQR, DynamicQRBuilder, MpesaExpress, - MpesaExpressBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder, - SingleInvoiceBuilder, TransactionReversal, TransactionReversalBuilder, - TransactionStatusBuilder, -}; +use crate::services::*; use crate::{auth, MpesaError, MpesaResult, ResponseError}; /// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) @@ -232,8 +226,8 @@ impl Mpesa { #[cfg(feature = "account_balance")] #[doc = include_str!("../docs/client/account_balance.md")] - pub fn account_balance<'a>(&'a self, initiator_name: &'a str) -> AccountBalanceBuilder { - AccountBalanceBuilder::new(self, initiator_name) + pub fn account_balance<'a>(&'a self) -> AccountBalanceBuilder { + AccountBalance::builder(self) } #[cfg(feature = "express_request")] diff --git a/src/constants.rs b/src/constants.rs index cf99c0865..04b8252aa 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -33,7 +33,7 @@ impl Display for CommandId { /// Identifier types - both sender and receiver - identify an M-Pesa transaction’s sending and receiving party as /// either a shortcode, a till number or a MSISDN (phone number). /// There are three identifier types that can be used with M-Pesa APIs. -#[derive(Debug, Serialize_repr, Deserialize_repr, Copy, Clone)] +#[derive(Debug, Serialize_repr, Deserialize_repr, Copy, Clone, PartialEq, Eq)] #[repr(u16)] pub enum IdentifierTypes { MSISDN = 1, diff --git a/src/services/account_balance.rs b/src/services/account_balance.rs index b9711cf8a..0b13e1fb5 100644 --- a/src/services/account_balance.rs +++ b/src/services/account_balance.rs @@ -1,5 +1,7 @@ #![doc = include_str!("../../docs/client/account_balance.md")] +use derive_builder::Builder; +use reqwest::Url; use serde::{Deserialize, Serialize}; use crate::constants::{CommandId, IdentifierTypes}; @@ -8,24 +10,19 @@ use crate::{Mpesa, MpesaError, MpesaResult}; const ACCOUNT_BALANCE_URL: &str = "mpesa/accountbalance/v1/query"; #[derive(Debug, Serialize)] -/// Account Balance payload -struct AccountBalancePayload<'mpesa> { - #[serde(rename(serialize = "Initiator"))] - initiator: &'mpesa str, - #[serde(rename(serialize = "SecurityCredential"))] - security_credential: &'mpesa str, +#[serde(rename_all = "PascalCase")] +pub struct AccountBalanceRequest<'mpesa> { + pub initiator: &'mpesa str, + pub security_credential: String, #[serde(rename(serialize = "CommandID"))] - command_id: CommandId, - #[serde(rename(serialize = "PartyA"))] - party_a: &'mpesa str, - #[serde(rename(serialize = "IdentifierType"))] - identifier_type: &'mpesa str, - #[serde(rename(serialize = "Remarks"))] - remarks: &'mpesa str, + pub command_id: CommandId, + pub party_a: &'mpesa str, + pub identifier_type: IdentifierTypes, + pub remarks: &'mpesa str, #[serde(rename(serialize = "QueueTimeOutURL"))] - queue_time_out_url: &'mpesa str, + pub queue_time_out_url: Url, #[serde(rename(serialize = "ResultURL"))] - result_url: &'mpesa str, + pub result_url: Url, } #[derive(Debug, Deserialize, Clone)] @@ -39,146 +36,98 @@ pub struct AccountBalanceResponse { #[serde(rename(deserialize = "ResponseDescription"))] pub response_description: String, } -#[derive(Debug)] -pub struct AccountBalanceBuilder<'mpesa> { - initiator_name: &'mpesa str, - client: &'mpesa Mpesa, - command_id: Option, - party_a: Option<&'mpesa str>, - identifier_type: Option, - remarks: Option<&'mpesa str>, - queue_timeout_url: Option<&'mpesa str>, - result_url: Option<&'mpesa str>, -} - -impl<'mpesa> AccountBalanceBuilder<'mpesa> { - /// Creates a new `AccountBalanceBuilder`. - /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request - pub fn new( - client: &'mpesa Mpesa, - initiator_name: &'mpesa str, - ) -> AccountBalanceBuilder<'mpesa> { - AccountBalanceBuilder { - initiator_name, - client, - command_id: None, - party_a: None, - identifier_type: None, - remarks: None, - queue_timeout_url: None, - result_url: None, - } - } +#[derive(Builder, Debug, Clone)] +#[builder(build_fn(error = "MpesaError"))] +pub struct AccountBalance<'mpesa> { + #[builder(pattern = "immutable", private)] + client: &'mpesa Mpesa, + #[builder(setter(into))] + /// The credential/ username used to authenticate the transaction request + initiator_name: &'mpesa str, /// Adds a `CommandId`, the unique command passed to the MPESA system. /// Defaults to `CommandId::AccountBalance` if not passed explicitly. /// /// # Errors /// If `CommandId` is invalid - pub fn command_id(mut self, command_id: CommandId) -> AccountBalanceBuilder<'mpesa> { - self.command_id = Some(command_id); - self - } - + #[builder(default = "crate::CommandId::AccountBalance")] + command_id: CommandId, /// Adds `PartyA`, the shortcode of the organization receiving the transaction. /// This is a required field. /// /// # Errors /// If `Party A` is not provided or invalid - pub fn party_a(mut self, party_a: &'mpesa str) -> AccountBalanceBuilder<'mpesa> { - self.party_a = Some(party_a); - self - } - - /// Adds the `ReceiverIdentifierType`, the type of organization receiving the transaction. + party_a: &'mpesa str, + // Adds the `ReceiverIdentifierType`, the type of organization receiving the transaction. /// Defaults to `IdentifierTypes::ShortCode` if not passed explicitly /// /// # Errors /// If invalid `ReceiverIdentifierType` is provided - pub fn identifier_type( - mut self, - identifier_type: IdentifierTypes, - ) -> AccountBalanceBuilder<'mpesa> { - self.identifier_type = Some(identifier_type); - self - } - + #[builder(default = "crate::IdentifierTypes::ShortCode")] + identifier_type: IdentifierTypes, /// Adds `Remarks`, a comment sent along transaction. /// Optional field that defaults to `"None"` if no value is provided - pub fn remarks(mut self, remarks: &'mpesa str) -> AccountBalanceBuilder<'mpesa> { - self.remarks = Some(remarks); - self - } - + #[builder(setter(into, strip_option), default = "Some(\"None\")")] + remarks: Option<&'mpesa str>, // Adds `QueueTimeoutUrl` This is a required field /// /// # Error /// If `QueueTimeoutUrl` is invalid or not provided - pub fn timeout_url(mut self, timeout_url: &'mpesa str) -> AccountBalanceBuilder<'mpesa> { - self.queue_timeout_url = Some(timeout_url); - self - } - + #[builder(try_setter, setter(into))] + queue_timeout_url: Url, // Adds `ResultUrl` This is a required field /// /// # Error /// If `ResultUrl` is invalid or not provided - pub fn result_url(mut self, result_url: &'mpesa str) -> AccountBalanceBuilder<'mpesa> { - self.result_url = Some(result_url); - self - } + #[builder(try_setter, setter(into))] + result_url: Url, +} - /// Adds `QueueTimeoutUrl` and `ResultUrl`. This is a required field - /// - /// # Error - /// If either `QueueTimeoutUrl` and `ResultUrl` is invalid or not provided - #[deprecated] - pub fn urls( - mut self, - timeout_url: &'mpesa str, - result_url: &'mpesa str, - ) -> AccountBalanceBuilder<'mpesa> { - self.queue_timeout_url = Some(timeout_url); - self.result_url = Some(result_url); - self +impl<'mpesa> TryFrom> for AccountBalanceRequest<'mpesa> { + type Error = MpesaError; + + fn try_from(value: AccountBalance<'mpesa>) -> MpesaResult { + Ok(AccountBalanceRequest { + command_id: value.command_id, + identifier_type: value.identifier_type, + initiator: value.initiator_name, + party_a: value.party_a, + queue_time_out_url: value.queue_timeout_url, + remarks: value.remarks.unwrap_or_default(), + result_url: value.result_url, + security_credential: value.client.gen_security_credentials()?, + }) } +} - /// # AccountBalance API - /// - /// Enquire the balance on an M-Pesa BuyGoods (Till Number). - /// A successful request returns a `C2bRegisterResponse` type. - /// See more [here](https://developer.safaricom.co.ke/docs#account-balance-api) - /// - /// # Errors - /// Returns a `MpesaError` on failure - pub async fn send(self) -> MpesaResult { - let credentials = self.client.gen_security_credentials()?; +impl<'mpesa> AccountBalance<'mpesa> { + /// Creates a new `AccountBalanceBuilder` + pub(crate) fn builder(client: &'mpesa Mpesa) -> AccountBalanceBuilder<'mpesa> { + AccountBalanceBuilder::default().client(client) + } - let payload = AccountBalancePayload { - command_id: self.command_id.unwrap_or(CommandId::AccountBalance), - party_a: self - .party_a - .ok_or(MpesaError::Message("party_a is required"))?, - identifier_type: &self - .identifier_type - .unwrap_or(IdentifierTypes::ShortCode) - .to_string(), - remarks: self.remarks.unwrap_or_else(|| stringify!(None)), - initiator: self.initiator_name, - queue_time_out_url: self - .queue_timeout_url - .ok_or(MpesaError::Message("queue_timeout_url is required"))?, - result_url: self - .result_url - .ok_or(MpesaError::Message("result_url is required"))?, - security_credential: &credentials, - }; + pub fn from_request( + client: &'mpesa Mpesa, + request: AccountBalanceRequest<'mpesa>, + ) -> AccountBalance<'mpesa> { + AccountBalance { + client, + command_id: request.command_id, + identifier_type: request.identifier_type, + initiator_name: request.initiator, + party_a: request.party_a, + queue_timeout_url: request.queue_time_out_url, + remarks: Some(request.remarks), + result_url: request.result_url, + } + } + pub async fn send(self) -> MpesaResult { self.client - .send(crate::client::Request { + .send::(crate::client::Request { method: reqwest::Method::POST, path: ACCOUNT_BALANCE_URL, - body: payload, + body: self.try_into()?, }) .await } diff --git a/src/services/mod.rs b/src/services/mod.rs index 5f2acc123..68919b8a3 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -29,7 +29,9 @@ mod transaction_reversal; mod transaction_status; #[cfg(feature = "account_balance")] -pub use account_balance::{AccountBalanceBuilder, AccountBalanceResponse}; +pub use account_balance::{ + AccountBalance, AccountBalanceBuilder, AccountBalanceRequest, AccountBalanceResponse, +}; #[cfg(feature = "b2b")] pub use b2b::{B2bBuilder, B2bResponse}; #[cfg(feature = "b2c")] diff --git a/tests/mpesa-rust/account_balance_test.rs b/tests/mpesa-rust/account_balance_test.rs index ebbc65a9d..104ec73a4 100644 --- a/tests/mpesa-rust/account_balance_test.rs +++ b/tests/mpesa-rust/account_balance_test.rs @@ -1,4 +1,4 @@ -use mpesa::MpesaError; +use mpesa::services::{AccountBalance, AccountBalanceRequest}; use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, ResponseTemplate}; @@ -6,7 +6,7 @@ use wiremock::{Mock, ResponseTemplate}; use crate::get_mpesa_client; #[tokio::test] -async fn account_balance_success() { +async fn account_balance_using_builder_pattern() { let (client, server) = get_mpesa_client!(); let sample_response_body = json!({ "OriginatorConversationID": "29464-48063588-1", @@ -21,10 +21,15 @@ async fn account_balance_success() { .mount(&server) .await; let response = client - .account_balance("testapi496") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") + .account_balance() + .initiator_name("testapi496") + .try_result_url("https://testdomain.com/ok") + .unwrap() + .try_queue_timeout_url("https://testdomain.com/err") + .unwrap() .party_a("600496") + .build() + .unwrap() .send() .await .unwrap(); @@ -38,70 +43,8 @@ async fn account_balance_success() { } #[tokio::test] -async fn account_balance_fails_if_party_a_is_not_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "OriginatorConversationID": "29464-48063588-1", - "ConversationID": "AG_20230206_201056794190723278ff", - "ResponseDescription": "Accept the service request successfully.", - "ResponseCode": "0" - }); - Mock::given(method("POST")) - .and(path("/mpesa/accountbalance/v1/query")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .account_balance("testapi496") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "party_a is required") - } else { - panic!("Expected error"); - } -} - -#[tokio::test] -async fn account_balance_fails_if_result_url_is_not_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "OriginatorConversationID": "29464-48063588-1", - "ConversationID": "AG_20230206_201056794190723278ff", - "ResponseDescription": "Accept the service request successfully.", - "ResponseCode": "0" - }); - Mock::given(method("POST")) - .and(path("/mpesa/accountbalance/v1/query")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .account_balance("testapi496") - .party_a("600496") - .timeout_url("https://testdomain.com/err") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "result_url is required") - } else { - panic!("Expected error"); - } -} - -#[tokio::test] -async fn account_balance_fails_if_queue_timeout_url_is_not_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); +async fn account_balance_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); let sample_response_body = json!({ "OriginatorConversationID": "29464-48063588-1", "ConversationID": "AG_20230206_201056794190723278ff", @@ -111,21 +54,25 @@ async fn account_balance_fails_if_queue_timeout_url_is_not_provided() { Mock::given(method("POST")) .and(path("/mpesa/accountbalance/v1/query")) .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) + .expect(1) .mount(&server) .await; - if let Err(e) = client - .account_balance("testapi496") - .party_a("600496") - .result_url("https://testdomain.com/ok") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "queue_timeout_url is required") - } else { - panic!("Expected error"); - } + let request = AccountBalanceRequest { + command_id: mpesa::CommandId::AccountBalance, + identifier_type: mpesa::IdentifierTypes::TillNumber, + initiator: "testapi496", + party_a: "600496", + queue_time_out_url: "https://testdomain.com/err".try_into().unwrap(), + remarks: "None", + result_url: "https://testdomain.com/ok".try_into().unwrap(), + security_credential: "dummy".to_owned() + }; + let response = AccountBalance::from_request(&client, request).send().await.unwrap(); + assert_eq!(response.originator_conversation_id, "29464-48063588-1"); + assert_eq!(response.conversation_id, "AG_20230206_201056794190723278ff"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); } From 10a2097e80e3902b9e19181ad8cc80e1d1b82bb6 Mon Sep 17 00:00:00 2001 From: Collins Muriuki Date: Mon, 29 Jan 2024 01:47:04 +0300 Subject: [PATCH 2/3] feat: expose `gen_security_credentials` to public api --- src/client.rs | 2 +- tests/mpesa-rust/account_balance_test.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client.rs b/src/client.rs index 9f8211c16..bcff05ba4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -261,7 +261,7 @@ impl Mpesa { /// /// # Errors /// Returns `EncryptionError` variant of `MpesaError` - pub(crate) fn gen_security_credentials(&self) -> MpesaResult { + pub fn gen_security_credentials(&self) -> MpesaResult { let pem = self.certificate.as_bytes(); let cert = X509::from_pem(pem)?; // getting the public and rsa keys diff --git a/tests/mpesa-rust/account_balance_test.rs b/tests/mpesa-rust/account_balance_test.rs index 104ec73a4..8faa37be0 100644 --- a/tests/mpesa-rust/account_balance_test.rs +++ b/tests/mpesa-rust/account_balance_test.rs @@ -65,7 +65,7 @@ async fn account_balance_using_struct_initialization() { queue_time_out_url: "https://testdomain.com/err".try_into().unwrap(), remarks: "None", result_url: "https://testdomain.com/ok".try_into().unwrap(), - security_credential: "dummy".to_owned() + security_credential: client.gen_security_credentials().unwrap() }; let response = AccountBalance::from_request(&client, request).send().await.unwrap(); assert_eq!(response.originator_conversation_id, "29464-48063588-1"); From 3c3b93bc115eca1057c93a26c98a646ac0060ab6 Mon Sep 17 00:00:00 2001 From: Collins Muriuki Date: Mon, 29 Jan 2024 01:49:46 +0300 Subject: [PATCH 3/3] fix: Fix clippy and rustfmt ci failures --- src/client.rs | 2 +- tests/mpesa-rust/account_balance_test.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/client.rs b/src/client.rs index bcff05ba4..f70ad9241 100644 --- a/src/client.rs +++ b/src/client.rs @@ -226,7 +226,7 @@ impl Mpesa { #[cfg(feature = "account_balance")] #[doc = include_str!("../docs/client/account_balance.md")] - pub fn account_balance<'a>(&'a self) -> AccountBalanceBuilder { + pub fn account_balance(&self) -> AccountBalanceBuilder { AccountBalance::builder(self) } diff --git a/tests/mpesa-rust/account_balance_test.rs b/tests/mpesa-rust/account_balance_test.rs index 8faa37be0..f2233304e 100644 --- a/tests/mpesa-rust/account_balance_test.rs +++ b/tests/mpesa-rust/account_balance_test.rs @@ -65,9 +65,12 @@ async fn account_balance_using_struct_initialization() { queue_time_out_url: "https://testdomain.com/err".try_into().unwrap(), remarks: "None", result_url: "https://testdomain.com/ok".try_into().unwrap(), - security_credential: client.gen_security_credentials().unwrap() + security_credential: client.gen_security_credentials().unwrap(), }; - let response = AccountBalance::from_request(&client, request).send().await.unwrap(); + let response = AccountBalance::from_request(&client, request) + .send() + .await + .unwrap(); assert_eq!(response.originator_conversation_id, "29464-48063588-1"); assert_eq!(response.conversation_id, "AG_20230206_201056794190723278ff"); assert_eq!(