diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 9b780e29..4d6a7e08 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -106,7 +106,7 @@ def post( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'data'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -118,7 +118,7 @@ def get( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -130,7 +130,7 @@ def patch( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -142,7 +142,7 @@ def delete( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -156,7 +156,7 @@ def make_request( host: str, request_path: str = '', params: Optional[dict] = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ): url = f'https://{host}{request_path}' @@ -167,6 +167,9 @@ def make_request( self._headers['Authorization'] = self._auth.create_jwt_auth_string() elif auth_type == 'basic': self._headers['Authorization'] = self._auth.create_basic_auth_string() + elif auth_type == 'body': + params['api_key'] = self._auth.api_key + params['api_secret'] = self._auth.api_secret elif auth_type == 'signature': params['api_key'] = self._auth.api_key params['sig'] = self._auth.sign_params(params) diff --git a/pants.toml b/pants.toml index 14f4ac99..a9c671a5 100644 --- a/pants.toml +++ b/pants.toml @@ -37,6 +37,7 @@ filter = [ 'users/src', 'utils/src', 'testutils', + 'verify/src', ] [black] diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 7172cc4c..763a7b9b 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,9 +1,7 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from typing_extensions import Annotated - -PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] +from vonage_utils.types.phone_number import PhoneNumber class Link(BaseModel): diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py index 77f68f3d..d6943487 100644 --- a/users/src/vonage_users/requests.py +++ b/users/src/vonage_users/requests.py @@ -6,7 +6,7 @@ class ListUsersRequest(BaseModel): """Request object for listing users.""" - page_size: Optional[int] = Field(2, ge=1, le=100) + page_size: Optional[int] = Field(100, ge=1, le=100) order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None cursor: Optional[str] = Field( None, diff --git a/verify/BUILD b/verify/BUILD new file mode 100644 index 00000000..910bc085 --- /dev/null +++ b/verify/BUILD @@ -0,0 +1,16 @@ +resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + +files(sources=['tests/data/*']) + +python_distribution( + name='vonage-verify', + dependencies=[ + ':pyproject', + ':readme', + 'verify/src/vonage_verify', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/verify/CHANGES.md b/verify/CHANGES.md new file mode 100644 index 00000000..be516a55 --- /dev/null +++ b/verify/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/verify/README.md b/verify/README.md new file mode 100644 index 00000000..6dccb42d --- /dev/null +++ b/verify/README.md @@ -0,0 +1,27 @@ +# Vonage Verify Package + +This package contains the code to use Vonage's Verify API in Python. There is a more current package to user Vonage's Verify v2 API which is recommended to use for most use cases. The v2 API lets you send messages via multiple channels, including Email, SMS, MMS, WhatsApp, Messenger and others. You can also make Silent Authentication requests with Verify v2 to give an end user a more seamless experience. + +This package includes methods for sending 2-factor authentication (2FA) messages and returns... + + +asdf +asdf + + +## Usage + +It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. + +### Make a Verify Request + + diff --git a/verify/pyproject.toml b/verify/pyproject.toml new file mode 100644 index 00000000..43d2ab4d --- /dev/null +++ b/verify/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-verify' +version = '1.0.0' +description = 'Vonage verify package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.1.1", + "vonage-utils>=1.0.0", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/verify/src/vonage_verify/BUILD b/verify/src/vonage_verify/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/verify/src/vonage_verify/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/verify/src/vonage_verify/__init__.py b/verify/src/vonage_verify/__init__.py new file mode 100644 index 00000000..442b7842 --- /dev/null +++ b/verify/src/vonage_verify/__init__.py @@ -0,0 +1,11 @@ +# from .errors import PartialFailureError, SmsError +from .requests import Psd2Request, VerifyRequest + +# from .responses import MessageResponse, SmsResponse +from .verify import Verify + +__all__ = [ + 'Verify', + 'VerifyRequest', + 'Psd2Request', +] diff --git a/verify/src/vonage_verify/errors.py b/verify/src/vonage_verify/errors.py new file mode 100644 index 00000000..547633bf --- /dev/null +++ b/verify/src/vonage_verify/errors.py @@ -0,0 +1,16 @@ +from vonage_utils.errors import VonageError + + +class VerifyError(VonageError): + """Indicates an error with the Vonage Verify Package.""" + + +# class PartialFailureError(SmsError): +# """Indicates that a request was partially successful.""" + +# def __init__(self, response: Response): +# self.message = ( +# 'Sms.send_message method partially failed. Not all of the message(s) sent successfully.', +# ) +# super().__init__(self.message) +# self.response = response diff --git a/verify/src/vonage_verify/language_codes.py b/verify/src/vonage_verify/language_codes.py new file mode 100644 index 00000000..1dcdcef5 --- /dev/null +++ b/verify/src/vonage_verify/language_codes.py @@ -0,0 +1,67 @@ +from enum import Enum + + +class LanguageCode(str, Enum): + ar_xa = 'ar-xa' + cs_cz = 'cs-cz' + cy_cy = 'cy-cy' + cy_gb = 'cy-gb' + da_dk = 'da-dk' + de_de = 'de-de' + el_gr = 'el-gr' + en_au = 'en-au' + en_gb = 'en-gb' + en_in = 'en-in' + en_us = 'en-us' + es_es = 'es-es' + es_mx = 'es-mx' + es_us = 'es-us' + fi_fi = 'fi-fi' + fil_ph = 'fil-ph' + fr_ca = 'fr-ca' + fr_fr = 'fr-fr' + hi_in = 'hi-in' + hu_hu = 'hu-hu' + id_id = 'id-id' + is_is = 'is-is' + it_it = 'it-it' + ja_jp = 'ja-jp' + ko_kr = 'ko-kr' + nb_no = 'nb-no' + nl_nl = 'nl-nl' + pl_pl = 'pl-pl' + pt_br = 'pt-br' + pt_pt = 'pt-pt' + ro_ro = 'ro-ro' + ru_ru = 'ru-ru' + sv_se = 'sv-se' + th_th = 'th-th' + tr_tr = 'tr-tr' + vi_vn = 'vi-vn' + yue_cn = 'yue-cn' + zh_cn = 'zh-cn' + zh_tw = 'zh-tw' + + +class Psd2LanguageCode(Enum): + en_gb = 'en-gb' + bg_bg = 'bg-bg' + cs_cz = 'cs-cz' + da_dk = 'da-dk' + de_de = 'de-de' + ee_et = 'ee-et' + el_gr = 'el-gr' + es_es = 'es-es' + fi_fi = 'fi-fi' + fr_fr = 'fr-fr' + ga_ie = 'ga-ie' + hu_hu = 'hu-hu' + it_it = 'it-it' + lv_lv = 'lv-lv' + lt_lt = 'lt-lt' + mt_mt = 'mt-mt' + nl_nl = 'nl-nl' + pl_pl = 'pl-pl' + sk_sk = 'sk-sk' + sl_si = 'sl-si' + sv_se = 'sv-se' diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py new file mode 100644 index 00000000..86d1fc6a --- /dev/null +++ b/verify/src/vonage_verify/requests.py @@ -0,0 +1,56 @@ +from logging import getLogger +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types.phone_number import PhoneNumber + +from .language_codes import LanguageCode, Psd2LanguageCode + +logger = getLogger('vonage_verify') + + +class BaseVerifyRequest(BaseModel): + """Base request object containing the data and options for a verification request.""" + + number: PhoneNumber + country: Optional[str] = Field(None, max_length=2) + code_length: Optional[Literal[4, 6]] = 4 + pin_expiry: Optional[int] = Field(None, ge=60, le=3600) + next_event_wait: Optional[int] = Field(None, ge=60, le=900) + workflow_id: Optional[Literal[1, 2, 3, 4, 5, 6, 7]] = None + + @model_validator(mode='after') + def check_expiry_and_next_event_timing(self): + if self.pin_expiry is None or self.next_event_wait is None: + return self + if self.pin_expiry % self.next_event_wait != 0: + logger.debug( + f'The pin_expiry should be a multiple of next_event_wait.' + f'The current values are: pin_expiry={self.pin_expiry}, next_event_wait={self.next_event_wait}.' + f'The value of pin_expiry will be set to next_event_wait.' + ) + self.pin_expiry = self.next_event_wait + return self + + +class VerifyRequest(BaseVerifyRequest): + """Request object for a verification request. + + You must set the `number` and `brand` fields. + """ + + brand: str = Field(..., max_length=18) + sender_id: Optional[str] = Field('VERIFY', max_length=11) + lg: Optional[LanguageCode] = None + pin_code: Optional[str] = Field(None, min_length=4, max_length=10) + + +class Psd2Request(BaseVerifyRequest): + """Request object for a PSD2 verification request. + + You must set the `number`, `payee` and `amount` fields. + """ + + payee: str = Field(..., max_length=18) + amount: float + lg: Optional[Psd2LanguageCode] = None diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py new file mode 100644 index 00000000..837e1180 --- /dev/null +++ b/verify/src/vonage_verify/responses.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +class VerifyResponse(BaseModel): + request_id: str + status: str + + +# 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[MessageResponse] diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py new file mode 100644 index 00000000..a39d78ec --- /dev/null +++ b/verify/src/vonage_verify/verify.py @@ -0,0 +1,107 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .requests import BaseVerifyRequest, Psd2Request, VerifyRequest +from .responses import VerifyResponse + + +class Verify: + """Calls Vonage's Verify API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._sent_post_data_type = 'form' + self._sent_get_data_type = 'query_params' + self._auth_type = 'body' + + @validate_call + def start_verification(self, verify_request: VerifyRequest) -> VerifyResponse: + """Start a verification process.""" + return self._make_verify_request(verify_request) + + @validate_call + def start_psd2_verification(self, verify_request: Psd2Request) -> VerifyResponse: + """Start a PSD2 verification process.""" + return self._make_verify_request(verify_request) + + def _make_verify_request(self, verify_request: BaseVerifyRequest) -> VerifyResponse: + if type(verify_request) == VerifyRequest: + request_path = '/verify/json' + elif type(verify_request) == Psd2Request: + request_path = '/verify/psd2/json' + + response = self._http_client.post( + self._http_client.api_host, + request_path, + verify_request.model_dump(by_alias=True), + self._auth_type, + self._sent_post_data_type, + ) + return VerifyResponse(**response) + + # @validate_call + # def send(self, message: SmsMessage) -> SmsResponse: + # """Send an SMS message.""" + # response = self._http_client.post( + # self._http_client.rest_host, + # '/sms/json', + # message.model_dump(by_alias=True), + # self._auth_type, + # self._sent_data_type, + # ) + + # if int(response['message-count']) > 1: + # self._check_for_partial_failure(response) + # else: + # self._check_for_error(response) + # return SmsResponse(**response) + + # def _check_for_partial_failure(self, response_data): + # successful_messages = 0 + # total_messages = int(response_data['message-count']) + + # for message in response_data['messages']: + # if message['status'] == '0': + # successful_messages += 1 + # if successful_messages < total_messages: + # raise PartialFailureError(response_data) + + # def _check_for_error(self, response_data): + # message = response_data['messages'][0] + # if int(message['status']) != 0: + # raise SmsError( + # f'Sms.send_message method failed with error code {message["status"]}: {message["error-text"]}' + # ) + + # @validate_call + # def submit_sms_conversion( + # self, message_id: str, delivered: bool = True, timestamp: datetime = None + # ): + # """ + # Note: Not available without having this feature manually enabled on your account. + + # Notifies Vonage that an SMS was successfully received. + + # This method is used to submit conversion data about SMS messages that were successfully delivered. + # If you are using the Verify API for two-factor authentication (2FA), this information is sent to Vonage automatically, + # so you do not need to use this method for 2FA messages. + + # Args: + # message_id (str): The `message-id` returned by the `Sms.send` call. + # delivered (bool, optional): Set to `True` if the user replied to the message you sent. Otherwise, set to `False`. + # timestamp (datetime, optional): A `datetime` object containing the time the SMS arrived. + # """ + # params = { + # 'message-id': message_id, + # 'delivered': delivered, + # 'timestamp': (timestamp or datetime.now(timezone.utc)).strftime( + # '%Y-%m-%d %H:%M:%S' + # ), + # } + # self._http_client.post( + # self._http_client.api_host, + # '/conversions/sms', + # params, + # self._auth_type, + # self._sent_data_type, + # ) diff --git a/verify/tests/BUILD b/verify/tests/BUILD new file mode 100644 index 00000000..4b3ba9ae --- /dev/null +++ b/verify/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['verify', 'testutils']) diff --git a/verify/tests/data/verify_request.json b/verify/tests/data/verify_request.json new file mode 100644 index 00000000..74136a5b --- /dev/null +++ b/verify/tests/data/verify_request.json @@ -0,0 +1,4 @@ +{ + "request_id": "abcdef0123456789abcdef0123456789", + "status": "0" +} \ No newline at end of file diff --git a/verify/tests/test_verify.py b/verify/tests/test_verify.py new file mode 100644 index 00000000..c676372d --- /dev/null +++ b/verify/tests/test_verify.py @@ -0,0 +1,63 @@ +from os.path import abspath + +import responses +from vonage_http_client.http_client import HttpClient +from vonage_verify.language_codes import LanguageCode, Psd2LanguageCode +from vonage_verify.requests import Psd2Request, VerifyRequest +from vonage_verify.verify import Verify + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + + +verify = Verify(HttpClient(get_mock_api_key_auth())) + +data = { + 'number': '1234567890', + 'country': 'US', + 'code_length': 6, + 'pin_expiry': 600, + 'next_event_wait': 150, + 'workflow_id': 2, +} + + +def test_create_valid_verify_request_model(): + params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', 'lg': LanguageCode.en_us, **data} + request = VerifyRequest(**params) + + assert request.model_dump(exclude_none=True) == params + + +def test_create_valid_psd2_request_model(): + params = {'payee': 'Acme Inc.', 'amount': 99.99, 'lg': Psd2LanguageCode.en_gb, **data} + request = Psd2Request(**params) + + assert request.model_dump(exclude_none=True) == params + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request.json' + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' + + +@responses.activate +def test_make_psd2_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/psd2/json', 'verify_request.json' + ) + params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} + request = Psd2Request(**params) + + response = verify.start_psd2_verification(request) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 62e093f5..6bf57af6 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.0a4 +- Add support for the [Vonage Verify API](https://developer.vonage.com/en/api/verify). + # 3.99.0a3 - Add support for the [Vonage Users API](https://developer.vonage.com/en/api/application.v2#User). diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index fbf8b59b..feab796b 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,6 +1,6 @@ from vonage_utils import VonageError -from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Users, Vonage +from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Users, Verify, Vonage __all__ = [ 'Vonage', @@ -9,5 +9,6 @@ 'NumberInsightV2', 'Sms', 'Users', + 'Verify', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 21141aab..cd56f8d1 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -5,6 +5,7 @@ from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 from vonage_sms.sms import Sms from vonage_users.users import Users +from vonage_verify.verify import Verify from ._version import __version__ @@ -12,6 +13,11 @@ class Vonage: """Main Server SDK class for using Vonage APIs. + When creating an instance, it will create the authentication objects and + an HTTP Client needed for using Vonage APIs. + Use an instance of this class to access the Vonage APIs, e.g. to access + methods associated with the Vonage SMS API, call `vonage.sms.method_name()`. + Args: auth (Auth): Class dealing with authentication objects and methods. http_client_options (HttpClientOptions, optional): Options for the HTTP client. @@ -25,6 +31,7 @@ def __init__( self.number_insight_v2 = NumberInsightV2(self._http_client) self.sms = Sms(self._http_client) self.users = Users(self._http_client) + self.verify = Verify(self._http_client) @property def http_client(self): diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 0362521c..9303d98e 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -4,6 +4,7 @@ version = '1.0.0' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +dependencies = ["pydantic>=2.6.1"] requires-python = ">=3.8" classifiers = [ "Programming Language :: Python", diff --git a/vonage_utils/src/vonage_utils/types/BUILD b/vonage_utils/src/vonage_utils/types/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/vonage_utils/src/vonage_utils/types/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/vonage_utils/src/vonage_utils/types/phone_number.py b/vonage_utils/src/vonage_utils/types/phone_number.py new file mode 100644 index 00000000..6d43856e --- /dev/null +++ b/vonage_utils/src/vonage_utils/types/phone_number.py @@ -0,0 +1,5 @@ +from typing import Annotated + +from pydantic import Field + +PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')]