Skip to content

Commit

Permalink
add http client module and custom error parsing logic, expand test suite
Browse files Browse the repository at this point in the history
  • Loading branch information
maxkahan committed Jan 31, 2024
1 parent ae69ee3 commit b76c207
Show file tree
Hide file tree
Showing 23 changed files with 461 additions and 68 deletions.
7 changes: 1 addition & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,4 @@ repos:
rev: v4.4.0
hooks:
- id: check-yaml
- id: trailing-whitespace
- repo: https://github.com/ambv/black
rev: 23.7.0
hooks:
- id: black
language_version: python3.11
- id: trailing-whitespace
2 changes: 1 addition & 1 deletion http_client/BUILD
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
resource(name='pyproject', source='pyproject.toml')

resource(name='dummy_private_key', source='tests/data/dummy_private_key.txt')
files(sources=['tests/data/*'])

python_distribution(
name='vonage-http-client',
Expand Down
2 changes: 1 addition & 1 deletion http_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = 'vonage-http-client'
description = 'An asynchronous HTTP client for making requests to Vonage APIs.'
version = '0.1.0'
dependencies = ['vonage-jwt']
dependencies = ['vonage-jwt', 'requests']

[build-system]
requires = ["setuptools>=61.0", "wheel"]
Expand Down
14 changes: 7 additions & 7 deletions http_client/src/http_client/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import base64
from base64 import b64encode
from typing import Optional

from pydantic import validate_call
Expand Down Expand Up @@ -54,21 +54,21 @@ def jwt_claims(self, claims: dict):
self._jwt_claims = claims

def create_jwt_auth_string(self):
return b"Bearer " + self.generate_application_jwt()
return b'Bearer ' + self.generate_application_jwt()

def generate_application_jwt(self):
try:
return self._jwt_client.generate_application_jwt(self._jwt_claims)
except AttributeError as err:
if "_jwt_client" in str(err):
if '_jwt_client' in str(err):
raise JWTGenerationError(
'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".'
)
else:
raise err

def create_basic_auth_string(self):
hash = base64.b64encode(
f"{self.api_key}:{self.api_secret}".encode("utf-8")
).decode("ascii")
return f"Basic {hash}"
hash = b64encode(f'{self.api_key}:{self.api_secret}'.encode('utf-8')).decode(
'ascii'
)
return f'Basic {hash}'
95 changes: 95 additions & 0 deletions http_client/src/http_client/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,100 @@
from json import JSONDecodeError

from requests import Response
from vonage_error import VonageError


class JWTGenerationError(VonageError):
"""Indicates an error with generating a JWT."""


class InvalidHttpClientOptionsError(VonageError):
"""The options passed to the HTTP Client were invalid."""


class HttpRequestError(VonageError):
"""Exception indicating an error in the response received from a Vonage SDK request.
Args:
response (requests.Response): The HTTP response object.
content_type (str): The response content type.
Attributes:
response (requests.Response): The HTTP response object.
message (str): The returned error message.
"""

def __init__(self, response: Response, content_type: str):
self.response = response
self.set_error_message(self.response, content_type)
super().__init__(self.message)

def set_error_message(self, response: Response, content_type: str):
body = None
if content_type == 'application/json':
try:
body = response.json()
except JSONDecodeError:
pass
else:
body = response.text

if body:
self.message = f'{response.status_code} response from {response.url}. Error response body: {body}'
else:
self.message = f'{response.status_code} response from {response.url}.'


class AuthenticationError(HttpRequestError):
"""Exception indicating authentication failure in a Vonage SDK request.
This error is raised when the HTTP response status code is 401 (Unauthorized).
Args:
response (requests.Response): The HTTP response object.
content_type (str): The response content type.
Attributes (inherited from HttpRequestError parent exception):
response (requests.Response): The HTTP response object.
message (str): The returned error message.
"""

def __init__(self, response: Response, content_type: str):
super().__init__(response, content_type)


class RateLimitedError(HttpRequestError):
"""Exception indicating a rate limit was hit when making too many requests to a Vonage endpoint.
This error is raised when the HTTP response status code is 429 (Too Many Requests).
Args:
response (requests.Response): The HTTP response object.
content_type (str): The response content type.
Attributes (inherited from HttpRequestError parent exception):
response (requests.Response): The HTTP response object.
message (str): The returned error message.
"""

def __init__(self, response: Response, content_type: str):
super().__init__(response, content_type)


class ServerError(HttpRequestError):
"""Exception indicating an error was returned by a Vonage server in response to a Vonage SDK
request.
This error is raised when the HTTP response status code is 500 (Internal Server Error).
Args:
response (requests.Response): The HTTP response object.
content_type (str): The response content type.
Attributes (inherited from HttpRequestError parent exception):
response (requests.Response): The HTTP response object.
message (str): The returned error message.
"""

def __init__(self, response: Response, content_type: str):
super().__init__(response, content_type)
136 changes: 120 additions & 16 deletions http_client/src/http_client/http_client.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,136 @@
import asyncio
import logging
from logging import getLogger
from platform import python_version
from typing import Literal, Optional

import aiohttp
from http_client.auth import Auth
from http_client.errors import (
AuthenticationError,
HttpRequestError,
InvalidHttpClientOptionsError,
RateLimitedError,
ServerError,
)
from pydantic import BaseModel, Field, ValidationError, validate_call
from requests import Response
from requests.adapters import HTTPAdapter
from requests.sessions import Session
from typing_extensions import Annotated

logger = logging.getLogger("vonage")
logger = getLogger('vonage-http-client')


class HttpClient:
"""An asynchronous HTTP client used to send authenticated requests to Vonage APIs.
The methods for making HTTP calls are built to be asynchronous but can also be called
synchronously, which is the default. This is to make using the Vonage SDK the simplest possible
experience without customization.
"""A synchronous HTTP client used to send authenticated requests to Vonage APIs.
Args:
auth (:class: Auth): An instance of the Auth class containing credentials to use when making HTTP requests.
http_client_options (dict, optional): Customization options for the HTTP Client.
The http_client_options dict can have any of the following fields:
api_host (str, optional): The API host to use for HTTP requests. Defaults to 'api.nexmo.com'.
rest_host (str, optional): The REST host to use for HTTP requests. Defaults to 'rest.nexmo.com'.
timeout (int, optional): The timeout for HTTP requests in seconds. Defaults to None.
pool_connections (int, optional): The number of pool connections. Must be > 0. Default is 10.
pool_maxsize (int, optional): The maximum size of the connection pool. Must be > 0. Default is 10.
max_retries (int, optional): The maximum number of retries for HTTP requests. Must be >= 0. Default is 3.
"""

def __init__(self, auth: Auth, client_options: dict = None):
def __init__(self, auth: Auth, http_client_options: dict = None):
self._auth = auth
self._client_options = client_options # as yet undefined
try:
if http_client_options is not None:
self._http_client_options = HttpClientOptions.model_validate(
http_client_options
)
else:
self._http_client_options = HttpClientOptions()
except ValidationError as err:
raise InvalidHttpClientOptionsError(
'Invalid options provided to the HTTP Client'
) from err

self._api_host = self._http_client_options.api_host
self._rest_host = self._http_client_options.rest_host

self._timeout = self._http_client_options.timeout
self._session = Session()
self._adapter = HTTPAdapter(
pool_connections=self._http_client_options.pool_connections,
pool_maxsize=self._http_client_options.pool_maxsize,
max_retries=self._http_client_options.max_retries,
)
self._session.mount('https://', self._adapter)

self.session = aiohttp.ClientSession()
self._user_agent = f'vonage-python-sdk python/{python_version()}'
self._headers = {'User-Agent': self._user_agent, 'Accept': 'application/json'}

@property
def auth(self):
return self._auth
def http_client_options(self):
return self._http_client_options

@property
def api_host(self):
return self._api_host

@property
def rest_host(self):
return self._rest_host

def post(self, host: str, request_path: str = '', params: dict = None):
return self.make_request('POST', host, request_path, params)

def get(self, host: str, request_path: str = '', params: dict = None):
return self.make_request('GET', host, request_path, params)

@validate_call
def make_request(
self,
request_type: Literal['GET', 'POST'],
host: str,
request_path: str = '',
params: Optional[dict] = None,
):
url = f'https://{host}{request_path}'
logger.debug(
f'{request_type} request to {url}, with data: {params}; headers: {self._headers}'
)
with self._session.request(
request_type,
url,
json=params,
headers=self._headers,
timeout=self._timeout,
) as response:
return self._parse_response(response)

def _parse_response(self, response: Response):
logger.debug(
f'Response received from {response.url} with status code: {response.status_code}; headers: {response.headers}'
)
content_type = response.headers['Content-Type'].split(';', 1)[0]
if 200 <= response.status_code < 300:
if response.status_code == 204:
return None
if content_type == 'application/json':
return response.json()
return response.text
if response.status_code >= 400:
logger.warning(
f'Http Response Error! Status code: {response.status_code}; content: {repr(response.text)}; from url: {response.url}'
)
if response.status_code == 401:
raise AuthenticationError(response, content_type)
elif response.status_code == 429:
raise RateLimitedError(response, content_type)
elif response.status_code == 500:
raise ServerError(response, content_type)
raise HttpRequestError(response, content_type)


def make_request(self, method):
...
class HttpClientOptions(BaseModel):
api_host: str = 'api.nexmo.com'
rest_host: Optional[str] = 'rest.nexmo.com'
timeout: Optional[Annotated[int, Field(ge=0)]] = None
pool_connections: Optional[Annotated[int, Field(ge=1)]] = 10
pool_maxsize: Optional[Annotated[int, Field(ge=1)]] = 10
max_retries: Optional[Annotated[int, Field(ge=0)]] = 3
2 changes: 1 addition & 1 deletion http_client/tests/BUILD
Original file line number Diff line number Diff line change
@@ -1 +1 @@
python_tests(dependencies=['http_client:dummy_private_key'])
python_tests(dependencies=['http_client', 'libs'])
1 change: 1 addition & 0 deletions http_client/tests/data/400.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"Error": "Bad Request"}
1 change: 1 addition & 0 deletions http_client/tests/data/401.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"Error": "Authentication Failed"}
1 change: 1 addition & 0 deletions http_client/tests/data/429.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"Error": "Too Many Requests"}
1 change: 1 addition & 0 deletions http_client/tests/data/500.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"Error": "Internal Server Error"}
3 changes: 3 additions & 0 deletions http_client/tests/data/example_get.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"hello": "world"
}
1 change: 1 addition & 0 deletions http_client/tests/data/example_get_text.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello, World!
3 changes: 3 additions & 0 deletions http_client/tests/data/example_post.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"hello": "world!"
}
22 changes: 10 additions & 12 deletions http_client/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ def read_file(path):
return input_file.read()


api_key = "qwerasdf"
api_secret = "1234qwerasdfzxcv"
application_id = "asdfzxcv"
private_key = read_file("data/dummy_private_key.txt")
jwt_claims = {"iat": 1701729971}
api_key = 'qwerasdf'
api_secret = '1234qwerasdfzxcv'
application_id = 'asdfzxcv'
private_key = read_file('data/dummy_private_key.txt')
jwt_claims = {'iat': 1701729971}


def test_create_auth_class_and_get_objects():
Expand Down Expand Up @@ -47,7 +47,7 @@ def test_set_new_jwt_claims():
assert auth.jwt_claims == jwt_claims


test_jwt = b"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBsaWNhdGlvbl9pZCI6ImFzZGYxMjM0IiwiaWF0IjoxNjg1NzMxMzkxLCJqdGkiOiIwYzE1MDJhZS05YmI5LTQ4YzQtYmQyZC0yOGFhNWUxYjZkMTkiLCJleHAiOjE2ODU3MzIyOTF9.mAkGeVgWOb7Mrzka7DSj32vSM8RaFpYse_2E7jCQ4DuH8i32wq9FxXGgfwdBQDHzgku3RYIjLM1xlVrGjNM3MsnZgR7ymQ6S4bdTTOmSK0dKbk91SrN7ZAC9k2a6JpCC2ZYgXpZ5BzpDTdy9BYu6msHKmkL79_aabFAhrH36Nk26pLvoI0-KiGImEex-aRR4iiaXhOebXBeqiQTRPKoKizREq4-8zBQv_j6yy4AiEYvBatQ8L_sjHsLj9jjITreX8WRvEW-G4TPpPLMaHACHTDMpJSOZAnegAkzTV2frVRmk6DyVXnemm4L0RQD1XZDaH7JPsKk24Hd2WZQyIgHOqQ"
test_jwt = b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBsaWNhdGlvbl9pZCI6ImFzZGYxMjM0IiwiaWF0IjoxNjg1NzMxMzkxLCJqdGkiOiIwYzE1MDJhZS05YmI5LTQ4YzQtYmQyZC0yOGFhNWUxYjZkMTkiLCJleHAiOjE2ODU3MzIyOTF9.mAkGeVgWOb7Mrzka7DSj32vSM8RaFpYse_2E7jCQ4DuH8i32wq9FxXGgfwdBQDHzgku3RYIjLM1xlVrGjNM3MsnZgR7ymQ6S4bdTTOmSK0dKbk91SrN7ZAC9k2a6JpCC2ZYgXpZ5BzpDTdy9BYu6msHKmkL79_aabFAhrH36Nk26pLvoI0-KiGImEex-aRR4iiaXhOebXBeqiQTRPKoKizREq4-8zBQv_j6yy4AiEYvBatQ8L_sjHsLj9jjITreX8WRvEW-G4TPpPLMaHACHTDMpJSOZAnegAkzTV2frVRmk6DyVXnemm4L0RQD1XZDaH7JPsKk24Hd2WZQyIgHOqQ'


def vonage_jwt_mock(self):
Expand All @@ -56,16 +56,16 @@ def vonage_jwt_mock(self):

def test_generate_application_jwt():
auth = Auth(application_id=application_id, private_key=private_key)
with patch("http_client.auth.Auth.generate_application_jwt", vonage_jwt_mock):
with patch('http_client.auth.Auth.generate_application_jwt', vonage_jwt_mock):
jwt = auth.generate_application_jwt()
assert jwt == test_jwt


def test_create_jwt_auth_string():
auth = Auth(application_id=application_id, private_key=private_key)
with patch("http_client.auth.Auth.generate_application_jwt", vonage_jwt_mock):
with patch('http_client.auth.Auth.generate_application_jwt', vonage_jwt_mock):
header_auth_string = auth.create_jwt_auth_string()
assert header_auth_string == b"Bearer " + test_jwt
assert header_auth_string == b'Bearer ' + test_jwt


def test_create_jwt_error_no_application_id_or_private_key():
Expand All @@ -76,6 +76,4 @@ def test_create_jwt_error_no_application_id_or_private_key():

def test_create_basic_auth_string():
auth = Auth(api_key=api_key, api_secret=api_secret)
assert (
auth.create_basic_auth_string() == "Basic cXdlcmFzZGY6MTIzNHF3ZXJhc2Rmenhjdg=="
)
assert auth.create_basic_auth_string() == 'Basic cXdlcmFzZGY6MTIzNHF3ZXJhc2Rmenhjdg='
Loading

0 comments on commit b76c207

Please sign in to comment.