Skip to content

Commit fcdf8f6

Browse files
Ihor BilousIhor Bilous
authored andcommitted
Fix issue #21: Add ContactsApi, related models, tests, examples
1 parent a964db6 commit fcdf8f6

File tree

11 files changed

+761
-2
lines changed

11 files changed

+761
-2
lines changed

examples/contacts/contacts.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from typing import Optional
2+
from typing import Union
3+
4+
import mailtrap as mt
5+
from mailtrap.models.common import DeletedObject
6+
from mailtrap.models.contacts import Contact
7+
8+
API_TOKEN = "YOU_API_TOKEN"
9+
ACCOUNT_ID = "YOU_ACCOUNT_ID"
10+
11+
client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
12+
contacts_api = client.contacts_api.contacts
13+
14+
15+
def create_contact(
16+
email: str,
17+
fields: Optional[dict[str, Union[str, int, float, bool, str]]] = None,
18+
list_ids: Optional[list[int]] = None,
19+
) -> Contact:
20+
params = mt.CreateContactParams(
21+
email=email,
22+
fields=fields,
23+
list_ids=list_ids,
24+
)
25+
return contacts_api.create(params)
26+
27+
28+
def update_contact(
29+
contact_id_or_email: int,
30+
new_email: Optional[str] = None,
31+
fields: Optional[dict[str, Union[str, int, float, bool, str]]] = None,
32+
list_ids_included: Optional[list[int]] = None,
33+
list_ids_excluded: Optional[list[int]] = None,
34+
unsubscribed: Optional[bool] = None,
35+
) -> Contact:
36+
params = mt.UpdateContactParams(
37+
email=new_email,
38+
fields=fields,
39+
list_ids_included=list_ids_included,
40+
list_ids_excluded=list_ids_excluded,
41+
unsubscribed=unsubscribed,
42+
)
43+
return contacts_api.update(contact_id_or_email, params)
44+
45+
46+
def get_contact(contact_id_or_email: int) -> Contact:
47+
return contacts_api.get_by_id(contact_id_or_email)
48+
49+
50+
def delete_contact(contact_id_or_email: int) -> DeletedObject:
51+
return contacts_api.delete(contact_id_or_email)
52+
53+
54+
if __name__ == "__main__":
55+
created_contact = create_contact(
56+
57+
fields={
58+
"first_name": "Test",
59+
"last_name": "Test",
60+
},
61+
)
62+
print(created_contact)
63+
updated_contact = update_contact(
64+
created_contact.id,
65+
fields={
66+
"first_name": "John",
67+
"last_name": "Doe",
68+
},
69+
)
70+
print(updated_contact)
71+
deleted_contact = delete_contact(updated_contact.id)
72+
print(deleted_contact)

mailtrap/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
from .exceptions import AuthorizationError
55
from .exceptions import ClientConfigurationError
66
from .exceptions import MailtrapError
7-
from .models.contacts import ContactField
87
from .models.contacts import ContactListParams
98
from .models.contacts import CreateContactFieldParams
9+
from .models.contacts import CreateContactParams
1010
from .models.contacts import UpdateContactFieldParams
11+
from .models.contacts import UpdateContactParams
1112
from .models.mail import Address
1213
from .models.mail import Attachment
1314
from .models.mail import BaseMail

mailtrap/api/contacts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from mailtrap.api.resources.contact_fields import ContactFieldsApi
22
from mailtrap.api.resources.contact_lists import ContactListsApi
3+
from mailtrap.api.resources.contacts import ContactsApi
34
from mailtrap.http import HttpClient
45

56

@@ -15,3 +16,7 @@ def contact_fields(self) -> ContactFieldsApi:
1516
@property
1617
def contact_lists(self) -> ContactListsApi:
1718
return ContactListsApi(account_id=self._account_id, client=self._client)
19+
20+
@property
21+
def contacts(self) -> ContactsApi:
22+
return ContactsApi(account_id=self._account_id, client=self._client)

mailtrap/api/resources/contacts.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import Optional
2+
3+
from mailtrap.http import HttpClient
4+
from mailtrap.models.common import DeletedObject
5+
from mailtrap.models.contacts import Contact
6+
from mailtrap.models.contacts import ContactResponse
7+
from mailtrap.models.contacts import CreateContactParams
8+
from mailtrap.models.contacts import UpdateContactParams
9+
10+
11+
class ContactsApi:
12+
def __init__(self, client: HttpClient, account_id: str) -> None:
13+
self._account_id = account_id
14+
self._client = client
15+
16+
def get_by_id(self, contact_id_or_email: str) -> Contact:
17+
response = self._client.get(self._api_path(contact_id_or_email))
18+
return ContactResponse(**response).data
19+
20+
def create(self, contact_params: CreateContactParams) -> Contact:
21+
response = self._client.post(
22+
self._api_path(),
23+
json={"contact": contact_params.api_data},
24+
)
25+
return ContactResponse(**response).data
26+
27+
def update(
28+
self, contact_id_or_email: str, contact_params: UpdateContactParams
29+
) -> Contact:
30+
response = self._client.patch(
31+
self._api_path(contact_id_or_email),
32+
json={"contact": contact_params.api_data},
33+
)
34+
return ContactResponse(**response).data
35+
36+
def delete(self, contact_id_or_email: str) -> DeletedObject:
37+
self._client.delete(self._api_path(contact_id_or_email))
38+
return DeletedObject(contact_id_or_email)
39+
40+
def _api_path(self, contact_id_or_email: Optional[str] = None) -> str:
41+
path = f"/api/accounts/{self._account_id}/contacts"
42+
if contact_id_or_email:
43+
return f"{path}/{contact_id_or_email}"
44+
return path

mailtrap/models/common.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any
22
from typing import TypeVar
3+
from typing import Union
34
from typing import cast
45

56
from pydantic import TypeAdapter
@@ -20,4 +21,4 @@ def api_data(self: T) -> dict[str, Any]:
2021

2122
@dataclass
2223
class DeletedObject:
23-
id: int
24+
id: Union[int, str]

mailtrap/models/contacts.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from enum import Enum
12
from typing import Optional
3+
from typing import Union
24

35
from pydantic.dataclasses import dataclass
46

@@ -39,3 +41,57 @@ class ContactListParams(RequestParams):
3941
class ContactList:
4042
id: int
4143
name: str
44+
45+
46+
class ContactStatus(str, Enum):
47+
SUBSCRIBED = "subscribed"
48+
UNSUBSCRIBED = "unsubscribed"
49+
50+
51+
@dataclass
52+
class CreateContactParams(RequestParams):
53+
email: str
54+
fields: Optional[dict[str, Union[str, int, float, bool, str]]] = (
55+
None # field_merge_tag: value
56+
)
57+
list_ids: Optional[list[int]] = None
58+
59+
60+
@dataclass
61+
class UpdateContactParams(RequestParams):
62+
email: Optional[str] = None
63+
fields: Optional[dict[str, Union[str, int, float, bool, str]]] = (
64+
None # field_merge_tag: value
65+
)
66+
list_ids_included: Optional[list[int]] = None
67+
list_ids_excluded: Optional[list[int]] = None
68+
unsubscribed: Optional[bool] = None
69+
70+
def __post_init__(self) -> None:
71+
if all(
72+
value is None
73+
for value in [
74+
self.email,
75+
self.fields,
76+
self.list_ids_included,
77+
self.list_ids_excluded,
78+
self.unsubscribed,
79+
]
80+
):
81+
raise ValueError("At least one field must be provided for update action")
82+
83+
84+
@dataclass
85+
class Contact:
86+
id: str
87+
email: str
88+
fields: dict[str, Union[str, int, float, bool, str]] # field_merge_tag: value
89+
list_ids: list[int]
90+
status: ContactStatus
91+
created_at: int
92+
updated_at: int
93+
94+
95+
@dataclass
96+
class ContactResponse:
97+
data: Contact

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,13 @@
99
NOT_FOUND_STATUS_CODE = 404
1010
NOT_FOUND_ERROR_MESSAGE = "Not Found"
1111
NOT_FOUND_RESPONSE = {"error": NOT_FOUND_ERROR_MESSAGE}
12+
13+
RATE_LIMIT_ERROR_STATUS_CODE = 429
14+
RATE_LIMIT_ERROR_MESSAGE = "Rate limit exceeded"
15+
RATE_LIMIT_ERROR_RESPONSE = {"errors": RATE_LIMIT_ERROR_MESSAGE}
16+
17+
INTERNAL_SERVER_ERROR_STATUS_CODE = 500
18+
INTERNAL_SERVER_ERROR_MESSAGE = "Unexpected error"
19+
INTERNAL_SERVER_ERROR_RESPONSE = {"errors": "Unexpected error"}
20+
21+
VALIDATION_ERRORS_STATUS_CODE = 422

tests/unit/api/test_contact_fields.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
1818
BASE_CONTACT_FIELDS_URL = (
1919
f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/contacts/fields"
2020
)
21+
VALIDATION_ERRORS_RESPONSE = {
22+
"errors": {
23+
"name": [["is too long (maximum is 80 characters)", "has already been taken"]],
24+
"merge_tag": [
25+
["is too long (maximum is 80 characters)", "has already been taken"]
26+
],
27+
}
28+
}
29+
VALIDATION_ERRORS_MESSAGE = (
30+
"name: ['is too long (maximum is 80 characters)', 'has already been taken']; "
31+
"merge_tag: ['is too long (maximum is 80 characters)', 'has already been taken']"
32+
)
2133

2234

2335
@pytest.fixture
@@ -62,6 +74,16 @@ class TestContactsApi:
6274
conftest.FORBIDDEN_RESPONSE,
6375
conftest.FORBIDDEN_ERROR_MESSAGE,
6476
),
77+
(
78+
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
79+
conftest.RATE_LIMIT_ERROR_RESPONSE,
80+
conftest.RATE_LIMIT_ERROR_MESSAGE,
81+
),
82+
(
83+
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
84+
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
85+
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
86+
),
6587
],
6688
)
6789
@responses.activate
@@ -117,6 +139,16 @@ def test_get_contact_fields_should_return_contact_field_list(
117139
conftest.NOT_FOUND_RESPONSE,
118140
conftest.NOT_FOUND_ERROR_MESSAGE,
119141
),
142+
(
143+
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
144+
conftest.RATE_LIMIT_ERROR_RESPONSE,
145+
conftest.RATE_LIMIT_ERROR_MESSAGE,
146+
),
147+
(
148+
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
149+
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
150+
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
151+
),
120152
],
121153
)
122154
@responses.activate
@@ -169,6 +201,21 @@ def test_get_contact_field_should_return_contact_field(
169201
conftest.FORBIDDEN_RESPONSE,
170202
conftest.FORBIDDEN_ERROR_MESSAGE,
171203
),
204+
(
205+
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
206+
conftest.RATE_LIMIT_ERROR_RESPONSE,
207+
conftest.RATE_LIMIT_ERROR_MESSAGE,
208+
),
209+
(
210+
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
211+
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
212+
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
213+
),
214+
(
215+
conftest.VALIDATION_ERRORS_STATUS_CODE,
216+
VALIDATION_ERRORS_RESPONSE,
217+
VALIDATION_ERRORS_MESSAGE,
218+
),
172219
],
173220
)
174221
@responses.activate
@@ -235,6 +282,21 @@ def test_create_contact_field_should_return_created_contact_field(
235282
conftest.NOT_FOUND_RESPONSE,
236283
conftest.NOT_FOUND_ERROR_MESSAGE,
237284
),
285+
(
286+
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
287+
conftest.RATE_LIMIT_ERROR_RESPONSE,
288+
conftest.RATE_LIMIT_ERROR_MESSAGE,
289+
),
290+
(
291+
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
292+
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
293+
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
294+
),
295+
(
296+
conftest.VALIDATION_ERRORS_STATUS_CODE,
297+
VALIDATION_ERRORS_RESPONSE,
298+
VALIDATION_ERRORS_MESSAGE,
299+
),
238300
],
239301
)
240302
@responses.activate
@@ -301,6 +363,39 @@ def test_update_contact_field_should_return_updated_contact_field(
301363
conftest.NOT_FOUND_RESPONSE,
302364
conftest.NOT_FOUND_ERROR_MESSAGE,
303365
),
366+
(
367+
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
368+
conftest.RATE_LIMIT_ERROR_RESPONSE,
369+
conftest.RATE_LIMIT_ERROR_MESSAGE,
370+
),
371+
(
372+
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
373+
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
374+
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
375+
),
376+
(
377+
conftest.VALIDATION_ERRORS_STATUS_CODE,
378+
{
379+
"errors": {
380+
"usage": [
381+
(
382+
"This field is used in the steps of automation(s): "
383+
"%{automation names}."
384+
),
385+
(
386+
"This field is used in the conditions of segment(s): "
387+
"{segment names}."
388+
),
389+
]
390+
}
391+
},
392+
(
393+
"usage: This field is used in the steps of automation(s): "
394+
"%{automation names}.; "
395+
"usage: This field is used in the conditions of segment(s): "
396+
"{segment names}."
397+
),
398+
),
304399
],
305400
)
306401
@responses.activate

0 commit comments

Comments
 (0)