Skip to content

Commit 7d6eafd

Browse files
committed
add signature and error handling
1 parent 38843b4 commit 7d6eafd

File tree

6 files changed

+324
-107
lines changed

6 files changed

+324
-107
lines changed

http_client/src/vonage_http_client/auth.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from base64 import b64encode
2-
from typing import Optional
2+
from typing import Literal, Optional
3+
import hashlib
4+
import hmac
5+
from time import time
36

47
from pydantic import validate_call
58
from vonage_jwt.jwt import JwtClient
@@ -10,11 +13,17 @@
1013
class Auth:
1114
"""Deals with Vonage API authentication.
1215
16+
Some Vonage APIs require an API key and secret for authentication. Others require an application ID and JWT.
17+
It is also possible to use a message signature with the SMS API.
18+
1319
Args:
1420
- api_key (str): The API key for authentication.
1521
- api_secret (str): The API secret for authentication.
1622
- application_id (str): The application ID for JWT authentication.
1723
- private_key (str): The private key for JWT authentication.
24+
- signature_secret (str): The signature secret for authentication.
25+
- signature_method (str): The signature method for authentication.
26+
This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. If you want to use a simple MD5 hash, leave this as `None`.
1827
1928
Note:
2029
To use JWT authentication, provide values for both `application_id` and `private_key`.
@@ -27,8 +36,8 @@ def __init__(
2736
api_secret: Optional[str] = None,
2837
application_id: Optional[str] = None,
2938
private_key: Optional[str] = None,
30-
signature: Optional[str] = None,
31-
signature_method: Optional[str] = None,
39+
signature_secret: Optional[str] = None,
40+
signature_method: Optional[Literal['md5', 'sha1', 'sha256', 'sha512']] = None,
3241
) -> None:
3342
self._validate_input_combinations(
3443
api_key, api_secret, application_id, private_key
@@ -40,6 +49,9 @@ def __init__(
4049
if application_id is not None and private_key is not None:
4150
self._jwt_client = JwtClient(application_id, private_key)
4251

52+
self._signature_secret = signature_secret
53+
self._signature_method = getattr(hashlib, signature_method)
54+
4355
@property
4456
def api_key(self):
4557
return self._api_key
@@ -65,6 +77,42 @@ def create_basic_auth_string(self):
6577
)
6678
return f'Basic {hash}'
6779

80+
def sign_params(self, params: dict) -> dict:
81+
"""
82+
Signs the provided message parameters using the signature secret provided to the `Auth` class.
83+
If no signature secret is provided, the message parameters are signed using a simple MD5 hash.
84+
85+
Args:
86+
params (dict): The message parameters to be signed.
87+
88+
Returns:
89+
dict: The signed message parameters.
90+
"""
91+
92+
if self._signature_method:
93+
hasher = hmac.new(
94+
self._signature_secret.encode(),
95+
digestmod=self._signature_method,
96+
)
97+
else:
98+
hasher = hashlib.md5()
99+
100+
if not params.get("timestamp"):
101+
params["timestamp"] = int(time())
102+
103+
for key in sorted(params):
104+
value = params[key]
105+
106+
if isinstance(value, str):
107+
value = value.replace("&", "_").replace("=", "_")
108+
109+
hasher.update(f"&{key}={value}".encode("utf-8"))
110+
111+
if self._signature_method is None:
112+
hasher.update(self._signature_secret.encode())
113+
114+
return hasher.hexdigest()
115+
68116
def _validate_input_combinations(
69117
self, api_key, api_secret, application_id, private_key
70118
):

http_client/src/vonage_http_client/http_client.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def post(
105105
request_path: str = '',
106106
params: dict = None,
107107
auth_type: str = 'jwt',
108-
):
108+
) -> Union[dict, None]:
109109
return self.make_request('POST', host, request_path, params, auth_type)
110110

111111
def get(
@@ -114,7 +114,7 @@ def get(
114114
request_path: str = '',
115115
params: dict = None,
116116
auth_type: str = 'jwt',
117-
):
117+
) -> Union[dict, None]:
118118
return self.make_request('GET', host, request_path, params, auth_type)
119119

120120
@validate_call
@@ -124,7 +124,7 @@ def make_request(
124124
host: str,
125125
request_path: str = '',
126126
params: Optional[dict] = None,
127-
auth_type: Literal['jwt', 'basic'] = 'jwt',
127+
auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt',
128128
):
129129
url = f'https://{host}{request_path}'
130130
logger.debug(
@@ -134,6 +134,16 @@ def make_request(
134134
self._headers['Authorization'] = self._auth.create_jwt_auth_string()
135135
elif auth_type == 'basic':
136136
self._headers['Authorization'] = self._auth.create_basic_auth_string()
137+
elif auth_type == 'signature':
138+
params = self._auth.sign_params(params)
139+
with self._session.request(
140+
request_type,
141+
url,
142+
params=params,
143+
headers=self._headers,
144+
timeout=self._timeout,
145+
) as response:
146+
return self._parse_response(response)
137147

138148
with self._session.request(
139149
request_type,

http_client/tests/test_auth.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from os.path import dirname, join
22
from unittest.mock import patch
3+
import hashlib
4+
35

46
from pydantic import ValidationError
57
from pytest import raises
@@ -102,3 +104,55 @@ def test_create_jwt_error_no_application_id_or_private_key():
102104
def test_create_basic_auth_string():
103105
auth = Auth(api_key=api_key, api_secret=api_secret)
104106
assert auth.create_basic_auth_string() == 'Basic cXdlcmFzZGY6MTIzNHF3ZXJhc2Rmenhjdg=='
107+
108+
109+
def test_auth_init_with_valid_combinations():
110+
api_key = 'qwerasdf'
111+
api_secret = '1234qwerasdfzxcv'
112+
application_id = 'asdfzxcv'
113+
private_key = 'dummy_private_key'
114+
signature_secret = 'signature_secret'
115+
signature_method = 'sha256'
116+
117+
auth = Auth(
118+
api_key=api_key,
119+
api_secret=api_secret,
120+
application_id=application_id,
121+
private_key=private_key,
122+
signature_secret=signature_secret,
123+
signature_method=signature_method,
124+
)
125+
126+
assert auth._api_key == api_key
127+
assert auth._api_secret == api_secret
128+
assert auth._jwt_client.application_id == application_id
129+
assert auth._jwt_client.private_key == private_key
130+
assert auth._signature_secret == signature_secret
131+
assert auth._signature_method == hashlib.sha256
132+
133+
134+
def test_auth_init_with_invalid_combinations():
135+
api_key = 'qwerasdf'
136+
api_secret = '1234qwerasdfzxcv'
137+
application_id = 'asdfzxcv'
138+
private_key = 'dummy_private_key'
139+
signature_secret = 'signature_secret'
140+
signature_method = 'invalid_method'
141+
142+
with patch('vonage_http_client.auth.hashlib') as mock_hashlib:
143+
mock_hashlib.sha256.side_effect = AttributeError
144+
145+
auth = Auth(
146+
api_key=api_key,
147+
api_secret=api_secret,
148+
application_id=application_id,
149+
private_key=private_key,
150+
signature_secret=signature_secret,
151+
signature_method=signature_method,
152+
)
153+
154+
assert auth._api_key == api_key
155+
assert auth._api_secret == api_secret
156+
assert auth._jwt_client is None
157+
assert auth._signature_secret == signature_secret
158+
assert auth._signature_method is None

sms/src/vonage_sms/errors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1+
from requests import Response
12
from vonage_utils.errors import VonageError
23

34

45
class SmsError(VonageError):
56
"""Indicates an error with the Vonage SMS Package."""
7+
8+
9+
class PartialFailureError(SmsError):
10+
"""Indicates that a request was partially successful."""
11+
12+
def __init__(self, response: Response):
13+
self.message = (
14+
'Sms.send_message method partially failed. Not all of the message(s) sent successfully.',
15+
)
16+
super().__init__(self.message)
17+
self.response = response

sms/src/vonage_sms/sms.py

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,104 @@
11
from copy import deepcopy
22
from dataclasses import dataclass
3-
from typing import List, Literal, Optional, Union
3+
from typing import List, Literal, Optional
44

55
from pydantic import BaseModel, Field, field_validator, validate_call
66
from vonage_http_client.http_client import HttpClient
7-
from vonage_sms.errors import SmsError
7+
8+
from .errors import SmsError, PartialFailureError
89

910

1011
class SmsMessage(BaseModel):
1112
to: str
1213
from_: str = Field(..., alias="from")
1314
text: str
14-
type: Optional[str] = None
1515
sig: Optional[str] = Field(None, min_length=16, max_length=60)
16-
status_report_req: Optional[int] = Field(
17-
None,
18-
alias="status-report-req",
19-
description="Set to 1 to receive a Delivery Receipt",
20-
)
21-
client_ref: Optional[str] = Field(
22-
None, alias="client-ref", description="Your own reference. Up to 40 characters."
23-
)
24-
network_code: Optional[str] = Field(
25-
None,
26-
alias="network-code",
27-
description="A 4-5 digit number that represents the mobile carrier network code",
28-
)
16+
client_ref: Optional[str] = Field(None, alias="client-ref", max_length=100)
17+
type: Optional[Literal['text', 'binary', 'unicode']] = None
18+
ttl: Optional[int] = Field(None, ge=20000, le=604800000)
19+
status_report_req: Optional[bool] = Field(None, alias='status-report-req')
20+
callback: Optional[str] = Field(None, max_length=100)
21+
message_class: Optional[int] = Field(None, alias='message-class', ge=0, le=3)
22+
body: Optional[str] = None
23+
udh: Optional[str] = None
24+
protocol_id: Optional[int] = Field(None, alias='protocol-id', ge=0, le=255)
25+
account_ref: Optional[str] = Field(None, alias='account-ref')
26+
entity_id: Optional[str] = Field(None, alias='entity-id')
27+
content_id: Optional[str] = Field(None, alias='content-id')
28+
29+
@field_validator('body', 'udh')
30+
@classmethod
31+
def validate_body(cls, value, values):
32+
if 'type' not in values or not values['type'] == 'binary':
33+
raise ValueError(
34+
'This parameter can only be set when the "type" parameter is set to "binary".'
35+
)
36+
if values['type'] == 'binary' and not value:
37+
raise ValueError('This parameter is required for binary messages.')
38+
39+
40+
@dataclass
41+
class MessageResponse:
42+
to: str
43+
message_id: str
44+
status: str
45+
remaining_balance: str
46+
message_price: str
47+
network: str
48+
client_ref: Optional[str] = None
49+
account_ref: Optional[str] = None
2950

3051

3152
@dataclass
3253
class SmsResponse:
33-
id: str
54+
message_count: str
55+
messages: List[MessageResponse]
3456

3557

3658
class Sms:
3759
"""Calls Vonage's SMS API."""
3860

3961
def __init__(self, http_client: HttpClient) -> None:
4062
self._http_client = deepcopy(http_client)
41-
self._auth_type = 'basic'
63+
if self._http_client._auth._signature_secret:
64+
self._auth_type = 'signature'
65+
else:
66+
self._auth_type = 'basic'
4267

4368
@validate_call
4469
def send(self, message: SmsMessage) -> SmsResponse:
4570
"""Send an SMS message."""
4671
response = self._http_client.post(
47-
self._http_client.api_host,
48-
'/v2/ni',
49-
message.model_dump(),
72+
self._http_client.rest_host,
73+
'/sms/json',
74+
message.model_dump(by_alias=True),
5075
self._auth_type,
5176
)
77+
78+
if int(response['message-count']) > 1:
79+
self.check_for_partial_failure(response)
80+
else:
81+
self.check_for_error(response)
82+
83+
messages = []
84+
for message in response['messages']:
85+
messages.append(MessageResponse(**message))
86+
87+
return SmsResponse(message_count=response['message-count'], messages=messages)
88+
89+
def check_for_partial_failure(self, response_data):
90+
successful_messages = 0
91+
total_messages = int(response_data['message-count'])
92+
93+
for message in response_data['messages']:
94+
if message['status'] == '0':
95+
successful_messages += 1
96+
if successful_messages < total_messages:
97+
raise PartialFailureError(response_data)
98+
99+
def check_for_error(self, response_data):
100+
message = response_data['messages'][0]
101+
if int(message['status']) != 0:
102+
raise SmsError(
103+
f'Sms.send_message method failed with error code {message["status"]}: {message["error-text"]}'
104+
)

0 commit comments

Comments
 (0)