use reqwest::Error;
use serde::Deserialize;
const OAUTH_URL: &str =
"https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials";
#[derive(Deserialize, Debug)]
pub struct GenerateAccessTokenResponse {
pub access_token: String,
pub expires_in: String,
}
pub struct Mpesa {
pub consumer_key: String,
pub consumer_secret: String,
}
impl Default for Mpesa {
fn default() -> Self {
Self::new()
}
}
impl Mpesa {
pub fn new() -> Self {
Self {
consumer_key: String::new(),
consumer_secret: String::new(),
}
}
pub fn with_credentials(consumer_key: String, consumer_secret: String) -> Self {
Self {
consumer_key,
consumer_secret,
}
}
pub async fn generate_access_token(self) -> Result<GenerateAccessTokenResponse, Error> {
let http_client = reqwest::Client::new();
http_client
.get(OAUTH_URL)
.basic_auth(self.consumer_key, Some(self.consumer_secret))
.send()
.await?
.error_for_status()?
.json::<GenerateAccessTokenResponse>()
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct TestConfig {
consumer_key: String,
consumer_secret: String,
}
impl TestConfig {
fn load() -> Self {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml");
let contents = std::fs::read_to_string(path)
.expect("copy config.toml.example to config.toml and add sandbox credentials");
toml::from_str(&contents).expect("config.toml is malformed")
}
}
#[test]
fn new_creates_client_with_empty_credentials() {
let mpesa = Mpesa::new();
assert!(mpesa.consumer_key.is_empty());
assert!(mpesa.consumer_secret.is_empty());
}
#[test]
fn with_credentials_sets_consumer_key_and_secret() {
let mpesa = Mpesa::with_credentials("test-key".into(), "test-secret".into());
assert_eq!(mpesa.consumer_key, "test-key");
assert_eq!(mpesa.consumer_secret, "test-secret");
}
#[tokio::test]
async fn generate_access_token_fails_with_invalid_credentials() {
let mpesa = Mpesa::with_credentials("invalid-key".into(), "invalid-secret".into());
let err = mpesa
.generate_access_token()
.await
.expect_err("expected request to fail with invalid credentials");
assert_eq!(err.status(), Some(reqwest::StatusCode::BAD_REQUEST));
}
#[tokio::test]
async fn generate_access_token_returns_valid_response() {
let test_config = TestConfig::load();
let mpesa = Mpesa::with_credentials(test_config.consumer_key, test_config.consumer_secret);
let response = mpesa.generate_access_token().await.unwrap();
assert!(!response.access_token.is_empty());
let expires_in: u64 = response
.expires_in
.parse()
.expect("expires_in should be a positive integer");
assert!(expires_in > 0);
assert!(expires_in <= 3600);
}
}