Skip to content

Commit

Permalink
add users api endpoints and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
maxkahan committed Mar 27, 2024
1 parent a4c6e5a commit 4c9b303
Show file tree
Hide file tree
Showing 16 changed files with 536 additions and 191 deletions.
24 changes: 21 additions & 3 deletions http_client/src/vonage_http_client/errors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from json import JSONDecodeError
from json import dumps, JSONDecodeError

from requests import Response
from vonage_utils.errors import VonageError
Expand Down Expand Up @@ -37,14 +37,14 @@ def set_error_message(self, response: Response, content_type: str):
body = None
if content_type == 'application/json':
try:
body = response.json()
body = dumps(response.json(), indent=4)
except JSONDecodeError:
pass
else:
body = response.text

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

Expand All @@ -67,6 +67,24 @@ def __init__(self, response: Response, content_type: str):
super().__init__(response, content_type)


class NotFoundError(HttpRequestError):
"""Exception indicating a resource was not found in a Vonage SDK request.
This error is raised when the HTTP response status code is 404 (Not Found).
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.
Expand Down
32 changes: 30 additions & 2 deletions http_client/src/vonage_http_client/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Literal, Optional, Union

from pydantic import BaseModel, Field, ValidationError, validate_call
from requests import Response
from requests import Response, delete
from requests.adapters import HTTPAdapter
from requests.sessions import Session
from typing_extensions import Annotated
Expand All @@ -13,6 +13,7 @@
AuthenticationError,
HttpRequestError,
InvalidHttpClientOptionsError,
NotFoundError,
RateLimitedError,
ServerError,
)
Expand Down Expand Up @@ -120,10 +121,34 @@ def get(
) -> Union[dict, None]:
return self.make_request('GET', host, request_path, params, auth_type, body_type)

def patch(
self,
host: str,
request_path: str = '',
params: dict = None,
auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt',
body_type: Literal['json', 'data'] = 'json',
) -> Union[dict, None]:
return self.make_request(
'PATCH', host, request_path, params, auth_type, body_type
)

def delete(
self,
host: str,
request_path: str = '',
params: dict = None,
auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt',
body_type: Literal['json', 'data'] = 'json',
) -> Union[dict, None]:
return self.make_request(
'DELETE', host, request_path, params, auth_type, body_type
)

@validate_call
def make_request(
self,
request_type: Literal['GET', 'POST'],
request_type: Literal['GET', 'POST', 'PATCH', 'DELETE'],
host: str,
request_path: str = '',
params: Optional[dict] = None,
Expand All @@ -150,6 +175,7 @@ def make_request(
}

if body_type == 'json':
self._headers['Content-Type'] = 'application/json'
request_params['json'] = params
else:
request_params['data'] = params
Expand Down Expand Up @@ -178,6 +204,8 @@ def _parse_response(self, response: Response) -> Union[dict, None]:
)
if response.status_code == 401 or response.status_code == 403:
raise AuthenticationError(response, content_type)
elif response.status_code == 404:
raise NotFoundError(response, content_type)
elif response.status_code == 429:
raise RateLimitedError(response, content_type)
elif response.status_code == 500:
Expand Down
6 changes: 6 additions & 0 deletions http_client/tests/data/404.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"title": "Not found.",
"type": "https://developer.vonage.com/api/conversation#user:error:not-found",
"detail": "User does not exist, or you do not have access.",
"instance": "00a5916655d650e920ccf0daf40ef4ee"
}
11 changes: 11 additions & 0 deletions http_client/tests/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ def test_authentication_error_no_content():
assert type(err.response) == Response


@responses.activate
def test_not_found_error():
build_response(path, 'GET', 'https://example.com/get_json', '404.json', 404)

client = HttpClient(Auth())
try:
client.get(host='example.com', request_path='/get_json', auth_type='basic')
except HttpRequestError as err:
assert err.response.json()['title'] == 'Not found.'


@responses.activate
def test_rate_limited_error():
build_response(path, 'GET', 'https://example.com/get_json', '429.json', 429)
Expand Down
2 changes: 1 addition & 1 deletion testutils/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def _filter_none_values(data: dict) -> dict:
@validate_call
def build_response(
file_path: str,
method: Literal['GET', 'POST'],
method: Literal['GET', 'POST', 'PATCH', 'DELETE'],
url: str,
mock_path: str = None,
status_code: int = 200,
Expand Down
65 changes: 47 additions & 18 deletions users/src/vonage_users/common.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
from typing import Dict, List, Optional

from pydantic import BaseModel, Field, HttpUrl
from dataclasses import field
from typing import List, Optional

from pydantic import (
BaseModel,
Field,
ValidationInfo,
field_validator,
model_validator,
root_validator,
)
from typing_extensions import Annotated

PhoneNumber = Annotated[str, Field(pattern='^[1-9]\d{6,14}$')]

PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')]


class Link(BaseModel):
href: str


class ResourceLink(BaseModel):
self: Link


class PstnChannel(BaseModel):
number: int


class SipChannel(BaseModel):
uri: str = Field(..., pattern='^(sip|sips):\+?([\w|:.\-@;,=%&]+)')
uri: str = Field(..., pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)')
username: str = None
password: str = None

Expand All @@ -21,9 +38,11 @@ class VbcChannel(BaseModel):


class WebsocketChannel(BaseModel):
uri: str = Field(pattern='^(ws|wss)://[a-zA-Z0-9~#%@&-_?\/.,:;)(][]*$')
content_type: str = Field(pattern="^audio/l16;rate=(8000|16000)$")
headers: Optional[Dict[str, str]] = None
uri: str = Field(pattern=r'^(ws|wss):\/\/[a-zA-Z0-9~#%@&-_?\/.,:;)(\]\[]*$')
content_type: Optional[str] = Field(
None, alias='content-type', pattern='^audio/l16;rate=(8000|16000)$'
)
headers: Optional[dict] = None


class SmsChannel(BaseModel):
Expand All @@ -47,24 +66,34 @@ class MessengerChannel(BaseModel):


class Channels(BaseModel):
pstn: Optional[List[PstnChannel]] = None
sip: Optional[List[SipChannel]] = None
vbc: Optional[List[VbcChannel]] = None
websocket: Optional[List[WebsocketChannel]] = None
sms: Optional[List[SmsChannel]] = None
mms: Optional[List[MmsChannel]] = None
whatsapp: Optional[List[WhatsappChannel]] = None
viber: Optional[List[ViberChannel]] = None
messenger: Optional[List[MessengerChannel]] = None
pstn: Optional[List[PstnChannel]] = None
sip: Optional[List[SipChannel]] = None
websocket: Optional[List[WebsocketChannel]] = None
vbc: Optional[List[VbcChannel]] = None


class Properties(BaseModel):
custom_data: Optional[Dict[str, str]]
custom_data: Optional[dict] = None


class User(BaseModel):
name: Optional[str] = Field(None, example="my_user_name")
display_name: Optional[str] = Field(None, example="My User Name")
image_url: Optional[HttpUrl] = Field(None, example="https://example.com/image.png")
properties: Optional[Properties]
channels: Optional[Channels]
name: Optional[str] = None
display_name: Optional[str] = None
image_url: Optional[str] = None
channels: Optional[Channels] = None
properties: Optional[Properties] = None
links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True)
link: Optional[str] = None
id: Optional[str] = None

@model_validator(mode='after')
@classmethod
def get_link(cls, data):
if data.links is not None:
data.link = data.links.self.href
return data
47 changes: 1 addition & 46 deletions users/src/vonage_users/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,10 @@
class ListUsersRequest(BaseModel):
"""Request object for listing users."""

page_size: Optional[int] = Field(10, ge=1, le=100)
page_size: Optional[int] = Field(2, ge=1, le=100)
order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None
cursor: Optional[str] = Field(
None,
description="The cursor to start returning results from. You are not expected to provide this manually, but to follow the url provided in _links.next.href or _links.prev.href in the response which contains a cursor value.",
)
name: Optional[str] = None


# class SmsMessage(BaseModel):
# """Message object containing the data and options for an SMS message."""

# to: str
# from_: str = Field(..., serialization_alias='from')
# text: str
# sig: Optional[str] = Field(None, min_length=16, max_length=60)
# client_ref: Optional[str] = Field(
# None, serialization_alias='client-ref', max_length=100
# )
# type: Optional[Literal['text', 'binary', 'unicode']] = None
# ttl: Optional[int] = Field(None, ge=20000, le=604800000)
# status_report_req: Optional[bool] = Field(
# None, serialization_alias='status-report-req'
# )
# callback: Optional[str] = Field(None, max_length=100)
# message_class: Optional[int] = Field(
# None, serialization_alias='message-class', ge=0, le=3
# )
# body: Optional[str] = None
# udh: Optional[str] = None
# protocol_id: Optional[int] = Field(
# None, serialization_alias='protocol-id', ge=0, le=255
# )
# account_ref: Optional[str] = Field(None, serialization_alias='account-ref')
# entity_id: Optional[str] = Field(None, serialization_alias='entity-id')
# content_id: Optional[str] = Field(None, serialization_alias='content-id')

# @field_validator('body', 'udh')
# @classmethod
# def validate_body(cls, value, info: ValidationInfo):
# data = info.data
# if 'type' not in data or not data['type'] == 'binary':
# raise ValueError(
# 'This parameter can only be set when the "type" parameter is set to "binary".'
# )
# return value

# @model_validator(mode='after')
# def validate_type(self) -> 'SmsMessage':
# if self.type == 'binary' and self.body is None and self.udh is None:
# raise ValueError('This parameter is required for binary messages.')
# return self
57 changes: 14 additions & 43 deletions users/src/vonage_users/responses.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
from typing import List, Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator


class Link(BaseModel):
href: str


class UserLinks(BaseModel):
self: Link
from vonage_users.common import Link, ResourceLink


class Links(BaseModel):
Expand All @@ -18,49 +12,26 @@ class Links(BaseModel):
prev: Optional[Link] = None


class User(BaseModel):
class UserSummary(BaseModel):
id: Optional[str]
name: Optional[str]
display_name: Optional[str]
links: Optional[UserLinks] = Field(..., validation_alias='_links')
display_name: Optional[str] = None
links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True)
link: Optional[str] = None

@model_validator(mode='after')
@classmethod
def get_link(cls, data):
if data.links is not None:
data.link = data.links.self.href
return data


class Embedded(BaseModel):
users: List[User] = []
users: List[UserSummary] = []


class ListUsersResponse(BaseModel):
page_size: int
embedded: Embedded = Field(..., validation_alias='_embedded')
links: Links = Field(..., validation_alias='_links')


class CreateUserResponse(BaseModel):
id: str
name: str
display_name: str
links: UserLinks = Field(..., validation_alias='_links')


# class MessageResponse(BaseModel):
# to: str
# message_id: str = Field(..., validation_alias='message-id')
# status: str
# remaining_balance: str = Field(..., validation_alias='remaining-balance')
# message_price: str = Field(..., validation_alias='message-price')
# network: str
# client_ref: Optional[str] = Field(None, validation_alias='client-ref')
# account_ref: Optional[str] = Field(None, validation_alias='account-ref')


# class SmsResponse(BaseModel):
# message_count: str = Field(..., validation_alias='message-count')
# messages: List[dict]

# @field_validator('messages')
# @classmethod
# def create_message_response(cls, value):
# messages = []
# for message in value:
# messages.append(MessageResponse(**message))
# return messages
Loading

0 comments on commit 4c9b303

Please sign in to comment.