Skip to content

Commit 4c9b303

Browse files
committed
add users api endpoints and tests
1 parent a4c6e5a commit 4c9b303

File tree

16 files changed

+536
-191
lines changed

16 files changed

+536
-191
lines changed

http_client/src/vonage_http_client/errors.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from json import JSONDecodeError
1+
from json import dumps, JSONDecodeError
22

33
from requests import Response
44
from vonage_utils.errors import VonageError
@@ -37,14 +37,14 @@ def set_error_message(self, response: Response, content_type: str):
3737
body = None
3838
if content_type == 'application/json':
3939
try:
40-
body = response.json()
40+
body = dumps(response.json(), indent=4)
4141
except JSONDecodeError:
4242
pass
4343
else:
4444
body = response.text
4545

4646
if body:
47-
self.message = f'{response.status_code} response from {response.url}. Error response body: {body}'
47+
self.message = f'{response.status_code} response from {response.url}. Error response body: \n{body}'
4848
else:
4949
self.message = f'{response.status_code} response from {response.url}.'
5050

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

6969

70+
class NotFoundError(HttpRequestError):
71+
"""Exception indicating a resource was not found in a Vonage SDK request.
72+
73+
This error is raised when the HTTP response status code is 404 (Not Found).
74+
75+
Args:
76+
response (requests.Response): The HTTP response object.
77+
content_type (str): The response content type.
78+
79+
Attributes (inherited from HttpRequestError parent exception):
80+
response (requests.Response): The HTTP response object.
81+
message (str): The returned error message.
82+
"""
83+
84+
def __init__(self, response: Response, content_type: str):
85+
super().__init__(response, content_type)
86+
87+
7088
class RateLimitedError(HttpRequestError):
7189
"""Exception indicating a rate limit was hit when making too many requests to a Vonage endpoint.
7290

http_client/src/vonage_http_client/http_client.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Literal, Optional, Union
55

66
from pydantic import BaseModel, Field, ValidationError, validate_call
7-
from requests import Response
7+
from requests import Response, delete
88
from requests.adapters import HTTPAdapter
99
from requests.sessions import Session
1010
from typing_extensions import Annotated
@@ -13,6 +13,7 @@
1313
AuthenticationError,
1414
HttpRequestError,
1515
InvalidHttpClientOptionsError,
16+
NotFoundError,
1617
RateLimitedError,
1718
ServerError,
1819
)
@@ -120,10 +121,34 @@ def get(
120121
) -> Union[dict, None]:
121122
return self.make_request('GET', host, request_path, params, auth_type, body_type)
122123

124+
def patch(
125+
self,
126+
host: str,
127+
request_path: str = '',
128+
params: dict = None,
129+
auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt',
130+
body_type: Literal['json', 'data'] = 'json',
131+
) -> Union[dict, None]:
132+
return self.make_request(
133+
'PATCH', host, request_path, params, auth_type, body_type
134+
)
135+
136+
def delete(
137+
self,
138+
host: str,
139+
request_path: str = '',
140+
params: dict = None,
141+
auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt',
142+
body_type: Literal['json', 'data'] = 'json',
143+
) -> Union[dict, None]:
144+
return self.make_request(
145+
'DELETE', host, request_path, params, auth_type, body_type
146+
)
147+
123148
@validate_call
124149
def make_request(
125150
self,
126-
request_type: Literal['GET', 'POST'],
151+
request_type: Literal['GET', 'POST', 'PATCH', 'DELETE'],
127152
host: str,
128153
request_path: str = '',
129154
params: Optional[dict] = None,
@@ -150,6 +175,7 @@ def make_request(
150175
}
151176

152177
if body_type == 'json':
178+
self._headers['Content-Type'] = 'application/json'
153179
request_params['json'] = params
154180
else:
155181
request_params['data'] = params
@@ -178,6 +204,8 @@ def _parse_response(self, response: Response) -> Union[dict, None]:
178204
)
179205
if response.status_code == 401 or response.status_code == 403:
180206
raise AuthenticationError(response, content_type)
207+
elif response.status_code == 404:
208+
raise NotFoundError(response, content_type)
181209
elif response.status_code == 429:
182210
raise RateLimitedError(response, content_type)
183211
elif response.status_code == 500:

http_client/tests/data/404.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"title": "Not found.",
3+
"type": "https://developer.vonage.com/api/conversation#user:error:not-found",
4+
"detail": "User does not exist, or you do not have access.",
5+
"instance": "00a5916655d650e920ccf0daf40ef4ee"
6+
}

http_client/tests/test_http_client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,17 @@ def test_authentication_error_no_content():
182182
assert type(err.response) == Response
183183

184184

185+
@responses.activate
186+
def test_not_found_error():
187+
build_response(path, 'GET', 'https://example.com/get_json', '404.json', 404)
188+
189+
client = HttpClient(Auth())
190+
try:
191+
client.get(host='example.com', request_path='/get_json', auth_type='basic')
192+
except HttpRequestError as err:
193+
assert err.response.json()['title'] == 'Not found.'
194+
195+
185196
@responses.activate
186197
def test_rate_limited_error():
187198
build_response(path, 'GET', 'https://example.com/get_json', '429.json', 429)

testutils/testutils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def _filter_none_values(data: dict) -> dict:
1717
@validate_call
1818
def build_response(
1919
file_path: str,
20-
method: Literal['GET', 'POST'],
20+
method: Literal['GET', 'POST', 'PATCH', 'DELETE'],
2121
url: str,
2222
mock_path: str = None,
2323
status_code: int = 200,

users/src/vonage_users/common.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1-
from typing import Dict, List, Optional
2-
3-
from pydantic import BaseModel, Field, HttpUrl
1+
from dataclasses import field
2+
from typing import List, Optional
3+
4+
from pydantic import (
5+
BaseModel,
6+
Field,
7+
ValidationInfo,
8+
field_validator,
9+
model_validator,
10+
root_validator,
11+
)
412
from typing_extensions import Annotated
513

6-
PhoneNumber = Annotated[str, Field(pattern='^[1-9]\d{6,14}$')]
14+
15+
PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')]
16+
17+
18+
class Link(BaseModel):
19+
href: str
20+
21+
22+
class ResourceLink(BaseModel):
23+
self: Link
724

825

926
class PstnChannel(BaseModel):
1027
number: int
1128

1229

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

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

2239

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

2847

2948
class SmsChannel(BaseModel):
@@ -47,24 +66,34 @@ class MessengerChannel(BaseModel):
4766

4867

4968
class Channels(BaseModel):
50-
pstn: Optional[List[PstnChannel]] = None
51-
sip: Optional[List[SipChannel]] = None
52-
vbc: Optional[List[VbcChannel]] = None
53-
websocket: Optional[List[WebsocketChannel]] = None
5469
sms: Optional[List[SmsChannel]] = None
5570
mms: Optional[List[MmsChannel]] = None
5671
whatsapp: Optional[List[WhatsappChannel]] = None
5772
viber: Optional[List[ViberChannel]] = None
5873
messenger: Optional[List[MessengerChannel]] = None
74+
pstn: Optional[List[PstnChannel]] = None
75+
sip: Optional[List[SipChannel]] = None
76+
websocket: Optional[List[WebsocketChannel]] = None
77+
vbc: Optional[List[VbcChannel]] = None
5978

6079

6180
class Properties(BaseModel):
62-
custom_data: Optional[Dict[str, str]]
81+
custom_data: Optional[dict] = None
6382

6483

6584
class User(BaseModel):
66-
name: Optional[str] = Field(None, example="my_user_name")
67-
display_name: Optional[str] = Field(None, example="My User Name")
68-
image_url: Optional[HttpUrl] = Field(None, example="https://example.com/image.png")
69-
properties: Optional[Properties]
70-
channels: Optional[Channels]
85+
name: Optional[str] = None
86+
display_name: Optional[str] = None
87+
image_url: Optional[str] = None
88+
channels: Optional[Channels] = None
89+
properties: Optional[Properties] = None
90+
links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True)
91+
link: Optional[str] = None
92+
id: Optional[str] = None
93+
94+
@model_validator(mode='after')
95+
@classmethod
96+
def get_link(cls, data):
97+
if data.links is not None:
98+
data.link = data.links.self.href
99+
return data

users/src/vonage_users/requests.py

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,55 +6,10 @@
66
class ListUsersRequest(BaseModel):
77
"""Request object for listing users."""
88

9-
page_size: Optional[int] = Field(10, ge=1, le=100)
9+
page_size: Optional[int] = Field(2, ge=1, le=100)
1010
order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None
1111
cursor: Optional[str] = Field(
1212
None,
1313
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.",
1414
)
1515
name: Optional[str] = None
16-
17-
18-
# class SmsMessage(BaseModel):
19-
# """Message object containing the data and options for an SMS message."""
20-
21-
# to: str
22-
# from_: str = Field(..., serialization_alias='from')
23-
# text: str
24-
# sig: Optional[str] = Field(None, min_length=16, max_length=60)
25-
# client_ref: Optional[str] = Field(
26-
# None, serialization_alias='client-ref', max_length=100
27-
# )
28-
# type: Optional[Literal['text', 'binary', 'unicode']] = None
29-
# ttl: Optional[int] = Field(None, ge=20000, le=604800000)
30-
# status_report_req: Optional[bool] = Field(
31-
# None, serialization_alias='status-report-req'
32-
# )
33-
# callback: Optional[str] = Field(None, max_length=100)
34-
# message_class: Optional[int] = Field(
35-
# None, serialization_alias='message-class', ge=0, le=3
36-
# )
37-
# body: Optional[str] = None
38-
# udh: Optional[str] = None
39-
# protocol_id: Optional[int] = Field(
40-
# None, serialization_alias='protocol-id', ge=0, le=255
41-
# )
42-
# account_ref: Optional[str] = Field(None, serialization_alias='account-ref')
43-
# entity_id: Optional[str] = Field(None, serialization_alias='entity-id')
44-
# content_id: Optional[str] = Field(None, serialization_alias='content-id')
45-
46-
# @field_validator('body', 'udh')
47-
# @classmethod
48-
# def validate_body(cls, value, info: ValidationInfo):
49-
# data = info.data
50-
# if 'type' not in data or not data['type'] == 'binary':
51-
# raise ValueError(
52-
# 'This parameter can only be set when the "type" parameter is set to "binary".'
53-
# )
54-
# return value
55-
56-
# @model_validator(mode='after')
57-
# def validate_type(self) -> 'SmsMessage':
58-
# if self.type == 'binary' and self.body is None and self.udh is None:
59-
# raise ValueError('This parameter is required for binary messages.')
60-
# return self
Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
from typing import List, Optional
22

3-
from pydantic import BaseModel, Field
3+
from pydantic import BaseModel, Field, model_validator
44

5-
6-
class Link(BaseModel):
7-
href: str
8-
9-
10-
class UserLinks(BaseModel):
11-
self: Link
5+
from vonage_users.common import Link, ResourceLink
126

137

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

2014

21-
class User(BaseModel):
15+
class UserSummary(BaseModel):
2216
id: Optional[str]
2317
name: Optional[str]
24-
display_name: Optional[str]
25-
links: Optional[UserLinks] = Field(..., validation_alias='_links')
18+
display_name: Optional[str] = None
19+
links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True)
20+
link: Optional[str] = None
21+
22+
@model_validator(mode='after')
23+
@classmethod
24+
def get_link(cls, data):
25+
if data.links is not None:
26+
data.link = data.links.self.href
27+
return data
2628

2729

2830
class Embedded(BaseModel):
29-
users: List[User] = []
31+
users: List[UserSummary] = []
3032

3133

3234
class ListUsersResponse(BaseModel):
3335
page_size: int
3436
embedded: Embedded = Field(..., validation_alias='_embedded')
3537
links: Links = Field(..., validation_alias='_links')
36-
37-
38-
class CreateUserResponse(BaseModel):
39-
id: str
40-
name: str
41-
display_name: str
42-
links: UserLinks = Field(..., validation_alias='_links')
43-
44-
45-
# class MessageResponse(BaseModel):
46-
# to: str
47-
# message_id: str = Field(..., validation_alias='message-id')
48-
# status: str
49-
# remaining_balance: str = Field(..., validation_alias='remaining-balance')
50-
# message_price: str = Field(..., validation_alias='message-price')
51-
# network: str
52-
# client_ref: Optional[str] = Field(None, validation_alias='client-ref')
53-
# account_ref: Optional[str] = Field(None, validation_alias='account-ref')
54-
55-
56-
# class SmsResponse(BaseModel):
57-
# message_count: str = Field(..., validation_alias='message-count')
58-
# messages: List[dict]
59-
60-
# @field_validator('messages')
61-
# @classmethod
62-
# def create_message_response(cls, value):
63-
# messages = []
64-
# for message in value:
65-
# messages.append(MessageResponse(**message))
66-
# return messages

0 commit comments

Comments
 (0)