Skip to content

Commit ffd51ae

Browse files
authored
Merge pull request #99 from RafaelJohn9/develop
Develop
2 parents 1fbd53b + 033570f commit ffd51ae

19 files changed

Lines changed: 484 additions & 26 deletions

File tree

docs/docs/transaction-status.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ from mpesakit.transaction_status import TransactionStatusIdentifierType
139139
140140
client = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
141141
142-
resp = client.transaction.query_status(
142+
resp = client.transactions.query_status(
143143
initiator="api_user",
144144
security_credential="ENCRYPTED_CREDENTIAL",
145145
transaction_id="LK12345",

mpesakit/account_balance/account_balance.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ def query(self, request: AccountBalanceRequest) -> AccountBalanceResponse:
4343
"Authorization": f"Bearer {self.token_manager.get_token()}",
4444
"Content-Type": "application/json",
4545
}
46-
response_data = self.http_client.post(url, json=dict(request), headers=headers)
46+
response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers)
4747
return AccountBalanceResponse(**response_data)

mpesakit/b2c/b2c.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ def send_payment(self, request: B2CRequest) -> B2CResponse:
4343
"Authorization": f"Bearer {self.token_manager.get_token()}",
4444
"Content-Type": "application/json",
4545
}
46-
response_data = self.http_client.post(url, json=dict(request), headers=headers)
46+
response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers)
4747
return B2CResponse(**response_data)

mpesakit/business_buy_goods/business_buy_goods.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ def buy_goods(self, request: BusinessBuyGoodsRequest) -> BusinessBuyGoodsRespons
4343
"Authorization": f"Bearer {self.token_manager.get_token()}",
4444
"Content-Type": "application/json",
4545
}
46-
response_data = self.http_client.post(url, json=dict(request), headers=headers)
46+
response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers)
4747
return BusinessBuyGoodsResponse(**response_data)

mpesakit/business_paybill/business_paybill.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ def paybill(self, request: BusinessPayBillRequest) -> BusinessPayBillResponse:
4343
"Authorization": f"Bearer {self.token_manager.get_token()}",
4444
"Content-Type": "application/json",
4545
}
46-
response_data = self.http_client.post(url, json=dict(request), headers=headers)
46+
response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers)
4747
return BusinessPayBillResponse(**response_data)

mpesakit/c2b/c2b.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def register_url(self, request: C2BRegisterUrlRequest) -> C2BRegisterUrlResponse
4141
"Authorization": f"Bearer {self.token_manager.get_token()}",
4242
"Content-Type": "application/json",
4343
}
44-
response_data = self.http_client.post(url, json=dict(request), headers=headers)
44+
response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers)
4545

4646
# Safaricom API Bug: There is a typo in the response field name
4747
# "OriginatorCoversationID" should be "OriginatorConversationID"

mpesakit/dynamic_qr_code/dynamic_qr_code.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,6 @@ def generate(self, request: DynamicQRGenerateRequest) -> DynamicQRGenerateRespon
4444
"Content-Type": "application/json",
4545
}
4646

47-
response_data = self.http_client.post(url, json=dict(request), headers=headers)
47+
response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers)
4848

4949
return DynamicQRGenerateResponse(**response_data)

mpesakit/http_client/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from .http_client import HttpClient
1+
from .http_client import HttpClient,AsyncHttpClient
22
from .mpesa_http_client import MpesaHttpClient
3+
from .mpesa_async_http_client import MpesaAsyncHttpClient
34

4-
__all__ = ["HttpClient", "MpesaHttpClient"]
5+
__all__ = ["HttpClient", "MpesaHttpClient","AsyncHttpClient","MpesaAsyncHttpClient"]

mpesakit/http_client/http_client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,28 @@ def get(
2626
) -> Dict[str, Any]:
2727
"""Sends a GET request."""
2828
pass
29+
30+
class AsyncHttpClient(ABC):
31+
"""Abstract base HTTP client for making asynchronous GET and POST requests.
32+
33+
This is the base class for all asynchronous HTTP client implementations.
34+
It provides the foundation for making asynchronous HTTP requests, which are
35+
essential for interacting with the Mpesa API non-blockingly.
36+
"""
37+
38+
@abstractmethod
39+
async def post(
40+
self, url: str, json: Dict[str, Any], headers: Dict[str, str]
41+
) -> Dict[str, Any]:
42+
"""Sends an asynchronous POST request."""
43+
pass
44+
45+
@abstractmethod
46+
async def get(
47+
self,
48+
url: str,
49+
params: Optional[Dict[str, Any]] = None,
50+
headers: Optional[Dict[str, str]] = None,
51+
) -> Dict[str, Any]:
52+
"""Sends an asynchronous GET request."""
53+
pass
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""MpesaAsyncHttpClient: An asynchronous client for making HTTP requests to the M-Pesa API."""
2+
3+
from typing import Dict, Any, Optional
4+
import httpx
5+
6+
from mpesakit.errors import MpesaError, MpesaApiException
7+
from .http_client import AsyncHttpClient
8+
9+
10+
class MpesaAsyncHttpClient(AsyncHttpClient):
11+
"""An asynchronous client for making HTTP requests to the M-Pesa API.
12+
13+
This client handles asynchronous GET and POST requests using the httpx library.
14+
It supports both sandbox and production environments.
15+
16+
Attributes:
17+
base_url (str): The base URL for the M-Pesa API.
18+
"""
19+
20+
base_url: str
21+
_client: httpx.AsyncClient
22+
23+
def __init__(self, env: str = "sandbox"):
24+
"""Initializes the MpesaAsyncHttpClient with the specified environment."""
25+
self.base_url = self._resolve_base_url(env)
26+
self._client = httpx.AsyncClient(base_url=self.base_url)
27+
28+
def _resolve_base_url(self, env: str) -> str:
29+
if env.lower() == "production":
30+
return "https://api.safaricom.co.ke"
31+
return "https://sandbox.safaricom.co.ke"
32+
33+
34+
async def __aenter__(self):
35+
return self
36+
37+
async def __aexit__(self, exc_type, exc_val, exc_tb):
38+
await self._client.aclose()
39+
40+
41+
async def post(
42+
self, url: str, json: Dict[str, Any], headers: Dict[str, str]
43+
) -> Dict[str, Any]:
44+
"""Sends an asynchronous POST request to the M-Pesa API."""
45+
try:
46+
47+
response = await self._client.post(
48+
url, json=json, headers=headers, timeout=10
49+
)
50+
51+
52+
try:
53+
response_data = response.json()
54+
except ValueError:
55+
response_data = {"errorMessage": response.text.strip() or ""}
56+
57+
if not response.is_success:
58+
error_message = response_data.get("errorMessage", "")
59+
raise MpesaApiException(
60+
MpesaError(
61+
error_code=f"HTTP_{response.status_code}",
62+
error_message=error_message,
63+
status_code=response.status_code,
64+
raw_response=response_data,
65+
)
66+
)
67+
68+
return response_data
69+
70+
except httpx.TimeoutException:
71+
raise MpesaApiException(
72+
MpesaError(
73+
error_code="REQUEST_TIMEOUT",
74+
error_message="Request to Mpesa timed out.",
75+
status_code=None,
76+
)
77+
)
78+
except httpx.ConnectError:
79+
raise MpesaApiException(
80+
MpesaError(
81+
error_code="CONNECTION_ERROR",
82+
error_message="Failed to connect to Mpesa API. Check network or URL.",
83+
status_code=None,
84+
)
85+
)
86+
except httpx.HTTPError as e:
87+
88+
raise MpesaApiException(
89+
MpesaError(
90+
error_code="REQUEST_FAILED",
91+
error_message=f"HTTP request failed: {str(e)}",
92+
status_code=None,
93+
raw_response=None,
94+
)
95+
)
96+
97+
async def get(
98+
self,
99+
url: str,
100+
params: Optional[Dict[str, Any]] = None,
101+
headers: Optional[Dict[str, str]] = None,
102+
) -> Dict[str, Any]:
103+
"""Sends an asynchronous GET request to the M-Pesa API."""
104+
try:
105+
if headers is None:
106+
headers = {}
107+
108+
response = await self._client.get(
109+
url, params=params, headers=headers, timeout=10
110+
)
111+
112+
try:
113+
response_data = response.json()
114+
except ValueError:
115+
response_data = {"errorMessage": response.text.strip() or ""}
116+
117+
if not response.is_success:
118+
error_message = response_data.get("errorMessage", "")
119+
raise MpesaApiException(
120+
MpesaError(
121+
error_code=f"HTTP_{response.status_code}",
122+
error_message=error_message,
123+
status_code=response.status_code,
124+
raw_response=response_data,
125+
)
126+
)
127+
128+
return response_data
129+
130+
131+
except httpx.TimeoutException:
132+
raise MpesaApiException(
133+
MpesaError(
134+
error_code="REQUEST_TIMEOUT",
135+
error_message="Request to Mpesa timed out.",
136+
status_code=None,
137+
)
138+
)
139+
except httpx.ConnectError:
140+
raise MpesaApiException(
141+
MpesaError(
142+
error_code="CONNECTION_ERROR",
143+
error_message="Failed to connect to Mpesa API. Check network or URL.",
144+
status_code=None,
145+
)
146+
)
147+
except httpx.HTTPError as e:
148+
raise MpesaApiException(
149+
MpesaError(
150+
error_code="REQUEST_FAILED",
151+
error_message=f"HTTP request failed: {str(e)}",
152+
status_code=None,
153+
raw_response=None,
154+
)
155+
)
156+
157+
async def aclose(self):
158+
"""Manually close the underlying httpx client connection pool."""
159+
await self._client.aclose()

0 commit comments

Comments
 (0)