From 914516393db1172ea409a456730f4e48c5cf324c Mon Sep 17 00:00:00 2001 From: Lorna Jane Mitchell Date: Wed, 23 Oct 2019 10:37:06 +0200 Subject: [PATCH 001/401] Add simple contributing file (#131) --- CONTRIBUTING.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c0a1767e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Getting Involved + +Thanks for your interest in the project, we'd love to have you involved! Check out the sections below to find out more about what to do next... + +## Opening an Issue + +We always welcome issues, if you've seen something that isn't quite right or you have a suggestion for a new feature, please go ahead and open an issue in this project. Include as much information as you have, it really helps. + +## Making a Code Change + +We're always open to pull requests, but these should be small and clearly described so that we can understand what you're trying to do. Feel free to open an issue first and get some discussion going. + +When you're ready to start coding, fork this repository to your own GitHub account and make your changes in a new branch. Once you're happy, open a pull request and explain what the change is and why you think we should include it in our project. + + From 01bc00cb3954a6fe7d21abffcc8ff790ff5cd684 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Mon, 28 Oct 2019 09:47:45 +0000 Subject: [PATCH 002/401] Tox config and new src directory. (#133) * Tox config and new src directory. * Remove branch testing until we can focus on branch coverage. --- setup.cfg | 7 +++++++ setup.py | 20 ++++++++------------ {nexmo => src/nexmo}/__init__.py | 8 ++++++-- {nexmo => src/nexmo}/_internal.py | 0 {nexmo => src/nexmo}/errors.py | 0 tox.ini | 13 +++++++++++++ 6 files changed, 34 insertions(+), 14 deletions(-) rename {nexmo => src/nexmo}/__init__.py (99%) rename {nexmo => src/nexmo}/_internal.py (100%) rename {nexmo => src/nexmo}/errors.py (100%) create mode 100644 tox.ini diff --git a/setup.cfg b/setup.cfg index 42eb6747..aae3689f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,14 @@ norecursedirs = bin dist docs htmlcov .* {args} max-line-length=120 [coverage:run] +# TODO: Change this to True: +branch=False source= nexmo +[coverage:paths] +source = + src + .tox/*/site-packages + [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index d078053e..13ebb143 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,12 @@ import io import os -from setuptools import setup +from setuptools import setup, find_packages -with io.open(os.path.join(os.path.dirname(__file__), "README.md"), - encoding='utf-8') as f: +with io.open( + os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8" +) as f: long_description = f.read() setup( @@ -18,17 +19,12 @@ author="Nexmo", author_email="devrel@nexmo.com", license="MIT", - packages=["nexmo"], + packages=find_packages(where="src"), + package_dir={"": "src"}, platforms=["any"], - install_requires=[ - "requests>=2.4.2", - "PyJWT[crypto]>=1.6.4", - "pytz>=2018.5" - ], + install_requires=["requests>=2.4.2", "PyJWT[crypto]>=1.6.4", "pytz>=2018.5"], python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", - tests_require=[ - "cryptography>=2.3.1", - ], + tests_require=["cryptography>=2.3.1"], classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 2", diff --git a/nexmo/__init__.py b/src/nexmo/__init__.py similarity index 99% rename from nexmo/__init__.py rename to src/nexmo/__init__.py index 3a841515..e2578ef9 100644 --- a/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -577,7 +577,9 @@ def delete(self, host, request_uri, header_auth=False): else: params = {"api_key": self.api_key, "api_secret": self.api_secret} logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.delete(uri, params=params, headers=headers)) + return self.parse( + host, self.session.delete(uri, params=params, headers=headers) + ) def parse(self, host, response): logger.debug("Response headers %r", response.headers) @@ -657,7 +659,9 @@ def _jwt_signed_delete(self, request_uri): api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, self.session.delete(uri, headers=self._headers())) + return self.parse( + self.api_host, self.session.delete(uri, headers=self._headers()) + ) def _headers(self): token = self.generate_application_jwt() diff --git a/nexmo/_internal.py b/src/nexmo/_internal.py similarity index 100% rename from nexmo/_internal.py rename to src/nexmo/_internal.py diff --git a/nexmo/errors.py b/src/nexmo/errors.py similarity index 100% rename from nexmo/errors.py rename to src/nexmo/errors.py diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..ad8d7c66 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = py27,py36,coverage-report + +[testenv] +deps = -rrequirements.txt +commands = coverage run --parallel -m pytest tests + +[testenv:coverage-report] +deps = coverage +skip_install = true +commands = + coverage combine + coverage report From 054ac79c0713692a4098a7377b88ac53d6139665 Mon Sep 17 00:00:00 2001 From: Lorna Jane Mitchell Date: Fri, 17 Jan 2020 13:34:09 +0000 Subject: [PATCH 003/401] Add Number Management API usage examples to README --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 6d7f2178..9e89bf13 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Voice API](#voice-api) * [Verify API](#verify-api) * [Number Insight API](#number-insight-api) +* [Number Management API](#number-management-api) * [Managing Secrets](#managing-secrets) * [Application API](#application-api) * [License](#license) @@ -261,6 +262,39 @@ client.get_advanced_number_insight(number='447700900000') Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +## Number Management API + +### List Your Numbers + +```python +client.get_account_numbers() +``` + +Docs: [https://developer.nexmo.com/api/numbers#getOwnedNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getOwnedNumbers) + +### Search for a Number + +```python +client.get_available_numbers('GB', {"type":"SMS"}) +``` + +Docs: [https://developer.nexmo.com/api/numbers#getAvailableNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getAvailableNumbers) + +### Buy a Number + +```python +client.buy_number({"country": 'GB', "msisdn": '447700900000'}) +``` + +Docs: [https://developer.nexmo.com/api/numbers#buyANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#buyANumber) + +### Cancel a Number + +```python +client.cancel_number({"country": 'GB', "msisdn": '447700900000'}) +``` + +Docs: [https://developer.nexmo.com/api/numbers#cancelANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#cancelANumber) ## Managing Secrets From 07aaf944247728733d584e5d61eaa9f589016967 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Thu, 27 Feb 2020 12:40:53 -0500 Subject: [PATCH 004/401] Adding Overriding API Url's Instructions --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d7f2178..25eee47a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Number Insight API](#number-insight-api) * [Managing Secrets](#managing-secrets) * [Application API](#application-api) +* [Overriding API url's](#overriding-api-url's) * [License](#license) @@ -360,6 +361,33 @@ specify a different token identifier: client.auth(nbf=nbf, exp=exp, jti=jti) ``` +## Overriding API url's + +By default, our API url's are hardcoded. For use cases where these url's are not accessible, best practices to override these url's are the following: + +- Setting new API url's when creating an instance of the client: + +```python +import nexmo +client = nexmo.Client() +client.host = 'new.host.url' +client.api_host = 'new.api.host' +``` +- Creating a new class that extends from client class and overrides these values in the constructor: + +```python +import nexmo +class MyClient(nexmo.Client): + def __init__(self): + super().__init__() + self.host = 'new.hosts.url' + self.api_host = 'new.api.hosts' + +#Usage +client = MyClient() +``` + + Contributing ------------ @@ -377,7 +405,6 @@ The tests are all written with pytest. You run them with: make test ``` - License ------- From 9ac7f44d905d0a661b766be6e1b0ee6494cdbc23 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Thu, 27 Feb 2020 13:08:50 -0500 Subject: [PATCH 005/401] showing initialisation w/ auth --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 25eee47a..5390748e 100644 --- a/README.md +++ b/README.md @@ -376,15 +376,14 @@ client.api_host = 'new.api.host' - Creating a new class that extends from client class and overrides these values in the constructor: ```python -import nexmo class MyClient(nexmo.Client): - def __init__(self): - super().__init__() + def __init__(self, NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH): + super().__init__(application_id=APPLICATION_ID, private_key=APPLICATION_PRIVATE_KEY_PATH, key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) self.host = 'new.hosts.url' self.api_host = 'new.api.hosts' -#Usage -client = MyClient() +#usage +client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) ``` From bcc2d59ad3b725670fdd9d828661e0bf121a00cc Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 9 Mar 2020 18:17:47 -0400 Subject: [PATCH 006/401] Added get_async_advanced_number_insight method. callback is required --- src/nexmo/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e2578ef9..a7fa1b77 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -316,9 +316,17 @@ def get_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/number/lookup/json", params or kwargs) + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) + else: + raise ClientError("Error: Callback needed for async advanced number insight") + def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) + def request_number_insight(self, params=None, **kwargs): return self.post(self.host, "/ni/json", params or kwargs) From 77a54dae2cb8c24ecd8f277f9d1e64aea1f5f6e7 Mon Sep 17 00:00:00 2001 From: Ben Greenberg Date: Wed, 11 Mar 2020 13:48:05 +0200 Subject: [PATCH 007/401] Fix link in table of contents Fix the link to the section within the table of contents --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5390748e..51ffb0ad 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Number Insight API](#number-insight-api) * [Managing Secrets](#managing-secrets) * [Application API](#application-api) -* [Overriding API url's](#overriding-api-url's) +* [Overriding API url's](#overriding-api-urls) * [License](#license) From 33dd845e61a46e542d8ca16e3db36a97b1fbb598 Mon Sep 17 00:00:00 2001 From: Lorna Jane Mitchell Date: Thu, 12 Mar 2020 18:22:27 +0000 Subject: [PATCH 008/401] Add Vonage wordmark to Nexmo repo --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5390748e..4099ed1c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Nexmo Client Library for Python [![Python versions supported](https://img.shields.io/pypi/pyversions/nexmo.svg)](https://pypi.python.org/pypi/nexmo) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +Nexmo is now known as Vonage + This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. From fd748826f550c3b7cc86364c97f94011b6906f4e Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 17 Mar 2020 17:39:58 -0400 Subject: [PATCH 009/401] adding signature auth fix --- src/nexmo/__init__.py | 172 ++++++++++++++++-------------------------- 1 file changed, 65 insertions(+), 107 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e2578ef9..c4e53442 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,3 +1,5 @@ +from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .errors import * from datetime import datetime import logging from platform import python_version @@ -27,8 +29,6 @@ except ImportError: JSONDecodeError = ValueError -from .errors import * -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param __version__ = "2.4.0" @@ -80,12 +80,8 @@ def __init__( self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) - self.signature_secret = signature_secret or os.environ.get( - "NEXMO_SIGNATURE_SECRET", None - ) - self.signature_method = signature_method or os.environ.get( - "NEXMO_SIGNATURE_METHOD", None - ) + self.signature_secret = signature_secret or os.environ.get("NEXMO_SIGNATURE_SECRET", None) + self.signature_method = signature_method or os.environ.get("NEXMO_SIGNATURE_METHOD", None) if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: self.signature_method = getattr(hashlib, signature_method) @@ -102,7 +98,7 @@ def __init__( self.api_host = "api.nexmo.com" - user_agent = "nexmo-python/{version} python/{python_version}".format( + user_agent = "nexmo-python/{version}/{python_version}".format( version=__version__, python_version=python_version() ) @@ -129,30 +125,33 @@ def auth(self, params=None, **kwargs): self.auth_params = params or kwargs def send_message(self, params): - return self.post(self.host, "/sms/json", params) + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_NEXMO_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host, "/sms/json", params, signature_auth=True) def get_balance(self): return self.get(self.host, "/account/get-balance") def get_country_pricing(self, country_code): - return self.get( - self.host, "/account/get-pricing/outbound", {"country": country_code} - ) + return self.get(self.host, "/account/get-pricing/outbound", {"country": country_code}) def get_prefix_pricing(self, prefix): - return self.get( - self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) + return self.get(self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix}) def get_sms_pricing(self, number): - return self.get( - self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) + return self.get(self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number}) def get_voice_pricing(self, number): - return self.get( - self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) + return self.get(self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number}) def update_settings(self, params=None, **kwargs): return self.post(self.host, "/account/settings", params or kwargs) @@ -164,9 +163,7 @@ def get_account_numbers(self, params=None, **kwargs): return self.get(self.host, "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host, "/number/search", dict(params or kwargs, country=country_code) - ) + return self.get(self.host, "/number/search", dict(params or kwargs, country=country_code)) def buy_number(self, params=None, **kwargs): return self.post(self.host, "/number/buy", params or kwargs) @@ -204,11 +201,8 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): :param timestamp: A `datetime` object containing the time the SMS arrived. :return: The parsed response from the server. On success, the bytestring b'OK' """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } + params = {"message-id": message_id, "delivered": delivered, "timestamp": timestamp or datetime.now(pytz.utc)} + # Ensure timestamp is a string: _format_date_param(params, "timestamp") return self.post(self.api_host, "/conversions/sms", params) @@ -247,11 +241,7 @@ def send_verification_request(self, params=None, **kwargs): return self.post(self.api_host, "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host, - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) + return self.post(self.api_host, "/verify/check/json", dict(params or kwargs, request_id=request_id)) def check_verification_request(self, params=None, **kwargs): warnings.warn( @@ -263,9 +253,7 @@ def check_verification_request(self, params=None, **kwargs): return self.post(self.api_host, "/verify/check/json", params or kwargs) def get_verification(self, request_id): - return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} - ) + return self.get(self.api_host, "/verify/search/json", {"request_id": request_id}) def get_verification_request(self, request_id): warnings.warn( @@ -274,30 +262,16 @@ def get_verification_request(self, request_id): stacklevel=2, ) - return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} - ) + return self.get(self.api_host, "/verify/search/json", {"request_id": request_id}) def cancel_verification(self, request_id): - return self.post( - self.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) + return self.post(self.api_host, "/verify/control/json", {"request_id": request_id, "cmd": "cancel"}) def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) + return self.post(self.api_host, "/verify/control/json", {"request_id": request_id, "cmd": "trigger_next_event"}) def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) + warnings.warn("nexmo.Client#control_verification_request is deprecated", DeprecationWarning, stacklevel=2) return self.post(self.api_host, "/verify/control/json", params or kwargs) @@ -316,6 +290,13 @@ def get_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/number/lookup/json", params or kwargs) + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) + else: + raise ClientError("Error: Callback needed for async advanced number insight") + def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) @@ -418,11 +399,7 @@ def redact_transaction(self, id, product, type=None): return self._post_json(self.api_host, "/v1/redact/transaction", params) def list_secrets(self, api_key): - return self.get( - self.api_host, - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) + return self.get(self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True) def get_secret(self, api_key, secret_id): return self.get( @@ -455,9 +432,7 @@ def check_signature(self, params): def signature(self, params): if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) + hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method) else: hasher = hashlib.md5() @@ -492,21 +467,27 @@ def get(self, host, request_uri, params=None, header_auth=False): headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) else: params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret + params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) ) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) - def post(self, host, request_uri, params, header_auth=False): + def post(self, host, request_uri, params, signature_auth=False, header_auth=False): """ - Post form-encoded data to `request_uri`. - - Auth is either key/secret added to the post data, or basic auth, - if `header_auth` is True. + Low-level method to make a post request to a Nexmo API server. + This method automatically adds authentication, picking the first applicable authentication method from the following: + - If the signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. + - Otherwise the client's key and secret are appended to the post request's params. + :param bool signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) headers = self.headers - if header_auth: + if signature_auth and self.signature_secret: + params["api_key"] = self.api_key + params["sig"] = self.signature(params) + elif header_auth: h = base64.b64encode( ( "{api_key}:{api_secret}".format( @@ -532,12 +513,8 @@ def _post_json(self, host, request_uri, json): ).encode("utf-8") ) ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) + headers = dict(self.headers or {}, Authorization="Basic {hash}".format(hash=auth)) + logger.debug("POST to %r with body: %r, headers: %r", request_uri, json, headers) return self.parse(host, self.session.post(uri, headers=headers, json=json)) def put(self, host, request_uri, params, header_auth=False): @@ -588,6 +565,7 @@ def parse(self, host, response): elif response.status_code == 204: return None elif 200 <= response.status_code < 300: + # Strip off any encoding from the content-type header: content_mime = response.headers.get("content-type").split(";", 1)[0] if content_mime == "application/json": @@ -595,35 +573,22 @@ def parse(self, host, response): else: return response.content elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) + logger.warning("Client error: %s %r", response.status_code, response.content) + message = "{code} response from {host}".format(code=response.status_code, host=host) + # Test for standard error format: try: error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): + if "type" in error_data and "title" in error_data and "detail" in error_data: message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], + title=error_data["title"], detail=error_data["detail"], type=error_data["type"] ) except JSONDecodeError: pass raise ClientError(message) elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) + logger.warning("Server error: %s %r", response.status_code, response.content) + message = "{code} response from {host}".format(code=response.status_code, host=host) raise ServerError(message) def _jwt_signed_get(self, request_uri, params=None): @@ -631,28 +596,21 @@ def _jwt_signed_get(self, request_uri, params=None): api_host=self.api_host, request_uri=request_uri ) - return self.parse( - self.api_host, - self.session.get(uri, params=params or {}, headers=self._headers()), - ) + return self.parse(self.api_host, requests.get(uri, params=params or {}, headers=self._headers())) def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) - ) + return self.parse(self.api_host, requests.post(uri, json=params, headers=self._headers())) def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) - ) + return self.parse(self.api_host, requests.put(uri, json=params, headers=self._headers())) def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( From 73df7dbaba95c3ffcb9dbccb376967e695f79603 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Mon, 23 Mar 2020 11:26:19 -0400 Subject: [PATCH 010/401] reverting user agent change --- src/nexmo/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index c4e53442..2555c818 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -98,7 +98,7 @@ def __init__( self.api_host = "api.nexmo.com" - user_agent = "nexmo-python/{version}/{python_version}".format( + user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() ) From 03aee3428eb25b7d62fe9b06dca201d90f32a402 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Tue, 24 Mar 2020 19:22:23 -0400 Subject: [PATCH 011/401] removing additional accidental spacing --- src/nexmo/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index a7fa1b77..0d3b985e 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -326,7 +326,6 @@ def get_async_advanced_number_insight(self, params=None, **kwargs): def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) - def request_number_insight(self, params=None, **kwargs): return self.post(self.host, "/ni/json", params or kwargs) From 71da8ebdc4388a39d11af71117073707d9c8f5c6 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Wed, 25 Mar 2020 12:25:18 -0400 Subject: [PATCH 012/401] Removing Async Advanced NI --- src/nexmo/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 2555c818..134df29e 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -290,13 +290,6 @@ def get_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/number/lookup/json", params or kwargs) - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) - else: - raise ClientError("Error: Callback needed for async advanced number insight") - def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) From fab2ab44319ac08228e0ce918560e01ff2acccb6 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Wed, 25 Mar 2020 12:37:12 -0400 Subject: [PATCH 013/401] reverting formatting changes --- src/nexmo/__init__.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 134df29e..bc8bb5e8 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -142,16 +142,24 @@ def get_balance(self): return self.get(self.host, "/account/get-balance") def get_country_pricing(self, country_code): - return self.get(self.host, "/account/get-pricing/outbound", {"country": country_code}) + return self.get( + self.host, "/account/get-pricing/outbound", {"country": country_code} + ) def get_prefix_pricing(self, prefix): - return self.get(self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix}) + return self.get( + self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) def get_sms_pricing(self, number): - return self.get(self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number}) + return self.get( + self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) def get_voice_pricing(self, number): - return self.get(self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number}) + return self.get( + self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) def update_settings(self, params=None, **kwargs): return self.post(self.host, "/account/settings", params or kwargs) @@ -253,7 +261,9 @@ def check_verification_request(self, params=None, **kwargs): return self.post(self.api_host, "/verify/check/json", params or kwargs) def get_verification(self, request_id): - return self.get(self.api_host, "/verify/search/json", {"request_id": request_id}) + return self.get( + self.api_host, "/verify/search/json", {"request_id": request_id} + ) def get_verification_request(self, request_id): warnings.warn( @@ -262,16 +272,30 @@ def get_verification_request(self, request_id): stacklevel=2, ) - return self.get(self.api_host, "/verify/search/json", {"request_id": request_id}) + return self.get( + self.api_host, "/verify/search/json", {"request_id": request_id} + ) def cancel_verification(self, request_id): - return self.post(self.api_host, "/verify/control/json", {"request_id": request_id, "cmd": "cancel"}) + return self.post( + self.api_host, + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"} + ) def trigger_next_verification_event(self, request_id): - return self.post(self.api_host, "/verify/control/json", {"request_id": request_id, "cmd": "trigger_next_event"}) + return self.post( + self.api_host, + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"} + ) def control_verification_request(self, params=None, **kwargs): - warnings.warn("nexmo.Client#control_verification_request is deprecated", DeprecationWarning, stacklevel=2) + warnings.warn( + "nexmo.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2 + ) return self.post(self.api_host, "/verify/control/json", params or kwargs) From d9cd45e52486dc1bad1bf4c0c695be7c43714e04 Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 25 Mar 2020 12:47:26 -0400 Subject: [PATCH 014/401] fixing indentation --- src/nexmo/__init__.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index bc8bb5e8..e5e5e2c8 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -98,28 +98,28 @@ def __init__( self.api_host = "api.nexmo.com" - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) + user_agent = "nexmo-python/{version} python/{python_version}".format( + version=__version__, python_version=python_version() + ) - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) - self.headers = {"User-Agent": user_agent} + self.headers = {"User-Agent": user_agent} - self.auth_params = {} + self.auth_params = {} - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) - self.session = requests.Session() + self.session = requests.Session() def auth(self, params=None, **kwargs): self.auth_params = params or kwargs @@ -279,7 +279,7 @@ def get_verification_request(self, request_id): def cancel_verification(self, request_id): return self.post( self.api_host, - "/verify/control/json", + "/verify/control/json", {"request_id": request_id, "cmd": "cancel"} ) @@ -292,8 +292,8 @@ def trigger_next_verification_event(self, request_id): def control_verification_request(self, params=None, **kwargs): warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, + "nexmo.Client#control_verification_request is deprecated", + DeprecationWarning, stacklevel=2 ) From 32385e33cf0fa44b94af00551bb41b6a0a42937e Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 25 Mar 2020 12:53:50 -0400 Subject: [PATCH 015/401] minor style changes --- src/nexmo/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e5e5e2c8..b6f4ee4a 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -80,8 +80,13 @@ def __init__( self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) - self.signature_secret = signature_secret or os.environ.get("NEXMO_SIGNATURE_SECRET", None) - self.signature_method = signature_method or os.environ.get("NEXMO_SIGNATURE_METHOD", None) + self.signature_secret = signature_secret or os.environ.get( + "NEXMO_SIGNATURE_SECRET", None + ) + + self.signature_method = signature_method or os.environ.get( + "NEXMO_SIGNATURE_METHOD", None + ) if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: self.signature_method = getattr(hashlib, signature_method) From ea025393943ad4c0d8a98cc9fa972b23ded3d261 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Fri, 27 Mar 2020 14:44:31 -0400 Subject: [PATCH 016/401] Adding Signature Auth to Readme --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 51ffb0ad..7913f6e2 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,53 @@ be enabled on your account first. ```python response = client.submit_sms_conversion(message_id) ``` +### Signing a Message +*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages).* + +The SMS API supports the ability to sign messages by generating and adding a signature using a "Signature Secret" rather than your API secret. The algorithms supported are: + +md5hash1 +md5 +sha1 +sha256 +sha512 + +Both your application and Nexmo need to agree on which algorithm is used. In the dashboard, visit your account settings page and under "API Settings" you can select the algorithm to use. This is also the location where you will find your "Signature Secret" (it's different from the API secret). + +### Create a client using these credentials and the algorithm to use, for example: + +```python +client = nexmo.Client( + key = os.getenv('NEXMO_API_KEY'), + signature_secret = os.getenv('NEXMO_SIGNATURE_SECRET'), + signature_method = 'sha256' +) +``` + +Using this client, your SMS API messages will be sent as signed messages. + +### Verifying an Incoming Message Signature + +*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)*. + +If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. + +To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. + +Then use the `check_signature()` method with the actual signature that was received (usually present in request.form or request.args. you can merge those in a single variable called params) to make sure that it is correct. + +### Get the params + +```python +if request.is_json: + params = request.get_json() +else: + params = request.args or request.form +is_valid = client.check_signature(params)// is it valid? Will be true or false +``` + +Using your signature secret and the other supplied parameters, the signature can be calculated and checked against the incoming signature value. ## Voice API From d3f84e92a3a624147eaf702a6f7897f8ec4ad4a7 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez Manrique <61435963+superdiana@users.noreply.github.com> Date: Fri, 27 Mar 2020 17:56:54 -0400 Subject: [PATCH 017/401] correcting formatting --- src/nexmo/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index b6f4ee4a..f5b55a2e 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -488,9 +488,7 @@ def get(self, host, request_uri, params=None, header_auth=False): ).decode("ascii") headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) else: - params = dict( - params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) - ) + params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) From 2c57cf6fc74c6c96d97751d85367924c30e4f08f Mon Sep 17 00:00:00 2001 From: superdiana Date: Fri, 27 Mar 2020 18:02:14 -0400 Subject: [PATCH 018/401] indentation fixes --- src/nexmo/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index f5b55a2e..56d5845f 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -488,7 +488,7 @@ def get(self, host, request_uri, params=None, header_auth=False): ).decode("ascii") headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) else: - params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) + params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) From 50ffaadbebdc2562b28d2552c863c103e1c30b49 Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 30 Mar 2020 13:15:47 -0400 Subject: [PATCH 019/401] indentation fixes --- src/nexmo/__init__.py | 117 +++++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 37 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 56d5845f..c977d18a 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -103,28 +103,28 @@ def __init__( self.api_host = "api.nexmo.com" - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version + user_agent = "nexmo-python/{version} python/{python_version}".format( + version=__version__, python_version=python_version() ) - self.headers = {"User-Agent": user_agent} + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) + + self.headers = {"User-Agent": user_agent} - self.auth_params = {} + self.auth_params = {} - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) - self.session = requests.Session() + self.session = requests.Session() def auth(self, params=None, **kwargs): self.auth_params = params or kwargs @@ -176,7 +176,9 @@ def get_account_numbers(self, params=None, **kwargs): return self.get(self.host, "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get(self.host, "/number/search", dict(params or kwargs, country=country_code)) + return self.get( + self.host, "/number/search", dict(params or kwargs, country=country_code) + ) def buy_number(self, params=None, **kwargs): return self.post(self.host, "/number/buy", params or kwargs) @@ -214,7 +216,11 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): :param timestamp: A `datetime` object containing the time the SMS arrived. :return: The parsed response from the server. On success, the bytestring b'OK' """ - params = {"message-id": message_id, "delivered": delivered, "timestamp": timestamp or datetime.now(pytz.utc)} + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } # Ensure timestamp is a string: _format_date_param(params, "timestamp") @@ -254,7 +260,11 @@ def send_verification_request(self, params=None, **kwargs): return self.post(self.api_host, "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): - return self.post(self.api_host, "/verify/check/json", dict(params or kwargs, request_id=request_id)) + return self.post( + self.api_host, + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) def check_verification_request(self, params=None, **kwargs): warnings.warn( @@ -285,21 +295,21 @@ def cancel_verification(self, request_id): return self.post( self.api_host, "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"} + {"request_id": request_id, "cmd": "cancel"}, ) def trigger_next_verification_event(self, request_id): return self.post( self.api_host, "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"} + {"request_id": request_id, "cmd": "trigger_next_event"}, ) def control_verification_request(self, params=None, **kwargs): warnings.warn( "nexmo.Client#control_verification_request is deprecated", DeprecationWarning, - stacklevel=2 + stacklevel=2, ) return self.post(self.api_host, "/verify/control/json", params or kwargs) @@ -421,7 +431,11 @@ def redact_transaction(self, id, product, type=None): return self._post_json(self.api_host, "/v1/redact/transaction", params) def list_secrets(self, api_key): - return self.get(self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True) + return self.get( + self.api_host, + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) def get_secret(self, api_key, secret_id): return self.get( @@ -454,7 +468,9 @@ def check_signature(self, params): def signature(self, params): if self.signature_method: - hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method) + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) else: hasher = hashlib.md5() @@ -488,7 +504,9 @@ def get(self, host, request_uri, params=None, header_auth=False): ).decode("ascii") headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) else: - params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) @@ -533,8 +551,12 @@ def _post_json(self, host, request_uri, json): ).encode("utf-8") ) ).decode("ascii") - headers = dict(self.headers or {}, Authorization="Basic {hash}".format(hash=auth)) - logger.debug("POST to %r with body: %r, headers: %r", request_uri, json, headers) + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) return self.parse(host, self.session.post(uri, headers=headers, json=json)) def put(self, host, request_uri, params, header_auth=False): @@ -593,22 +615,36 @@ def parse(self, host, response): else: return response.content elif 400 <= response.status_code < 500: - logger.warning("Client error: %s %r", response.status_code, response.content) - message = "{code} response from {host}".format(code=response.status_code, host=host) + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) # Test for standard error format: try: error_data = response.json() - if "type" in error_data and "title" in error_data and "detail" in error_data: + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): message = "{title}: {detail} ({type})".format( - title=error_data["title"], detail=error_data["detail"], type=error_data["type"] + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], ) except JSONDecodeError: pass raise ClientError(message) elif 500 <= response.status_code < 600: - logger.warning("Server error: %s %r", response.status_code, response.content) - message = "{code} response from {host}".format(code=response.status_code, host=host) + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) raise ServerError(message) def _jwt_signed_get(self, request_uri, params=None): @@ -616,21 +652,28 @@ def _jwt_signed_get(self, request_uri, params=None): api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, requests.get(uri, params=params or {}, headers=self._headers())) + return self.parse( + self.api_host, + requests.get(uri, params=params or {}, headers=self._headers()), + ) def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, requests.post(uri, json=params, headers=self._headers())) + return self.parse( + self.api_host, requests.post(uri, json=params, headers=self._headers()) + ) def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, requests.put(uri, json=params, headers=self._headers())) + return self.parse( + self.api_host, requests.put(uri, json=params, headers=self._headers()) + ) def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( From a1ee776cf0b38df330358c93258d336c41aee625 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 31 Mar 2020 15:52:35 -0400 Subject: [PATCH 020/401] reverting requests to self.session --- src/nexmo/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index c977d18a..9432d712 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -221,7 +221,6 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): "delivered": delivered, "timestamp": timestamp or datetime.now(pytz.utc), } - # Ensure timestamp is a string: _format_date_param(params, "timestamp") return self.post(self.api_host, "/conversions/sms", params) @@ -654,7 +653,7 @@ def _jwt_signed_get(self, request_uri, params=None): return self.parse( self.api_host, - requests.get(uri, params=params or {}, headers=self._headers()), + self.session.get(uri, params=params or {}, headers=self._headers()), ) def _jwt_signed_post(self, request_uri, params): @@ -663,7 +662,7 @@ def _jwt_signed_post(self, request_uri, params): ) return self.parse( - self.api_host, requests.post(uri, json=params, headers=self._headers()) + self.api_host, self.session.post(uri, json=params, headers=self._headers()) ) def _jwt_signed_put(self, request_uri, params): @@ -672,7 +671,7 @@ def _jwt_signed_put(self, request_uri, params): ) return self.parse( - self.api_host, requests.put(uri, json=params, headers=self._headers()) + self.api_host, self.session.put(uri, json=params, headers=self._headers()) ) def _jwt_signed_delete(self, request_uri): From 3de5d5398187961242d21bdcdb5a37f674256f46 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 31 Mar 2020 16:07:01 -0400 Subject: [PATCH 021/401] renaming to supports_signature_auth --- src/nexmo/__init__.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 9432d712..2dc1445a 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -141,7 +141,7 @@ def send_message(self, params): }) :param dict params: A dict of values described at `Send an SMS `_ """ - return self.post(self.host, "/sms/json", params, signature_auth=True) + return self.post(self.host, "/sms/json", params, supports_signature_auth=True) def get_balance(self): return self.get(self.host, "/account/get-balance") @@ -509,19 +509,26 @@ def get(self, host, request_uri, params=None, header_auth=False): logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) - def post(self, host, request_uri, params, signature_auth=False, header_auth=False): + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): """ Low-level method to make a post request to a Nexmo API server. This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - Otherwise the client's key and secret are appended to the post request's params. - :param bool signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) headers = self.headers - if signature_auth and self.signature_secret: + if supports_signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) elif header_auth: From 009075b63baa6586412ed200d96e83d7caa562b3 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 8 Apr 2020 08:36:37 -0400 Subject: [PATCH 022/401] additional instructions for API URL Override --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 8fe2376b..a7a728ac 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,24 @@ class MyClient(nexmo.Client): client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) ``` +Should the above instructions not be enough for your specific case, another way to customise is: + +```python +import nexmo + +class NexmoClient(nexmo.Client): + def __init__(....): + super().__init__(....) + api_server = BasicAuthenticatedServer( + "mycustomurl", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) +``` + +Then proceed to create your personalised instance of the class. Contributing ------------ From 811743e9f4af5aaf087476f070ffd893ce9e1e27 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 8 Apr 2020 08:43:59 -0400 Subject: [PATCH 023/401] Updating Overriding API URL Instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7a728ac..c4130afe 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,7 @@ class MyClient(nexmo.Client): client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) ``` -Should the above instructions not be enough for your specific case, another way to customise is: +For a more specific case, another way to customise is: ```python import nexmo From d44f393c76db604a01862ba81b6e386a6f671cfe Mon Sep 17 00:00:00 2001 From: superdiana Date: Thu, 7 May 2020 23:16:10 -0400 Subject: [PATCH 024/401] exception matrix --- src/nexmo/__init__.py | 123 ++++++++++++++---------------------------- src/nexmo/errors.py | 114 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 84 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e3a37117..f81ffbcc 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -65,6 +65,9 @@ class Client: provided by this library and can be used by Nexmo to track your app statistics. """ + #Call exception handler - as private for internal usage + __error_handler = ExceptionHandler() + def __init__( self, key=None, @@ -104,7 +107,7 @@ def __init__( self.api_host = "api.nexmo.com" user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() + version=__version__, python_version=python_version() ) if app_name and app_version: @@ -122,6 +125,7 @@ def __init__( api_key=self.api_key, api_secret=self.api_secret, ) + self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() @@ -141,7 +145,7 @@ def send_message(self, params): }) :param dict params: A dict of values described at `Send an SMS `_ """ - return self.post(self.host, "/sms/json", params, supports_signature_auth=True) + return self.post(self.host, "/sms/json", params, signature_auth=True) def get_balance(self): return self.get(self.host, "/account/get-balance") @@ -176,9 +180,7 @@ def get_account_numbers(self, params=None, **kwargs): return self.get(self.host, "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host, "/number/search", dict(params or kwargs, country=country_code) - ) + return self.get(self.host, "/number/search", dict(params or kwargs, country=country_code)) def buy_number(self, params=None, **kwargs): return self.post(self.host, "/number/buy", params or kwargs) @@ -216,11 +218,8 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): :param timestamp: A `datetime` object containing the time the SMS arrived. :return: The parsed response from the server. On success, the bytestring b'OK' """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } + params = {"message-id": message_id, "delivered": delivered, "timestamp": timestamp or datetime.now(pytz.utc)} + # Ensure timestamp is a string: _format_date_param(params, "timestamp") return self.post(self.api_host, "/conversions/sms", params) @@ -259,11 +258,7 @@ def send_verification_request(self, params=None, **kwargs): return self.post(self.api_host, "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host, - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) + return self.post(self.api_host, "/verify/check/json", dict(params or kwargs, request_id=request_id)) def check_verification_request(self, params=None, **kwargs): warnings.warn( @@ -294,21 +289,21 @@ def cancel_verification(self, request_id): return self.post( self.api_host, "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, + {"request_id": request_id, "cmd": "cancel"} ) def trigger_next_verification_event(self, request_id): return self.post( self.api_host, "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, + {"request_id": request_id, "cmd": "trigger_next_event"} ) def control_verification_request(self, params=None, **kwargs): warnings.warn( "nexmo.Client#control_verification_request is deprecated", DeprecationWarning, - stacklevel=2, + stacklevel=2 ) return self.post(self.api_host, "/verify/control/json", params or kwargs) @@ -328,13 +323,6 @@ def get_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/number/lookup/json", params or kwargs) - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) - else: - raise ClientError("Error: Callback needed for async advanced number insight") - def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) @@ -437,11 +425,7 @@ def redact_transaction(self, id, product, type=None): return self._post_json(self.api_host, "/v1/redact/transaction", params) def list_secrets(self, api_key): - return self.get( - self.api_host, - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) + return self.get(self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True) def get_secret(self, api_key, secret_id): return self.get( @@ -474,9 +458,7 @@ def check_signature(self, params): def signature(self, params): if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) + hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method) else: hasher = hashlib.md5() @@ -510,32 +492,23 @@ def get(self, host, request_uri, params=None, header_auth=False): ).decode("ascii") headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) + params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): + def post(self, host, request_uri, params, signature_auth=False, header_auth=False): """ Low-level method to make a post request to a Nexmo API server. This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) headers = self.headers - if supports_signature_auth and self.signature_secret: + if signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) elif header_auth: @@ -564,12 +537,8 @@ def _post_json(self, host, request_uri, json): ).encode("utf-8") ) ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) + headers = dict(self.headers or {}, Authorization="Basic {hash}".format(hash=auth)) + logger.debug("POST to %r with body: %r, headers: %r", request_uri, json, headers) return self.parse(host, self.session.post(uri, headers=headers, json=json)) def put(self, host, request_uri, params, header_auth=False): @@ -624,40 +593,33 @@ def parse(self, host, response): # Strip off any encoding from the content-type header: content_mime = response.headers.get("content-type").split(";", 1)[0] if content_mime == "application/json": - return response.json() + #Check for exceptions before retrieve data + data = response.json() + if "messages" in data and self.__error_handler.validate_code(data["messages"][0]["status"]): + exception_code = data["messages"][0]["status"] + exception_text = data["messages"][0]["error-text"] + #raise exception + self.__error_handler.trigger(exception_code, exception_text) + return data else: return response.content elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) + logger.warning("Client error: %s %r", response.status_code, response.content) + message = "{code} response from {host}".format(code=response.status_code, host=host) # Test for standard error format: try: error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): + if "type" in error_data and "title" in error_data and "detail" in error_data: message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], + title=error_data["title"], detail=error_data["detail"], type=error_data["type"] ) except JSONDecodeError: pass raise ClientError(message) elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) + logger.warning("Server error: %s %r", response.status_code, response.content) + message = "{code} response from {host}".format(code=response.status_code, host=host) raise ServerError(message) def _jwt_signed_get(self, request_uri, params=None): @@ -665,28 +627,21 @@ def _jwt_signed_get(self, request_uri, params=None): api_host=self.api_host, request_uri=request_uri ) - return self.parse( - self.api_host, - self.session.get(uri, params=params or {}, headers=self._headers()), - ) + return self.parse(self.api_host, requests.get(uri, params=params or {}, headers=self._headers())) def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) - ) + return self.parse(self.api_host, requests.post(uri, json=params, headers=self._headers())) def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) - ) + return self.parse(self.api_host, requests.put(uri, json=params, headers=self._headers())) def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( diff --git a/src/nexmo/errors.py b/src/nexmo/errors.py index 88995700..c2781f9e 100644 --- a/src/nexmo/errors.py +++ b/src/nexmo/errors.py @@ -12,3 +12,117 @@ class ServerError(Error): class AuthenticationError(ClientError): pass + +class NexmoError(ClientError): + pass + +#Code 1: Throttled +class ThrottledError(NexmoError): + pass + +#Code 2: Missing params +class MissingParamError(NexmoError): + pass + +#Code 3: Invalid params +class InvalidParamError(NexmoError): + pass + +#Code 4: Invalid credentials +class CredentialError(NexmoError): + pass + +#Code 5: Internal Error +class InternalError(NexmoError): + pass + +#Code 6: Invalid message +class InvalidMessageError(NexmoError): + pass + +#Code 7: Number barred +class NumberBarredError(NexmoError): + pass + +#Code 8: Partner account barred +class PartnerAccountBarredError(NexmoError): + pass + +#Code 9: Partner quota exceeded +class PartnerQuotaExceededError(NexmoError): + pass + +#Code 11: Account not enabled for REST +class AccountNoRestError(NexmoError): + pass + +#Code 12: Message too long +class MessageLengthError(NexmoError): + pass + +#Code 13: Communication Failed +class CommunicationError(NexmoError): + pass + +#Code 14: Invalid Signature +class InvalidSignatureError(NexmoError): + pass + +#Code 15: Illegal Sender Address - rejected +class IllegalSenderError(NexmoError): + pass + +#Code 16: Invalid TTL +class InvalidTTLError(NexmoError): + pass + +#Code 19: Facility not allowed +class FacilityError(NexmoError): + pass + +#Code 20: Invalid Message class +class InvalidMessageClassError(NexmoError): + pass + +#Code 23: Bad callback :: Missing Protocol +class MissingProtocolError(NexmoError): + pass + +#Code 29: Non White-listed Destination +class BlackListDestinationError(NexmoError): + pass + +#Code 34: Invalid or Missing Msisdn Param +class MsisdnError(NexmoError): + pass + +class ExceptionHandler(): + #Register exceptions by error code in the private exception matrix + __exceptions = { + '1': ThrottledError, + '2': MissingParamError, + '3': InvalidParamError, + '4': CredentialError, + '5': InternalError, + '6': InvalidMessageError, + '7': NumberBarredError, + '8': PartnerAccountBarredError, + '9': PartnerQuotaExceededError, + '11': AccountNoRestError, + '12': MessageLengthError, + '13': CommunicationError, + '14': InvalidSignatureError, + '15': IllegalSenderError, + '16': InvalidTTLError, + '19': FacilityError, + '20': InvalidMessageClassError, + '23': MissingProtocolError, + '29': BlackListDestinationError, + '34': MsisdnError + } + + def validate_code(self, code): + return code in self.__exceptions + + def trigger(self, code, message): + raise self.__exceptions[code](message) \ No newline at end of file From b6b37f7db844508a9568a890363875e8b18afcf4 Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 11 May 2020 11:39:10 -0400 Subject: [PATCH 025/401] Adapting tests to new exceptions --- src/nexmo/__init__.py | 5 ++++- src/nexmo/errors.py | 24 +++++++++++++++++++++++- tests/test_account.py | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index f81ffbcc..a1ca019f 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -616,7 +616,10 @@ def parse(self, host, response): ) except JSONDecodeError: pass - raise ClientError(message) + if self.__error_handler.validate_code(str(response.status_code)): + self.__error_handler.trigger(str(response.status_code), response.content or message) + else: + raise ClientError(message) elif 500 <= response.status_code < 600: logger.warning("Server error: %s %r", response.status_code, response.content) message = "{code} response from {host}".format(code=response.status_code, host=host) diff --git a/src/nexmo/errors.py b/src/nexmo/errors.py index c2781f9e..26776fa4 100644 --- a/src/nexmo/errors.py +++ b/src/nexmo/errors.py @@ -1,3 +1,5 @@ +import json + class Error(Exception): pass @@ -96,6 +98,25 @@ class BlackListDestinationError(NexmoError): class MsisdnError(NexmoError): pass +#Code 400: Bad request - when voice Api +class BadRequestError(NexmoError): + def __init__(self, data): + error_data = data + message = data + if isinstance(data, bytes): + #convert data bytes in dict and re-assign + error_data = json.loads(data.decode('utf-8')) + if "type" in error_data and "title" in error_data: + if "detail" in error_data: + message = "{title}: {detail} ({type})".format( + title=error_data["title"], detail=error_data["detail"], type=error_data["type"] + ) + elif "invalid_parameters" in error_data: + message = "{title}: Invalid parameters, reason: {reason}, param hint: {name}".format( + title=error_data["title"], reason=error_data["invalid_parameters"][0]["reason"], name=error_data["invalid_parameters"][0]["name"] + ) + super().__init__(message) + class ExceptionHandler(): #Register exceptions by error code in the private exception matrix __exceptions = { @@ -118,7 +139,8 @@ class ExceptionHandler(): '20': InvalidMessageClassError, '23': MissingProtocolError, '29': BlackListDestinationError, - '34': MsisdnError + '34': MsisdnError, + '400': BadRequestError } def validate_code(self, code): diff --git a/tests/test_account.py b/tests/test_account.py index 07e94303..e302b144 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -225,6 +225,6 @@ def test_create_secret_validation(client): client.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" + """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" in str(ce) ) From e069e3d478dfed93856bdceb427b96ad0380d720 Mon Sep 17 00:00:00 2001 From: superdiana Date: Thu, 28 May 2020 19:20:35 -0400 Subject: [PATCH 026/401] adding voice & sms tests --- tests/test_sms.py | 142 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_voice.py | 40 +++++++++++++ 2 files changed, 182 insertions(+) diff --git a/tests/test_sms.py b/tests/test_sms.py index 8d3b18ad..e3ce0c78 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,5 +1,6 @@ import nexmo from util import * +import json @responses.activate @@ -31,6 +32,147 @@ def test_client_error(client): client.send_message({}) excinfo.match(r"400 response from rest.nexmo.com") +@responses.activate +def test_missing_param_error(client): + responses.add( + responses.POST, + "https://rest.nexmo.com/sms/json", + status=200, + body=json.dumps( + { + "messages": [ + { + "status": "2", + "error-text": "Missing from param" + } + ] + } + ), + content_type='application/json' + ) + + with pytest.raises(nexmo.errors.MissingParamError) as excinfo: + client.send_message({}) + excinfo.match(r"Missing from param") + +@responses.activate +def test_invalid_param_error(client): + responses.add( + responses.POST, + "https://rest.nexmo.com/sms/json", + status=200, + body=json.dumps( + { + "messages": [ + { + "status": "3", + "error-text": "to address is not numeric" + } + ] + } + ), + content_type='application/json' + ) + + with pytest.raises(nexmo.InvalidParamError) as excinfo: + client.send_message({'from': 'Vonage SMS API','to': '8888888888aaa','text': 'Hello from Vonage'}) + excinfo.match(r"to address is not numeric") + +@responses.activate +def test_credential_error(client): + responses.add( + responses.POST, + "https://rest.nexmo.com/sms/json", + status=200, + body=json.dumps( + { + "messages": [ + { + "status": "4", + "error-text": "Bad Credentials" + } + ] + } + ), + content_type='application/json' + ) + + with pytest.raises(nexmo.CredentialError) as excinfo: + client.send_message({'from': 'Vonage SMS API','to': '8888888888aaa','text': 'Hello from Vonage'}) + excinfo.match(r"Bad Credentials") + +@responses.activate +def test_invalid_message_error(client): + responses.add( + responses.POST, + "https://rest.nexmo.com/sms/json", + status=200, + body=json.dumps( + { + "messages": [ + { + "status": "6", + "error-text": "Unroutable message - rejected" + } + ] + } + ), + content_type='application/json' + ) + + with pytest.raises(nexmo.InvalidMessageError) as excinfo: + client.send_message({'from': 'Vonage SMS API','to': '88888888888','text': 'Hello from Vonage'}) + excinfo.match(r"Unroutable message - rejected") + +@responses.activate +def test_invalid_ttl_error(client): + responses.add( + responses.POST, + "https://rest.nexmo.com/sms/json", + status=200, + body=json.dumps( + { + "messages": [ + { + "status": "16", + "error-text": "value for ttl field out of range" + } + ] + } + ), + content_type='application/json' + ) + + with pytest.raises(nexmo.InvalidTTLError) as excinfo: + client.send_message({'ttl':1, 'from': 'Vonage SMS API','to': '88888888888','text': 'Hello from Vonage'}) + excinfo.match(r"value for ttl field out of range") + +""" When the signature method in the client definition is not the same defined in the account settings. +client = nexmo.Client(key='API_KEY', signature_secret='LARGE_KEY', signature_method='sha512') +In the user settings md5 hash signature has been defined +""" +@responses.activate +def test_invalid_signature_error(client): + responses.add( + responses.POST, + "https://rest.nexmo.com/sms/json", + status=200, + body=json.dumps( + { + "messages": [ + { + "status": "14", + "error-text": "Invalid Signature" + } + ] + } + ), + content_type='application/json' + ) + + with pytest.raises(nexmo.InvalidSignatureError) as excinfo: + client.send_message({'from': 'Vonage SMS API','to': '88888888888','text': 'Hello from Vonage'}) + excinfo.match(r"Invalid Signature") @responses.activate def test_server_error(client): diff --git a/tests/test_voice.py b/tests/test_voice.py index 9b7e4f68..4ab03a6d 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -5,6 +5,7 @@ import nexmo from util import * +import json @responses.activate @@ -149,3 +150,42 @@ def test_authorization_with_private_key_object(client, dummy_data): request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" ) assert token["application_id"] == dummy_data.application_id + +#Error scenarios for voice API +@responses.activate +def test_bad_request_error(client): + responses.add( + responses.POST, + "https://api.nexmo.com/v1/calls", + status=400, + body=json.dumps( + { + 'type': 400, + 'title': 'Bad Request', + 'invalid_parameters': + [ + { + 'reason': 'can contain up to 15 digits prefixed with +', + 'name': 'number' + } + ] + } + ), + content_type='application/json' + ) + with pytest.raises(nexmo.BadRequestError) as excinfo: + client.create_call( + { + 'to': [ + {'type': 'phone', 'number': '50588804403aaa'} + ], + 'from': { + 'type': 'phone', + 'number': '19899846327' + }, + 'ncco':[ + {"action": "talk","text": "Please wait while we connect you."} + ] + } + ) + excinfo.match(r"Invalid parameters, reason: can contain up to 15 digits prefixed with \+, param hint: number") \ No newline at end of file From fd4b023e2637b9534338cc8c8c1a49c77f9e24f7 Mon Sep 17 00:00:00 2001 From: superdiana Date: Fri, 29 May 2020 15:04:54 -0400 Subject: [PATCH 027/401] Corrections --- src/nexmo/__init__.py | 127 +++++++++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 33 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index a1ca019f..92d01662 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -65,7 +65,7 @@ class Client: provided by this library and can be used by Nexmo to track your app statistics. """ - #Call exception handler - as private for internal usage + # Call exception handler - as private for internal usage __error_handler = ExceptionHandler() def __init__( @@ -107,7 +107,7 @@ def __init__( self.api_host = "api.nexmo.com" user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() + version=__version__, python_version=python_version() ) if app_name and app_version: @@ -125,7 +125,6 @@ def __init__( api_key=self.api_key, api_secret=self.api_secret, ) - self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() @@ -145,7 +144,7 @@ def send_message(self, params): }) :param dict params: A dict of values described at `Send an SMS `_ """ - return self.post(self.host, "/sms/json", params, signature_auth=True) + return self.post(self.host, "/sms/json", params, supports_signature_auth=True) def get_balance(self): return self.get(self.host, "/account/get-balance") @@ -180,7 +179,9 @@ def get_account_numbers(self, params=None, **kwargs): return self.get(self.host, "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get(self.host, "/number/search", dict(params or kwargs, country=country_code)) + return self.get( + self.host, "/number/search", dict(params or kwargs, country=country_code) + ) def buy_number(self, params=None, **kwargs): return self.post(self.host, "/number/buy", params or kwargs) @@ -218,8 +219,11 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): :param timestamp: A `datetime` object containing the time the SMS arrived. :return: The parsed response from the server. On success, the bytestring b'OK' """ - params = {"message-id": message_id, "delivered": delivered, "timestamp": timestamp or datetime.now(pytz.utc)} - + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } # Ensure timestamp is a string: _format_date_param(params, "timestamp") return self.post(self.api_host, "/conversions/sms", params) @@ -258,7 +262,11 @@ def send_verification_request(self, params=None, **kwargs): return self.post(self.api_host, "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): - return self.post(self.api_host, "/verify/check/json", dict(params or kwargs, request_id=request_id)) + return self.post( + self.api_host, + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) def check_verification_request(self, params=None, **kwargs): warnings.warn( @@ -289,21 +297,21 @@ def cancel_verification(self, request_id): return self.post( self.api_host, "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"} + {"request_id": request_id, "cmd": "cancel"}, ) def trigger_next_verification_event(self, request_id): return self.post( self.api_host, "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"} + {"request_id": request_id, "cmd": "trigger_next_event"}, ) def control_verification_request(self, params=None, **kwargs): warnings.warn( "nexmo.Client#control_verification_request is deprecated", DeprecationWarning, - stacklevel=2 + stacklevel=2, ) return self.post(self.api_host, "/verify/control/json", params or kwargs) @@ -323,6 +331,15 @@ def get_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/number/lookup/json", params or kwargs) + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) + else: + raise ClientError( + "Error: Callback needed for async advanced number insight" + ) + def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) @@ -425,7 +442,11 @@ def redact_transaction(self, id, product, type=None): return self._post_json(self.api_host, "/v1/redact/transaction", params) def list_secrets(self, api_key): - return self.get(self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True) + return self.get( + self.api_host, + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) def get_secret(self, api_key, secret_id): return self.get( @@ -458,7 +479,9 @@ def check_signature(self, params): def signature(self, params): if self.signature_method: - hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method) + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) else: hasher = hashlib.md5() @@ -492,23 +515,32 @@ def get(self, host, request_uri, params=None, header_auth=False): ).decode("ascii") headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) else: - params=dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.get(uri, params=params, headers=headers)) - def post(self, host, request_uri, params, signature_auth=False, header_auth=False): + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): """ Low-level method to make a post request to a Nexmo API server. This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - Otherwise the client's key and secret are appended to the post request's params. - :param bool signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) headers = self.headers - if signature_auth and self.signature_secret: + if supports_signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) elif header_auth: @@ -537,8 +569,12 @@ def _post_json(self, host, request_uri, json): ).encode("utf-8") ) ).decode("ascii") - headers = dict(self.headers or {}, Authorization="Basic {hash}".format(hash=auth)) - logger.debug("POST to %r with body: %r, headers: %r", request_uri, json, headers) + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) return self.parse(host, self.session.post(uri, headers=headers, json=json)) def put(self, host, request_uri, params, header_auth=False): @@ -593,36 +629,54 @@ def parse(self, host, response): # Strip off any encoding from the content-type header: content_mime = response.headers.get("content-type").split(";", 1)[0] if content_mime == "application/json": - #Check for exceptions before retrieve data + # Check for exceptions before retrieve data data = response.json() - if "messages" in data and self.__error_handler.validate_code(data["messages"][0]["status"]): + if "messages" in data and self.__error_handler.validate_code( + data["messages"][0]["status"] + ): exception_code = data["messages"][0]["status"] exception_text = data["messages"][0]["error-text"] - #raise exception + # raise exception self.__error_handler.trigger(exception_code, exception_text) return data else: return response.content elif 400 <= response.status_code < 500: - logger.warning("Client error: %s %r", response.status_code, response.content) - message = "{code} response from {host}".format(code=response.status_code, host=host) + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) # Test for standard error format: try: error_data = response.json() - if "type" in error_data and "title" in error_data and "detail" in error_data: + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): message = "{title}: {detail} ({type})".format( - title=error_data["title"], detail=error_data["detail"], type=error_data["type"] + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], ) except JSONDecodeError: pass if self.__error_handler.validate_code(str(response.status_code)): - self.__error_handler.trigger(str(response.status_code), response.content or message) + self.__error_handler.trigger( + str(response.status_code), response.content or message + ) else: raise ClientError(message) elif 500 <= response.status_code < 600: - logger.warning("Server error: %s %r", response.status_code, response.content) - message = "{code} response from {host}".format(code=response.status_code, host=host) + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) raise ServerError(message) def _jwt_signed_get(self, request_uri, params=None): @@ -630,21 +684,28 @@ def _jwt_signed_get(self, request_uri, params=None): api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, requests.get(uri, params=params or {}, headers=self._headers())) + return self.parse( + self.api_host, + self.session.get(uri, params=params or {}, headers=self._headers()), + ) def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, requests.post(uri, json=params, headers=self._headers())) + return self.parse( + self.api_host, self.session.post(uri, json=params, headers=self._headers()) + ) def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( api_host=self.api_host, request_uri=request_uri ) - return self.parse(self.api_host, requests.put(uri, json=params, headers=self._headers())) + return self.parse( + self.api_host, self.session.put(uri, json=params, headers=self._headers()) + ) def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( From 59b4b9ff6ddf6451b40ae2e6e99de0acdf5363fe Mon Sep 17 00:00:00 2001 From: superdiana Date: Fri, 29 May 2020 15:54:49 -0400 Subject: [PATCH 028/401] drop support for python 2.7 --- .travis.yml | 1 - setup.py | 4 +- src/nexmo/__init__.py | 33 ++-------- src/nexmo/errors.py | 136 ---------------------------------------- tests/test_account.py | 2 +- tests/test_nexmo.py | 5 +- tests/test_sms.py | 142 ------------------------------------------ tests/test_voice.py | 40 ------------ tox.ini | 2 +- 9 files changed, 9 insertions(+), 356 deletions(-) diff --git a/.travis.yml b/.travis.yml index b3179dee..f48401dc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python python: - - "2.7" - "3.4" - "3.5" - "3.6" diff --git a/setup.py b/setup.py index 13ebb143..f3793bda 100644 --- a/setup.py +++ b/setup.py @@ -23,12 +23,10 @@ package_dir={"": "src"}, platforms=["any"], install_requires=["requests>=2.4.2", "PyJWT[crypto]>=1.6.4", "pytz>=2018.5"], - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", + python_requires=">=3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", tests_require=["cryptography>=2.3.1"], classifiers=[ "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 92d01662..e4919743 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -16,13 +16,9 @@ from uuid import uuid4 import warnings -if sys.version_info[0] == 3: - string_types = (str, bytes) - from urllib.parse import urlparse -else: - string_types = (unicode, str) - from urlparse import urlparse +string_types = (str, bytes) +from urllib.parse import urlparse try: from json import JSONDecodeError @@ -65,9 +61,6 @@ class Client: provided by this library and can be used by Nexmo to track your app statistics. """ - # Call exception handler - as private for internal usage - __error_handler = ExceptionHandler() - def __init__( self, key=None, @@ -336,9 +329,7 @@ def get_async_advanced_number_insight(self, params=None, **kwargs): if "callback" in argoparams: return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) else: - raise ClientError( - "Error: Callback needed for async advanced number insight" - ) + raise ClientError("Error: Callback needed for async advanced number insight") def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/advanced/json", params or kwargs) @@ -629,16 +620,7 @@ def parse(self, host, response): # Strip off any encoding from the content-type header: content_mime = response.headers.get("content-type").split(";", 1)[0] if content_mime == "application/json": - # Check for exceptions before retrieve data - data = response.json() - if "messages" in data and self.__error_handler.validate_code( - data["messages"][0]["status"] - ): - exception_code = data["messages"][0]["status"] - exception_text = data["messages"][0]["error-text"] - # raise exception - self.__error_handler.trigger(exception_code, exception_text) - return data + return response.json() else: return response.content elif 400 <= response.status_code < 500: @@ -664,12 +646,7 @@ def parse(self, host, response): ) except JSONDecodeError: pass - if self.__error_handler.validate_code(str(response.status_code)): - self.__error_handler.trigger( - str(response.status_code), response.content or message - ) - else: - raise ClientError(message) + raise ClientError(message) elif 500 <= response.status_code < 600: logger.warning( "Server error: %s %r", response.status_code, response.content diff --git a/src/nexmo/errors.py b/src/nexmo/errors.py index 26776fa4..88995700 100644 --- a/src/nexmo/errors.py +++ b/src/nexmo/errors.py @@ -1,5 +1,3 @@ -import json - class Error(Exception): pass @@ -14,137 +12,3 @@ class ServerError(Error): class AuthenticationError(ClientError): pass - -class NexmoError(ClientError): - pass - -#Code 1: Throttled -class ThrottledError(NexmoError): - pass - -#Code 2: Missing params -class MissingParamError(NexmoError): - pass - -#Code 3: Invalid params -class InvalidParamError(NexmoError): - pass - -#Code 4: Invalid credentials -class CredentialError(NexmoError): - pass - -#Code 5: Internal Error -class InternalError(NexmoError): - pass - -#Code 6: Invalid message -class InvalidMessageError(NexmoError): - pass - -#Code 7: Number barred -class NumberBarredError(NexmoError): - pass - -#Code 8: Partner account barred -class PartnerAccountBarredError(NexmoError): - pass - -#Code 9: Partner quota exceeded -class PartnerQuotaExceededError(NexmoError): - pass - -#Code 11: Account not enabled for REST -class AccountNoRestError(NexmoError): - pass - -#Code 12: Message too long -class MessageLengthError(NexmoError): - pass - -#Code 13: Communication Failed -class CommunicationError(NexmoError): - pass - -#Code 14: Invalid Signature -class InvalidSignatureError(NexmoError): - pass - -#Code 15: Illegal Sender Address - rejected -class IllegalSenderError(NexmoError): - pass - -#Code 16: Invalid TTL -class InvalidTTLError(NexmoError): - pass - -#Code 19: Facility not allowed -class FacilityError(NexmoError): - pass - -#Code 20: Invalid Message class -class InvalidMessageClassError(NexmoError): - pass - -#Code 23: Bad callback :: Missing Protocol -class MissingProtocolError(NexmoError): - pass - -#Code 29: Non White-listed Destination -class BlackListDestinationError(NexmoError): - pass - -#Code 34: Invalid or Missing Msisdn Param -class MsisdnError(NexmoError): - pass - -#Code 400: Bad request - when voice Api -class BadRequestError(NexmoError): - def __init__(self, data): - error_data = data - message = data - if isinstance(data, bytes): - #convert data bytes in dict and re-assign - error_data = json.loads(data.decode('utf-8')) - if "type" in error_data and "title" in error_data: - if "detail" in error_data: - message = "{title}: {detail} ({type})".format( - title=error_data["title"], detail=error_data["detail"], type=error_data["type"] - ) - elif "invalid_parameters" in error_data: - message = "{title}: Invalid parameters, reason: {reason}, param hint: {name}".format( - title=error_data["title"], reason=error_data["invalid_parameters"][0]["reason"], name=error_data["invalid_parameters"][0]["name"] - ) - super().__init__(message) - -class ExceptionHandler(): - #Register exceptions by error code in the private exception matrix - __exceptions = { - '1': ThrottledError, - '2': MissingParamError, - '3': InvalidParamError, - '4': CredentialError, - '5': InternalError, - '6': InvalidMessageError, - '7': NumberBarredError, - '8': PartnerAccountBarredError, - '9': PartnerQuotaExceededError, - '11': AccountNoRestError, - '12': MessageLengthError, - '13': CommunicationError, - '14': InvalidSignatureError, - '15': IllegalSenderError, - '16': InvalidTTLError, - '19': FacilityError, - '20': InvalidMessageClassError, - '23': MissingProtocolError, - '29': BlackListDestinationError, - '34': MsisdnError, - '400': BadRequestError - } - - def validate_code(self, code): - return code in self.__exceptions - - def trigger(self, code, message): - raise self.__exceptions[code](message) \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index e302b144..07e94303 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -225,6 +225,6 @@ def test_create_secret_validation(client): client.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" + """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" in str(ce) ) diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index f0774799..9c236e98 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -3,10 +3,7 @@ import sys -if sys.version_info[0] == 3: - bytes_type = bytes -else: - bytes_type = str +bytes_type = bytes @responses.activate diff --git a/tests/test_sms.py b/tests/test_sms.py index e3ce0c78..8d3b18ad 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,6 +1,5 @@ import nexmo from util import * -import json @responses.activate @@ -32,147 +31,6 @@ def test_client_error(client): client.send_message({}) excinfo.match(r"400 response from rest.nexmo.com") -@responses.activate -def test_missing_param_error(client): - responses.add( - responses.POST, - "https://rest.nexmo.com/sms/json", - status=200, - body=json.dumps( - { - "messages": [ - { - "status": "2", - "error-text": "Missing from param" - } - ] - } - ), - content_type='application/json' - ) - - with pytest.raises(nexmo.errors.MissingParamError) as excinfo: - client.send_message({}) - excinfo.match(r"Missing from param") - -@responses.activate -def test_invalid_param_error(client): - responses.add( - responses.POST, - "https://rest.nexmo.com/sms/json", - status=200, - body=json.dumps( - { - "messages": [ - { - "status": "3", - "error-text": "to address is not numeric" - } - ] - } - ), - content_type='application/json' - ) - - with pytest.raises(nexmo.InvalidParamError) as excinfo: - client.send_message({'from': 'Vonage SMS API','to': '8888888888aaa','text': 'Hello from Vonage'}) - excinfo.match(r"to address is not numeric") - -@responses.activate -def test_credential_error(client): - responses.add( - responses.POST, - "https://rest.nexmo.com/sms/json", - status=200, - body=json.dumps( - { - "messages": [ - { - "status": "4", - "error-text": "Bad Credentials" - } - ] - } - ), - content_type='application/json' - ) - - with pytest.raises(nexmo.CredentialError) as excinfo: - client.send_message({'from': 'Vonage SMS API','to': '8888888888aaa','text': 'Hello from Vonage'}) - excinfo.match(r"Bad Credentials") - -@responses.activate -def test_invalid_message_error(client): - responses.add( - responses.POST, - "https://rest.nexmo.com/sms/json", - status=200, - body=json.dumps( - { - "messages": [ - { - "status": "6", - "error-text": "Unroutable message - rejected" - } - ] - } - ), - content_type='application/json' - ) - - with pytest.raises(nexmo.InvalidMessageError) as excinfo: - client.send_message({'from': 'Vonage SMS API','to': '88888888888','text': 'Hello from Vonage'}) - excinfo.match(r"Unroutable message - rejected") - -@responses.activate -def test_invalid_ttl_error(client): - responses.add( - responses.POST, - "https://rest.nexmo.com/sms/json", - status=200, - body=json.dumps( - { - "messages": [ - { - "status": "16", - "error-text": "value for ttl field out of range" - } - ] - } - ), - content_type='application/json' - ) - - with pytest.raises(nexmo.InvalidTTLError) as excinfo: - client.send_message({'ttl':1, 'from': 'Vonage SMS API','to': '88888888888','text': 'Hello from Vonage'}) - excinfo.match(r"value for ttl field out of range") - -""" When the signature method in the client definition is not the same defined in the account settings. -client = nexmo.Client(key='API_KEY', signature_secret='LARGE_KEY', signature_method='sha512') -In the user settings md5 hash signature has been defined -""" -@responses.activate -def test_invalid_signature_error(client): - responses.add( - responses.POST, - "https://rest.nexmo.com/sms/json", - status=200, - body=json.dumps( - { - "messages": [ - { - "status": "14", - "error-text": "Invalid Signature" - } - ] - } - ), - content_type='application/json' - ) - - with pytest.raises(nexmo.InvalidSignatureError) as excinfo: - client.send_message({'from': 'Vonage SMS API','to': '88888888888','text': 'Hello from Vonage'}) - excinfo.match(r"Invalid Signature") @responses.activate def test_server_error(client): diff --git a/tests/test_voice.py b/tests/test_voice.py index 4ab03a6d..9b7e4f68 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -5,7 +5,6 @@ import nexmo from util import * -import json @responses.activate @@ -150,42 +149,3 @@ def test_authorization_with_private_key_object(client, dummy_data): request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" ) assert token["application_id"] == dummy_data.application_id - -#Error scenarios for voice API -@responses.activate -def test_bad_request_error(client): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/calls", - status=400, - body=json.dumps( - { - 'type': 400, - 'title': 'Bad Request', - 'invalid_parameters': - [ - { - 'reason': 'can contain up to 15 digits prefixed with +', - 'name': 'number' - } - ] - } - ), - content_type='application/json' - ) - with pytest.raises(nexmo.BadRequestError) as excinfo: - client.create_call( - { - 'to': [ - {'type': 'phone', 'number': '50588804403aaa'} - ], - 'from': { - 'type': 'phone', - 'number': '19899846327' - }, - 'ncco':[ - {"action": "talk","text": "Please wait while we connect you."} - ] - } - ) - excinfo.match(r"Invalid parameters, reason: can contain up to 15 digits prefixed with \+, param hint: number") \ No newline at end of file diff --git a/tox.ini b/tox.ini index ad8d7c66..40208908 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py36,coverage-report +envlist = py36,coverage-report [testenv] deps = -rrequirements.txt From 58a6d6da497c6651a3744010e0921d6d5d5350f4 Mon Sep 17 00:00:00 2001 From: superdiana Date: Thu, 4 Jun 2020 17:27:09 -0400 Subject: [PATCH 029/401] creating voice class --- setup.py | 4 +- src/nexmo/__init__.py | 90 ++----------------------- src/nexmo/voice.py | 117 +++++++++++++++++++++++++++++++++ tests/conftest.py | 8 +++ tests/test_nexmo.py | 8 ++- tests/test_voice.py | 46 ++++++------- tests/test_voice_deprecated.py | 12 ++-- tox.ini | 2 +- 8 files changed, 172 insertions(+), 115 deletions(-) create mode 100644 src/nexmo/voice.py diff --git a/setup.py b/setup.py index f3793bda..13ebb143 100644 --- a/setup.py +++ b/setup.py @@ -23,10 +23,12 @@ package_dir={"": "src"}, platforms=["any"], install_requires=["requests>=2.4.2", "PyJWT[crypto]>=1.6.4", "pytz>=2018.5"], - python_requires=">=3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", tests_require=["cryptography>=2.3.1"], classifiers=[ "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e4919743..a26133f2 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,5 +1,6 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * +from .voice import * from datetime import datetime import logging from platform import python_version @@ -16,9 +17,13 @@ from uuid import uuid4 import warnings +if sys.version_info[0] == 3: + string_types = (str, bytes) + from urllib.parse import urlparse -string_types = (str, bytes) -from urllib.parse import urlparse +else: + string_types = (unicode, str) + from urlparse import urlparse try: from json import JSONDecodeError @@ -233,15 +238,6 @@ def get_event_alert_numbers(self): def resubscribe_event_alert_number(self, params=None, **kwargs): return self.post(self.host, "/sc/us/alert/opt-in/manage/json", params or kwargs) - def initiate_call(self, params=None, **kwargs): - return self.post(self.host, "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts-prompt/json", params or kwargs) - def start_verification(self, params=None, **kwargs): return self.post(self.api_host, "/verify/json", params or kwargs) @@ -387,41 +383,6 @@ def delete_application(self, application_id): "/v1/applications/{application_id}".format(application_id=application_id), ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -656,43 +617,6 @@ def parse(self, host, response): ) raise ServerError(message) - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.delete(uri, headers=self._headers()) - ) - def _headers(self): token = self.generate_application_jwt() return dict(self.headers, Authorization=b"Bearer " + token) diff --git a/src/nexmo/voice.py b/src/nexmo/voice.py new file mode 100644 index 00000000..72354b8a --- /dev/null +++ b/src/nexmo/voice.py @@ -0,0 +1,117 @@ +import nexmo + +class Voice(): + #application_id and private_key are needed for the calling methods + #Passing a Nexmo Client is also possible + def __init__( + self, + client=None, + application_id=None, + private_key=None, + ): + try: + # Client is protected + self._client = client + if self._client is None: + self._client = nexmo.Client(application_id=application_id, private_key=private_key) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + # Creates a new call session + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + # Get call history paginated. Pass start and end dates to filter the retrieved information + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + # Get a single call record by identifier + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + # Update call data using custom ncco + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + # Plays audio streaming into call in progress - stream_url parameter is required + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + # Play an speech into specified call - text parameter (text to speech) is required + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + # plays DTMF tones into the specified call + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + # Stops audio recently played into specified call + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + # Stop a speech recently played into specified call + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + # Deprecated section + # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory + def initiate_call(self, params=None, **kwargs): + return self._client.post(self._client.host, "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self._client.post(self._client.api_host, "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self._client.post(self._client.api_host, "/tts-prompt/json", params or kwargs) + # End deprecated section + + # Utils methods + # _jwt_signed_post private method that Allows developer perform signed post request + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host, request_uri=request_uri + ) + + # Uses the client session to perform the call action with api + return self._client.parse( + self._client.api_host, self._client.session.post(uri, json=params, headers=self._client._headers()) + ) + + # _jwt_signed_post private method that Allows developer perform signed get request + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host, request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host, + self._client.session.get(uri, params=params or {}, headers=self._client._headers()), + ) + + # _jwt_signed_put private method that Allows developer perform signed put request + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host, request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host, self._client.session.put(uri, json=params, headers=self._client._headers()) + ) + + # _jwt_signed_put private method that Allows developer perform signed put request + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host, request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host, self._client.session.delete(uri, headers=self._client._headers()) + ) diff --git a/tests/conftest.py b/tests/conftest.py index 8bbf454f..17cd8967 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,3 +44,11 @@ def client(dummy_data): application_id=dummy_data.application_id, private_key=dummy_data.private_key, ) + +@pytest.fixture +def voice(client, dummy_data): + import nexmo + + return nexmo.Voice( + client + ) diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 9c236e98..c09241a5 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -3,7 +3,10 @@ import sys -bytes_type = bytes +if sys.version_info[0] == 3: + bytes_type = bytes +else: + bytes_type = str @responses.activate @@ -191,7 +194,8 @@ def test_client_can_make_application_requests_without_api_key(dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") client = nexmo.Client(application_id="myid", private_key=dummy_data.private_key) - client.create_call("123455") + voice = nexmo.Voice(client) + voice.create_call("123455") @responses.activate diff --git a/tests/test_voice.py b/tests/test_voice.py index 9b7e4f68..bae67983 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -8,7 +8,7 @@ @responses.activate -def test_create_call(client, dummy_data): +def test_create_call(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") params = { @@ -17,45 +17,45 @@ def test_create_call(client, dummy_data): "answer_url": ["https://example.com/answer"], } - assert isinstance(client.create_call(params), dict) + assert isinstance(voice.create_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" @responses.activate -def test_get_calls(client, dummy_data): +def test_get_calls(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls") - assert isinstance(client.get_calls(), dict) + assert isinstance(voice.get_calls(), dict) assert request_user_agent() == dummy_data.user_agent assert_re(r"\ABearer ", request_authorization()) @responses.activate -def test_get_call(client, dummy_data): +def test_get_call(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) + assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent assert_re(r"\ABearer ", request_authorization()) @responses.activate -def test_update_call(client, dummy_data): +def test_update_call(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"action": "hangup"}' @responses.activate -def test_send_audio(client, dummy_data): +def test_send_audio(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), dict, ) assert request_user_agent() == dummy_data.user_agent @@ -64,36 +64,36 @@ def test_send_audio(client, dummy_data): @responses.activate -def test_stop_audio(client, dummy_data): +def test_stop_audio(voice, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) + assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent @responses.activate -def test_send_speech(client, dummy_data): +def test_send_speech(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"text": "Hello"}' @responses.activate -def test_stop_speech(client, dummy_data): +def test_stop_speech(voice, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) + assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent @responses.activate -def test_send_dtmf(client, dummy_data): +def test_send_dtmf(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"digits": "1234"}' @@ -108,7 +108,8 @@ def test_user_provided_authorization(client, dummy_data): exp = nbf + 3600 client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") token = request_authorization().split()[1] @@ -131,7 +132,8 @@ def test_authorization_with_private_key_path(dummy_data): application_id=dummy_data.application_id, private_key=private_key, ) - client.get_call("xx-xx-xx-xx") + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") token = jwt.decode( request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" @@ -140,10 +142,10 @@ def test_authorization_with_private_key_path(dummy_data): @responses.activate -def test_authorization_with_private_key_object(client, dummy_data): +def test_authorization_with_private_key_object(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - client.get_call("xx-xx-xx-xx") + voice.get_call("xx-xx-xx-xx") token = jwt.decode( request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" diff --git a/tests/test_voice_deprecated.py b/tests/test_voice_deprecated.py index daa172a9..83709f6a 100644 --- a/tests/test_voice_deprecated.py +++ b/tests/test_voice_deprecated.py @@ -2,31 +2,31 @@ @responses.activate -def test_initiate_call(client, dummy_data): +def test_initiate_call(voice, dummy_data): stub(responses.POST, "https://rest.nexmo.com/call/json") params = {"to": "16365553226", "answer_url": "http://example.com/answer"} - assert isinstance(client.initiate_call(params), dict) + assert isinstance(voice.initiate_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "answer_url=http%3A%2F%2Fexample.com%2Fanswer" in request_body() @responses.activate -def test_initiate_tts_call(client, dummy_data): +def test_initiate_tts_call(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/tts/json") params = {"to": "16365553226", "text": "Hello"} - assert isinstance(client.initiate_tts_call(params), dict) + assert isinstance(voice.initiate_tts_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "text=Hello" in request_body() @responses.activate -def test_initiate_tts_prompt_call(client, dummy_data): +def test_initiate_tts_prompt_call(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/tts-prompt/json") params = { @@ -36,7 +36,7 @@ def test_initiate_tts_prompt_call(client, dummy_data): "bye_text": "Goodbye", } - assert isinstance(client.initiate_tts_prompt_call(params), dict) + assert isinstance(voice.initiate_tts_prompt_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "text=Hello" in request_body() diff --git a/tox.ini b/tox.ini index 40208908..ad8d7c66 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36,coverage-report +envlist = py27,py36,coverage-report [testenv] deps = -rrequirements.txt From 3c078e35d731adfacab6a29dd20812e0012d9b0a Mon Sep 17 00:00:00 2001 From: superdiana Date: Sun, 7 Jun 2020 17:30:39 -0400 Subject: [PATCH 030/401] adding SMS module & Tests --- setup.py | 4 +--- src/nexmo/__init__.py | 41 +++------------------------------- src/nexmo/sms.py | 51 +++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 10 +++++++++ tests/test_nexmo.py | 5 +---- tests/test_sms.py | 20 ++++++++--------- tox.ini | 2 +- 7 files changed, 77 insertions(+), 56 deletions(-) create mode 100644 src/nexmo/sms.py diff --git a/setup.py b/setup.py index 13ebb143..f3793bda 100644 --- a/setup.py +++ b/setup.py @@ -23,12 +23,10 @@ package_dir={"": "src"}, platforms=["any"], install_requires=["requests>=2.4.2", "PyJWT[crypto]>=1.6.4", "pytz>=2018.5"], - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", + python_requires=">=3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", tests_require=["cryptography>=2.3.1"], classifiers=[ "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index a26133f2..f3b49e73 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,6 +1,7 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * from .voice import * +from .sms import * from datetime import datetime import logging from platform import python_version @@ -17,13 +18,9 @@ from uuid import uuid4 import warnings -if sys.version_info[0] == 3: - string_types = (str, bytes) - from urllib.parse import urlparse -else: - string_types = (unicode, str) - from urlparse import urlparse +string_types = (str, bytes) +from urllib.parse import urlparse try: from json import JSONDecodeError @@ -130,20 +127,6 @@ def __init__( def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host, "/sms/json", params, supports_signature_auth=True) - def get_balance(self): return self.get(self.host, "/account/get-balance") @@ -208,24 +191,6 @@ def send_ussd_prompt_message(self, params=None, **kwargs): def send_2fa_message(self, params=None, **kwargs): return self.post(self.host, "/sc/us/2fa/json", params or kwargs) - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host, "/conversions/sms", params) - def send_event_alert_message(self, params=None, **kwargs): return self.post(self.host, "/sc/us/alert/json", params or kwargs) diff --git a/src/nexmo/sms.py b/src/nexmo/sms.py new file mode 100644 index 00000000..047378db --- /dev/null +++ b/src/nexmo/sms.py @@ -0,0 +1,51 @@ +import nexmo, pytz +from datetime import datetime +from ._internal import _format_date_param + +class Sms: + #To init Sms class pass a client reference or a key and secret + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = nexmo.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self._client.post(self._client.host, "/sms/json", params, supports_signature_auth=True) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc) + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self._client.post(self._client.api_host, "/conversions/sms", params) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 17cd8967..5d25bd5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ def client(dummy_data): private_key=dummy_data.private_key, ) +#Represents an instance of the Voice class for testing @pytest.fixture def voice(client, dummy_data): import nexmo @@ -52,3 +53,12 @@ def voice(client, dummy_data): return nexmo.Voice( client ) + +#Represents an instance of the Sms class for testing +@pytest.fixture +def sms(client, dummy_data): + import nexmo + + return nexmo.Sms( + client + ) \ No newline at end of file diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index c09241a5..74751e3f 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -3,10 +3,7 @@ import sys -if sys.version_info[0] == 3: - bytes_type = bytes -else: - bytes_type = str +bytes_type = bytes @responses.activate diff --git a/tests/test_sms.py b/tests/test_sms.py index 8d3b18ad..fda960e8 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -3,12 +3,12 @@ @responses.activate -def test_send_message(client, dummy_data): +def test_send_message(sms, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sms/json") params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - assert isinstance(client.send_message(params), dict) + assert isinstance(sms.send_message(params), dict) assert request_user_agent() == dummy_data.user_agent assert "from=Python" in request_body() assert "to=447525856424" in request_body() @@ -16,37 +16,37 @@ def test_send_message(client, dummy_data): @responses.activate -def test_authentication_error(client): +def test_authentication_error(sms): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) with pytest.raises(nexmo.AuthenticationError): - client.send_message({}) + sms.send_message({}) @responses.activate -def test_client_error(client): +def test_client_error(sms): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) with pytest.raises(nexmo.ClientError) as excinfo: - client.send_message({}) + sms.send_message({}) excinfo.match(r"400 response from rest.nexmo.com") @responses.activate -def test_server_error(client): +def test_server_error(sms): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) with pytest.raises(nexmo.ServerError) as excinfo: - client.send_message({}) + sms.send_message({}) excinfo.match(r"500 response from rest.nexmo.com") @responses.activate -def test_submit_sms_conversion(client): +def test_submit_sms_conversion(sms): responses.add( responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" ) - client.submit_sms_conversion("a-message-id") + sms.submit_sms_conversion("a-message-id") assert "message-id=a-message-id" in request_body() assert "timestamp" in request_body() diff --git a/tox.ini b/tox.ini index ad8d7c66..40208908 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py36,coverage-report +envlist = py36,coverage-report [testenv] deps = -rrequirements.txt From 7b9024aed1a3f2753892c1bd832fbe7c736d7aea Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 10 Jun 2020 12:45:11 -0400 Subject: [PATCH 031/401] Adding Voice & SMS Class to README --- README.md | 388 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 230 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index c4130afe..f6f9f0d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Nexmo Client Library for Python -=============================== +# Nexmo Client Library for Python [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) [![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) @@ -7,26 +6,20 @@ Nexmo Client Library for Python [![Python versions supported](https://img.shields.io/pypi/pyversions/nexmo.svg)](https://pypi.python.org/pypi/nexmo) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) -Nexmo is now known as Vonage - This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. -* [Installation](#installation) -* [Usage](#usage) -* [SMS API](#sms-api) -* [Voice API](#voice-api) -* [Verify API](#verify-api) -* [Number Insight API](#number-insight-api) -* [Number Management API](#number-management-api) -* [Managing Secrets](#managing-secrets) -* [Application API](#application-api) -* [Overriding API url's](#overriding-api-urls) -* [License](#license) - +- [Installation](#installation) +- [Usage](#usage) +- [SMS API](#sms-api) +- [Voice API](#voice-api) +- [Verify API](#verify-api) +- [Number Insight API](#number-insight-api) +- [Managing Secrets](#managing-secrets) +- [Application API](#application-api) +- [License](#license) -Installation ------------- +## Installation To install the Python client library using pip: @@ -42,9 +35,7 @@ Alternatively, you can clone the repository via the command line: or by opening it on GitHub desktop. - -Usage ------ +## Usage Begin by importing the `nexmo` module: @@ -72,81 +63,109 @@ To check signatures for incoming webhook requests, you'll also need to specify the `signature_secret` argument (or the `NEXMO_SIGNATURE_SECRET` environment variable). - ## SMS API -### Send a text message +## SMS Class -```python -response = client.send_message({'from': 'Python', 'to': 'YOUR-NUMBER', 'text': 'Hello world'}) +### Creating an instance of the SMS class -response = response['messages'][0] +To create an instance of the SMS class follow these steps: -if response['status'] == '0': - print('Sent message', response['message-id']) +- Import the class - print('Remaining balance is', response['remaining-balance']) -else: - print('Error:', response['error-text']) -``` +```python +#Option 1 +from nexmo import Sms -Docs: [https://developer.nexmo.com/api/sms#send-an-sms](https://developer.nexmo.com/api/sms?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#send-an-sms) +#Option 2 +from nexmo.sms import Sms -### Tell Nexmo the SMS was received +#Option 3 +import nexmo #then tou can use nexmo.Sms() to create an instance +``` -The following submits a successful conversion to Nexmo with the current timestamp. This feature must -be enabled on your account first. +- Create an instance ```python -response = client.submit_sms_conversion(message_id) -``` -### Signing a Message +#Option 1 - pass key and secret to the constructor +sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages).* +#Option 2 - Create a client instance and then pass the client to the Sms instance +client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +sms = Sms(client) +``` -The SMS API supports the ability to sign messages by generating and adding a signature using a "Signature Secret" rather than your API secret. The algorithms supported are: +### Send an SMS -md5hash1 -md5 -sha1 -sha256 -sha512 +```python + responseData = client.send_message( + { + "from": NEXMO_BRAND_NAME, + "to": TO_NUMBER, + "text": "A text message sent using the Nexmo SMS API", + } + ) +``` -Both your application and Nexmo need to agree on which algorithm is used. In the dashboard, visit your account settings page and under "API Settings" you can select the algorithm to use. This is also the location where you will find your "Signature Secret" (it's different from the API secret). +Reference: [Send sms](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms) -### Create a client using these credentials and the algorithm to use, for example: +**Using the Sms class** ```python -client = nexmo.Client( - key = os.getenv('NEXMO_API_KEY'), - signature_secret = os.getenv('NEXMO_SIGNATURE_SECRET'), - signature_method = 'sha256' -) +from nexmo import Sms +sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +sms.send_message({ + "from": NEXMO_BRAND_NAME, + "to": TO_NUMBER, + "text": "A text message sent using the Nexmo SMS API", +}) ``` -Using this client, your SMS API messages will be sent as signed messages. +Support link: [Send sms](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/17e17c6f05f6d28c53596f2412c627c2/SMSSendMessage.PNG) -### Verifying an Incoming Message Signature +### Send SMS with unicode -*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)*. +```python +responseData = client.send_message({ + 'from': NEXMO_BRAND_NAME, + 'to': TO_NUMBER, + 'text': 'こんにちは世界', + 'type': 'unicode', +}) +``` -If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. +Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms-with-unicode) -To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. +**Using Sms Class** -Then use the `check_signature()` method with the actual signature that was received (usually present in request.form or request.args. you can merge those in a single variable called params) to make sure that it is correct. +```python +sms.send_message({ + 'from': NEXMO_BRAND_NAME, + 'to': TO_NUMBER, + 'text': 'こんにちは世界', + 'type': 'unicode', +}) +``` -### Get the params +### Submit SMS Conversion ```python -if request.is_json: - params = request.get_json() -else: - params = request.args or request.form -is_valid = client.check_signature(params)// is it valid? Will be true or false +client.submit_sms_conversion("a-message-id") ``` -Using your signature secret and the other supplied parameters, the signature can be calculated and checked against the incoming signature value. +**With the SMS Class** + +```python +from nexmo import Client, Sms +client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) +sms = Sms(client) +response = sms.send_message({ + 'from': NEXMO_BRAND_NAME, + 'to': TO_NUMBER, + 'text': 'Hi from Vonage' +}) +sms.submit_sms_conversion(response['message-id']) +``` ## Voice API @@ -162,6 +181,21 @@ response = client.create_call({ Docs: [https://developer.nexmo.com/api/voice#createCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#createCall) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +voice.create_all({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +``` + +Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) + ### Retrieve a list of calls ```python @@ -170,6 +204,17 @@ response = client.get_calls() Docs: [https://developer.nexmo.com/api/voice#getCalls](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCalls) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +voice.get_calls() +``` + +Testing screenshots: [get calls](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/a5cc162f255dc83b8cdd1d2f80531925/NexmoVoiceGetCalls.PNG) + ### Retrieve a single call ```python @@ -178,6 +223,17 @@ response = client.get_call(uuid) Docs: [https://developer.nexmo.com/api/voice#getCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCall) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +voice.get_call(uuid) +``` + +Testing Screenshots: [get single call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/5cef34880afdc6a4c3cd3dee0e84aae2/NexmoVoiceGetSingleCall.PNG) + ### Update a call ```python @@ -186,6 +242,22 @@ response = client.update_call(uuid, action='hangup') Docs: [https://developer.nexmo.com/api/voice#updateCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#updateCall) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +response = voice.create_all({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.update_call(response['uuid'], action='hangup') +``` + +Support Link: [update call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/bdf7c0990b6d4019a2758a7148fdf1e4/VoiceUpdateCall.PNG) + ### Stream audio to a call ```python @@ -196,6 +268,23 @@ response = client.send_audio(uuid, stream_url=[stream_url]) Docs: [https://developer.nexmo.com/api/voice#startStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startStream) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_audio(response['uuid'],stream_url=[stream_url]) +``` + +Support link: [Send audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fdc22d76f6bb5c8abf625311f222512a/VoiceSendAudioStream.PNG) + ### Stop streaming audio to a call ```python @@ -204,6 +293,24 @@ response = client.stop_audio(uuid) Docs: [https://developer.nexmo.com/api/voice#stopStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopStream) +**Using voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') +voice = Voice(client) +stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_audio(response['uuid'],stream_url=[stream_url]) +voice.stop_audio(response['uuid']) +``` + +Support Link: [Stop audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/589be23c5a31694e310aacf0fa6a2314/VoiceSendStopAudioStream.PNG) + ### Send a synthesized speech message to a call ```python @@ -212,6 +319,22 @@ response = client.send_speech(uuid, text='Hello') Docs: [https://developer.nexmo.com/api/voice#startTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startTalk) +**Using voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_speech(response['uuid'], text='Hello from nexmo') +``` + +Support link: [Send speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/d608bfe3b1fb288c9f4854d76fba37af/VoiceSendSpeech.PNG) + ### Stop sending a synthesized speech message to a call ```python @@ -220,6 +343,23 @@ response = client.stop_speech(uuid) Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopTalk) +**Using voice class** + +```python +>>> from nexmo import Client, Voice +>>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) +>>> voice = Voice(client) +>>> response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +>>> voice.send_speech(response['uuid'], text='Hello from nexmo') +>>> voice.stop_speech(response['uuid']) +``` + +Support link: [Stop speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/246801f2e34d147955ac3531e4e7b65d/VoiceSendStopSpeech.PNG) + ### Send DTMF tones to a call ```python @@ -228,13 +368,28 @@ response = client.send_dtmf(uuid, digits='1234') Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startDTMF) +**Using voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_dtmf(response['uuid'], digits='1234') +``` + +Support link: [Send DTMF](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/7c4b25014d6c94eb886cbaa9a55d2ae3/VoiceSendDTMF.PNG) + ### Get recording -``` python +```python response = client.get_recording(RECORDING_URL) ``` - ## Verify API ### Start a verification @@ -311,58 +466,24 @@ client.get_advanced_number_insight(number='447700900000') Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) -## Number Management API - -### List Your Numbers - -```python -client.get_account_numbers() -``` - -Docs: [https://developer.nexmo.com/api/numbers#getOwnedNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getOwnedNumbers) - -### Search for a Number - -```python -client.get_available_numbers('GB', {"type":"SMS"}) -``` - -Docs: [https://developer.nexmo.com/api/numbers#getAvailableNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getAvailableNumbers) - -### Buy a Number - -```python -client.buy_number({"country": 'GB', "msisdn": '447700900000'}) -``` - -Docs: [https://developer.nexmo.com/api/numbers#buyANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#buyANumber) - -### Cancel a Number - -```python -client.cancel_number({"country": 'GB', "msisdn": '447700900000'}) -``` - -Docs: [https://developer.nexmo.com/api/numbers#cancelANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#cancelANumber) - ## Managing Secrets - An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. +An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. ### List Secrets - ```python +```python secrets = client.list_secrets(API_KEY) ``` ### Create A New Secret - Create a new secret (the created dates will help you know which is which): - ```python +Create a new secret (the created dates will help you know which is which): + +```python client.create_secret(API_KEY, 'awes0meNewSekret!!;'); ``` - ### Delete A Secret Delete the old secret (any application still using these credentials will stop working): @@ -371,7 +492,6 @@ Delete the old secret (any application still using these credentials will stop w client.delete_secret(API_KEY, 'my-secret-id') ``` - ## Application API ### Create an application @@ -414,7 +534,6 @@ response = client.application_v2.delete_application(uuid) Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) - ## Validate webhook signatures ```python @@ -431,7 +550,6 @@ Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://dev Note: you'll need to contact support@nexmo.com to enable message signing on your account before you can validate webhook signatures. - ## JWT parameters By default, the library generates short-lived tokens for JWT authentication. @@ -443,52 +561,7 @@ specify a different token identifier: client.auth(nbf=nbf, exp=exp, jti=jti) ``` -## Overriding API url's - -By default, our API url's are hardcoded. For use cases where these url's are not accessible, best practices to override these url's are the following: - -- Setting new API url's when creating an instance of the client: - -```python -import nexmo -client = nexmo.Client() -client.host = 'new.host.url' -client.api_host = 'new.api.host' -``` -- Creating a new class that extends from client class and overrides these values in the constructor: - -```python -class MyClient(nexmo.Client): - def __init__(self, NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH): - super().__init__(application_id=APPLICATION_ID, private_key=APPLICATION_PRIVATE_KEY_PATH, key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) - self.host = 'new.hosts.url' - self.api_host = 'new.api.hosts' - -#usage -client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) -``` - -For a more specific case, another way to customise is: - -```python -import nexmo - -class NexmoClient(nexmo.Client): - def __init__(....): - super().__init__(....) - api_server = BasicAuthenticatedServer( - "mycustomurl", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) -``` - -Then proceed to create your personalised instance of the class. - -Contributing ------------- +## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! @@ -504,8 +577,7 @@ The tests are all written with pytest. You run them with: make test ``` -License -------- +## License This library is released under the [MIT License][license]. From dc3c0cab8f9f3c9c8f9fb07f46c6f4e454a873ab Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 15 Jun 2020 21:18:09 -0400 Subject: [PATCH 032/401] adding PSD2 Support and Tests --- src/nexmo/__init__.py | 6 ++++-- tests/test_nexmo.py | 1 - tests/test_verify.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e4919743..7d48c3b3 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -16,8 +16,7 @@ from uuid import uuid4 import warnings - -string_types = (str, bytes) +string_types = (str, bytes) from urllib.parse import urlparse try: @@ -260,6 +259,9 @@ def check_verification(self, request_id, params=None, **kwargs): "/verify/check/json", dict(params or kwargs, request_id=request_id), ) + + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host, "/verify/psd2/json", params or kwargs) def check_verification_request(self, params=None, **kwargs): warnings.warn( diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 9c236e98..3ef895d3 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -5,7 +5,6 @@ bytes_type = bytes - @responses.activate def test_send_ussd_push_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd/json") diff --git a/tests/test_verify.py b/tests/test_verify.py index 8e0bb184..d35942e3 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -99,3 +99,14 @@ def test_control_verification_request(client, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "cmd=cancel" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_start_psd2_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_psd2_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() \ No newline at end of file From bdd0311138be5f0a37fa11d46784db4f7a87f0ef Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 15 Jun 2020 22:05:02 -0400 Subject: [PATCH 033/401] Getters/Setters to RW custom attrs --- src/nexmo/__init__.py | 227 ++++++++++++------------------------------ src/nexmo/sms.py | 51 ++++++++++ src/nexmo/voice.py | 117 ++++++++++++++++++++++ 3 files changed, 233 insertions(+), 162 deletions(-) create mode 100644 src/nexmo/sms.py create mode 100644 src/nexmo/voice.py diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e4919743..7510da97 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,5 +1,7 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * +from .voice import * +from .sms import * from datetime import datetime import logging from platform import python_version @@ -95,9 +97,9 @@ def __init__( with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() - self.host = "rest.nexmo.com" + self.__host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" + self.__api_host = "api.nexmo.com" user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() @@ -121,129 +123,102 @@ def __init__( self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + else: + self.__api_host = value def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host, "/sms/json", params, supports_signature_auth=True) - def get_balance(self): - return self.get(self.host, "/account/get-balance") + return self.get(self.__host, "/account/get-balance") def get_country_pricing(self, country_code): return self.get( - self.host, "/account/get-pricing/outbound", {"country": country_code} + self.__host, "/account/get-pricing/outbound", {"country": country_code} ) def get_prefix_pricing(self, prefix): return self.get( - self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} + self.__host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} ) def get_sms_pricing(self, number): return self.get( - self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} + self.__host, "/account/get-phone-pricing/outbound/sms", {"phone": number} ) def get_voice_pricing(self, number): return self.get( - self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} + self.__host, "/account/get-phone-pricing/outbound/voice", {"phone": number} ) def update_settings(self, params=None, **kwargs): - return self.post(self.host, "/account/settings", params or kwargs) + return self.post(self.__host, "/account/settings", params or kwargs) def topup(self, params=None, **kwargs): - return self.post(self.host, "/account/top-up", params or kwargs) + return self.post(self.__host, "/account/top-up", params or kwargs) def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host, "/account/numbers", params or kwargs) + return self.get(self.__host, "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): return self.get( - self.host, "/number/search", dict(params or kwargs, country=country_code) + self.__host, "/number/search", dict(params or kwargs, country=country_code) ) def buy_number(self, params=None, **kwargs): - return self.post(self.host, "/number/buy", params or kwargs) + return self.post(self.__host, "/number/buy", params or kwargs) def cancel_number(self, params=None, **kwargs): - return self.post(self.host, "/number/cancel", params or kwargs) + return self.post(self.__host, "/number/cancel", params or kwargs) def update_number(self, params=None, **kwargs): - return self.post(self.host, "/number/update", params or kwargs) + return self.post(self.__host, "/number/update", params or kwargs) def get_message(self, message_id): - return self.get(self.host, "/search/message", {"id": message_id}) + return self.get(self.__host, "/search/message", {"id": message_id}) def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host, "/search/rejections", params or kwargs) + return self.get(self.__host, "/search/rejections", params or kwargs) def search_messages(self, params=None, **kwargs): - return self.get(self.host, "/search/messages", params or kwargs) + return self.get(self.__host, "/search/messages", params or kwargs) def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd/json", params or kwargs) + return self.post(self.__host, "/ussd/json", params or kwargs) def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd-prompt/json", params or kwargs) + return self.post(self.__host, "/ussd-prompt/json", params or kwargs) def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host, "/conversions/sms", params) + return self.post(self.__host, "/sc/us/2fa/json", params or kwargs) def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/json", params or kwargs) + return self.post(self.__host, "/sc/us/alert/json", params or kwargs) def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/marketing/json", params or kwargs) + return self.post(self.__host, "/sc/us/marketing/json", params or kwargs) def get_event_alert_numbers(self): - return self.get(self.host, "/sc/us/alert/opt-in/query/json") + return self.get(self.__host, "/sc/us/alert/opt-in/query/json") def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/opt-in/manage/json", params or kwargs) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host, "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts-prompt/json", params or kwargs) + return self.post(self.__host, "/sc/us/alert/opt-in/manage/json", params or kwargs) def start_verification(self, params=None, **kwargs): - return self.post(self.api_host, "/verify/json", params or kwargs) + return self.post(self.__api_host, "/verify/json", params or kwargs) def send_verification_request(self, params=None, **kwargs): warnings.warn( @@ -252,11 +227,11 @@ def send_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.api_host, "/verify/json", params or kwargs) + return self.post(self.__api_host, "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): return self.post( - self.api_host, + self.__api_host, "/verify/check/json", dict(params or kwargs, request_id=request_id), ) @@ -268,11 +243,11 @@ def check_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.api_host, "/verify/check/json", params or kwargs) + return self.post(self.__api_host, "/verify/check/json", params or kwargs) def get_verification(self, request_id): return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} + self.__api_host, "/verify/search/json", {"request_id": request_id} ) def get_verification_request(self, request_id): @@ -283,19 +258,19 @@ def get_verification_request(self, request_id): ) return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} + self.__api_host, "/verify/search/json", {"request_id": request_id} ) def cancel_verification(self, request_id): return self.post( - self.api_host, + self.__api_host, "/verify/control/json", {"request_id": request_id, "cmd": "cancel"}, ) def trigger_next_verification_event(self, request_id): return self.post( - self.api_host, + self.__api_host, "/verify/control/json", {"request_id": request_id, "cmd": "trigger_next_event"}, ) @@ -307,13 +282,13 @@ def control_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.api_host, "/verify/control/json", params or kwargs) + return self.post(self.__api_host, "/verify/control/json", params or kwargs) def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/basic/json", params or kwargs) + return self.get(self.__api_host, "/ni/basic/json", params or kwargs) def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/standard/json", params or kwargs) + return self.get(self.__api_host, "/ni/standard/json", params or kwargs) def get_number_insight(self, params=None, **kwargs): warnings.warn( @@ -322,20 +297,20 @@ def get_number_insight(self, params=None, **kwargs): stacklevel=2, ) - return self.get(self.api_host, "/number/lookup/json", params or kwargs) + return self.get(self.__api_host, "/number/lookup/json", params or kwargs) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams: - return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) + return self.get(self.__api_host, "/ni/advanced/async/json", params or kwargs) else: raise ClientError("Error: Callback needed for async advanced number insight") def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/advanced/json", params or kwargs) + return self.get(self.__api_host, "/ni/advanced/json", params or kwargs) def request_number_insight(self, params=None, **kwargs): - return self.post(self.host, "/ni/json", params or kwargs) + return self.post(self.__host, "/ni/json", params or kwargs) def get_applications(self, params=None, **kwargs): warnings.warn( @@ -343,7 +318,7 @@ def get_applications(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.get(self.api_host, "/v1/applications", params or kwargs) + return self.get(self.__api_host, "/v1/applications", params or kwargs) def get_application(self, application_id): warnings.warn( @@ -352,7 +327,7 @@ def get_application(self, application_id): stacklevel=2, ) return self.get( - self.api_host, + self.__api_host, "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -362,7 +337,7 @@ def create_application(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.post(self.api_host, "/v1/applications", params or kwargs) + return self.post(self.__api_host, "/v1/applications", params or kwargs) def update_application(self, application_id, params=None, **kwargs): warnings.warn( @@ -371,7 +346,7 @@ def update_application(self, application_id, params=None, **kwargs): stacklevel=2, ) return self.put( - self.api_host, + self.__api_host, "/v1/applications/{application_id}".format(application_id=application_id), params or kwargs, ) @@ -383,45 +358,10 @@ def delete_application(self, application_id): stacklevel=2, ) return self.delete( - self.api_host, + self.__api_host, "/v1/applications/{application_id}".format(application_id=application_id), ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -430,18 +370,18 @@ def redact_transaction(self, id, product, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._post_json(self.api_host, "/v1/redact/transaction", params) + return self._post_json(self.__api_host, "/v1/redact/transaction", params) def list_secrets(self, api_key): return self.get( - self.api_host, + self.__api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True, ) def get_secret(self, api_key, secret_id): return self.get( - self.api_host, + self.__api_host, "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -451,12 +391,12 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} return self._post_json( - self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body + self.__api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body ) def delete_secret(self, api_key, secret_id): return self.delete( - self.api_host, + self.__api_host, "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -656,43 +596,6 @@ def parse(self, host, response): ) raise ServerError(message) - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.delete(uri, headers=self._headers()) - ) - def _headers(self): token = self.generate_application_jwt() return dict(self.headers, Authorization=b"Bearer " + token) diff --git a/src/nexmo/sms.py b/src/nexmo/sms.py new file mode 100644 index 00000000..2ff3ab23 --- /dev/null +++ b/src/nexmo/sms.py @@ -0,0 +1,51 @@ +import nexmo, pytz +from datetime import datetime +from ._internal import _format_date_param + +class Sms: + #To init Sms class pass a client reference or a key and secret + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = nexmo.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc) + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self._client.post(self._client.api_host(), "/conversions/sms", params) \ No newline at end of file diff --git a/src/nexmo/voice.py b/src/nexmo/voice.py new file mode 100644 index 00000000..b407009a --- /dev/null +++ b/src/nexmo/voice.py @@ -0,0 +1,117 @@ +import nexmo + +class Voice(): + #application_id and private_key are needed for the calling methods + #Passing a Nexmo Client is also possible + def __init__( + self, + client=None, + application_id=None, + private_key=None, + ): + try: + # Client is protected + self._client = client + if self._client is None: + self._client = nexmo.Client(application_id=application_id, private_key=private_key) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + # Creates a new call session + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + # Get call history paginated. Pass start and end dates to filter the retrieved information + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + # Get a single call record by identifier + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + # Update call data using custom ncco + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + # Plays audio streaming into call in progress - stream_url parameter is required + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + # Play an speech into specified call - text parameter (text to speech) is required + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + # plays DTMF tones into the specified call + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + # Stops audio recently played into specified call + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + # Stop a speech recently played into specified call + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + # Deprecated section + # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory + def initiate_call(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) + # End deprecated section + + # Utils methods + # _jwt_signed_post private method that Allows developer perform signed post request + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + # Uses the client session to perform the call action with api + return self._client.parse( + self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) + ) + + # _jwt_signed_post private method that Allows developer perform signed get request + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host(), + self._client.session.get(uri, params=params or {}, headers=self._client._headers()), + ) + + # _jwt_signed_put private method that Allows developer perform signed put request + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) + ) + + # _jwt_signed_put private method that Allows developer perform signed put request + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) + ) From a023b90586699510adeafa1df86c72b6305e6877 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Tue, 16 Jun 2020 19:48:46 -0400 Subject: [PATCH 034/401] Update README.md --- README.md | 56 ++++++++++++++++++++++++------------------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c4130afe..1e84ea24 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Number Management API](#number-management-api) * [Managing Secrets](#managing-secrets) * [Application API](#application-api) -* [Overriding API url's](#overriding-api-urls) +* [Overriding API attributes](#overriding-api-attributes) * [License](#license) @@ -443,50 +443,42 @@ specify a different token identifier: client.auth(nbf=nbf, exp=exp, jti=jti) ``` -## Overriding API url's +## Overriding API Attributes -By default, our API url's are hardcoded. For use cases where these url's are not accessible, best practices to override these url's are the following: +In order to rewrite/get the value of variables used across all the nexmo classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. -- Setting new API url's when creating an instance of the client: +An example using setters/getters with `Object references`: ```python -import nexmo -client = nexmo.Client() -client.host = 'new.host.url' -client.api_host = 'new.api.host' -``` -- Creating a new class that extends from client class and overrides these values in the constructor: +from nexmo import Client, Sms -```python -class MyClient(nexmo.Client): - def __init__(self, NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH): - super().__init__(application_id=APPLICATION_ID, private_key=APPLICATION_PRIVATE_KEY_PATH, key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) - self.host = 'new.hosts.url' - self.api_host = 'new.api.hosts' +#Defines the client +client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') +print(client.host()) # using getter for host -- value returned: rest.nexmo.com + +#Define the sms instance +sms = Sms(client) + +#Change the value in client +client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms -#usage -client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) ``` -For a more specific case, another way to customise is: +### Overriding API Host / Host Attributes + +These attributes are private in the client class and the only way to access them is using the getters/setters we provide. + ```python -import nexmo +from nexmo import Client -class NexmoClient(nexmo.Client): - def __init__(....): - super().__init__(....) - api_server = BasicAuthenticatedServer( - "mycustomurl", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) +client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') +print(client.host()) # return rest.nexmo.com +client.host('mio.nexmo.com') # rewrites the host value to mio.nexmo.com +print(client.api_host()) # returns api.nexmo.com +client.api_host('myapi.nexmo.com') # rewrite the value of api_host ``` -Then proceed to create your personalised instance of the class. - Contributing ------------ From b3bc2ab5a4fab6b4a82fa0766f6912f294d0672e Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 17 Jun 2020 14:43:16 -0400 Subject: [PATCH 035/401] Update README.md Co-authored-by: Ben Greenberg --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e84ea24..d46dfaf9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Number Management API](#number-management-api) * [Managing Secrets](#managing-secrets) * [Application API](#application-api) -* [Overriding API attributes](#overriding-api-attributes) +* [Overriding API Attributes](#overriding-api-attributes) * [License](#license) From cf809d03e0d10a3dea05a9c26c77e032efaa9300 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 17 Jun 2020 14:43:29 -0400 Subject: [PATCH 036/401] Update README.md Co-authored-by: Ben Greenberg --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d46dfaf9..84013c1a 100644 --- a/README.md +++ b/README.md @@ -445,7 +445,7 @@ client.auth(nbf=nbf, exp=exp, jti=jti) ## Overriding API Attributes -In order to rewrite/get the value of variables used across all the nexmo classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. +In order to rewrite/get the value of variables used across all the Nexmo classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. An example using setters/getters with `Object references`: From 89126ce7f3f254de75bc4daab9590d8c0567054b Mon Sep 17 00:00:00 2001 From: steve brazier Date: Thu, 25 Jun 2020 13:19:54 +0200 Subject: [PATCH 037/401] add timeout to the client --- src/nexmo/__init__.py | 15 ++++++++++----- src/nexmo/_internal.py | 11 ++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index e4919743..b44e7336 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -59,6 +59,7 @@ class Client: provided by this library and can be used by Nexmo to track your app statistics. :param str app_version: This optional value is added to the user-agent header provided by this library and can be used by Nexmo to track your app statistics. + :param float timeout: This optional value sets the timeout value for calling the api. """ def __init__( @@ -71,6 +72,7 @@ def __init__( private_key=None, app_name=None, app_version=None, + timeout=None ): self.api_key = key or os.environ.get("NEXMO_API_KEY", None) @@ -99,6 +101,8 @@ def __init__( self.api_host = "api.nexmo.com" + self.timeout = timeout + user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() ) @@ -117,6 +121,7 @@ def __init__( user_agent=user_agent, api_key=self.api_key, api_secret=self.api_secret, + timeout=self.timeout ) self.application_v2 = ApplicationV2(api_server) @@ -510,7 +515,7 @@ def get(self, host, request_uri, params=None, header_auth=False): params or {}, api_key=self.api_key, api_secret=self.api_secret ) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) + return self.parse(host, self.session.get(uri, params=params, headers=headers, timeout=self.timeout)) def post( self, @@ -546,7 +551,7 @@ def post( else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) + return self.parse(host, self.session.post(uri, data=params, headers=headers, timeout=self.timeout)) def _post_json(self, host, request_uri, json): """ @@ -566,7 +571,7 @@ def _post_json(self, host, request_uri, json): logger.debug( "POST to %r with body: %r, headers: %r", request_uri, json, headers ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) + return self.parse(host, self.session.post(uri, headers=headers, json=json, timeout=self.timeout)) def put(self, host, request_uri, params, header_auth=False): uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) @@ -585,7 +590,7 @@ def put(self, host, request_uri, params, header_auth=False): else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) + return self.parse(host, self.session.put(uri, json=params, headers=headers, timeout=self.timeout)) def delete(self, host, request_uri, header_auth=False): uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) @@ -606,7 +611,7 @@ def delete(self, host, request_uri, header_auth=False): params = {"api_key": self.api_key, "api_secret": self.api_secret} logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) return self.parse( - host, self.session.delete(uri, params=params, headers=headers) + host, self.session.delete(uri, params=params, headers=headers, timeout=self.timeout) ) def parse(self, host, response): diff --git a/src/nexmo/_internal.py b/src/nexmo/_internal.py index a32921f0..dee34ba2 100644 --- a/src/nexmo/_internal.py +++ b/src/nexmo/_internal.py @@ -13,9 +13,10 @@ class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret): + def __init__(self, host, user_agent, api_key, api_secret, timeout=None): self._host = host self._session = session = Session() + self.timeout = None session.auth = (api_key, api_secret) # Basic authentication. session.headers.update({"User-Agent": user_agent}) @@ -24,22 +25,22 @@ def _uri(self, path): def get(self, path, params=None, headers=None): return self._parse( - self._session.get(self._uri(path), params=params, headers=headers) + self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) ) def post(self, path, body=None, headers=None): return self._parse( - self._session.post(self._uri(path), json=body, headers=headers) + self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def put(self, path, body=None, headers=None): return self._parse( - self._session.put(self._uri(path), json=body, headers=headers) + self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def delete(self, path, body=None, headers=None): return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers) + self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def _parse(self, response): From 5a64ac01bf1406db7a727f62baf338c869f9a4e7 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 7 Jul 2020 16:38:43 -0400 Subject: [PATCH 038/401] removing clutter --- src/nexmo/__init__.py | 216 ++++++++++++++++++++++++++-------- src/nexmo/sms.py | 51 -------- src/nexmo/voice.py | 117 ------------------ tests/conftest.py | 2 + tests/test_getters_setters.py | 24 ++++ 5 files changed, 193 insertions(+), 217 deletions(-) delete mode 100644 src/nexmo/sms.py delete mode 100644 src/nexmo/voice.py create mode 100644 tests/test_getters_setters.py diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 7510da97..a7e713af 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,7 +1,5 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * -from .voice import * -from .sms import * from datetime import datetime import logging from platform import python_version @@ -17,6 +15,7 @@ import time from uuid import uuid4 import warnings +import re string_types = (str, bytes) @@ -96,6 +95,8 @@ def __init__( if isinstance(self.private_key, string_types) and "\n" not in self.private_key: with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() + + self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' self.__host = "rest.nexmo.com" @@ -128,6 +129,8 @@ def __init__( def host(self, value=None): if value is None: return self.__host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for host') else: self.__host = value @@ -135,90 +138,133 @@ def host(self, value=None): def api_host(self, value=None): if value is None: return self.__api_host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for api_host') else: self.__api_host = value def auth(self, params=None, **kwargs): self.auth_params = params or kwargs + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_NEXMO_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + def get_balance(self): - return self.get(self.__host, "/account/get-balance") + return self.get(self.host(), "/account/get-balance") def get_country_pricing(self, country_code): return self.get( - self.__host, "/account/get-pricing/outbound", {"country": country_code} + self.host(), "/account/get-pricing/outbound", {"country": country_code} ) def get_prefix_pricing(self, prefix): return self.get( - self.__host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} ) def get_sms_pricing(self, number): return self.get( - self.__host, "/account/get-phone-pricing/outbound/sms", {"phone": number} + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} ) def get_voice_pricing(self, number): return self.get( - self.__host, "/account/get-phone-pricing/outbound/voice", {"phone": number} + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} ) def update_settings(self, params=None, **kwargs): - return self.post(self.__host, "/account/settings", params or kwargs) + return self.post(self.host(), "/account/settings", params or kwargs) def topup(self, params=None, **kwargs): - return self.post(self.__host, "/account/top-up", params or kwargs) + return self.post(self.host(), "/account/top-up", params or kwargs) def get_account_numbers(self, params=None, **kwargs): - return self.get(self.__host, "/account/numbers", params or kwargs) + return self.get(self.host(), "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): return self.get( - self.__host, "/number/search", dict(params or kwargs, country=country_code) + self.host(), "/number/search", dict(params or kwargs, country=country_code) ) def buy_number(self, params=None, **kwargs): - return self.post(self.__host, "/number/buy", params or kwargs) + return self.post(self.host(), "/number/buy", params or kwargs) def cancel_number(self, params=None, **kwargs): - return self.post(self.__host, "/number/cancel", params or kwargs) + return self.post(self.host(), "/number/cancel", params or kwargs) def update_number(self, params=None, **kwargs): - return self.post(self.__host, "/number/update", params or kwargs) + return self.post(self.host(), "/number/update", params or kwargs) def get_message(self, message_id): - return self.get(self.__host, "/search/message", {"id": message_id}) + return self.get(self.host(), "/search/message", {"id": message_id}) def get_message_rejections(self, params=None, **kwargs): - return self.get(self.__host, "/search/rejections", params or kwargs) + return self.get(self.host(), "/search/rejections", params or kwargs) def search_messages(self, params=None, **kwargs): - return self.get(self.__host, "/search/messages", params or kwargs) + return self.get(self.host(), "/search/messages", params or kwargs) def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.__host, "/ussd/json", params or kwargs) + return self.post(self.host(), "/ussd/json", params or kwargs) def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.__host, "/ussd-prompt/json", params or kwargs) + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) def send_2fa_message(self, params=None, **kwargs): - return self.post(self.__host, "/sc/us/2fa/json", params or kwargs) + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.__host, "/sc/us/alert/json", params or kwargs) + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) def send_marketing_message(self, params=None, **kwargs): - return self.post(self.__host, "/sc/us/marketing/json", params or kwargs) + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) def get_event_alert_numbers(self): - return self.get(self.__host, "/sc/us/alert/opt-in/query/json") + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.__host, "/sc/us/alert/opt-in/manage/json", params or kwargs) + return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) def start_verification(self, params=None, **kwargs): - return self.post(self.__api_host, "/verify/json", params or kwargs) + return self.post(self.api_host(), "/verify/json", params or kwargs) def send_verification_request(self, params=None, **kwargs): warnings.warn( @@ -227,11 +273,11 @@ def send_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.__api_host, "/verify/json", params or kwargs) + return self.post(self.api_host(), "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): return self.post( - self.__api_host, + self.api_host(), "/verify/check/json", dict(params or kwargs, request_id=request_id), ) @@ -243,11 +289,11 @@ def check_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.__api_host, "/verify/check/json", params or kwargs) + return self.post(self.api_host(), "/verify/check/json", params or kwargs) def get_verification(self, request_id): return self.get( - self.__api_host, "/verify/search/json", {"request_id": request_id} + self.api_host(), "/verify/search/json", {"request_id": request_id} ) def get_verification_request(self, request_id): @@ -258,19 +304,19 @@ def get_verification_request(self, request_id): ) return self.get( - self.__api_host, "/verify/search/json", {"request_id": request_id} + self.api_host(), "/verify/search/json", {"request_id": request_id} ) def cancel_verification(self, request_id): return self.post( - self.__api_host, + self.api_host(), "/verify/control/json", {"request_id": request_id, "cmd": "cancel"}, ) def trigger_next_verification_event(self, request_id): return self.post( - self.__api_host, + self.api_host(), "/verify/control/json", {"request_id": request_id, "cmd": "trigger_next_event"}, ) @@ -282,13 +328,13 @@ def control_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.__api_host, "/verify/control/json", params or kwargs) + return self.post(self.api_host(), "/verify/control/json", params or kwargs) def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.__api_host, "/ni/basic/json", params or kwargs) + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.__api_host, "/ni/standard/json", params or kwargs) + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) def get_number_insight(self, params=None, **kwargs): warnings.warn( @@ -297,20 +343,20 @@ def get_number_insight(self, params=None, **kwargs): stacklevel=2, ) - return self.get(self.__api_host, "/number/lookup/json", params or kwargs) + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams: - return self.get(self.__api_host, "/ni/advanced/async/json", params or kwargs) + return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) else: raise ClientError("Error: Callback needed for async advanced number insight") def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.__api_host, "/ni/advanced/json", params or kwargs) + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) def request_number_insight(self, params=None, **kwargs): - return self.post(self.__host, "/ni/json", params or kwargs) + return self.post(self.host(), "/ni/json", params or kwargs) def get_applications(self, params=None, **kwargs): warnings.warn( @@ -318,7 +364,7 @@ def get_applications(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.get(self.__api_host, "/v1/applications", params or kwargs) + return self.get(self.api_host(), "/v1/applications", params or kwargs) def get_application(self, application_id): warnings.warn( @@ -327,7 +373,7 @@ def get_application(self, application_id): stacklevel=2, ) return self.get( - self.__api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -337,7 +383,7 @@ def create_application(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.post(self.__api_host, "/v1/applications", params or kwargs) + return self.post(self.api_host(), "/v1/applications", params or kwargs) def update_application(self, application_id, params=None, **kwargs): warnings.warn( @@ -346,7 +392,7 @@ def update_application(self, application_id, params=None, **kwargs): stacklevel=2, ) return self.put( - self.__api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), params or kwargs, ) @@ -358,10 +404,45 @@ def delete_application(self, application_id): stacklevel=2, ) return self.delete( - self.__api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), ) + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -370,18 +451,18 @@ def redact_transaction(self, id, product, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._post_json(self.__api_host, "/v1/redact/transaction", params) + return self._post_json(self.api_host(), "/v1/redact/transaction", params) def list_secrets(self, api_key): return self.get( - self.__api_host, + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True, ) def get_secret(self, api_key, secret_id): return self.get( - self.__api_host, + self.api_host(), "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -391,12 +472,12 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} return self._post_json( - self.__api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body ) def delete_secret(self, api_key, secret_id): return self.delete( - self.__api_host, + self.api_host(), "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -596,6 +677,43 @@ def parse(self, host, response): ) raise ServerError(message) + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.post(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.delete(uri, headers=self._headers()) + ) + def _headers(self): token = self.generate_application_jwt() return dict(self.headers, Authorization=b"Bearer " + token) diff --git a/src/nexmo/sms.py b/src/nexmo/sms.py deleted file mode 100644 index 2ff3ab23..00000000 --- a/src/nexmo/sms.py +++ /dev/null @@ -1,51 +0,0 @@ -import nexmo, pytz -from datetime import datetime -from ._internal import _format_date_param - -class Sms: - #To init Sms class pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = nexmo.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc) - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self._client.post(self._client.api_host(), "/conversions/sms", params) \ No newline at end of file diff --git a/src/nexmo/voice.py b/src/nexmo/voice.py deleted file mode 100644 index b407009a..00000000 --- a/src/nexmo/voice.py +++ /dev/null @@ -1,117 +0,0 @@ -import nexmo - -class Voice(): - #application_id and private_key are needed for the calling methods - #Passing a Nexmo Client is also possible - def __init__( - self, - client=None, - application_id=None, - private_key=None, - ): - try: - # Client is protected - self._client = client - if self._client is None: - self._client = nexmo.Client(application_id=application_id, private_key=private_key) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - # Creates a new call session - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - # Get call history paginated. Pass start and end dates to filter the retrieved information - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - # Get a single call record by identifier - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - # Update call data using custom ncco - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - # Plays audio streaming into call in progress - stream_url parameter is required - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - # Play an speech into specified call - text parameter (text to speech) is required - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - # plays DTMF tones into the specified call - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - # Stops audio recently played into specified call - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - # Stop a speech recently played into specified call - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - # Deprecated section - # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory - def initiate_call(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) - # End deprecated section - - # Utils methods - # _jwt_signed_post private method that Allows developer perform signed post request - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - # Uses the client session to perform the call action with api - return self._client.parse( - self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) - ) - - # _jwt_signed_post private method that Allows developer perform signed get request - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), - self._client.session.get(uri, params=params or {}, headers=self._client._headers()), - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) - ) diff --git a/tests/conftest.py b/tests/conftest.py index 8bbf454f..ea37ec88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,8 @@ def __init__(self): self.user_agent = "nexmo-python/{} python/{}".format( nexmo.__version__, platform.python_version() ) + self.host = "rest.nexmo.com" + self.api_host = "api.nexmo.com" @pytest.fixture(scope="session") diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py new file mode 100644 index 00000000..30abda7c --- /dev/null +++ b/tests/test_getters_setters.py @@ -0,0 +1,24 @@ +from util import * + +@responses.activate +def test_getters(client, dummy_data): + assert client.host() == dummy_data.host + assert client.api_host() == dummy_data.api_host + +@responses.activate +def test_setters(client, dummy_data): + try: + client.host('host.nexmo.com') + client.api_host('host.nexmo.com') + assert client.host() != dummy_data.host + assert client.api_host() != dummy_data.api_host + except: + assert False + +@responses.activate +def test_fail_setter_url_format(client, dummy_data): + try: + client.host('1000.1000') + assert False + except: + assert True \ No newline at end of file From 502914137310ca726c0973cf64447efb123bc8c4 Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 15 Jul 2020 12:04:32 -0400 Subject: [PATCH 039/401] creating verify class --- src/nexmo/__init__.py | 92 +++++------------------------------------- src/nexmo/_internal.py | 11 +++-- src/nexmo/verify.py | 51 +++++++++++++++++++++++ tests/conftest.py | 10 +++++ tests/test_nexmo.py | 1 + tests/test_verify.py | 69 ++++++------------------------- 6 files changed, 90 insertions(+), 144 deletions(-) create mode 100644 src/nexmo/verify.py diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 2ab84b83..f28562db 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,5 +1,6 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * +from .verify import * from datetime import datetime import logging from platform import python_version @@ -16,7 +17,8 @@ from uuid import uuid4 import warnings -string_types = (str, bytes) + +string_types = (str, bytes) from urllib.parse import urlparse try: @@ -58,7 +60,6 @@ class Client: provided by this library and can be used by Nexmo to track your app statistics. :param str app_version: This optional value is added to the user-agent header provided by this library and can be used by Nexmo to track your app statistics. - :param float timeout: This optional value sets the timeout value for calling the api. """ def __init__( @@ -71,7 +72,6 @@ def __init__( private_key=None, app_name=None, app_version=None, - timeout=None ): self.api_key = key or os.environ.get("NEXMO_API_KEY", None) @@ -100,8 +100,6 @@ def __init__( self.api_host = "api.nexmo.com" - self.timeout = timeout - user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() ) @@ -120,12 +118,14 @@ def __init__( user_agent=user_agent, api_key=self.api_key, api_secret=self.api_secret, - timeout=self.timeout ) self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() + # Internal Verify Object - a method that return a verify instance, just for cool definitions + self.Verify = Verify(self) + def auth(self, params=None, **kwargs): self.auth_params = params or kwargs @@ -246,76 +246,6 @@ def initiate_tts_call(self, params=None, **kwargs): def initiate_tts_prompt_call(self, params=None, **kwargs): return self.post(self.api_host, "/tts-prompt/json", params or kwargs) - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host, "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#send_verification_request is deprecated (use #start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host, "/verify/json", params or kwargs) - - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host, - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host, "/verify/psd2/json", params or kwargs) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#check_verification_request is deprecated (use #check_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host, "/verify/check/json", params or kwargs) - - def get_verification(self, request_id): - return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "nexmo.Client#get_verification_request is deprecated (use #get_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} - ) - - def cancel_verification(self, request_id): - return self.post( - self.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host, "/verify/control/json", params or kwargs) - def get_basic_number_insight(self, params=None, **kwargs): return self.get(self.api_host, "/ni/basic/json", params or kwargs) @@ -517,7 +447,7 @@ def get(self, host, request_uri, params=None, header_auth=False): params or {}, api_key=self.api_key, api_secret=self.api_secret ) logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers, timeout=self.timeout)) + return self.parse(host, self.session.get(uri, params=params, headers=headers)) def post( self, @@ -553,7 +483,7 @@ def post( else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers, timeout=self.timeout)) + return self.parse(host, self.session.post(uri, data=params, headers=headers)) def _post_json(self, host, request_uri, json): """ @@ -573,7 +503,7 @@ def _post_json(self, host, request_uri, json): logger.debug( "POST to %r with body: %r, headers: %r", request_uri, json, headers ) - return self.parse(host, self.session.post(uri, headers=headers, json=json, timeout=self.timeout)) + return self.parse(host, self.session.post(uri, headers=headers, json=json)) def put(self, host, request_uri, params, header_auth=False): uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) @@ -592,7 +522,7 @@ def put(self, host, request_uri, params, header_auth=False): else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers, timeout=self.timeout)) + return self.parse(host, self.session.put(uri, json=params, headers=headers)) def delete(self, host, request_uri, header_auth=False): uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) @@ -613,7 +543,7 @@ def delete(self, host, request_uri, header_auth=False): params = {"api_key": self.api_key, "api_secret": self.api_secret} logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) return self.parse( - host, self.session.delete(uri, params=params, headers=headers, timeout=self.timeout) + host, self.session.delete(uri, params=params, headers=headers) ) def parse(self, host, response): diff --git a/src/nexmo/_internal.py b/src/nexmo/_internal.py index dee34ba2..a32921f0 100644 --- a/src/nexmo/_internal.py +++ b/src/nexmo/_internal.py @@ -13,10 +13,9 @@ class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret, timeout=None): + def __init__(self, host, user_agent, api_key, api_secret): self._host = host self._session = session = Session() - self.timeout = None session.auth = (api_key, api_secret) # Basic authentication. session.headers.update({"User-Agent": user_agent}) @@ -25,22 +24,22 @@ def _uri(self, path): def get(self, path, params=None, headers=None): return self._parse( - self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) + self._session.get(self._uri(path), params=params, headers=headers) ) def post(self, path, body=None, headers=None): return self._parse( - self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) + self._session.post(self._uri(path), json=body, headers=headers) ) def put(self, path, body=None, headers=None): return self._parse( - self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) + self._session.put(self._uri(path), json=body, headers=headers) ) def delete(self, path, body=None, headers=None): return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) + self._session.delete(self._uri(path), json=body, headers=headers) ) def _parse(self, response): diff --git a/src/nexmo/verify.py b/src/nexmo/verify.py new file mode 100644 index 00000000..5ea1ba31 --- /dev/null +++ b/src/nexmo/verify.py @@ -0,0 +1,51 @@ +import nexmo +import warnings + +class Verify: + def __init__( + self, + client=None, + key=None, + secret=None + ): + try: + self._client = client + if self._client is None: + self._client = nexmo.Client( + key=key, + secret=secret + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def start_verification(self, params=None, **kwargs): + return self._client.post(self._client.api_host, "/verify/json", params or kwargs) + + def check(self, request_id, params=None, **kwargs): + return self._client.post( + self._client.api_host, + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def search(self, request_id): + return self._client.get( + self._client.api_host, "/verify/search/json", {"request_id": request_id} + ) + + def cancel(self, request_id): + return self._client.post( + self._client.api_host, + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_event(self, request_id): + return self._client.post( + self._client.api_host, + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def psd2(self, params=None, **kwargs): + return self._client.post(self._client.api_host, "/verify/psd2/json", params or kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 8bbf454f..90491f08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,3 +44,13 @@ def client(dummy_data): application_id=dummy_data.application_id, private_key=dummy_data.private_key, ) + + +#Represents an instance of the Verify class for testing +@pytest.fixture +def verify(client, dummy_data): + import nexmo + + return nexmo.Verify( + client + ) diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 3ef895d3..9c236e98 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -5,6 +5,7 @@ bytes_type = bytes + @responses.activate def test_send_ussd_push_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd/json") diff --git a/tests/test_verify.py b/tests/test_verify.py index d35942e3..08e0aed5 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2,35 +2,23 @@ @responses.activate -def test_start_verification(client, dummy_data): +def test_start_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(client.start_verification(params), dict) + assert isinstance(verify.start_verification(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() @responses.activate -def test_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(client, dummy_data): +def test_check_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/check/json") assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict + verify.check("8g88g88eg8g8gg9g90", code="123445"), dict ) assert request_user_agent() == dummy_data.user_agent assert "code=123445" in request_body() @@ -38,75 +26,42 @@ def test_check_verification(client, dummy_data): @responses.activate -def test_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(client, dummy_data): +def test_get_verification(verify, dummy_data): stub(responses.GET, "https://api.nexmo.com/verify/search/json") - assert isinstance(client.get_verification("xxx"), dict) + assert isinstance(verify.search("xxx"), dict) assert request_user_agent() == dummy_data.user_agent assert "request_id=xxx" in request_query() @responses.activate -def test_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(client, dummy_data): +def test_cancel_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) + assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) assert request_user_agent() == dummy_data.user_agent assert "cmd=cancel" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() @responses.activate -def test_trigger_next_verification_event(client, dummy_data): +def test_trigger_next_verification_event(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict + verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict ) assert request_user_agent() == dummy_data.user_agent assert "cmd=trigger_next_event" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - @responses.activate -def test_start_psd2_verification(client, dummy_data): +def test_start_psd2_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(client.start_psd2_verification_request(params), dict) + assert isinstance(verify.psd2(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file From 9c3a55d5299531b7117f69765172a36e1a5cb02f Mon Sep 17 00:00:00 2001 From: superdiana Date: Thu, 16 Jul 2020 12:53:24 -0400 Subject: [PATCH 040/401] adding readme --- README.md | 202 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 142 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index c4130afe..515f4359 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Nexmo Client Library for Python -=============================== +# Nexmo Client Library for Python [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) [![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) @@ -12,21 +11,19 @@ Nexmo Client Library for Python This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. -* [Installation](#installation) -* [Usage](#usage) -* [SMS API](#sms-api) -* [Voice API](#voice-api) -* [Verify API](#verify-api) -* [Number Insight API](#number-insight-api) -* [Number Management API](#number-management-api) -* [Managing Secrets](#managing-secrets) -* [Application API](#application-api) -* [Overriding API url's](#overriding-api-urls) -* [License](#license) +- [Installation](#installation) +- [Usage](#usage) +- [SMS API](#sms-api) +- [Voice API](#voice-api) +- [Verify API](#verify-api) +- [Number Insight API](#number-insight-api) +- [Number Management API](#number-management-api) +- [Managing Secrets](#managing-secrets) +- [Application API](#application-api) +- [Overriding API url's](#overriding-api-urls) +- [License](#license) - -Installation ------------- +## Installation To install the Python client library using pip: @@ -42,9 +39,7 @@ Alternatively, you can clone the repository via the command line: or by opening it on GitHub desktop. - -Usage ------ +## Usage Begin by importing the `nexmo` module: @@ -72,7 +67,6 @@ To check signatures for incoming webhook requests, you'll also need to specify the `signature_secret` argument (or the `NEXMO_SIGNATURE_SECRET` environment variable). - ## SMS API ### Send a text message @@ -100,9 +94,10 @@ be enabled on your account first. ```python response = client.submit_sms_conversion(message_id) ``` + ### Signing a Message -*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages).* +_You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)._ The SMS API supports the ability to sign messages by generating and adding a signature using a "Signature Secret" rather than your API secret. The algorithms supported are: @@ -128,11 +123,11 @@ Using this client, your SMS API messages will be sent as signed messages. ### Verifying an Incoming Message Signature -*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)*. +_You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)_. -If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. +If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. -To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. +To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. Then use the `check_signature()` method with the actual signature that was received (usually present in request.form or request.args. you can merge those in a single variable called params) to make sure that it is correct. @@ -230,60 +225,151 @@ Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo. ### Get recording -``` python +```python response = client.get_recording(RECORDING_URL) ``` - ## Verify API -### Start a verification +### Create an instance of the class + +To create an instance of the Verify class, Just follow the following steps: + +- **Import the class from module** (3 different ways) ```python -response = client.start_verification(number='441632960960', brand='MyApp') +#First way +from nexmo import Verify -if response['status'] == '0': - print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) +#Second way +from nexmo.verify import Verify + +#Third valid way +import nexmo #then tou can use nexmo.Verify() to create an instance +``` + +- **Create the instance** + +```python +#First way - pass key and secret to the constructor +verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) + +#Second way - Create a client instance and then pass the client to the Verify constructor +client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +verify = Verify(client) +``` + +### Search for a Verification request + +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.search('69e2626cbc23451fbbc02f627a959677') + +if response is not None: + print(response['status']) +``` + +### Send verification code + +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') + +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) +### Send verification code with workflow -The response contains a verification request id which you will need to -store temporarily (in the session, database, url, etc). +```python +client = Client(key='API_KEY', secret='API_SECRET') -### Check a verification +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) + +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Check verification code ```python -response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') +client = Client(key='API_KEY', secret='API_SECRET') -if response['status'] == '0': - print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) +verify = Verify(client) +response = verify.check(REQUEST_ID, code=CODE) + +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) +### Cancel Verification Request -The verification request id comes from the call to the start_verification method. -The PIN code is entered into your application by the user. +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.cancel(REQUEST_ID) -### Cancel a verification +if response["status"] == "0": + print("Cancellation successful") +else: + print("Error: %s" % response["error_text"]) +``` + +### Trigger next verification proccess ```python -client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.trigger_next_event(REQUEST_ID) + +if response["status"] == "0": + print("Next verification stage triggered") +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +### Send payment authentication code -### Trigger next verification step +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) + +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Send payment authentication code with workflow ```python -client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) + +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +Docs: [https://developer.nexmo.com/api/verify](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) ## Number Insight API @@ -347,22 +433,22 @@ Docs: [https://developer.nexmo.com/api/numbers#cancelANumber](https://developer. ## Managing Secrets - An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. +An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. ### List Secrets - ```python +```python secrets = client.list_secrets(API_KEY) ``` ### Create A New Secret - Create a new secret (the created dates will help you know which is which): - ```python +Create a new secret (the created dates will help you know which is which): + +```python client.create_secret(API_KEY, 'awes0meNewSekret!!;'); ``` - ### Delete A Secret Delete the old secret (any application still using these credentials will stop working): @@ -371,7 +457,6 @@ Delete the old secret (any application still using these credentials will stop w client.delete_secret(API_KEY, 'my-secret-id') ``` - ## Application API ### Create an application @@ -414,7 +499,6 @@ response = client.application_v2.delete_application(uuid) Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) - ## Validate webhook signatures ```python @@ -431,7 +515,6 @@ Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://dev Note: you'll need to contact support@nexmo.com to enable message signing on your account before you can validate webhook signatures. - ## JWT parameters By default, the library generates short-lived tokens for JWT authentication. @@ -455,6 +538,7 @@ client = nexmo.Client() client.host = 'new.host.url' client.api_host = 'new.api.host' ``` + - Creating a new class that extends from client class and overrides these values in the constructor: ```python @@ -487,8 +571,7 @@ class NexmoClient(nexmo.Client): Then proceed to create your personalised instance of the class. -Contributing ------------- +## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! @@ -504,8 +587,7 @@ The tests are all written with pytest. You run them with: make test ``` -License -------- +## License This library is released under the [MIT License][license]. From c2af6fd2afe3738ed84796d7a9c7a94bb7e072fa Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 5 Aug 2020 13:00:09 -0400 Subject: [PATCH 041/401] Update __init__.py --- src/nexmo/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index a7e713af..9da62851 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,5 +1,7 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * +from .voice import * +from .sms import * from datetime import datetime import logging from platform import python_version From d02eb7cb95154d3ded746c633a6e4be9bb6d61f2 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Mon, 10 Aug 2020 19:44:12 -0400 Subject: [PATCH 042/401] Revert "Merge pull request #156 from Nexmo/create-verify-class" This reverts commit 125c3357d1e59f1ea83318350c2421323743fd2d. --- README.md | 186 +++++++------------------------------- src/nexmo/__init__.py | 200 +++++++++++++++++++++++++++++------------ src/nexmo/_internal.py | 11 +-- src/nexmo/verify.py | 51 ----------- tests/conftest.py | 18 +++- tests/test_nexmo.py | 1 - tests/test_verify.py | 69 +++++++++++--- 7 files changed, 254 insertions(+), 282 deletions(-) delete mode 100644 src/nexmo/verify.py diff --git a/README.md b/README.md index 75741691..b95392c4 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,18 @@ This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. -- [Installation](#installation) -- [Usage](#usage) -- [SMS API](#sms-api) -- [Voice API](#voice-api) -- [Verify API](#verify-api) -- [Number Insight API](#number-insight-api) -- [Number Management API](#number-management-api) -- [Managing Secrets](#managing-secrets) -- [Application API](#application-api) -- [Overriding API url's](#overriding-api-urls) -- [License](#license) +* [Installation](#installation) +* [Usage](#usage) +* [SMS API](#sms-api) +* [Voice API](#voice-api) +* [Verify API](#verify-api) +* [Number Insight API](#number-insight-api) +* [Number Management API](#number-management-api) +* [Managing Secrets](#managing-secrets) +* [Application API](#application-api) +* [Overriding API Attributes](#overriding-api-attributes) +* [License](#license) + ## Installation @@ -89,14 +90,6 @@ import nexmo #then tou can use nexmo.Sms() to create an instance - Create an instance -```python -response = client.submit_sms_conversion(message_id) -``` - -### Signing a Message - -_You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)._ - ```python #Option 1 - pass key and secret to the constructor sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) @@ -136,12 +129,6 @@ Support link: [Send sms](https://gitlab.com/codeonrocks/client/nexmo-python/uplo ### Send SMS with unicode -_You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)_. - -If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. - -To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. - ```python responseData = client.send_message({ 'from': NEXMO_BRAND_NAME, @@ -155,7 +142,6 @@ Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/cod **Using Sms Class** - ```python sms.send_message({ 'from': NEXMO_BRAND_NAME, @@ -410,145 +396,53 @@ response = client.get_recording(RECORDING_URL) ## Verify API -### Create an instance of the class - -To create an instance of the Verify class, Just follow the following steps: - -- **Import the class from module** (3 different ways) +### Start a verification ```python -#First way -from nexmo import Verify - -#Second way -from nexmo.verify import Verify - -#Third valid way -import nexmo #then tou can use nexmo.Verify() to create an instance -``` - -- **Create the instance** - -```python -#First way - pass key and secret to the constructor -verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) - -#Second way - Create a client instance and then pass the client to the Verify constructor -client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -verify = Verify(client) -``` - -### Search for a Verification request - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.search('69e2626cbc23451fbbc02f627a959677') - -if response is not None: - print(response['status']) -``` - -### Send verification code - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') +response = client.start_verification(number='441632960960', brand='MyApp') -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) +if response['status'] == '0': + print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) else: - print("Error: %s" % response["error_text"]) + print('Error:', response['error_text']) ``` -### Send verification code with workflow +Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) - -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` +The response contains a verification request id which you will need to +store temporarily (in the session, database, url, etc). -### Check verification code +### Check a verification ```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.check(REQUEST_ID, code=CODE) +response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) +if response['status'] == '0': + print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) else: - print("Error: %s" % response["error_text"]) + print('Error:', response['error_text']) ``` -### Cancel Verification Request +Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) -```python -client = Client(key='API_KEY', secret='API_SECRET') +The verification request id comes from the call to the start_verification method. +The PIN code is entered into your application by the user. -verify = Verify(client) -response = verify.cancel(REQUEST_ID) - -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) -``` - -### Trigger next verification proccess +### Cancel a verification ```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.trigger_next_event(REQUEST_ID) - -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) +client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') ``` -### Send payment authentication code - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) - -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` +Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) -### Send payment authentication code with workflow +### Trigger next verification step ```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) - -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') ``` -Docs: [https://developer.nexmo.com/api/verify](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) +Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) ## Number Insight API @@ -677,15 +571,6 @@ In order to rewrite/get the value of variables used across all the Nexmo classes An example using setters/getters with `Object references`: -```python -import nexmo -client = nexmo.Client() -client.host = 'new.host.url' -client.api_host = 'new.api.host' -``` - -- Creating a new class that extends from client class and overrides these values in the constructor: - ```python from nexmo import Client, Sms @@ -716,9 +601,6 @@ print(client.api_host()) # returns api.nexmo.com client.api_host('myapi.nexmo.com') # rewrite the value of api_host ``` -Then proceed to create your personalised instance of the class. - - ## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index f28562db..9da62851 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,6 +1,7 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * -from .verify import * +from .voice import * +from .sms import * from datetime import datetime import logging from platform import python_version @@ -16,6 +17,7 @@ import time from uuid import uuid4 import warnings +import re string_types = (str, bytes) @@ -95,10 +97,12 @@ def __init__( if isinstance(self.private_key, string_types) and "\n" not in self.private_key: with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() + + self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' - self.host = "rest.nexmo.com" + self.__host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" + self.__api_host = "api.nexmo.com" user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() @@ -122,9 +126,24 @@ def __init__( self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() - - # Internal Verify Object - a method that return a verify instance, just for cool definitions - self.Verify = Verify(self) + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for host') + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for api_host') + else: + self.__api_host = value def auth(self, params=None, **kwargs): self.auth_params = params or kwargs @@ -141,71 +160,71 @@ def send_message(self, params): }) :param dict params: A dict of values described at `Send an SMS `_ """ - return self.post(self.host, "/sms/json", params, supports_signature_auth=True) + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) def get_balance(self): - return self.get(self.host, "/account/get-balance") + return self.get(self.host(), "/account/get-balance") def get_country_pricing(self, country_code): return self.get( - self.host, "/account/get-pricing/outbound", {"country": country_code} + self.host(), "/account/get-pricing/outbound", {"country": country_code} ) def get_prefix_pricing(self, prefix): return self.get( - self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} ) def get_sms_pricing(self, number): return self.get( - self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} ) def get_voice_pricing(self, number): return self.get( - self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} ) def update_settings(self, params=None, **kwargs): - return self.post(self.host, "/account/settings", params or kwargs) + return self.post(self.host(), "/account/settings", params or kwargs) def topup(self, params=None, **kwargs): - return self.post(self.host, "/account/top-up", params or kwargs) + return self.post(self.host(), "/account/top-up", params or kwargs) def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host, "/account/numbers", params or kwargs) + return self.get(self.host(), "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): return self.get( - self.host, "/number/search", dict(params or kwargs, country=country_code) + self.host(), "/number/search", dict(params or kwargs, country=country_code) ) def buy_number(self, params=None, **kwargs): - return self.post(self.host, "/number/buy", params or kwargs) + return self.post(self.host(), "/number/buy", params or kwargs) def cancel_number(self, params=None, **kwargs): - return self.post(self.host, "/number/cancel", params or kwargs) + return self.post(self.host(), "/number/cancel", params or kwargs) def update_number(self, params=None, **kwargs): - return self.post(self.host, "/number/update", params or kwargs) + return self.post(self.host(), "/number/update", params or kwargs) def get_message(self, message_id): - return self.get(self.host, "/search/message", {"id": message_id}) + return self.get(self.host(), "/search/message", {"id": message_id}) def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host, "/search/rejections", params or kwargs) + return self.get(self.host(), "/search/rejections", params or kwargs) def search_messages(self, params=None, **kwargs): - return self.get(self.host, "/search/messages", params or kwargs) + return self.get(self.host(), "/search/messages", params or kwargs) def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd/json", params or kwargs) + return self.post(self.host(), "/ussd/json", params or kwargs) def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd-prompt/json", params or kwargs) + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/2fa/json", params or kwargs) + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ @@ -223,34 +242,101 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): } # Ensure timestamp is a string: _format_date_param(params, "timestamp") - return self.post(self.api_host, "/conversions/sms", params) + return self.post(self.api_host(), "/conversions/sms", params) def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/json", params or kwargs) + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/marketing/json", params or kwargs) + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) def get_event_alert_numbers(self): - return self.get(self.host, "/sc/us/alert/opt-in/query/json") + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/opt-in/manage/json", params or kwargs) + return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) def initiate_call(self, params=None, **kwargs): - return self.post(self.host, "/call/json", params or kwargs) + return self.post(self.host(), "/call/json", params or kwargs) def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts/json", params or kwargs) + return self.post(self.api_host(), "/tts/json", params or kwargs) def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts-prompt/json", params or kwargs) + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) + + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#send_verification_request is deprecated (use #start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#check_verification_request is deprecated (use #check_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "nexmo.Client#get_verification_request is deprecated (use #get_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/basic/json", params or kwargs) + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/standard/json", params or kwargs) + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) def get_number_insight(self, params=None, **kwargs): warnings.warn( @@ -259,20 +345,20 @@ def get_number_insight(self, params=None, **kwargs): stacklevel=2, ) - return self.get(self.api_host, "/number/lookup/json", params or kwargs) + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams: - return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) + return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) else: raise ClientError("Error: Callback needed for async advanced number insight") def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/advanced/json", params or kwargs) + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) def request_number_insight(self, params=None, **kwargs): - return self.post(self.host, "/ni/json", params or kwargs) + return self.post(self.host(), "/ni/json", params or kwargs) def get_applications(self, params=None, **kwargs): warnings.warn( @@ -280,7 +366,7 @@ def get_applications(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.get(self.api_host, "/v1/applications", params or kwargs) + return self.get(self.api_host(), "/v1/applications", params or kwargs) def get_application(self, application_id): warnings.warn( @@ -289,7 +375,7 @@ def get_application(self, application_id): stacklevel=2, ) return self.get( - self.api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -299,7 +385,7 @@ def create_application(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.post(self.api_host, "/v1/applications", params or kwargs) + return self.post(self.api_host(), "/v1/applications", params or kwargs) def update_application(self, application_id, params=None, **kwargs): warnings.warn( @@ -308,7 +394,7 @@ def update_application(self, application_id, params=None, **kwargs): stacklevel=2, ) return self.put( - self.api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), params or kwargs, ) @@ -320,7 +406,7 @@ def delete_application(self, application_id): stacklevel=2, ) return self.delete( - self.api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -367,18 +453,18 @@ def redact_transaction(self, id, product, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._post_json(self.api_host, "/v1/redact/transaction", params) + return self._post_json(self.api_host(), "/v1/redact/transaction", params) def list_secrets(self, api_key): return self.get( - self.api_host, + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True, ) def get_secret(self, api_key, secret_id): return self.get( - self.api_host, + self.api_host(), "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -388,12 +474,12 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} return self._post_json( - self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body ) def delete_secret(self, api_key, secret_id): return self.delete( - self.api_host, + self.api_host(), "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -595,39 +681,39 @@ def parse(self, host, response): def _jwt_signed_get(self, request_uri, params=None): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, + self.api_host(), self.session.get(uri, params=params or {}, headers=self._headers()), ) def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) + self.api_host(), self.session.post(uri, json=params, headers=self._headers()) ) def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) ) def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, self.session.delete(uri, headers=self._headers()) + self.api_host(), self.session.delete(uri, headers=self._headers()) ) def _headers(self): diff --git a/src/nexmo/_internal.py b/src/nexmo/_internal.py index a32921f0..dee34ba2 100644 --- a/src/nexmo/_internal.py +++ b/src/nexmo/_internal.py @@ -13,9 +13,10 @@ class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret): + def __init__(self, host, user_agent, api_key, api_secret, timeout=None): self._host = host self._session = session = Session() + self.timeout = None session.auth = (api_key, api_secret) # Basic authentication. session.headers.update({"User-Agent": user_agent}) @@ -24,22 +25,22 @@ def _uri(self, path): def get(self, path, params=None, headers=None): return self._parse( - self._session.get(self._uri(path), params=params, headers=headers) + self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) ) def post(self, path, body=None, headers=None): return self._parse( - self._session.post(self._uri(path), json=body, headers=headers) + self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def put(self, path, body=None, headers=None): return self._parse( - self._session.put(self._uri(path), json=body, headers=headers) + self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def delete(self, path, body=None, headers=None): return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers) + self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def _parse(self, response): diff --git a/src/nexmo/verify.py b/src/nexmo/verify.py deleted file mode 100644 index 5ea1ba31..00000000 --- a/src/nexmo/verify.py +++ /dev/null @@ -1,51 +0,0 @@ -import nexmo -import warnings - -class Verify: - def __init__( - self, - client=None, - key=None, - secret=None - ): - try: - self._client = client - if self._client is None: - self._client = nexmo.Client( - key=key, - secret=secret - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def start_verification(self, params=None, **kwargs): - return self._client.post(self._client.api_host, "/verify/json", params or kwargs) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host, - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host, "/verify/search/json", {"request_id": request_id} - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def psd2(self, params=None, **kwargs): - return self._client.post(self._client.api_host, "/verify/psd2/json", params or kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 90491f08..6aa2411b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,8 @@ def __init__(self): self.user_agent = "nexmo-python/{} python/{}".format( nexmo.__version__, platform.python_version() ) + self.host = "rest.nexmo.com" + self.api_host = "api.nexmo.com" @pytest.fixture(scope="session") @@ -45,12 +47,20 @@ def client(dummy_data): private_key=dummy_data.private_key, ) - -#Represents an instance of the Verify class for testing +#Represents an instance of the Voice class for testing @pytest.fixture -def verify(client, dummy_data): +def voice(client, dummy_data): import nexmo - return nexmo.Verify( + return nexmo.Voice( client ) + +#Represents an instance of the Sms class for testing +@pytest.fixture +def sms(client, dummy_data): + import nexmo + + return nexmo.Sms( + client + ) \ No newline at end of file diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 74751e3f..3dd46c6f 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -5,7 +5,6 @@ bytes_type = bytes - @responses.activate def test_send_ussd_push_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd/json") diff --git a/tests/test_verify.py b/tests/test_verify.py index 08e0aed5..d35942e3 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2,23 +2,35 @@ @responses.activate -def test_start_verification(verify, dummy_data): +def test_start_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(verify.start_verification(params), dict) + assert isinstance(client.start_verification(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() @responses.activate -def test_check_verification(verify, dummy_data): +def test_send_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.send_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_check_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/check/json") assert isinstance( - verify.check("8g88g88eg8g8gg9g90", code="123445"), dict + client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict ) assert request_user_agent() == dummy_data.user_agent assert "code=123445" in request_body() @@ -26,42 +38,75 @@ def test_check_verification(verify, dummy_data): @responses.activate -def test_get_verification(verify, dummy_data): +def test_check_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.check_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_get_verification(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/verify/search/json") - assert isinstance(verify.search("xxx"), dict) + assert isinstance(client.get_verification("xxx"), dict) assert request_user_agent() == dummy_data.user_agent assert "request_id=xxx" in request_query() @responses.activate -def test_cancel_verification(verify, dummy_data): +def test_get_verification_request(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification_request("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_cancel_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) + assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) assert request_user_agent() == dummy_data.user_agent assert "cmd=cancel" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() @responses.activate -def test_trigger_next_verification_event(verify, dummy_data): +def test_trigger_next_verification_event(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") assert isinstance( - verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict + client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict ) assert request_user_agent() == dummy_data.user_agent assert "cmd=trigger_next_event" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_control_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.control_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + @responses.activate -def test_start_psd2_verification(verify, dummy_data): +def test_start_psd2_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(verify.psd2(params), dict) + assert isinstance(client.start_psd2_verification_request(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file From 71aa6f52f71ca54e7767a1bdc1d31d7b6b629bd8 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Mon, 10 Aug 2020 22:14:15 -0400 Subject: [PATCH 043/401] Bugfixing parenthesis error --- src/nexmo/sms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nexmo/sms.py b/src/nexmo/sms.py index 047378db..e0f3460c 100644 --- a/src/nexmo/sms.py +++ b/src/nexmo/sms.py @@ -30,7 +30,7 @@ def send_message(self, params): Requires a client initialized with `key` and either `secret` or `signature_secret`. :param dict params: A dict of values described at `Send an SMS `_ """ - return self._client.post(self._client.host, "/sms/json", params, supports_signature_auth=True) + return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ @@ -48,4 +48,4 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): } # Ensure timestamp is a string: _format_date_param(params, "timestamp") - return self._client.post(self._client.api_host, "/conversions/sms", params) \ No newline at end of file + return self._client.post(self._client.api_host(), "/conversions/sms", params) From e50fc5b03bf32bbffc7ecb4458f3e70d01e07591 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Mon, 10 Aug 2020 22:14:58 -0400 Subject: [PATCH 044/401] Bugfixing parenthesis error --- src/nexmo/voice.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/nexmo/voice.py b/src/nexmo/voice.py index 72354b8a..b407009a 100644 --- a/src/nexmo/voice.py +++ b/src/nexmo/voice.py @@ -64,54 +64,54 @@ def stop_speech(self, uuid): # Deprecated section # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory def initiate_call(self, params=None, **kwargs): - return self._client.post(self._client.host, "/call/json", params or kwargs) + return self._client.post(self._client.host(), "/call/json", params or kwargs) def initiate_tts_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host, "/tts/json", params or kwargs) + return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) def initiate_tts_prompt_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host, "/tts-prompt/json", params or kwargs) + return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) # End deprecated section # Utils methods # _jwt_signed_post private method that Allows developer perform signed post request def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host, request_uri=request_uri + api_host=self._client.api_host(), request_uri=request_uri ) # Uses the client session to perform the call action with api return self._client.parse( - self._client.api_host, self._client.session.post(uri, json=params, headers=self._client._headers()) + self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) ) # _jwt_signed_post private method that Allows developer perform signed get request def _jwt_signed_get(self, request_uri, params=None): uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host, request_uri=request_uri + api_host=self._client.api_host(), request_uri=request_uri ) return self._client.parse( - self._client.api_host, + self._client.api_host(), self._client.session.get(uri, params=params or {}, headers=self._client._headers()), ) # _jwt_signed_put private method that Allows developer perform signed put request def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host, request_uri=request_uri + api_host=self._client.api_host(), request_uri=request_uri ) return self._client.parse( - self._client.api_host, self._client.session.put(uri, json=params, headers=self._client._headers()) + self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) ) # _jwt_signed_put private method that Allows developer perform signed put request def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host, request_uri=request_uri + api_host=self._client.api_host(), request_uri=request_uri ) return self._client.parse( - self._client.api_host, self._client.session.delete(uri, headers=self._client._headers()) + self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) ) From a40392d9318c2b86e3d2c4a3c8b35c43a13dc4a2 Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 12 Aug 2020 10:41:39 -0400 Subject: [PATCH 045/401] client cleanup and adding verify class --- src/nexmo/__init__.py | 184 +----------------------------------------- src/nexmo/verify.py | 51 ++++++++++++ tests/conftest.py | 9 +++ tests/test_verify.py | 69 +++------------- 4 files changed, 76 insertions(+), 237 deletions(-) create mode 100644 src/nexmo/verify.py diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 9da62851..1eb0cd92 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -2,6 +2,7 @@ from .errors import * from .voice import * from .sms import * +from .verify import * from datetime import datetime import logging from platform import python_version @@ -126,6 +127,9 @@ def __init__( self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() + + # Internal Verify Object - a method that return a verify instance, just for cool definitions + self.Verify = Verify(self) # Get and Set __host attribute def host(self, value=None): @@ -148,20 +152,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - def get_balance(self): return self.get(self.host(), "/account/get-balance") @@ -226,24 +216,6 @@ def send_ussd_prompt_message(self, params=None, **kwargs): def send_2fa_message(self, params=None, **kwargs): return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - def send_event_alert_message(self, params=None, **kwargs): return self.post(self.host(), "/sc/us/alert/json", params or kwargs) @@ -256,82 +228,6 @@ def get_event_alert_numbers(self): def resubscribe_event_alert_number(self, params=None, **kwargs): return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#send_verification_request is deprecated (use #start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#check_verification_request is deprecated (use #check_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "nexmo.Client#get_verification_request is deprecated (use #get_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - def get_basic_number_insight(self, params=None, **kwargs): return self.get(self.api_host(), "/ni/basic/json", params or kwargs) @@ -410,41 +306,6 @@ def delete_application(self, application_id): "/v1/applications/{application_id}".format(application_id=application_id), ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -679,43 +540,6 @@ def parse(self, host, response): ) raise ServerError(message) - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - def _headers(self): token = self.generate_application_jwt() return dict(self.headers, Authorization=b"Bearer " + token) diff --git a/src/nexmo/verify.py b/src/nexmo/verify.py new file mode 100644 index 00000000..b724fdbb --- /dev/null +++ b/src/nexmo/verify.py @@ -0,0 +1,51 @@ +import nexmo +import warnings + +class Verify: + def __init__( + self, + client=None, + key=None, + secret=None + ): + try: + self._client = client + if self._client is None: + self._client = nexmo.Client( + key=key, + secret=secret + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def start_verification(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/verify/json", params or kwargs) + + def check(self, request_id, params=None, **kwargs): + return self._client.post( + self._client.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def search(self, request_id): + return self._client.get( + self._client.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def cancel(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_event(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def psd2(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/verify/psd2/json", params or kwargs) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 6aa2411b..a74d689e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,4 +63,13 @@ def sms(client, dummy_data): return nexmo.Sms( client + ) + +#Represents an instance of the Verify class for testing +@pytest.fixture +def verify(client, dummy_data): + import nexmo + + return nexmo.Verify( + client ) \ No newline at end of file diff --git a/tests/test_verify.py b/tests/test_verify.py index d35942e3..08e0aed5 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2,35 +2,23 @@ @responses.activate -def test_start_verification(client, dummy_data): +def test_start_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(client.start_verification(params), dict) + assert isinstance(verify.start_verification(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() @responses.activate -def test_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(client, dummy_data): +def test_check_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/check/json") assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict + verify.check("8g88g88eg8g8gg9g90", code="123445"), dict ) assert request_user_agent() == dummy_data.user_agent assert "code=123445" in request_body() @@ -38,75 +26,42 @@ def test_check_verification(client, dummy_data): @responses.activate -def test_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(client, dummy_data): +def test_get_verification(verify, dummy_data): stub(responses.GET, "https://api.nexmo.com/verify/search/json") - assert isinstance(client.get_verification("xxx"), dict) + assert isinstance(verify.search("xxx"), dict) assert request_user_agent() == dummy_data.user_agent assert "request_id=xxx" in request_query() @responses.activate -def test_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(client, dummy_data): +def test_cancel_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) + assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) assert request_user_agent() == dummy_data.user_agent assert "cmd=cancel" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() @responses.activate -def test_trigger_next_verification_event(client, dummy_data): +def test_trigger_next_verification_event(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict + verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict ) assert request_user_agent() == dummy_data.user_agent assert "cmd=trigger_next_event" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - @responses.activate -def test_start_psd2_verification(client, dummy_data): +def test_start_psd2_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(client.start_psd2_verification_request(params), dict) + assert isinstance(verify.psd2(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file From 22457535196eac103f172f9f2960612a370cfa1f Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 12 Aug 2020 13:52:43 -0400 Subject: [PATCH 046/401] updating references --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index b95392c4..cdc5ae98 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,6 @@ sms.send_message({ }) ``` -Support link: [Send sms](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/17e17c6f05f6d28c53596f2412c627c2/SMSSendMessage.PNG) ### Send SMS with unicode @@ -138,7 +137,6 @@ responseData = client.send_message({ }) ``` -Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms-with-unicode) **Using Sms Class** @@ -198,7 +196,6 @@ voice.create_all({ }) ``` -Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) ### Retrieve a list of calls @@ -217,8 +214,6 @@ voice = Voice(client) voice.get_calls() ``` -Testing screenshots: [get calls](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/a5cc162f255dc83b8cdd1d2f80531925/NexmoVoiceGetCalls.PNG) - ### Retrieve a single call ```python @@ -236,8 +231,6 @@ voice = Voice(client) voice.get_call(uuid) ``` -Testing Screenshots: [get single call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/5cef34880afdc6a4c3cd3dee0e84aae2/NexmoVoiceGetSingleCall.PNG) - ### Update a call ```python @@ -260,8 +253,6 @@ response = voice.create_all({ voice.update_call(response['uuid'], action='hangup') ``` -Support Link: [update call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/bdf7c0990b6d4019a2758a7148fdf1e4/VoiceUpdateCall.PNG) - ### Stream audio to a call ```python @@ -287,8 +278,6 @@ response = voice.create_call({ voice.send_audio(response['uuid'],stream_url=[stream_url]) ``` -Support link: [Send audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fdc22d76f6bb5c8abf625311f222512a/VoiceSendAudioStream.PNG) - ### Stop streaming audio to a call ```python @@ -313,8 +302,6 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) voice.stop_audio(response['uuid']) ``` -Support Link: [Stop audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/589be23c5a31694e310aacf0fa6a2314/VoiceSendStopAudioStream.PNG) - ### Send a synthesized speech message to a call ```python @@ -337,7 +324,6 @@ response = voice.create_call({ voice.send_speech(response['uuid'], text='Hello from nexmo') ``` -Support link: [Send speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/d608bfe3b1fb288c9f4854d76fba37af/VoiceSendSpeech.PNG) ### Stop sending a synthesized speech message to a call @@ -362,8 +348,6 @@ Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.c >>> voice.stop_speech(response['uuid']) ``` -Support link: [Stop speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/246801f2e34d147955ac3531e4e7b65d/VoiceSendStopSpeech.PNG) - ### Send DTMF tones to a call ```python @@ -386,7 +370,6 @@ response = voice.create_call({ voice.send_dtmf(response['uuid'], digits='1234') ``` -Support link: [Send DTMF](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/7c4b25014d6c94eb886cbaa9a55d2ae3/VoiceSendDTMF.PNG) ### Get recording From 78cdc086900371f1efb7a3e58806d5cc0e5259d5 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 12 Aug 2020 13:58:18 -0400 Subject: [PATCH 047/401] Add supported API's & rmv old method references --- README.md | 129 +++++++++++++++--------------------------------------- 1 file changed, 36 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index cdc5ae98..2933a075 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Managing Secrets](#managing-secrets) * [Application API](#application-api) * [Overriding API Attributes](#overriding-api-attributes) +* [Frequently Asked Questions](#frecuently-asked-questions) * [License](#license) @@ -69,9 +70,9 @@ environment variable). ## SMS API -## SMS Class +### SMS Class -### Creating an instance of the SMS class +#### Creating an instance of the SMS class To create an instance of the SMS class follow these steps: @@ -101,20 +102,6 @@ sms = Sms(client) ### Send an SMS -```python - responseData = client.send_message( - { - "from": NEXMO_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Nexmo SMS API", - } - ) -``` - -Reference: [Send sms](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms) - -**Using the Sms class** - ```python from nexmo import Sms sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) @@ -137,6 +124,7 @@ responseData = client.send_message({ }) ``` +Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms-with-unicode) **Using Sms Class** @@ -173,17 +161,6 @@ sms.submit_sms_conversion(response['message-id']) ### Make a call -```python -response = client.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -``` - -Docs: [https://developer.nexmo.com/api/voice#createCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#createCall) - -**with voice class** ```python from nexmo import Client, Voice @@ -196,17 +173,10 @@ voice.create_all({ }) ``` +Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) ### Retrieve a list of calls -```python -response = client.get_calls() -``` - -Docs: [https://developer.nexmo.com/api/voice#getCalls](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCalls) - -**with voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -214,15 +184,9 @@ voice = Voice(client) voice.get_calls() ``` -### Retrieve a single call - -```python -response = client.get_call(uuid) -``` -Docs: [https://developer.nexmo.com/api/voice#getCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCall) +### Retrieve a single call -**with voice class** ```python from nexmo import Client, Voice @@ -231,15 +195,8 @@ voice = Voice(client) voice.get_call(uuid) ``` -### Update a call - -```python -response = client.update_call(uuid, action='hangup') -``` - -Docs: [https://developer.nexmo.com/api/voice#updateCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#updateCall) -**with voice class** +### Update a call ```python from nexmo import Client, Voice @@ -253,17 +210,8 @@ response = voice.create_all({ voice.update_call(response['uuid'], action='hangup') ``` -### Stream audio to a call - -```python -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = client.send_audio(uuid, stream_url=[stream_url]) -``` - -Docs: [https://developer.nexmo.com/api/voice#startStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startStream) - -**with voice class** +### Stream audio to a call ```python from nexmo import Client, Voice @@ -278,15 +226,8 @@ response = voice.create_call({ voice.send_audio(response['uuid'],stream_url=[stream_url]) ``` -### Stop streaming audio to a call - -```python -response = client.stop_audio(uuid) -``` -Docs: [https://developer.nexmo.com/api/voice#stopStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopStream) - -**Using voice class** +### Stop streaming audio to a call ```python from nexmo import Client, Voice @@ -302,15 +243,8 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) voice.stop_audio(response['uuid']) ``` -### Send a synthesized speech message to a call - -```python -response = client.send_speech(uuid, text='Hello') -``` - -Docs: [https://developer.nexmo.com/api/voice#startTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startTalk) -**Using voice class** +### Send a synthesized speech message to a call ```python from nexmo import Client, Voice @@ -324,17 +258,8 @@ response = voice.create_call({ voice.send_speech(response['uuid'], text='Hello from nexmo') ``` - ### Stop sending a synthesized speech message to a call -```python -response = client.stop_speech(uuid) -``` - -Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopTalk) - -**Using voice class** - ```python >>> from nexmo import Client, Voice >>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) @@ -350,13 +275,6 @@ Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.c ### Send DTMF tones to a call -```python -response = client.send_dtmf(uuid, digits='1234') -``` - -Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startDTMF) - -**Using voice class** ```python from nexmo import Client, Voice @@ -370,7 +288,6 @@ response = voice.create_call({ voice.send_dtmf(response['uuid'], digits='1234') ``` - ### Get recording ```python @@ -584,6 +501,32 @@ print(client.api_host()) # returns api.nexmo.com client.api_host('myapi.nexmo.com') # rewrite the value of api_host ``` +## Frequently Asked Questions + +### Supported APIs + +The following is a list of Vonage APIs and whether the Python SDK provides support for them: + +| API | API Release Status | Supported? +|----------|:---------:|:-------------:| +| Account API | General Availability |✅| +| Alerts API | General Availability |✅| +| Application API | General Availability |✅| +| Audit API | Beta |❌| +| Conversation API | Beta |❌| +| Dispatch API | Beta |❌| +| External Accounts API | Beta |❌| +| Media API | Beta | ❌| +| Messages API | Beta |❌| +| Number Insight API | General Availability |✅| +| Number Management API | General Availability |✅| +| Pricing API | General Availability |✅| +| Redact API | General Availability |✅| +| Reports API | Beta |❌| +| SMS API | General Availability |✅| +| Verify API | General Availability |✅| +| Voice API | General Availability |✅| + ## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! From 7a278cd6ba361970ffa3cb814c0f0fb044c55b41 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 12 Aug 2020 13:58:47 -0400 Subject: [PATCH 048/401] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2933a075..d6e15670 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Managing Secrets](#managing-secrets) * [Application API](#application-api) * [Overriding API Attributes](#overriding-api-attributes) -* [Frequently Asked Questions](#frecuently-asked-questions) +* [Frequently Asked Questions](#frequently-asked-questions) * [License](#license) From 0896a892e6ab6fddef7feef8a0108892254d6b77 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 12 Aug 2020 14:06:28 -0400 Subject: [PATCH 049/401] Adding Verify Class to Readme --- README.md | 159 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 114 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index d6e15670..106d8062 100644 --- a/README.md +++ b/README.md @@ -112,22 +112,8 @@ sms.send_message({ }) ``` - ### Send SMS with unicode -```python -responseData = client.send_message({ - 'from': NEXMO_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'こんにちは世界', - 'type': 'unicode', -}) -``` - -Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms-with-unicode) - -**Using Sms Class** - ```python sms.send_message({ 'from': NEXMO_BRAND_NAME, @@ -139,12 +125,6 @@ sms.send_message({ ### Submit SMS Conversion -```python -client.submit_sms_conversion("a-message-id") -``` - -**With the SMS Class** - ```python from nexmo import Client, Sms client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) @@ -173,8 +153,6 @@ voice.create_all({ }) ``` -Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) - ### Retrieve a list of calls ```python @@ -294,55 +272,146 @@ voice.send_dtmf(response['uuid'], digits='1234') response = client.get_recording(RECORDING_URL) ``` -## Verify API +# Verify Class + +## Creating an instance of the class -### Start a verification +To create an instance of the Verify class, Just follow these steps: + +- **Import the class from module** (3 different ways) ```python -response = client.start_verification(number='441632960960', brand='MyApp') +#First way +from nexmo import Verify + +#Second way +from nexmo.verify import Verify + +#Third valid way +import nexmo #then use nexmo.Verify() to create an instance +``` + +- **Create the instance** + +```python +#First way - pass key and secret to the constructor +verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +​ +#Second way - Create a client instance and then pass the client to the Verify contructor +client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +verify = Verify(client) +``` + +### Search for a Verification request + +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.search('69e2626cbc23451fbbc02f627a959677') -if response['status'] == '0': - print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) +if response is not None: + print(response['status']) +``` + +### Send verification code + +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') + +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) +### Send verification code with workflow -The response contains a verification request id which you will need to -store temporarily (in the session, database, url, etc). +```python +client = Client(key='API_KEY', secret='API_SECRET') -### Check a verification +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) + +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Check verification code ```python -response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.check(REQUEST_ID, code=CODE) -if response['status'] == '0': - print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) +### Cancel Verification Request -The verification request id comes from the call to the start_verification method. -The PIN code is entered into your application by the user. +```python +client = Client(key='API_KEY', secret='API_SECRET') -### Cancel a verification +verify = Verify(client) +response = verify.cancel(REQUEST_ID) + +if response["status"] == "0": + print("Cancellation successful") +else: + print("Error: %s" % response["error_text"]) +``` + +### Trigger next verification proccess ```python -client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.trigger_next_event(REQUEST_ID) + +if response["status"] == "0": + print("Next verification stage triggered") +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +### Send payment authentication code + +```python +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) + +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` -### Trigger next verification step +### Send payment authentication code with workflow ```python -client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') +client = Client(key='API_KEY', secret='API_SECRET') + +verify = Verify(client) +verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) + +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) ## Number Insight API From 5120dc6d1803e49b040fe210bfce40d05258b2b4 Mon Sep 17 00:00:00 2001 From: superdiana Date: Thu, 13 Aug 2020 21:34:59 -0400 Subject: [PATCH 050/401] Adding Support for Python 3.8 --- .travis.yml | 5 +++-- setup.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index f48401dc..9b197592 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,10 +4,11 @@ python: - "3.4" - "3.5" - "3.6" -# Enable 3.7 without globally enabling sudo and dist: xenial for other build jobs + - "3.7" +# Enable 3.8 without globally enabling sudo and dist: xenial for other build jobs matrix: include: - - python: 3.7 + - python: 3.8 dist: xenial sudo: true diff --git a/setup.py b/setup.py index f3793bda..01c90b82 100644 --- a/setup.py +++ b/setup.py @@ -32,5 +32,6 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], ) From 5e9c9cbac5f9671fc5bcf4a87368b90de4db4099 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Fri, 14 Aug 2020 09:54:36 -0400 Subject: [PATCH 051/401] correcting spacing --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 01c90b82..d9789d40 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,6 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.8", ], ) From 2290728e40cbc2f660d8cbb00fb722e2c61af2ac Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Fri, 14 Aug 2020 09:57:58 -0400 Subject: [PATCH 052/401] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d9789d40..9907da31 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,6 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.8", ], ) From b8f7fd525012535b7ca3e8f2d549b2fcbcb12527 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Tue, 18 Aug 2020 20:33:26 -0400 Subject: [PATCH 053/401] Updates on dropping Python 2.7 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 106d8062..1d03e7bf 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,10 @@ client.api_host('myapi.nexmo.com') # rewrite the value of api_host ``` ## Frequently Asked Questions +### Dropping support for Python 2.7 +Back in 2014 when Guido van Rossum, Python's creator and principal author, made the announcement, January 1, 2020 seemed pretty far away. Python 2.7’s sunset has happened, after which there’ll be absolutely no more support from the core Python team. Many utilized projects pledge to drop Python 2 support in or before 2020. [(Official statement here)](https://www.python.org/doc/sunset-python-2/). + +Just because 2.7 isn’t going to be maintained past 2020 doesn’t mean your applications or libraries suddenly stop working but as of this moment we won't give official support for upcoming releases. Please read the official ["Porting Python 2 Code to Python 3" guide](https://docs.python.org/3/howto/pyporting.html). Please also read the [Python 3 Statement Practicalities](https://python3statement.org/practicalities/) for advice on sunsetting your Python 2 code. ### Supported APIs From 69b1d9a8e7cd84a6ade884966079bab9b4846820 Mon Sep 17 00:00:00 2001 From: superdiana Date: Fri, 21 Aug 2020 11:48:53 -0400 Subject: [PATCH 054/401] Release v2.5.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f3793bda..ec282f1d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="nexmo", - version="2.4.0", + version="2.5.0", description="Nexmo Client Library for Python", long_description=long_description, long_description_content_type="text/markdown", From a1fa66afc48748b2aaecafb478d8440d55c771e5 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 10:01:10 -0400 Subject: [PATCH 055/401] 2.5.1 Patch --- README.md | 475 +++++++++++++-------------------- setup.py | 3 +- src/nexmo/__init__.py | 293 +++++++++++++++----- src/nexmo/_internal.py | 11 +- src/nexmo/sms.py | 51 ---- src/nexmo/verify.py | 51 ---- src/nexmo/voice.py | 117 -------- tests/conftest.py | 29 -- tests/test_getters_setters.py | 24 -- tests/test_nexmo.py | 3 +- tests/test_sms.py | 20 +- tests/test_verify.py | 69 ++++- tests/test_voice.py | 46 ++-- tests/test_voice_deprecated.py | 12 +- 14 files changed, 508 insertions(+), 696 deletions(-) delete mode 100644 src/nexmo/sms.py delete mode 100644 src/nexmo/verify.py delete mode 100644 src/nexmo/voice.py delete mode 100644 tests/test_getters_setters.py diff --git a/README.md b/README.md index 1d03e7bf..c4130afe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# Nexmo Client Library for Python +Nexmo Client Library for Python +=============================== [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) [![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) @@ -6,10 +7,11 @@ [![Python versions supported](https://img.shields.io/pypi/pyversions/nexmo.svg)](https://pypi.python.org/pypi/nexmo) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +Nexmo is now known as Vonage + This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. - * [Installation](#installation) * [Usage](#usage) * [SMS API](#sms-api) @@ -19,12 +21,12 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. * [Number Management API](#number-management-api) * [Managing Secrets](#managing-secrets) * [Application API](#application-api) -* [Overriding API Attributes](#overriding-api-attributes) -* [Frequently Asked Questions](#frequently-asked-questions) +* [Overriding API url's](#overriding-api-urls) * [License](#license) -## Installation +Installation +------------ To install the Python client library using pip: @@ -40,7 +42,9 @@ Alternatively, you can clone the repository via the command line: or by opening it on GitHub desktop. -## Usage + +Usage +----- Begin by importing the `nexmo` module: @@ -68,395 +72,297 @@ To check signatures for incoming webhook requests, you'll also need to specify the `signature_secret` argument (or the `NEXMO_SIGNATURE_SECRET` environment variable). + ## SMS API -### SMS Class +### Send a text message -#### Creating an instance of the SMS class +```python +response = client.send_message({'from': 'Python', 'to': 'YOUR-NUMBER', 'text': 'Hello world'}) -To create an instance of the SMS class follow these steps: +response = response['messages'][0] -- Import the class +if response['status'] == '0': + print('Sent message', response['message-id']) -```python -#Option 1 -from nexmo import Sms + print('Remaining balance is', response['remaining-balance']) +else: + print('Error:', response['error-text']) +``` -#Option 2 -from nexmo.sms import Sms +Docs: [https://developer.nexmo.com/api/sms#send-an-sms](https://developer.nexmo.com/api/sms?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#send-an-sms) -#Option 3 -import nexmo #then tou can use nexmo.Sms() to create an instance -``` +### Tell Nexmo the SMS was received -- Create an instance +The following submits a successful conversion to Nexmo with the current timestamp. This feature must +be enabled on your account first. ```python -#Option 1 - pass key and secret to the constructor -sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) - -#Option 2 - Create a client instance and then pass the client to the Sms instance -client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -sms = Sms(client) +response = client.submit_sms_conversion(message_id) ``` +### Signing a Message -### Send an SMS +*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages).* -```python -from nexmo import Sms -sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -sms.send_message({ - "from": NEXMO_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Nexmo SMS API", -}) -``` +The SMS API supports the ability to sign messages by generating and adding a signature using a "Signature Secret" rather than your API secret. The algorithms supported are: -### Send SMS with unicode +md5hash1 +md5 +sha1 +sha256 +sha512 + +Both your application and Nexmo need to agree on which algorithm is used. In the dashboard, visit your account settings page and under "API Settings" you can select the algorithm to use. This is also the location where you will find your "Signature Secret" (it's different from the API secret). + +### Create a client using these credentials and the algorithm to use, for example: ```python -sms.send_message({ - 'from': NEXMO_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'こんにちは世界', - 'type': 'unicode', -}) +client = nexmo.Client( + key = os.getenv('NEXMO_API_KEY'), + signature_secret = os.getenv('NEXMO_SIGNATURE_SECRET'), + signature_method = 'sha256' +) ``` -### Submit SMS Conversion +Using this client, your SMS API messages will be sent as signed messages. + +### Verifying an Incoming Message Signature + +*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)*. + +If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. + +To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. + +Then use the `check_signature()` method with the actual signature that was received (usually present in request.form or request.args. you can merge those in a single variable called params) to make sure that it is correct. + +### Get the params ```python -from nexmo import Client, Sms -client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) -sms = Sms(client) -response = sms.send_message({ - 'from': NEXMO_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'Hi from Vonage' -}) -sms.submit_sms_conversion(response['message-id']) +if request.is_json: + params = request.get_json() +else: + params = request.args or request.form +is_valid = client.check_signature(params)// is it valid? Will be true or false ``` +Using your signature secret and the other supplied parameters, the signature can be calculated and checked against the incoming signature value. + ## Voice API ### Make a call - ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -voice.create_all({ +response = client.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) ``` +Docs: [https://developer.nexmo.com/api/voice#createCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#createCall) + ### Retrieve a list of calls ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -voice.get_calls() +response = client.get_calls() ``` +Docs: [https://developer.nexmo.com/api/voice#getCalls](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCalls) ### Retrieve a single call - ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -voice.get_call(uuid) +response = client.get_call(uuid) ``` +Docs: [https://developer.nexmo.com/api/voice#getCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCall) ### Update a call ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -response = voice.create_all({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -voice.update_call(response['uuid'], action='hangup') +response = client.update_call(uuid, action='hangup') ``` +Docs: [https://developer.nexmo.com/api/voice#updateCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#updateCall) ### Stream audio to a call ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -voice.send_audio(response['uuid'],stream_url=[stream_url]) + +response = client.send_audio(uuid, stream_url=[stream_url]) ``` +Docs: [https://developer.nexmo.com/api/voice#startStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startStream) ### Stop streaming audio to a call ```python -from nexmo import Client, Voice -client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') -voice = Voice(client) -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -voice.send_audio(response['uuid'],stream_url=[stream_url]) -voice.stop_audio(response['uuid']) +response = client.stop_audio(uuid) ``` +Docs: [https://developer.nexmo.com/api/voice#stopStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopStream) ### Send a synthesized speech message to a call ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -response = voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -voice.send_speech(response['uuid'], text='Hello from nexmo') +response = client.send_speech(uuid, text='Hello') ``` +Docs: [https://developer.nexmo.com/api/voice#startTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startTalk) + ### Stop sending a synthesized speech message to a call ```python ->>> from nexmo import Client, Voice ->>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) ->>> voice = Voice(client) ->>> response = voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) ->>> voice.send_speech(response['uuid'], text='Hello from nexmo') ->>> voice.stop_speech(response['uuid']) +response = client.stop_speech(uuid) ``` -### Send DTMF tones to a call +Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopTalk) +### Send DTMF tones to a call ```python -from nexmo import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -response = voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -voice.send_dtmf(response['uuid'], digits='1234') +response = client.send_dtmf(uuid, digits='1234') ``` +Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startDTMF) + ### Get recording -```python +``` python response = client.get_recording(RECORDING_URL) ``` -# Verify Class - -## Creating an instance of the class -To create an instance of the Verify class, Just follow these steps: +## Verify API -- **Import the class from module** (3 different ways) +### Start a verification ```python -#First way -from nexmo import Verify - -#Second way -from nexmo.verify import Verify - -#Third valid way -import nexmo #then use nexmo.Verify() to create an instance -``` - -- **Create the instance** +response = client.start_verification(number='441632960960', brand='MyApp') -```python -#First way - pass key and secret to the constructor -verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -​ -#Second way - Create a client instance and then pass the client to the Verify contructor -client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -verify = Verify(client) +if response['status'] == '0': + print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) +else: + print('Error:', response['error_text']) ``` -### Search for a Verification request +Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.search('69e2626cbc23451fbbc02f627a959677') +The response contains a verification request id which you will need to +store temporarily (in the session, database, url, etc). -if response is not None: - print(response['status']) -``` - -### Send verification code +### Check a verification ```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') +response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) +if response['status'] == '0': + print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) else: - print("Error: %s" % response["error_text"]) + print('Error:', response['error_text']) ``` -### Send verification code with workflow +Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) -```python -client = Client(key='API_KEY', secret='API_SECRET') +The verification request id comes from the call to the start_verification method. +The PIN code is entered into your application by the user. -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +### Cancel a verification -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +```python +client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') ``` -### Check verification code +Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) -```python -client = Client(key='API_KEY', secret='API_SECRET') +### Trigger next verification step -verify = Verify(client) -response = verify.check(REQUEST_ID, code=CODE) - -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) +```python +client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') ``` -### Cancel Verification Request +Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) -```python -client = Client(key='API_KEY', secret='API_SECRET') +## Number Insight API -verify = Verify(client) -response = verify.cancel(REQUEST_ID) +### Basic Number Insight -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) +```python +client.get_basic_number_insight(number='447700900000') ``` -### Trigger next verification proccess - -```python -client = Client(key='API_KEY', secret='API_SECRET') +Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) -verify = Verify(client) -response = verify.trigger_next_event(REQUEST_ID) +### Standard Number Insight -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) +```python +client.get_standard_number_insight(number='447700900000') ``` -### Send payment authentication code - -```python -client = Client(key='API_KEY', secret='API_SECRET') +Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) -verify = Verify(client) -response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +### Advanced Number Insight -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +```python +client.get_advanced_number_insight(number='447700900000') ``` -### Send payment authentication code with workflow +Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) -```python -client = Client(key='API_KEY', secret='API_SECRET') +## Number Management API -verify = Verify(client) -verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +### List Your Numbers -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +```python +client.get_account_numbers() ``` +Docs: [https://developer.nexmo.com/api/numbers#getOwnedNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getOwnedNumbers) -## Number Insight API - -### Basic Number Insight +### Search for a Number ```python -client.get_basic_number_insight(number='447700900000') +client.get_available_numbers('GB', {"type":"SMS"}) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) +Docs: [https://developer.nexmo.com/api/numbers#getAvailableNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getAvailableNumbers) -### Standard Number Insight +### Buy a Number ```python -client.get_standard_number_insight(number='447700900000') +client.buy_number({"country": 'GB', "msisdn": '447700900000'}) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) +Docs: [https://developer.nexmo.com/api/numbers#buyANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#buyANumber) -### Advanced Number Insight +### Cancel a Number ```python -client.get_advanced_number_insight(number='447700900000') +client.cancel_number({"country": 'GB', "msisdn": '447700900000'}) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +Docs: [https://developer.nexmo.com/api/numbers#cancelANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#cancelANumber) ## Managing Secrets -An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. + An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. ### List Secrets -```python + ```python secrets = client.list_secrets(API_KEY) ``` ### Create A New Secret -Create a new secret (the created dates will help you know which is which): - -```python + Create a new secret (the created dates will help you know which is which): + ```python client.create_secret(API_KEY, 'awes0meNewSekret!!;'); ``` + ### Delete A Secret Delete the old secret (any application still using these credentials will stop working): @@ -465,6 +371,7 @@ Delete the old secret (any application still using these credentials will stop w client.delete_secret(API_KEY, 'my-secret-id') ``` + ## Application API ### Create an application @@ -507,6 +414,7 @@ response = client.application_v2.delete_application(uuid) Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) + ## Validate webhook signatures ```python @@ -523,6 +431,7 @@ Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://dev Note: you'll need to contact support@nexmo.com to enable message signing on your account before you can validate webhook signatures. + ## JWT parameters By default, the library generates short-lived tokens for JWT authentication. @@ -534,73 +443,52 @@ specify a different token identifier: client.auth(nbf=nbf, exp=exp, jti=jti) ``` -## Overriding API Attributes +## Overriding API url's -In order to rewrite/get the value of variables used across all the Nexmo classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. +By default, our API url's are hardcoded. For use cases where these url's are not accessible, best practices to override these url's are the following: -An example using setters/getters with `Object references`: +- Setting new API url's when creating an instance of the client: ```python -from nexmo import Client, Sms - -#Defines the client -client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # using getter for host -- value returned: rest.nexmo.com - -#Define the sms instance -sms = Sms(client) - -#Change the value in client -client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms - +import nexmo +client = nexmo.Client() +client.host = 'new.host.url' +client.api_host = 'new.api.host' ``` - -### Overriding API Host / Host Attributes - -These attributes are private in the client class and the only way to access them is using the getters/setters we provide. - +- Creating a new class that extends from client class and overrides these values in the constructor: ```python -from nexmo import Client +class MyClient(nexmo.Client): + def __init__(self, NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH): + super().__init__(application_id=APPLICATION_ID, private_key=APPLICATION_PRIVATE_KEY_PATH, key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) + self.host = 'new.hosts.url' + self.api_host = 'new.api.hosts' -client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # return rest.nexmo.com -client.host('mio.nexmo.com') # rewrites the host value to mio.nexmo.com -print(client.api_host()) # returns api.nexmo.com -client.api_host('myapi.nexmo.com') # rewrite the value of api_host +#usage +client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) ``` -## Frequently Asked Questions -### Dropping support for Python 2.7 -Back in 2014 when Guido van Rossum, Python's creator and principal author, made the announcement, January 1, 2020 seemed pretty far away. Python 2.7’s sunset has happened, after which there’ll be absolutely no more support from the core Python team. Many utilized projects pledge to drop Python 2 support in or before 2020. [(Official statement here)](https://www.python.org/doc/sunset-python-2/). +For a more specific case, another way to customise is: -Just because 2.7 isn’t going to be maintained past 2020 doesn’t mean your applications or libraries suddenly stop working but as of this moment we won't give official support for upcoming releases. Please read the official ["Porting Python 2 Code to Python 3" guide](https://docs.python.org/3/howto/pyporting.html). Please also read the [Python 3 Statement Practicalities](https://python3statement.org/practicalities/) for advice on sunsetting your Python 2 code. - -### Supported APIs +```python +import nexmo -The following is a list of Vonage APIs and whether the Python SDK provides support for them: +class NexmoClient(nexmo.Client): + def __init__(....): + super().__init__(....) + api_server = BasicAuthenticatedServer( + "mycustomurl", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) +``` -| API | API Release Status | Supported? -|----------|:---------:|:-------------:| -| Account API | General Availability |✅| -| Alerts API | General Availability |✅| -| Application API | General Availability |✅| -| Audit API | Beta |❌| -| Conversation API | Beta |❌| -| Dispatch API | Beta |❌| -| External Accounts API | Beta |❌| -| Media API | Beta | ❌| -| Messages API | Beta |❌| -| Number Insight API | General Availability |✅| -| Number Management API | General Availability |✅| -| Pricing API | General Availability |✅| -| Redact API | General Availability |✅| -| Reports API | Beta |❌| -| SMS API | General Availability |✅| -| Verify API | General Availability |✅| -| Voice API | General Availability |✅| +Then proceed to create your personalised instance of the class. -## Contributing +Contributing +------------ We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! @@ -616,7 +504,8 @@ The tests are all written with pytest. You run them with: make test ``` -## License +License +------- This library is released under the [MIT License][license]. diff --git a/setup.py b/setup.py index f3b7f6f4..f3793bda 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="nexmo", - version="2.5.0", + version="2.4.0", description="Nexmo Client Library for Python", long_description=long_description, long_description_content_type="text/markdown", @@ -32,6 +32,5 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", ], ) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 1eb0cd92..7d48c3b3 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,8 +1,5 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * -from .voice import * -from .sms import * -from .verify import * from datetime import datetime import logging from platform import python_version @@ -18,10 +15,8 @@ import time from uuid import uuid4 import warnings -import re - -string_types = (str, bytes) +string_types = (str, bytes) from urllib.parse import urlparse try: @@ -98,12 +93,10 @@ def __init__( if isinstance(self.private_key, string_types) and "\n" not in self.private_key: with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() - - self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' - self.__host = "rest.nexmo.com" + self.host = "rest.nexmo.com" - self.__api_host = "api.nexmo.com" + self.api_host = "api.nexmo.com" user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() @@ -128,111 +121,201 @@ def __init__( self.session = requests.Session() - # Internal Verify Object - a method that return a verify instance, just for cool definitions - self.Verify = Verify(self) - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for host') - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for api_host') - else: - self.__api_host = value - def auth(self, params=None, **kwargs): self.auth_params = params or kwargs + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_NEXMO_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host, "/sms/json", params, supports_signature_auth=True) + def get_balance(self): - return self.get(self.host(), "/account/get-balance") + return self.get(self.host, "/account/get-balance") def get_country_pricing(self, country_code): return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} + self.host, "/account/get-pricing/outbound", {"country": country_code} ) def get_prefix_pricing(self, prefix): return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} ) def get_sms_pricing(self, number): return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} ) def get_voice_pricing(self, number): return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} ) def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) + return self.post(self.host, "/account/settings", params or kwargs) def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) + return self.post(self.host, "/account/top-up", params or kwargs) def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) + return self.get(self.host, "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) + self.host, "/number/search", dict(params or kwargs, country=country_code) ) def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) + return self.post(self.host, "/number/buy", params or kwargs) def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) + return self.post(self.host, "/number/cancel", params or kwargs) def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) + return self.post(self.host, "/number/update", params or kwargs) def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) + return self.get(self.host, "/search/message", {"id": message_id}) def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) + return self.get(self.host, "/search/rejections", params or kwargs) def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) + return self.get(self.host, "/search/messages", params or kwargs) def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) + return self.post(self.host, "/ussd/json", params or kwargs) def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + return self.post(self.host, "/ussd-prompt/json", params or kwargs) def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + return self.post(self.host, "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host, "/conversions/sms", params) def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + return self.post(self.host, "/sc/us/alert/json", params or kwargs) def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + return self.post(self.host, "/sc/us/marketing/json", params or kwargs) def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + return self.get(self.host, "/sc/us/alert/opt-in/query/json") def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) + return self.post(self.host, "/sc/us/alert/opt-in/manage/json", params or kwargs) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host, "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host, "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host, "/tts-prompt/json", params or kwargs) + + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host, "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#send_verification_request is deprecated (use #start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host, "/verify/json", params or kwargs) + + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host, + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host, "/verify/psd2/json", params or kwargs) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#check_verification_request is deprecated (use #check_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host, "/verify/check/json", params or kwargs) + + def get_verification(self, request_id): + return self.get( + self.api_host, "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "nexmo.Client#get_verification_request is deprecated (use #get_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host, "/verify/search/json", {"request_id": request_id} + ) + + def cancel_verification(self, request_id): + return self.post( + self.api_host, + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host, + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host, "/verify/control/json", params or kwargs) def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + return self.get(self.api_host, "/ni/basic/json", params or kwargs) def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + return self.get(self.api_host, "/ni/standard/json", params or kwargs) def get_number_insight(self, params=None, **kwargs): warnings.warn( @@ -241,20 +324,20 @@ def get_number_insight(self, params=None, **kwargs): stacklevel=2, ) - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + return self.get(self.api_host, "/number/lookup/json", params or kwargs) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams: - return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) + return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) else: raise ClientError("Error: Callback needed for async advanced number insight") def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + return self.get(self.api_host, "/ni/advanced/json", params or kwargs) def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) + return self.post(self.host, "/ni/json", params or kwargs) def get_applications(self, params=None, **kwargs): warnings.warn( @@ -262,7 +345,7 @@ def get_applications(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) + return self.get(self.api_host, "/v1/applications", params or kwargs) def get_application(self, application_id): warnings.warn( @@ -271,7 +354,7 @@ def get_application(self, application_id): stacklevel=2, ) return self.get( - self.api_host(), + self.api_host, "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -281,7 +364,7 @@ def create_application(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) + return self.post(self.api_host, "/v1/applications", params or kwargs) def update_application(self, application_id, params=None, **kwargs): warnings.warn( @@ -290,7 +373,7 @@ def update_application(self, application_id, params=None, **kwargs): stacklevel=2, ) return self.put( - self.api_host(), + self.api_host, "/v1/applications/{application_id}".format(application_id=application_id), params or kwargs, ) @@ -302,10 +385,45 @@ def delete_application(self, application_id): stacklevel=2, ) return self.delete( - self.api_host(), + self.api_host, "/v1/applications/{application_id}".format(application_id=application_id), ) + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -314,18 +432,18 @@ def redact_transaction(self, id, product, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) + return self._post_json(self.api_host, "/v1/redact/transaction", params) def list_secrets(self, api_key): return self.get( - self.api_host(), + self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True, ) def get_secret(self, api_key, secret_id): return self.get( - self.api_host(), + self.api_host, "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -335,12 +453,12 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body ) def delete_secret(self, api_key, secret_id): return self.delete( - self.api_host(), + self.api_host, "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -540,6 +658,43 @@ def parse(self, host, response): ) raise ServerError(message) + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host, request_uri=request_uri + ) + + return self.parse( + self.api_host, + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host, request_uri=request_uri + ) + + return self.parse( + self.api_host, self.session.post(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host, request_uri=request_uri + ) + + return self.parse( + self.api_host, self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host, request_uri=request_uri + ) + + return self.parse( + self.api_host, self.session.delete(uri, headers=self._headers()) + ) + def _headers(self): token = self.generate_application_jwt() return dict(self.headers, Authorization=b"Bearer " + token) diff --git a/src/nexmo/_internal.py b/src/nexmo/_internal.py index dee34ba2..a32921f0 100644 --- a/src/nexmo/_internal.py +++ b/src/nexmo/_internal.py @@ -13,10 +13,9 @@ class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret, timeout=None): + def __init__(self, host, user_agent, api_key, api_secret): self._host = host self._session = session = Session() - self.timeout = None session.auth = (api_key, api_secret) # Basic authentication. session.headers.update({"User-Agent": user_agent}) @@ -25,22 +24,22 @@ def _uri(self, path): def get(self, path, params=None, headers=None): return self._parse( - self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) + self._session.get(self._uri(path), params=params, headers=headers) ) def post(self, path, body=None, headers=None): return self._parse( - self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) + self._session.post(self._uri(path), json=body, headers=headers) ) def put(self, path, body=None, headers=None): return self._parse( - self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) + self._session.put(self._uri(path), json=body, headers=headers) ) def delete(self, path, body=None, headers=None): return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) + self._session.delete(self._uri(path), json=body, headers=headers) ) def _parse(self, response): diff --git a/src/nexmo/sms.py b/src/nexmo/sms.py deleted file mode 100644 index e0f3460c..00000000 --- a/src/nexmo/sms.py +++ /dev/null @@ -1,51 +0,0 @@ -import nexmo, pytz -from datetime import datetime -from ._internal import _format_date_param - -class Sms: - #To init Sms class pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = nexmo.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc) - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self._client.post(self._client.api_host(), "/conversions/sms", params) diff --git a/src/nexmo/verify.py b/src/nexmo/verify.py deleted file mode 100644 index b724fdbb..00000000 --- a/src/nexmo/verify.py +++ /dev/null @@ -1,51 +0,0 @@ -import nexmo -import warnings - -class Verify: - def __init__( - self, - client=None, - key=None, - secret=None - ): - try: - self._client = client - if self._client is None: - self._client = nexmo.Client( - key=key, - secret=secret - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def start_verification(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/verify/json", params or kwargs) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def psd2(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/verify/psd2/json", params or kwargs) \ No newline at end of file diff --git a/src/nexmo/voice.py b/src/nexmo/voice.py deleted file mode 100644 index b407009a..00000000 --- a/src/nexmo/voice.py +++ /dev/null @@ -1,117 +0,0 @@ -import nexmo - -class Voice(): - #application_id and private_key are needed for the calling methods - #Passing a Nexmo Client is also possible - def __init__( - self, - client=None, - application_id=None, - private_key=None, - ): - try: - # Client is protected - self._client = client - if self._client is None: - self._client = nexmo.Client(application_id=application_id, private_key=private_key) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - # Creates a new call session - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - # Get call history paginated. Pass start and end dates to filter the retrieved information - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - # Get a single call record by identifier - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - # Update call data using custom ncco - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - # Plays audio streaming into call in progress - stream_url parameter is required - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - # Play an speech into specified call - text parameter (text to speech) is required - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - # plays DTMF tones into the specified call - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - # Stops audio recently played into specified call - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - # Stop a speech recently played into specified call - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - # Deprecated section - # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory - def initiate_call(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) - # End deprecated section - - # Utils methods - # _jwt_signed_post private method that Allows developer perform signed post request - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - # Uses the client session to perform the call action with api - return self._client.parse( - self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) - ) - - # _jwt_signed_post private method that Allows developer perform signed get request - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), - self._client.session.get(uri, params=params or {}, headers=self._client._headers()), - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) - ) diff --git a/tests/conftest.py b/tests/conftest.py index a74d689e..8bbf454f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,8 +27,6 @@ def __init__(self): self.user_agent = "nexmo-python/{} python/{}".format( nexmo.__version__, platform.python_version() ) - self.host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" @pytest.fixture(scope="session") @@ -46,30 +44,3 @@ def client(dummy_data): application_id=dummy_data.application_id, private_key=dummy_data.private_key, ) - -#Represents an instance of the Voice class for testing -@pytest.fixture -def voice(client, dummy_data): - import nexmo - - return nexmo.Voice( - client - ) - -#Represents an instance of the Sms class for testing -@pytest.fixture -def sms(client, dummy_data): - import nexmo - - return nexmo.Sms( - client - ) - -#Represents an instance of the Verify class for testing -@pytest.fixture -def verify(client, dummy_data): - import nexmo - - return nexmo.Verify( - client - ) \ No newline at end of file diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py deleted file mode 100644 index 30abda7c..00000000 --- a/tests/test_getters_setters.py +++ /dev/null @@ -1,24 +0,0 @@ -from util import * - -@responses.activate -def test_getters(client, dummy_data): - assert client.host() == dummy_data.host - assert client.api_host() == dummy_data.api_host - -@responses.activate -def test_setters(client, dummy_data): - try: - client.host('host.nexmo.com') - client.api_host('host.nexmo.com') - assert client.host() != dummy_data.host - assert client.api_host() != dummy_data.api_host - except: - assert False - -@responses.activate -def test_fail_setter_url_format(client, dummy_data): - try: - client.host('1000.1000') - assert False - except: - assert True \ No newline at end of file diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 3dd46c6f..3ef895d3 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -190,8 +190,7 @@ def test_client_can_make_application_requests_without_api_key(dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") client = nexmo.Client(application_id="myid", private_key=dummy_data.private_key) - voice = nexmo.Voice(client) - voice.create_call("123455") + client.create_call("123455") @responses.activate diff --git a/tests/test_sms.py b/tests/test_sms.py index fda960e8..8d3b18ad 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -3,12 +3,12 @@ @responses.activate -def test_send_message(sms, dummy_data): +def test_send_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sms/json") params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - assert isinstance(sms.send_message(params), dict) + assert isinstance(client.send_message(params), dict) assert request_user_agent() == dummy_data.user_agent assert "from=Python" in request_body() assert "to=447525856424" in request_body() @@ -16,37 +16,37 @@ def test_send_message(sms, dummy_data): @responses.activate -def test_authentication_error(sms): +def test_authentication_error(client): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) with pytest.raises(nexmo.AuthenticationError): - sms.send_message({}) + client.send_message({}) @responses.activate -def test_client_error(sms): +def test_client_error(client): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) with pytest.raises(nexmo.ClientError) as excinfo: - sms.send_message({}) + client.send_message({}) excinfo.match(r"400 response from rest.nexmo.com") @responses.activate -def test_server_error(sms): +def test_server_error(client): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) with pytest.raises(nexmo.ServerError) as excinfo: - sms.send_message({}) + client.send_message({}) excinfo.match(r"500 response from rest.nexmo.com") @responses.activate -def test_submit_sms_conversion(sms): +def test_submit_sms_conversion(client): responses.add( responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" ) - sms.submit_sms_conversion("a-message-id") + client.submit_sms_conversion("a-message-id") assert "message-id=a-message-id" in request_body() assert "timestamp" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py index 08e0aed5..d35942e3 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -2,23 +2,35 @@ @responses.activate -def test_start_verification(verify, dummy_data): +def test_start_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(verify.start_verification(params), dict) + assert isinstance(client.start_verification(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() @responses.activate -def test_check_verification(verify, dummy_data): +def test_send_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.send_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_check_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/check/json") assert isinstance( - verify.check("8g88g88eg8g8gg9g90", code="123445"), dict + client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict ) assert request_user_agent() == dummy_data.user_agent assert "code=123445" in request_body() @@ -26,42 +38,75 @@ def test_check_verification(verify, dummy_data): @responses.activate -def test_get_verification(verify, dummy_data): +def test_check_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.check_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_get_verification(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/verify/search/json") - assert isinstance(verify.search("xxx"), dict) + assert isinstance(client.get_verification("xxx"), dict) assert request_user_agent() == dummy_data.user_agent assert "request_id=xxx" in request_query() @responses.activate -def test_cancel_verification(verify, dummy_data): +def test_get_verification_request(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification_request("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_cancel_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) + assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) assert request_user_agent() == dummy_data.user_agent assert "cmd=cancel" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() @responses.activate -def test_trigger_next_verification_event(verify, dummy_data): +def test_trigger_next_verification_event(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/control/json") assert isinstance( - verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict + client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict ) assert request_user_agent() == dummy_data.user_agent assert "cmd=trigger_next_event" in request_body() assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_control_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.control_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + @responses.activate -def test_start_psd2_verification(verify, dummy_data): +def test_start_psd2_verification(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") params = {"number": "447525856424", "brand": "MyApp"} - assert isinstance(verify.psd2(params), dict) + assert isinstance(client.start_psd2_verification_request(params), dict) assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file diff --git a/tests/test_voice.py b/tests/test_voice.py index bae67983..9b7e4f68 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -8,7 +8,7 @@ @responses.activate -def test_create_call(voice, dummy_data): +def test_create_call(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") params = { @@ -17,45 +17,45 @@ def test_create_call(voice, dummy_data): "answer_url": ["https://example.com/answer"], } - assert isinstance(voice.create_call(params), dict) + assert isinstance(client.create_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" @responses.activate -def test_get_calls(voice, dummy_data): +def test_get_calls(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls") - assert isinstance(voice.get_calls(), dict) + assert isinstance(client.get_calls(), dict) assert request_user_agent() == dummy_data.user_agent assert_re(r"\ABearer ", request_authorization()) @responses.activate -def test_get_call(voice, dummy_data): +def test_get_call(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) + assert isinstance(client.get_call("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent assert_re(r"\ABearer ", request_authorization()) @responses.activate -def test_update_call(voice, dummy_data): +def test_update_call(client, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"action": "hangup"}' @responses.activate -def test_send_audio(voice, dummy_data): +def test_send_audio(client, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), dict, ) assert request_user_agent() == dummy_data.user_agent @@ -64,36 +64,36 @@ def test_send_audio(voice, dummy_data): @responses.activate -def test_stop_audio(voice, dummy_data): +def test_stop_audio(client, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) + assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent @responses.activate -def test_send_speech(voice, dummy_data): +def test_send_speech(client, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"text": "Hello"}' @responses.activate -def test_stop_speech(voice, dummy_data): +def test_stop_speech(client, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) + assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent @responses.activate -def test_send_dtmf(voice, dummy_data): +def test_send_dtmf(client, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"digits": "1234"}' @@ -108,8 +108,7 @@ def test_user_provided_authorization(client, dummy_data): exp = nbf + 3600 client.auth(application_id=application_id, nbf=nbf, exp=exp) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") + client.get_call("xx-xx-xx-xx") token = request_authorization().split()[1] @@ -132,8 +131,7 @@ def test_authorization_with_private_key_path(dummy_data): application_id=dummy_data.application_id, private_key=private_key, ) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") + client.get_call("xx-xx-xx-xx") token = jwt.decode( request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" @@ -142,10 +140,10 @@ def test_authorization_with_private_key_path(dummy_data): @responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): +def test_authorization_with_private_key_object(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - voice.get_call("xx-xx-xx-xx") + client.get_call("xx-xx-xx-xx") token = jwt.decode( request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" diff --git a/tests/test_voice_deprecated.py b/tests/test_voice_deprecated.py index 83709f6a..daa172a9 100644 --- a/tests/test_voice_deprecated.py +++ b/tests/test_voice_deprecated.py @@ -2,31 +2,31 @@ @responses.activate -def test_initiate_call(voice, dummy_data): +def test_initiate_call(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/call/json") params = {"to": "16365553226", "answer_url": "http://example.com/answer"} - assert isinstance(voice.initiate_call(params), dict) + assert isinstance(client.initiate_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "answer_url=http%3A%2F%2Fexample.com%2Fanswer" in request_body() @responses.activate -def test_initiate_tts_call(voice, dummy_data): +def test_initiate_tts_call(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/tts/json") params = {"to": "16365553226", "text": "Hello"} - assert isinstance(voice.initiate_tts_call(params), dict) + assert isinstance(client.initiate_tts_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "text=Hello" in request_body() @responses.activate -def test_initiate_tts_prompt_call(voice, dummy_data): +def test_initiate_tts_prompt_call(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/tts-prompt/json") params = { @@ -36,7 +36,7 @@ def test_initiate_tts_prompt_call(voice, dummy_data): "bye_text": "Goodbye", } - assert isinstance(voice.initiate_tts_prompt_call(params), dict) + assert isinstance(client.initiate_tts_prompt_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "text=Hello" in request_body() From 6e580c9041510bf912f05242e61a53f79853b3c0 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Tue, 25 Aug 2020 10:09:35 -0400 Subject: [PATCH 056/401] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4130afe..2e7cc27b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Nexmo Client Library for Python +Vonage Client Library for Python =============================== [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) From 335e9e6dc1818a7519719dac0c280489b27aa383 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Tue, 25 Aug 2020 10:10:32 -0400 Subject: [PATCH 057/401] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4130afe..2e7cc27b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Nexmo Client Library for Python +Vonage Client Library for Python =============================== [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) From 37bd98f77196bb7ceababd6fdff27c42e6bce1b6 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 10:57:02 -0400 Subject: [PATCH 058/401] bump version 2.5.1 --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index f3793bda..34ee5850 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,11 @@ setup( name="nexmo", - version="2.4.0", - description="Nexmo Client Library for Python", + version="2.5.1", + description="Vonage Client Library for Python", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/Nexmo/nexmo-python", + url="https://github.com/vonage/vonage-python-sdk", author="Nexmo", author_email="devrel@nexmo.com", license="MIT", From b37729e50496f0484f4ac1a6ea95d6a78c078742 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 11:29:14 -0400 Subject: [PATCH 059/401] adding requested changes --- setup.py | 5 +++-- src/nexmo/_internal.py | 2 +- tests/conftest.py | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 34ee5850..7176fa10 100644 --- a/setup.py +++ b/setup.py @@ -12,10 +12,10 @@ setup( name="nexmo", version="2.5.1", - description="Vonage Client Library for Python", + description="Nexmo Client Library for Python", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/vonage/vonage-python-sdk", + url="https://github.com/nexmo/nexmo-python", author="Nexmo", author_email="devrel@nexmo.com", license="MIT", @@ -32,5 +32,6 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], ) diff --git a/src/nexmo/_internal.py b/src/nexmo/_internal.py index a32921f0..4beb8c1e 100644 --- a/src/nexmo/_internal.py +++ b/src/nexmo/_internal.py @@ -13,7 +13,7 @@ class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret): + def __init__(self, host, user_agent, api_key, api_secret, timeout=None): self._host = host self._session = session = Session() session.auth = (api_key, api_secret) # Basic authentication. diff --git a/tests/conftest.py b/tests/conftest.py index 8bbf454f..d674ee1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ def __init__(self): self.user_agent = "nexmo-python/{} python/{}".format( nexmo.__version__, platform.python_version() ) + self.host = "rest.nexmo.com" @pytest.fixture(scope="session") From fd1ac0f55c855eeb82b3c99c85d247f071dc641d Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 11:42:46 -0400 Subject: [PATCH 060/401] updating readme --- README.md | 297 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 216 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 2e7cc27b..5d703856 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Vonage Client Library for Python -=============================== +# Vonage Client Library for Python [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) [![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) @@ -12,21 +11,19 @@ Vonage Client Library for Python This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. -* [Installation](#installation) -* [Usage](#usage) -* [SMS API](#sms-api) -* [Voice API](#voice-api) -* [Verify API](#verify-api) -* [Number Insight API](#number-insight-api) -* [Number Management API](#number-management-api) -* [Managing Secrets](#managing-secrets) -* [Application API](#application-api) -* [Overriding API url's](#overriding-api-urls) -* [License](#license) +- [Installation](#installation) +- [Usage](#usage) +- [SMS API](#sms-api) +- [Voice API](#voice-api) +- [Verify API](#verify-api) +- [Number Insight API](#number-insight-api) +- [Number Management API](#number-management-api) +- [Managing Secrets](#managing-secrets) +- [Application API](#application-api) +- [Overriding API url's](#overriding-api-urls) +- [License](#license) - -Installation ------------- +## Installation To install the Python client library using pip: @@ -42,9 +39,7 @@ Alternatively, you can clone the repository via the command line: or by opening it on GitHub desktop. - -Usage ------ +## Usage Begin by importing the `nexmo` module: @@ -72,81 +67,107 @@ To check signatures for incoming webhook requests, you'll also need to specify the `signature_secret` argument (or the `NEXMO_SIGNATURE_SECRET` environment variable). - ## SMS API -### Send a text message +## SMS Class -```python -response = client.send_message({'from': 'Python', 'to': 'YOUR-NUMBER', 'text': 'Hello world'}) +### Creating an instance of the SMS class -response = response['messages'][0] +To create an instance of the SMS class follow these steps: -if response['status'] == '0': - print('Sent message', response['message-id']) +- Import the class - print('Remaining balance is', response['remaining-balance']) -else: - print('Error:', response['error-text']) -``` +```python +#Option 1 +from nexmo import Sms -Docs: [https://developer.nexmo.com/api/sms#send-an-sms](https://developer.nexmo.com/api/sms?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#send-an-sms) +#Option 2 +from nexmo.sms import Sms -### Tell Nexmo the SMS was received +#Option 3 +import nexmo #then tou can use nexmo.Sms() to create an instance +``` -The following submits a successful conversion to Nexmo with the current timestamp. This feature must -be enabled on your account first. +- Create an instance ```python -response = client.submit_sms_conversion(message_id) -``` -### Signing a Message +#Option 1 - pass key and secret to the constructor +sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages).* +#Option 2 - Create a client instance and then pass the client to the Sms instance +client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +sms = Sms(client) +``` -The SMS API supports the ability to sign messages by generating and adding a signature using a "Signature Secret" rather than your API secret. The algorithms supported are: +### Send an SMS -md5hash1 -md5 -sha1 -sha256 -sha512 +```python + responseData = client.send_message( + { + "from": NEXMO_BRAND_NAME, + "to": TO_NUMBER, + "text": "A text message sent using the Nexmo SMS API", + } + ) +``` -Both your application and Nexmo need to agree on which algorithm is used. In the dashboard, visit your account settings page and under "API Settings" you can select the algorithm to use. This is also the location where you will find your "Signature Secret" (it's different from the API secret). +Reference: [Send sms](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms) -### Create a client using these credentials and the algorithm to use, for example: +**Using the Sms class** ```python -client = nexmo.Client( - key = os.getenv('NEXMO_API_KEY'), - signature_secret = os.getenv('NEXMO_SIGNATURE_SECRET'), - signature_method = 'sha256' -) +from nexmo import Sms +sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +sms.send_message({ + "from": NEXMO_BRAND_NAME, + "to": TO_NUMBER, + "text": "A text message sent using the Nexmo SMS API", +}) ``` -Using this client, your SMS API messages will be sent as signed messages. - -### Verifying an Incoming Message Signature +### Send SMS with unicode -*You may also like to read the [documentation about message signing](https://developer.nexmo.com/concepts/guides/signing-messages)*. +```python +responseData = client.send_message({ + 'from': NEXMO_BRAND_NAME, + 'to': TO_NUMBER, + 'text': 'こんにちは世界', + 'type': 'unicode', +}) +``` -If you have message signing enabled for incoming messages, the SMS webhook will include the fields sig, nonce and timestamp. +Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms-with-unicode) -To verify the signature is from Nexmo, you create a Signature object using the incoming data, your signature secret and the signature method. +**Using Sms Class** -Then use the `check_signature()` method with the actual signature that was received (usually present in request.form or request.args. you can merge those in a single variable called params) to make sure that it is correct. +```python +sms.send_message({ + 'from': NEXMO_BRAND_NAME, + 'to': TO_NUMBER, + 'text': 'こんにちは世界', + 'type': 'unicode', +}) +``` -### Get the params +### Submit SMS Conversion ```python -if request.is_json: - params = request.get_json() -else: - params = request.args or request.form -is_valid = client.check_signature(params)// is it valid? Will be true or false +client.submit_sms_conversion("a-message-id") ``` -Using your signature secret and the other supplied parameters, the signature can be calculated and checked against the incoming signature value. +**With the SMS Class** + +```python +from nexmo import Client, Sms +client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) +sms = Sms(client) +response = sms.send_message({ + 'from': NEXMO_BRAND_NAME, + 'to': TO_NUMBER, + 'text': 'Hi from Vonage' +}) +sms.submit_sms_conversion(response['message-id']) +``` ## Voice API @@ -162,6 +183,21 @@ response = client.create_call({ Docs: [https://developer.nexmo.com/api/voice#createCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#createCall) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +voice.create_all({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +``` + +Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) + ### Retrieve a list of calls ```python @@ -170,6 +206,17 @@ response = client.get_calls() Docs: [https://developer.nexmo.com/api/voice#getCalls](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCalls) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +voice.get_calls() +``` + +Testing screenshots: [get calls](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/a5cc162f255dc83b8cdd1d2f80531925/NexmoVoiceGetCalls.PNG) + ### Retrieve a single call ```python @@ -178,6 +225,17 @@ response = client.get_call(uuid) Docs: [https://developer.nexmo.com/api/voice#getCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCall) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +voice.get_call(uuid) +``` + +Testing Screenshots: [get single call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/5cef34880afdc6a4c3cd3dee0e84aae2/NexmoVoiceGetSingleCall.PNG) + ### Update a call ```python @@ -186,6 +244,20 @@ response = client.update_call(uuid, action='hangup') Docs: [https://developer.nexmo.com/api/voice#updateCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#updateCall) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +response = voice.create_all({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.update_call(response['uuid'], action='hangup') +``` + ### Stream audio to a call ```python @@ -196,6 +268,21 @@ response = client.send_audio(uuid, stream_url=[stream_url]) Docs: [https://developer.nexmo.com/api/voice#startStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startStream) +**with voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_audio(response['uuid'],stream_url=[stream_url]) +``` + ### Stop streaming audio to a call ```python @@ -204,6 +291,22 @@ response = client.stop_audio(uuid) Docs: [https://developer.nexmo.com/api/voice#stopStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopStream) +**Using voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') +voice = Voice(client) +stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_audio(response['uuid'],stream_url=[stream_url]) +voice.stop_audio(response['uuid']) +``` + ### Send a synthesized speech message to a call ```python @@ -212,6 +315,20 @@ response = client.send_speech(uuid, text='Hello') Docs: [https://developer.nexmo.com/api/voice#startTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startTalk) +**Using voice class** + +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_speech(response['uuid'], text='Hello from nexmo') +``` + ### Stop sending a synthesized speech message to a call ```python @@ -220,6 +337,21 @@ response = client.stop_speech(uuid) Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopTalk) +**Using voice class** + +```python +>>> from nexmo import Client, Voice +>>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) +>>> voice = Voice(client) +>>> response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +>>> voice.send_speech(response['uuid'], text='Hello from nexmo') +>>> voice.stop_speech(response['uuid']) +``` + ### Send DTMF tones to a call ```python @@ -228,13 +360,20 @@ response = client.send_dtmf(uuid, digits='1234') Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startDTMF) -### Get recording +**Using voice class** -``` python -response = client.get_recording(RECORDING_URL) +```python +from nexmo import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +voice = Voice(client) +response = voice.create_call({ + 'to': [{'type': 'phone', 'number': '14843331234'}], + 'from': {'type': 'phone', 'number': '14843335555'}, + 'answer_url': ['https://example.com/answer'] +}) +voice.send_dtmf(response['uuid'], digits='1234') ``` - ## Verify API ### Start a verification @@ -347,22 +486,22 @@ Docs: [https://developer.nexmo.com/api/numbers#cancelANumber](https://developer. ## Managing Secrets - An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. +An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. ### List Secrets - ```python +```python secrets = client.list_secrets(API_KEY) ``` ### Create A New Secret - Create a new secret (the created dates will help you know which is which): - ```python +Create a new secret (the created dates will help you know which is which): + +```python client.create_secret(API_KEY, 'awes0meNewSekret!!;'); ``` - ### Delete A Secret Delete the old secret (any application still using these credentials will stop working): @@ -371,7 +510,6 @@ Delete the old secret (any application still using these credentials will stop w client.delete_secret(API_KEY, 'my-secret-id') ``` - ## Application API ### Create an application @@ -414,7 +552,6 @@ response = client.application_v2.delete_application(uuid) Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) - ## Validate webhook signatures ```python @@ -431,7 +568,6 @@ Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://dev Note: you'll need to contact support@nexmo.com to enable message signing on your account before you can validate webhook signatures. - ## JWT parameters By default, the library generates short-lived tokens for JWT authentication. @@ -455,6 +591,7 @@ client = nexmo.Client() client.host = 'new.host.url' client.api_host = 'new.api.host' ``` + - Creating a new class that extends from client class and overrides these values in the constructor: ```python @@ -487,8 +624,7 @@ class NexmoClient(nexmo.Client): Then proceed to create your personalised instance of the class. -Contributing ------------- +## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! @@ -504,8 +640,7 @@ The tests are all written with pytest. You run them with: make test ``` -License -------- +## License This library is released under the [MIT License][license]. From 83e2523e4ac82b17a8560bb5ee58e68a299420cb Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 11:45:12 -0400 Subject: [PATCH 061/401] adding faq --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 5d703856..9b6be38c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. - [Managing Secrets](#managing-secrets) - [Application API](#application-api) - [Overriding API url's](#overriding-api-urls) +- [Frequently Asked Questions](#frequently-asked-questions) - [License](#license) ## Installation @@ -624,6 +625,38 @@ class NexmoClient(nexmo.Client): Then proceed to create your personalised instance of the class. +## Frequently Asked Questions + +### Dropping support for Python 2.7 + +Back in 2014 when Guido van Rossum, Python's creator and principal author, made the announcement, January 1, 2020 seemed pretty far away. Python 2.7’s sunset has happened, after which there’ll be absolutely no more support from the core Python team. Many utilized projects pledge to drop Python 2 support in or before 2020. [(Official statement here)](https://www.python.org/doc/sunset-python-2/). + +Just because 2.7 isn’t going to be maintained past 2020 doesn’t mean your applications or libraries suddenly stop working but as of this moment we won't give official support for upcoming releases. Please read the official ["Porting Python 2 Code to Python 3" guide](https://docs.python.org/3/howto/pyporting.html). Please also read the [Python 3 Statement Practicalities](https://python3statement.org/practicalities/) for advice on sunsetting your Python 2 code. + +### Supported APIs + +The following is a list of Vonage APIs and whether the Python SDK provides support for them: + +| API | API Release Status | Supported? | +| --------------------- | :------------------: | :--------: | +| Account API | General Availability | ✅ | +| Alerts API | General Availability | ✅ | +| Application API | General Availability | ✅ | +| Audit API | Beta | ❌ | +| Conversation API | Beta | ❌ | +| Dispatch API | Beta | ❌ | +| External Accounts API | Beta | ❌ | +| Media API | Beta | ❌ | +| Messages API | Beta | ❌ | +| Number Insight API | General Availability | ✅ | +| Number Management API | General Availability | ✅ | +| Pricing API | General Availability | ✅ | +| Redact API | General Availability | ✅ | +| Reports API | Beta | ❌ | +| SMS API | General Availability | ✅ | +| Verify API | General Availability | ✅ | +| Voice API | General Availability | ✅ | + ## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! From 937f977d0ddd267ebf5902e53c4a75568398b0a1 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 12:09:46 -0400 Subject: [PATCH 062/401] adding verify class to readme --- README.md | 281 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 253 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 9b6be38c..fdf73be2 100644 --- a/README.md +++ b/README.md @@ -197,8 +197,6 @@ voice.create_all({ }) ``` -Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) - ### Retrieve a list of calls ```python @@ -216,8 +214,6 @@ voice = Voice(client) voice.get_calls() ``` -Testing screenshots: [get calls](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/a5cc162f255dc83b8cdd1d2f80531925/NexmoVoiceGetCalls.PNG) - ### Retrieve a single call ```python @@ -235,8 +231,6 @@ voice = Voice(client) voice.get_call(uuid) ``` -Testing Screenshots: [get single call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/5cef34880afdc6a4c3cd3dee0e84aae2/NexmoVoiceGetSingleCall.PNG) - ### Update a call ```python @@ -377,53 +371,284 @@ voice.send_dtmf(response['uuid'], digits='1234') ## Verify API -### Start a verification +### Verify Class + +#### How to create an instance of the class + +To create an instance of the Verify class, Just follow the next steps: +​ + +- **Import the class from module** (3 different ways) + ​ + +```python +#First way +from nexmo import Verify +​ +#Second way +from nexmo.verify import Verify +​ +#Third valid way +import nexmo #then you can use nexmo.Verify() to create an instance +``` + +- **Create the instance** + +```python +#First way - pass key and secret to the constructor +verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +​ +#Second way - Create a client instance and then pass the client to the Verify constructor +client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +verify = Verify(client) +``` + +### Search for a Verification request + +- Previous + +````python +#Check the verification status, searching by request_id +response = client.get_verification(REQUEST_ID) +​ +if response is not None: + print(response['status']) +​ +```​ +[Reference](https://developer.nexmo.com/verify/code-snippets/search-verify-request) +​ +- New + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.search('69e2626cbc23451fbbc02f627a959677') +​ +if response is not None: + print(response['status']) +```​ + +### Send verification code +- Previous + ​ ```python -response = client.start_verification(number='441632960960', brand='MyApp') +response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc") +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +​ +```` + +[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request) +​ -if response['status'] == '0': - print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) +### Send verification code with workflow + +- Previous + ​ + +```python +response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc", workflow_id=1) +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +​ +``` -The response contains a verification request id which you will need to -store temporarily (in the session, database, url, etc). +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request-with-workflow) +​ -### Check a verification +- New + ​ ```python -response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Check verification code -if response['status'] == '0': - print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) +- Previous + ​ + +```python +response = client.check_verification(REQUEST_ID, code=CODE) +​ +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/check-verify-request) +​ -The verification request id comes from the call to the start_verification method. -The PIN code is entered into your application by the user. +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.check(REQUEST_ID, code=CODE) +​ +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) +else: + print("Error: %s" % response["error_text"]) +``` -### Cancel a verification +### Cancel Verification Request + +- Previous + ​ + +```python +response = client.cancel_verification(REQUEST_ID) +​ +if response["status"] == "0": + print("Cancellation successful") +else: + print("Error: %s" % response["error_text"]) +``` + +[Reference](https://developer.nexmo.com/verify/code-snippets/cancel-verify-request) + +- New ```python -client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.cancel(REQUEST_ID) +​ +if response["status"] == "0": + print("Cancellation successful") +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +### Trigger next verification proccess -### Trigger next verification step +- Previous + ​ ```python -client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') +response = client.trigger_next_verification_event(REQUEST_ID) +​ +if response["status"] == "0": + print("Next verification stage triggered") +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +[Reference](https://developer.nexmo.com/verify/code-snippets/trigger-next-verification-process) +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.trigger_next_event(REQUEST_ID) +​ +if response["status"] == "0": + print("Next verification stage triggered") +else: + print("Error: %s" % response["error_text"]) +``` + +### Send payment authentication code + +- Previous + ​ + +```python +response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Send payment authentication code with workflow + +- Previous + ​ + +```python +response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` ## Number Insight API From 70f03a3ba55cc27a3ea0e83a181a586ee4e2cb03 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 12:13:33 -0400 Subject: [PATCH 063/401] Both options classes and client --- README.md | 443 ++++++--------------------------- src/nexmo/__init__.py | 157 +++++++----- src/nexmo/_internal.py | 9 +- src/nexmo/sms.py | 51 ++++ src/nexmo/verify.py | 51 ++++ src/nexmo/voice.py | 117 +++++++++ tests/conftest.py | 28 +++ tests/test_getters_setters.py | 24 ++ tests/test_nexmo.py | 3 +- tests/test_sms.py | 20 +- tests/test_voice.py | 46 ++-- tests/test_voice_deprecated.py | 12 +- 12 files changed, 490 insertions(+), 471 deletions(-) create mode 100644 src/nexmo/sms.py create mode 100644 src/nexmo/verify.py create mode 100644 src/nexmo/voice.py create mode 100644 tests/test_getters_setters.py diff --git a/README.md b/README.md index fdf73be2..b95392c4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Vonage Client Library for Python +# Nexmo Client Library for Python [![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) [![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) @@ -6,23 +6,22 @@ [![Python versions supported](https://img.shields.io/pypi/pyversions/nexmo.svg)](https://pypi.python.org/pypi/nexmo) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) -Nexmo is now known as Vonage - This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. -- [Installation](#installation) -- [Usage](#usage) -- [SMS API](#sms-api) -- [Voice API](#voice-api) -- [Verify API](#verify-api) -- [Number Insight API](#number-insight-api) -- [Number Management API](#number-management-api) -- [Managing Secrets](#managing-secrets) -- [Application API](#application-api) -- [Overriding API url's](#overriding-api-urls) -- [Frequently Asked Questions](#frequently-asked-questions) -- [License](#license) + +* [Installation](#installation) +* [Usage](#usage) +* [SMS API](#sms-api) +* [Voice API](#voice-api) +* [Verify API](#verify-api) +* [Number Insight API](#number-insight-api) +* [Number Management API](#number-management-api) +* [Managing Secrets](#managing-secrets) +* [Application API](#application-api) +* [Overriding API Attributes](#overriding-api-attributes) +* [License](#license) + ## Installation @@ -126,6 +125,8 @@ sms.send_message({ }) ``` +Support link: [Send sms](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/17e17c6f05f6d28c53596f2412c627c2/SMSSendMessage.PNG) + ### Send SMS with unicode ```python @@ -197,6 +198,8 @@ voice.create_all({ }) ``` +Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) + ### Retrieve a list of calls ```python @@ -214,6 +217,8 @@ voice = Voice(client) voice.get_calls() ``` +Testing screenshots: [get calls](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/a5cc162f255dc83b8cdd1d2f80531925/NexmoVoiceGetCalls.PNG) + ### Retrieve a single call ```python @@ -231,6 +236,8 @@ voice = Voice(client) voice.get_call(uuid) ``` +Testing Screenshots: [get single call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/5cef34880afdc6a4c3cd3dee0e84aae2/NexmoVoiceGetSingleCall.PNG) + ### Update a call ```python @@ -253,6 +260,8 @@ response = voice.create_all({ voice.update_call(response['uuid'], action='hangup') ``` +Support Link: [update call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/bdf7c0990b6d4019a2758a7148fdf1e4/VoiceUpdateCall.PNG) + ### Stream audio to a call ```python @@ -278,6 +287,8 @@ response = voice.create_call({ voice.send_audio(response['uuid'],stream_url=[stream_url]) ``` +Support link: [Send audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fdc22d76f6bb5c8abf625311f222512a/VoiceSendAudioStream.PNG) + ### Stop streaming audio to a call ```python @@ -302,6 +313,8 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) voice.stop_audio(response['uuid']) ``` +Support Link: [Stop audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/589be23c5a31694e310aacf0fa6a2314/VoiceSendStopAudioStream.PNG) + ### Send a synthesized speech message to a call ```python @@ -324,6 +337,8 @@ response = voice.create_call({ voice.send_speech(response['uuid'], text='Hello from nexmo') ``` +Support link: [Send speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/d608bfe3b1fb288c9f4854d76fba37af/VoiceSendSpeech.PNG) + ### Stop sending a synthesized speech message to a call ```python @@ -347,6 +362,8 @@ Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.c >>> voice.stop_speech(response['uuid']) ``` +Support link: [Stop speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/246801f2e34d147955ac3531e4e7b65d/VoiceSendStopSpeech.PNG) + ### Send DTMF tones to a call ```python @@ -369,286 +386,63 @@ response = voice.create_call({ voice.send_dtmf(response['uuid'], digits='1234') ``` -## Verify API - -### Verify Class - -#### How to create an instance of the class - -To create an instance of the Verify class, Just follow the next steps: -​ - -- **Import the class from module** (3 different ways) - ​ - -```python -#First way -from nexmo import Verify -​ -#Second way -from nexmo.verify import Verify -​ -#Third valid way -import nexmo #then you can use nexmo.Verify() to create an instance -``` - -- **Create the instance** - -```python -#First way - pass key and secret to the constructor -verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -​ -#Second way - Create a client instance and then pass the client to the Verify constructor -client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) -verify = Verify(client) -``` - -### Search for a Verification request - -- Previous - -````python -#Check the verification status, searching by request_id -response = client.get_verification(REQUEST_ID) -​ -if response is not None: - print(response['status']) -​ -```​ -[Reference](https://developer.nexmo.com/verify/code-snippets/search-verify-request) -​ -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.search('69e2626cbc23451fbbc02f627a959677') -​ -if response is not None: - print(response['status']) -```​ - -### Send verification code - -- Previous - ​ -```python -response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc") -​ -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -​ -```` - -[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request) -​ - -- New - ​ - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') -​ -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -### Send verification code with workflow - -- Previous - ​ - -```python -response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc", workflow_id=1) -​ -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -​ -``` - -​ -[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request-with-workflow) -​ - -- New - ​ - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) -​ -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -### Check verification code +Support link: [Send DTMF](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/7c4b25014d6c94eb886cbaa9a55d2ae3/VoiceSendDTMF.PNG) -- Previous - ​ +### Get recording ```python -response = client.check_verification(REQUEST_ID, code=CODE) -​ -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) +response = client.get_recording(RECORDING_URL) ``` -​ -[Reference](https://developer.nexmo.com/verify/code-snippets/check-verify-request) -​ +## Verify API -- New - ​ +### Start a verification ```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.check(REQUEST_ID, code=CODE) -​ -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -### Cancel Verification Request +response = client.start_verification(number='441632960960', brand='MyApp') -- Previous - ​ - -```python -response = client.cancel_verification(REQUEST_ID) -​ -if response["status"] == "0": - print("Cancellation successful") +if response['status'] == '0': + print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) else: - print("Error: %s" % response["error_text"]) + print('Error:', response['error_text']) ``` -[Reference](https://developer.nexmo.com/verify/code-snippets/cancel-verify-request) - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.cancel(REQUEST_ID) -​ -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) -``` +Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) -### Trigger next verification proccess +The response contains a verification request id which you will need to +store temporarily (in the session, database, url, etc). -- Previous - ​ +### Check a verification ```python -response = client.trigger_next_verification_event(REQUEST_ID) -​ -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) -``` +response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') -[Reference](https://developer.nexmo.com/verify/code-snippets/trigger-next-verification-process) -​ - -- New - ​ - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.trigger_next_event(REQUEST_ID) -​ -if response["status"] == "0": - print("Next verification stage triggered") +if response['status'] == '0': + print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) else: - print("Error: %s" % response["error_text"]) + print('Error:', response['error_text']) ``` -### Send payment authentication code +Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) -- Previous - ​ +The verification request id comes from the call to the start_verification method. +The PIN code is entered into your application by the user. -```python -response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) -​ -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -​ - -- New - ​ +### Cancel a verification ```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) -​ -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') ``` -### Send payment authentication code with workflow +Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) -- Previous - ​ +### Trigger next verification step ```python -response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) -​ -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') ``` -- New - ​ - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) -​ -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` +Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) ## Number Insight API @@ -676,40 +470,6 @@ client.get_advanced_number_insight(number='447700900000') Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) -## Number Management API - -### List Your Numbers - -```python -client.get_account_numbers() -``` - -Docs: [https://developer.nexmo.com/api/numbers#getOwnedNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getOwnedNumbers) - -### Search for a Number - -```python -client.get_available_numbers('GB', {"type":"SMS"}) -``` - -Docs: [https://developer.nexmo.com/api/numbers#getAvailableNumbers](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getAvailableNumbers) - -### Buy a Number - -```python -client.buy_number({"country": 'GB', "msisdn": '447700900000'}) -``` - -Docs: [https://developer.nexmo.com/api/numbers#buyANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#buyANumber) - -### Cancel a Number - -```python -client.cancel_number({"country": 'GB', "msisdn": '447700900000'}) -``` - -Docs: [https://developer.nexmo.com/api/numbers#cancelANumber](https://developer.nexmo.com/api/numbers?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#cancelANumber) - ## Managing Secrets An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. @@ -805,82 +565,41 @@ specify a different token identifier: client.auth(nbf=nbf, exp=exp, jti=jti) ``` -## Overriding API url's +## Overriding API Attributes -By default, our API url's are hardcoded. For use cases where these url's are not accessible, best practices to override these url's are the following: +In order to rewrite/get the value of variables used across all the Nexmo classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. -- Setting new API url's when creating an instance of the client: +An example using setters/getters with `Object references`: ```python -import nexmo -client = nexmo.Client() -client.host = 'new.host.url' -client.api_host = 'new.api.host' -``` +from nexmo import Client, Sms -- Creating a new class that extends from client class and overrides these values in the constructor: +#Defines the client +client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') +print(client.host()) # using getter for host -- value returned: rest.nexmo.com -```python -class MyClient(nexmo.Client): - def __init__(self, NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH): - super().__init__(application_id=APPLICATION_ID, private_key=APPLICATION_PRIVATE_KEY_PATH, key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) - self.host = 'new.hosts.url' - self.api_host = 'new.api.hosts' +#Define the sms instance +sms = Sms(client) + +#Change the value in client +client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms -#usage -client = MyClient(NEXMO_API_KEY, NEXMO_API_SECRET, APPLICATION_ID, APPLICATION_PRIVATE_KEY_PATH) ``` -For a more specific case, another way to customise is: +### Overriding API Host / Host Attributes + +These attributes are private in the client class and the only way to access them is using the getters/setters we provide. + ```python -import nexmo +from nexmo import Client -class NexmoClient(nexmo.Client): - def __init__(....): - super().__init__(....) - api_server = BasicAuthenticatedServer( - "mycustomurl", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) -``` - -Then proceed to create your personalised instance of the class. - -## Frequently Asked Questions - -### Dropping support for Python 2.7 - -Back in 2014 when Guido van Rossum, Python's creator and principal author, made the announcement, January 1, 2020 seemed pretty far away. Python 2.7’s sunset has happened, after which there’ll be absolutely no more support from the core Python team. Many utilized projects pledge to drop Python 2 support in or before 2020. [(Official statement here)](https://www.python.org/doc/sunset-python-2/). - -Just because 2.7 isn’t going to be maintained past 2020 doesn’t mean your applications or libraries suddenly stop working but as of this moment we won't give official support for upcoming releases. Please read the official ["Porting Python 2 Code to Python 3" guide](https://docs.python.org/3/howto/pyporting.html). Please also read the [Python 3 Statement Practicalities](https://python3statement.org/practicalities/) for advice on sunsetting your Python 2 code. - -### Supported APIs - -The following is a list of Vonage APIs and whether the Python SDK provides support for them: - -| API | API Release Status | Supported? | -| --------------------- | :------------------: | :--------: | -| Account API | General Availability | ✅ | -| Alerts API | General Availability | ✅ | -| Application API | General Availability | ✅ | -| Audit API | Beta | ❌ | -| Conversation API | Beta | ❌ | -| Dispatch API | Beta | ❌ | -| External Accounts API | Beta | ❌ | -| Media API | Beta | ❌ | -| Messages API | Beta | ❌ | -| Number Insight API | General Availability | ✅ | -| Number Management API | General Availability | ✅ | -| Pricing API | General Availability | ✅ | -| Redact API | General Availability | ✅ | -| Reports API | Beta | ❌ | -| SMS API | General Availability | ✅ | -| Verify API | General Availability | ✅ | -| Voice API | General Availability | ✅ | +client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') +print(client.host()) # return rest.nexmo.com +client.host('mio.nexmo.com') # rewrites the host value to mio.nexmo.com +print(client.api_host()) # returns api.nexmo.com +client.api_host('myapi.nexmo.com') # rewrite the value of api_host +``` ## Contributing diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 7d48c3b3..9aa1c119 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,5 +1,8 @@ from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param from .errors import * +from .voice import * +from .sms import * +from .verify import * from datetime import datetime import logging from platform import python_version @@ -15,8 +18,10 @@ import time from uuid import uuid4 import warnings +import re -string_types = (str, bytes) + +string_types = (str, bytes) from urllib.parse import urlparse try: @@ -93,10 +98,12 @@ def __init__( if isinstance(self.private_key, string_types) and "\n" not in self.private_key: with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() + + self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' - self.host = "rest.nexmo.com" + self.__host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" + self.__api_host = "api.nexmo.com" user_agent = "nexmo-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() @@ -120,6 +127,24 @@ def __init__( self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for host') + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for api_host') + else: + self.__api_host = value def auth(self, params=None, **kwargs): self.auth_params = params or kwargs @@ -136,71 +161,71 @@ def send_message(self, params): }) :param dict params: A dict of values described at `Send an SMS `_ """ - return self.post(self.host, "/sms/json", params, supports_signature_auth=True) + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) def get_balance(self): - return self.get(self.host, "/account/get-balance") + return self.get(self.host(), "/account/get-balance") def get_country_pricing(self, country_code): return self.get( - self.host, "/account/get-pricing/outbound", {"country": country_code} + self.host(), "/account/get-pricing/outbound", {"country": country_code} ) def get_prefix_pricing(self, prefix): return self.get( - self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} ) def get_sms_pricing(self, number): return self.get( - self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} ) def get_voice_pricing(self, number): return self.get( - self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} ) def update_settings(self, params=None, **kwargs): - return self.post(self.host, "/account/settings", params or kwargs) + return self.post(self.host(), "/account/settings", params or kwargs) def topup(self, params=None, **kwargs): - return self.post(self.host, "/account/top-up", params or kwargs) + return self.post(self.host(), "/account/top-up", params or kwargs) def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host, "/account/numbers", params or kwargs) + return self.get(self.host(), "/account/numbers", params or kwargs) def get_available_numbers(self, country_code, params=None, **kwargs): return self.get( - self.host, "/number/search", dict(params or kwargs, country=country_code) + self.host(), "/number/search", dict(params or kwargs, country=country_code) ) def buy_number(self, params=None, **kwargs): - return self.post(self.host, "/number/buy", params or kwargs) + return self.post(self.host(), "/number/buy", params or kwargs) def cancel_number(self, params=None, **kwargs): - return self.post(self.host, "/number/cancel", params or kwargs) + return self.post(self.host(), "/number/cancel", params or kwargs) def update_number(self, params=None, **kwargs): - return self.post(self.host, "/number/update", params or kwargs) + return self.post(self.host(), "/number/update", params or kwargs) def get_message(self, message_id): - return self.get(self.host, "/search/message", {"id": message_id}) + return self.get(self.host(), "/search/message", {"id": message_id}) def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host, "/search/rejections", params or kwargs) + return self.get(self.host(), "/search/rejections", params or kwargs) def search_messages(self, params=None, **kwargs): - return self.get(self.host, "/search/messages", params or kwargs) + return self.get(self.host(), "/search/messages", params or kwargs) def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd/json", params or kwargs) + return self.post(self.host(), "/ussd/json", params or kwargs) def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd-prompt/json", params or kwargs) + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/2fa/json", params or kwargs) + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ @@ -218,31 +243,31 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): } # Ensure timestamp is a string: _format_date_param(params, "timestamp") - return self.post(self.api_host, "/conversions/sms", params) + return self.post(self.api_host(), "/conversions/sms", params) def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/json", params or kwargs) + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/marketing/json", params or kwargs) + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) def get_event_alert_numbers(self): - return self.get(self.host, "/sc/us/alert/opt-in/query/json") + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/opt-in/manage/json", params or kwargs) + return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) def initiate_call(self, params=None, **kwargs): - return self.post(self.host, "/call/json", params or kwargs) + return self.post(self.host(), "/call/json", params or kwargs) def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts/json", params or kwargs) + return self.post(self.api_host(), "/tts/json", params or kwargs) def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts-prompt/json", params or kwargs) + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) def start_verification(self, params=None, **kwargs): - return self.post(self.api_host, "/verify/json", params or kwargs) + return self.post(self.api_host(), "/verify/json", params or kwargs) def send_verification_request(self, params=None, **kwargs): warnings.warn( @@ -251,17 +276,14 @@ def send_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.api_host, "/verify/json", params or kwargs) + return self.post(self.api_host(), "/verify/json", params or kwargs) def check_verification(self, request_id, params=None, **kwargs): return self.post( - self.api_host, + self.api_host(), "/verify/check/json", dict(params or kwargs, request_id=request_id), ) - - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host, "/verify/psd2/json", params or kwargs) def check_verification_request(self, params=None, **kwargs): warnings.warn( @@ -270,11 +292,14 @@ def check_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.api_host, "/verify/check/json", params or kwargs) + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) def get_verification(self, request_id): return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} + self.api_host(), "/verify/search/json", {"request_id": request_id} ) def get_verification_request(self, request_id): @@ -285,19 +310,19 @@ def get_verification_request(self, request_id): ) return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} + self.api_host(), "/verify/search/json", {"request_id": request_id} ) def cancel_verification(self, request_id): return self.post( - self.api_host, + self.api_host(), "/verify/control/json", {"request_id": request_id, "cmd": "cancel"}, ) def trigger_next_verification_event(self, request_id): return self.post( - self.api_host, + self.api_host(), "/verify/control/json", {"request_id": request_id, "cmd": "trigger_next_event"}, ) @@ -309,13 +334,13 @@ def control_verification_request(self, params=None, **kwargs): stacklevel=2, ) - return self.post(self.api_host, "/verify/control/json", params or kwargs) + return self.post(self.api_host(), "/verify/control/json", params or kwargs) def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/basic/json", params or kwargs) + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/standard/json", params or kwargs) + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) def get_number_insight(self, params=None, **kwargs): warnings.warn( @@ -324,20 +349,20 @@ def get_number_insight(self, params=None, **kwargs): stacklevel=2, ) - return self.get(self.api_host, "/number/lookup/json", params or kwargs) + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams: - return self.get(self.api_host, "/ni/advanced/async/json", params or kwargs) + return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) else: raise ClientError("Error: Callback needed for async advanced number insight") def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/advanced/json", params or kwargs) + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) def request_number_insight(self, params=None, **kwargs): - return self.post(self.host, "/ni/json", params or kwargs) + return self.post(self.host(), "/ni/json", params or kwargs) def get_applications(self, params=None, **kwargs): warnings.warn( @@ -345,7 +370,7 @@ def get_applications(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.get(self.api_host, "/v1/applications", params or kwargs) + return self.get(self.api_host(), "/v1/applications", params or kwargs) def get_application(self, application_id): warnings.warn( @@ -354,7 +379,7 @@ def get_application(self, application_id): stacklevel=2, ) return self.get( - self.api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -364,7 +389,7 @@ def create_application(self, params=None, **kwargs): DeprecationWarning, stacklevel=2, ) - return self.post(self.api_host, "/v1/applications", params or kwargs) + return self.post(self.api_host(), "/v1/applications", params or kwargs) def update_application(self, application_id, params=None, **kwargs): warnings.warn( @@ -373,7 +398,7 @@ def update_application(self, application_id, params=None, **kwargs): stacklevel=2, ) return self.put( - self.api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), params or kwargs, ) @@ -385,7 +410,7 @@ def delete_application(self, application_id): stacklevel=2, ) return self.delete( - self.api_host, + self.api_host(), "/v1/applications/{application_id}".format(application_id=application_id), ) @@ -432,18 +457,18 @@ def redact_transaction(self, id, product, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._post_json(self.api_host, "/v1/redact/transaction", params) + return self._post_json(self.api_host(), "/v1/redact/transaction", params) def list_secrets(self, api_key): return self.get( - self.api_host, + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), header_auth=True, ) def get_secret(self, api_key, secret_id): return self.get( - self.api_host, + self.api_host(), "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -453,12 +478,12 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} return self._post_json( - self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body ) def delete_secret(self, api_key, secret_id): return self.delete( - self.api_host, + self.api_host(), "/accounts/{api_key}/secrets/{secret_id}".format( api_key=api_key, secret_id=secret_id ), @@ -660,39 +685,39 @@ def parse(self, host, response): def _jwt_signed_get(self, request_uri, params=None): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, + self.api_host(), self.session.get(uri, params=params or {}, headers=self._headers()), ) def _jwt_signed_post(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) + self.api_host(), self.session.post(uri, json=params, headers=self._headers()) ) def _jwt_signed_put(self, request_uri, params): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) ) def _jwt_signed_delete(self, request_uri): uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri + api_host=self.api_host(), request_uri=request_uri ) return self.parse( - self.api_host, self.session.delete(uri, headers=self._headers()) + self.api_host(), self.session.delete(uri, headers=self._headers()) ) def _headers(self): diff --git a/src/nexmo/_internal.py b/src/nexmo/_internal.py index 4beb8c1e..dee34ba2 100644 --- a/src/nexmo/_internal.py +++ b/src/nexmo/_internal.py @@ -16,6 +16,7 @@ class BasicAuthenticatedServer(object): def __init__(self, host, user_agent, api_key, api_secret, timeout=None): self._host = host self._session = session = Session() + self.timeout = None session.auth = (api_key, api_secret) # Basic authentication. session.headers.update({"User-Agent": user_agent}) @@ -24,22 +25,22 @@ def _uri(self, path): def get(self, path, params=None, headers=None): return self._parse( - self._session.get(self._uri(path), params=params, headers=headers) + self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) ) def post(self, path, body=None, headers=None): return self._parse( - self._session.post(self._uri(path), json=body, headers=headers) + self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def put(self, path, body=None, headers=None): return self._parse( - self._session.put(self._uri(path), json=body, headers=headers) + self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def delete(self, path, body=None, headers=None): return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers) + self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) ) def _parse(self, response): diff --git a/src/nexmo/sms.py b/src/nexmo/sms.py new file mode 100644 index 00000000..e0f3460c --- /dev/null +++ b/src/nexmo/sms.py @@ -0,0 +1,51 @@ +import nexmo, pytz +from datetime import datetime +from ._internal import _format_date_param + +class Sms: + #To init Sms class pass a client reference or a key and secret + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = nexmo.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc) + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self._client.post(self._client.api_host(), "/conversions/sms", params) diff --git a/src/nexmo/verify.py b/src/nexmo/verify.py new file mode 100644 index 00000000..b724fdbb --- /dev/null +++ b/src/nexmo/verify.py @@ -0,0 +1,51 @@ +import nexmo +import warnings + +class Verify: + def __init__( + self, + client=None, + key=None, + secret=None + ): + try: + self._client = client + if self._client is None: + self._client = nexmo.Client( + key=key, + secret=secret + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def start_verification(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/verify/json", params or kwargs) + + def check(self, request_id, params=None, **kwargs): + return self._client.post( + self._client.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def search(self, request_id): + return self._client.get( + self._client.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def cancel(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_event(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def psd2(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/verify/psd2/json", params or kwargs) \ No newline at end of file diff --git a/src/nexmo/voice.py b/src/nexmo/voice.py new file mode 100644 index 00000000..b407009a --- /dev/null +++ b/src/nexmo/voice.py @@ -0,0 +1,117 @@ +import nexmo + +class Voice(): + #application_id and private_key are needed for the calling methods + #Passing a Nexmo Client is also possible + def __init__( + self, + client=None, + application_id=None, + private_key=None, + ): + try: + # Client is protected + self._client = client + if self._client is None: + self._client = nexmo.Client(application_id=application_id, private_key=private_key) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + # Creates a new call session + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + # Get call history paginated. Pass start and end dates to filter the retrieved information + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + # Get a single call record by identifier + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + # Update call data using custom ncco + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + # Plays audio streaming into call in progress - stream_url parameter is required + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + # Play an speech into specified call - text parameter (text to speech) is required + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + # plays DTMF tones into the specified call + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + # Stops audio recently played into specified call + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + # Stop a speech recently played into specified call + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + # Deprecated section + # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory + def initiate_call(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) + # End deprecated section + + # Utils methods + # _jwt_signed_post private method that Allows developer perform signed post request + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + # Uses the client session to perform the call action with api + return self._client.parse( + self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) + ) + + # _jwt_signed_post private method that Allows developer perform signed get request + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host(), + self._client.session.get(uri, params=params or {}, headers=self._client._headers()), + ) + + # _jwt_signed_put private method that Allows developer perform signed put request + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) + ) + + # _jwt_signed_put private method that Allows developer perform signed put request + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self._client.api_host(), request_uri=request_uri + ) + + return self._client.parse( + self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) + ) diff --git a/tests/conftest.py b/tests/conftest.py index d674ee1a..fe39dcf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,7 @@ def __init__(self): nexmo.__version__, platform.python_version() ) self.host = "rest.nexmo.com" + self.api_host = "api.nexmo.com" @pytest.fixture(scope="session") @@ -45,3 +46,30 @@ def client(dummy_data): application_id=dummy_data.application_id, private_key=dummy_data.private_key, ) + +#Represents an instance of the Voice class for testing +@pytest.fixture +def voice(client, dummy_data): + import nexmo + + return nexmo.Voice( + client + ) + +#Represents an instance of the Sms class for testing +@pytest.fixture +def sms(client, dummy_data): + import nexmo + + return nexmo.Sms( + client + ) + +#Represents an instance of the Verify class for testing +@pytest.fixture +def verify(client, dummy_data): + import nexmo + + return nexmo.Verify( + client + ) diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py new file mode 100644 index 00000000..30abda7c --- /dev/null +++ b/tests/test_getters_setters.py @@ -0,0 +1,24 @@ +from util import * + +@responses.activate +def test_getters(client, dummy_data): + assert client.host() == dummy_data.host + assert client.api_host() == dummy_data.api_host + +@responses.activate +def test_setters(client, dummy_data): + try: + client.host('host.nexmo.com') + client.api_host('host.nexmo.com') + assert client.host() != dummy_data.host + assert client.api_host() != dummy_data.api_host + except: + assert False + +@responses.activate +def test_fail_setter_url_format(client, dummy_data): + try: + client.host('1000.1000') + assert False + except: + assert True \ No newline at end of file diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 3ef895d3..3dd46c6f 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -190,7 +190,8 @@ def test_client_can_make_application_requests_without_api_key(dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") client = nexmo.Client(application_id="myid", private_key=dummy_data.private_key) - client.create_call("123455") + voice = nexmo.Voice(client) + voice.create_call("123455") @responses.activate diff --git a/tests/test_sms.py b/tests/test_sms.py index 8d3b18ad..fda960e8 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -3,12 +3,12 @@ @responses.activate -def test_send_message(client, dummy_data): +def test_send_message(sms, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sms/json") params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - assert isinstance(client.send_message(params), dict) + assert isinstance(sms.send_message(params), dict) assert request_user_agent() == dummy_data.user_agent assert "from=Python" in request_body() assert "to=447525856424" in request_body() @@ -16,37 +16,37 @@ def test_send_message(client, dummy_data): @responses.activate -def test_authentication_error(client): +def test_authentication_error(sms): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) with pytest.raises(nexmo.AuthenticationError): - client.send_message({}) + sms.send_message({}) @responses.activate -def test_client_error(client): +def test_client_error(sms): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) with pytest.raises(nexmo.ClientError) as excinfo: - client.send_message({}) + sms.send_message({}) excinfo.match(r"400 response from rest.nexmo.com") @responses.activate -def test_server_error(client): +def test_server_error(sms): responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) with pytest.raises(nexmo.ServerError) as excinfo: - client.send_message({}) + sms.send_message({}) excinfo.match(r"500 response from rest.nexmo.com") @responses.activate -def test_submit_sms_conversion(client): +def test_submit_sms_conversion(sms): responses.add( responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" ) - client.submit_sms_conversion("a-message-id") + sms.submit_sms_conversion("a-message-id") assert "message-id=a-message-id" in request_body() assert "timestamp" in request_body() diff --git a/tests/test_voice.py b/tests/test_voice.py index 9b7e4f68..bae67983 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -8,7 +8,7 @@ @responses.activate -def test_create_call(client, dummy_data): +def test_create_call(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") params = { @@ -17,45 +17,45 @@ def test_create_call(client, dummy_data): "answer_url": ["https://example.com/answer"], } - assert isinstance(client.create_call(params), dict) + assert isinstance(voice.create_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" @responses.activate -def test_get_calls(client, dummy_data): +def test_get_calls(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls") - assert isinstance(client.get_calls(), dict) + assert isinstance(voice.get_calls(), dict) assert request_user_agent() == dummy_data.user_agent assert_re(r"\ABearer ", request_authorization()) @responses.activate -def test_get_call(client, dummy_data): +def test_get_call(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) + assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent assert_re(r"\ABearer ", request_authorization()) @responses.activate -def test_update_call(client, dummy_data): +def test_update_call(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"action": "hangup"}' @responses.activate -def test_send_audio(client, dummy_data): +def test_send_audio(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), dict, ) assert request_user_agent() == dummy_data.user_agent @@ -64,36 +64,36 @@ def test_send_audio(client, dummy_data): @responses.activate -def test_stop_audio(client, dummy_data): +def test_stop_audio(voice, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) + assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent @responses.activate -def test_send_speech(client, dummy_data): +def test_send_speech(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"text": "Hello"}' @responses.activate -def test_stop_speech(client, dummy_data): +def test_stop_speech(voice, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) + assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) assert request_user_agent() == dummy_data.user_agent @responses.activate -def test_send_dtmf(client, dummy_data): +def test_send_dtmf(voice, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert request_body() == b'{"digits": "1234"}' @@ -108,7 +108,8 @@ def test_user_provided_authorization(client, dummy_data): exp = nbf + 3600 client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") token = request_authorization().split()[1] @@ -131,7 +132,8 @@ def test_authorization_with_private_key_path(dummy_data): application_id=dummy_data.application_id, private_key=private_key, ) - client.get_call("xx-xx-xx-xx") + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") token = jwt.decode( request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" @@ -140,10 +142,10 @@ def test_authorization_with_private_key_path(dummy_data): @responses.activate -def test_authorization_with_private_key_object(client, dummy_data): +def test_authorization_with_private_key_object(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - client.get_call("xx-xx-xx-xx") + voice.get_call("xx-xx-xx-xx") token = jwt.decode( request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" diff --git a/tests/test_voice_deprecated.py b/tests/test_voice_deprecated.py index daa172a9..83709f6a 100644 --- a/tests/test_voice_deprecated.py +++ b/tests/test_voice_deprecated.py @@ -2,31 +2,31 @@ @responses.activate -def test_initiate_call(client, dummy_data): +def test_initiate_call(voice, dummy_data): stub(responses.POST, "https://rest.nexmo.com/call/json") params = {"to": "16365553226", "answer_url": "http://example.com/answer"} - assert isinstance(client.initiate_call(params), dict) + assert isinstance(voice.initiate_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "answer_url=http%3A%2F%2Fexample.com%2Fanswer" in request_body() @responses.activate -def test_initiate_tts_call(client, dummy_data): +def test_initiate_tts_call(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/tts/json") params = {"to": "16365553226", "text": "Hello"} - assert isinstance(client.initiate_tts_call(params), dict) + assert isinstance(voice.initiate_tts_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "text=Hello" in request_body() @responses.activate -def test_initiate_tts_prompt_call(client, dummy_data): +def test_initiate_tts_prompt_call(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/tts-prompt/json") params = { @@ -36,7 +36,7 @@ def test_initiate_tts_prompt_call(client, dummy_data): "bye_text": "Goodbye", } - assert isinstance(client.initiate_tts_prompt_call(params), dict) + assert isinstance(voice.initiate_tts_prompt_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert "to=16365553226" in request_body() assert "text=Hello" in request_body() From 4106353ee67612b1d5231d2abe5479b6fdfd59fd Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 12:23:28 -0400 Subject: [PATCH 064/401] properly documenting --- README.md | 45 +++++++++++---------------------------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index b95392c4..23a3f7ad 100644 --- a/README.md +++ b/README.md @@ -9,19 +9,17 @@ This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. - -* [Installation](#installation) -* [Usage](#usage) -* [SMS API](#sms-api) -* [Voice API](#voice-api) -* [Verify API](#verify-api) -* [Number Insight API](#number-insight-api) -* [Number Management API](#number-management-api) -* [Managing Secrets](#managing-secrets) -* [Application API](#application-api) -* [Overriding API Attributes](#overriding-api-attributes) -* [License](#license) - +- [Installation](#installation) +- [Usage](#usage) +- [SMS API](#sms-api) +- [Voice API](#voice-api) +- [Verify API](#verify-api) +- [Number Insight API](#number-insight-api) +- [Number Management API](#number-management-api) +- [Managing Secrets](#managing-secrets) +- [Application API](#application-api) +- [Overriding API Attributes](#overriding-api-attributes) +- [License](#license) ## Installation @@ -125,8 +123,6 @@ sms.send_message({ }) ``` -Support link: [Send sms](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/17e17c6f05f6d28c53596f2412c627c2/SMSSendMessage.PNG) - ### Send SMS with unicode ```python @@ -198,8 +194,6 @@ voice.create_all({ }) ``` -Testing screenshots:[create call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fc104415f55a4ad22ecf8defd90b926b/NexmoVoiceUsage.PNG) - ### Retrieve a list of calls ```python @@ -217,8 +211,6 @@ voice = Voice(client) voice.get_calls() ``` -Testing screenshots: [get calls](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/a5cc162f255dc83b8cdd1d2f80531925/NexmoVoiceGetCalls.PNG) - ### Retrieve a single call ```python @@ -236,8 +228,6 @@ voice = Voice(client) voice.get_call(uuid) ``` -Testing Screenshots: [get single call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/5cef34880afdc6a4c3cd3dee0e84aae2/NexmoVoiceGetSingleCall.PNG) - ### Update a call ```python @@ -260,8 +250,6 @@ response = voice.create_all({ voice.update_call(response['uuid'], action='hangup') ``` -Support Link: [update call](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/bdf7c0990b6d4019a2758a7148fdf1e4/VoiceUpdateCall.PNG) - ### Stream audio to a call ```python @@ -287,8 +275,6 @@ response = voice.create_call({ voice.send_audio(response['uuid'],stream_url=[stream_url]) ``` -Support link: [Send audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/fdc22d76f6bb5c8abf625311f222512a/VoiceSendAudioStream.PNG) - ### Stop streaming audio to a call ```python @@ -313,8 +299,6 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) voice.stop_audio(response['uuid']) ``` -Support Link: [Stop audio stream](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/589be23c5a31694e310aacf0fa6a2314/VoiceSendStopAudioStream.PNG) - ### Send a synthesized speech message to a call ```python @@ -337,8 +321,6 @@ response = voice.create_call({ voice.send_speech(response['uuid'], text='Hello from nexmo') ``` -Support link: [Send speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/d608bfe3b1fb288c9f4854d76fba37af/VoiceSendSpeech.PNG) - ### Stop sending a synthesized speech message to a call ```python @@ -362,8 +344,6 @@ Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.c >>> voice.stop_speech(response['uuid']) ``` -Support link: [Stop speech](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/246801f2e34d147955ac3531e4e7b65d/VoiceSendStopSpeech.PNG) - ### Send DTMF tones to a call ```python @@ -386,8 +366,6 @@ response = voice.create_call({ voice.send_dtmf(response['uuid'], digits='1234') ``` -Support link: [Send DTMF](https://gitlab.com/codeonrocks/client/nexmo-python/uploads/7c4b25014d6c94eb886cbaa9a55d2ae3/VoiceSendDTMF.PNG) - ### Get recording ```python @@ -590,7 +568,6 @@ client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be These attributes are private in the client class and the only way to access them is using the getters/setters we provide. - ```python from nexmo import Client From b2d2c20442611ea1a5f1b8477d5fcaf26a4e0f2d Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 12:30:22 -0400 Subject: [PATCH 065/401] adding verify class --- README.md | 337 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 315 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 23a3f7ad..ca0cb5dd 100644 --- a/README.md +++ b/README.md @@ -374,53 +374,346 @@ response = client.get_recording(RECORDING_URL) ## Verify API -### Start a verification +### Verify Class + +​ + +#### How to create an instance of the class + +​ +To create an instance of the Verify class, Just follow the next steps: +​ + +- **Import the class from module** (3 different ways) + ​ + +```python +#First way +from nexmo import Verify +​ +#Second way +from nexmo.verify import Verify +​ +#Third valid way +import nexmo #then tou can use nexmo.Verify() to create an instance +``` + +​ + +- **Create the instance** + ​ + +```python +#First way - pass key and secret to the constructor +verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +​ +#Second way - Create a client instance and then pass the client to the Verify contructor +client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +verify = Verify(client) +``` + +​ + +## Code snippets + +​ + +### Search for a Verification request + +​ + +- Previous + ​ + +```python +#Check the verification status, searching by request_id +response = client.get_verification(REQUEST_ID) +​ +if response is not None: + print(response['status']) +​ +``` + +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/search-verify-request) +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.search('69e2626cbc23451fbbc02f627a959677') +​ +if response is not None: + print(response['status']) +``` + +​ +[Testing Screenshots](https://gitlab.com/codeonrocks/client/nexmo-python/-/commit/540e316140c734bd0445a9d64984ede2077159b2) +​ + +### Send verification code + +​ + +- Previous + ​ + +```python +response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc") +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +​ +``` + +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request) +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +​ + +### Send verification code with workflow + +​ + +- Previous + ​ + +```python +response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc", workflow_id=1) +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +​ +``` + +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request-with-workflow) +​ + +- New + ​ ```python -response = client.start_verification(number='441632960960', brand='MyApp') +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +​ +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +​ + +### Check verification code + +​ + +- Previous + ​ + +```python +response = client.check_verification(REQUEST_ID, code=CODE) +​ +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/check-verify-request) +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.check(REQUEST_ID, code=CODE) +​ +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +​ + +### Cancel Verification Request + +​ + +- Previous + ​ + +```python +response = client.cancel_verification(REQUEST_ID) +​ +if response["status"] == "0": + print("Cancellation successful") +else: + print("Error: %s" % response["error_text"]) +``` -if response['status'] == '0': - print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/cancel-verify-request) +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.cancel(REQUEST_ID) +​ +if response["status"] == "0": + print("Cancellation successful") else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) +​ +​ + +### Trigger next verification proccess -The response contains a verification request id which you will need to -store temporarily (in the session, database, url, etc). +​ -### Check a verification +- Previous + ​ ```python -response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') +response = client.trigger_next_verification_event(REQUEST_ID) +​ +if response["status"] == "0": + print("Next verification stage triggered") +else: + print("Error: %s" % response["error_text"]) +``` + +​ +[Reference](https://developer.nexmo.com/verify/code-snippets/trigger-next-verification-process) +​ -if response['status'] == '0': - print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.trigger_next_event(REQUEST_ID) +​ +if response["status"] == "0": + print("Next verification stage triggered") else: - print('Error:', response['error_text']) + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) +​ +​ + +### Send payment authentication code -The verification request id comes from the call to the start_verification method. -The PIN code is entered into your application by the user. +​ -### Cancel a verification +- Previous + ​ ```python -client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') +response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +​ +[Reference](https://gitlab.com/codeonrocks/client/nexmo-python-code-snippets/-/blob/psd2-request-snippet/verify/psd2_request.py) +​ -### Trigger next verification step +- New + ​ ```python -client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +​ +​ + +### Send payment authentication code with workflow + +​ + +- Previous + ​ + +```python +response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +​ + +- New + ​ + +```python +client = Client(key='API_KEY', secret='API_SECRET') +​ +verify = Verify(client) +verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +​ +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` ## Number Insight API From 2d334f0537723d143af783da67c166b0e993f322 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Tue, 25 Aug 2020 12:35:28 -0400 Subject: [PATCH 066/401] improving spacing on readme --- README.md | 110 ++++++++++++------------------------------------------ 1 file changed, 23 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index ca0cb5dd..183dad95 100644 --- a/README.md +++ b/README.md @@ -420,26 +420,18 @@ verify = Verify(client) ### Search for a Verification request -​ - - Previous - ​ ```python #Check the verification status, searching by request_id response = client.get_verification(REQUEST_ID) -​ if response is not None: print(response['status']) -​ ``` -​ [Reference](https://developer.nexmo.com/verify/code-snippets/search-verify-request) -​ - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') @@ -451,264 +443,208 @@ if response is not None: print(response['status']) ``` -​ -[Testing Screenshots](https://gitlab.com/codeonrocks/client/nexmo-python/-/commit/540e316140c734bd0445a9d64984ede2077159b2) -​ - ### Send verification code -​ - - Previous - ​ ```python response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc") -​ + if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) -​ + ``` -​ [Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request) -​ - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') -​ verify = Verify(client) response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') -​ + if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ - ### Send verification code with workflow -​ - - Previous - ​ ```python response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc", workflow_id=1) -​ + if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) -​ + ``` -​ [Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request-with-workflow) -​ - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') -​ verify = Verify(client) response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) -​ + if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ ### Check verification code -​ - Previous - ​ ```python response = client.check_verification(REQUEST_ID, code=CODE) -​ + if response["status"] == "0": print("Verification successful, event_id is %s" % (response["event_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ [Reference](https://developer.nexmo.com/verify/code-snippets/check-verify-request) -​ + - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') -​ + verify = Verify(client) response = verify.check(REQUEST_ID, code=CODE) -​ + if response["status"] == "0": print("Verification successful, event_id is %s" % (response["event_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ - ### Cancel Verification Request -​ - - Previous - ​ ```python response = client.cancel_verification(REQUEST_ID) -​ + if response["status"] == "0": print("Cancellation successful") else: print("Error: %s" % response["error_text"]) ``` -​ [Reference](https://developer.nexmo.com/verify/code-snippets/cancel-verify-request) -​ + - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') -​ verify = Verify(client) response = verify.cancel(REQUEST_ID) -​ + if response["status"] == "0": print("Cancellation successful") else: print("Error: %s" % response["error_text"]) ``` -​ -​ ### Trigger next verification proccess -​ - - Previous - ​ ```python response = client.trigger_next_verification_event(REQUEST_ID) -​ + if response["status"] == "0": print("Next verification stage triggered") else: print("Error: %s" % response["error_text"]) ``` -​ [Reference](https://developer.nexmo.com/verify/code-snippets/trigger-next-verification-process) -​ - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') -​ + verify = Verify(client) response = verify.trigger_next_event(REQUEST_ID) -​ + if response["status"] == "0": print("Next verification stage triggered") else: print("Error: %s" % response["error_text"]) ``` -​ -​ ### Send payment authentication code -​ - Previous - ​ ```python response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) -​ + if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ [Reference](https://gitlab.com/codeonrocks/client/nexmo-python-code-snippets/-/blob/psd2-request-snippet/verify/psd2_request.py) -​ - New - ​ ```python client = Client(key='API_KEY', secret='API_SECRET') -​ + verify = Verify(client) response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) -​ + if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ -​ - ### Send payment authentication code with workflow -​ - - Previous - ​ ```python response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) -​ + if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) else: print("Error: %s" % response["error_text"]) ``` -​ - - New - ​ + ```python client = Client(key='API_KEY', secret='API_SECRET') -​ + verify = Verify(client) verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) -​ + if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) else: From 8c0244305f6c7749e2069469127c8d719b7fce57 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Tue, 25 Aug 2020 13:45:15 -0400 Subject: [PATCH 067/401] updating readme --- README.md | 393 +++++------------------------------------------------- 1 file changed, 35 insertions(+), 358 deletions(-) diff --git a/README.md b/README.md index 183dad95..d7fe45d8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ need a Nexmo account. Sign up [for free at nexmo.com][signup]. - [Managing Secrets](#managing-secrets) - [Application API](#application-api) - [Overriding API Attributes](#overriding-api-attributes) +- [Frequently Asked Questions](#frequently-asked-questions) - [License](#license) ## Installation @@ -83,7 +84,7 @@ from nexmo import Sms from nexmo.sms import Sms #Option 3 -import nexmo #then tou can use nexmo.Sms() to create an instance +import nexmo #then you can use nexmo.Sms() to create an instance ``` - Create an instance @@ -99,20 +100,6 @@ sms = Sms(client) ### Send an SMS -```python - responseData = client.send_message( - { - "from": NEXMO_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Nexmo SMS API", - } - ) -``` - -Reference: [Send sms](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms) - -**Using the Sms class** - ```python from nexmo import Sms sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) @@ -125,19 +112,6 @@ sms.send_message({ ### Send SMS with unicode -```python -responseData = client.send_message({ - 'from': NEXMO_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'こんにちは世界', - 'type': 'unicode', -}) -``` - -Reference: [Send sms with unicode](https://developer.nexmo.com/messaging/sms/code-snippets/send-an-sms-with-unicode) - -**Using Sms Class** - ```python sms.send_message({ 'from': NEXMO_BRAND_NAME, @@ -149,12 +123,6 @@ sms.send_message({ ### Submit SMS Conversion -```python -client.submit_sms_conversion("a-message-id") -``` - -**With the SMS Class** - ```python from nexmo import Client, Sms client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) @@ -171,18 +139,6 @@ sms.submit_sms_conversion(response['message-id']) ### Make a call -```python -response = client.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -``` - -Docs: [https://developer.nexmo.com/api/voice#createCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#createCall) - -**with voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -196,14 +152,6 @@ voice.create_all({ ### Retrieve a list of calls -```python -response = client.get_calls() -``` - -Docs: [https://developer.nexmo.com/api/voice#getCalls](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCalls) - -**with voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -213,14 +161,6 @@ voice.get_calls() ### Retrieve a single call -```python -response = client.get_call(uuid) -``` - -Docs: [https://developer.nexmo.com/api/voice#getCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCall) - -**with voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -230,14 +170,6 @@ voice.get_call(uuid) ### Update a call -```python -response = client.update_call(uuid, action='hangup') -``` - -Docs: [https://developer.nexmo.com/api/voice#updateCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#updateCall) - -**with voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -252,16 +184,6 @@ voice.update_call(response['uuid'], action='hangup') ### Stream audio to a call -```python -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' - -response = client.send_audio(uuid, stream_url=[stream_url]) -``` - -Docs: [https://developer.nexmo.com/api/voice#startStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startStream) - -**with voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -277,14 +199,6 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) ### Stop streaming audio to a call -```python -response = client.stop_audio(uuid) -``` - -Docs: [https://developer.nexmo.com/api/voice#stopStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopStream) - -**Using voice class** - ```python from nexmo import Client, Voice client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') @@ -301,14 +215,6 @@ voice.stop_audio(response['uuid']) ### Send a synthesized speech message to a call -```python -response = client.send_speech(uuid, text='Hello') -``` - -Docs: [https://developer.nexmo.com/api/voice#startTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startTalk) - -**Using voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -323,14 +229,6 @@ voice.send_speech(response['uuid'], text='Hello from nexmo') ### Stop sending a synthesized speech message to a call -```python -response = client.stop_speech(uuid) -``` - -Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopTalk) - -**Using voice class** - ```python >>> from nexmo import Client, Voice >>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) @@ -346,14 +244,6 @@ Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.c ### Send DTMF tones to a call -```python -response = client.send_dtmf(uuid, digits='1234') -``` - -Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startDTMF) - -**Using voice class** - ```python from nexmo import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) @@ -376,16 +266,12 @@ response = client.get_recording(RECORDING_URL) ### Verify Class -​ +#### Creating an instance of the class -#### How to create an instance of the class - -​ To create an instance of the Verify class, Just follow the next steps: ​ - **Import the class from module** (3 different ways) - ​ ```python #First way @@ -398,8 +284,6 @@ from nexmo.verify import Verify import nexmo #then tou can use nexmo.Verify() to create an instance ``` -​ - - **Create the instance** ​ @@ -412,245 +296,6 @@ client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) verify = Verify(client) ``` -​ - -## Code snippets - -​ - -### Search for a Verification request - -- Previous - -```python -#Check the verification status, searching by request_id -response = client.get_verification(REQUEST_ID) -if response is not None: - print(response['status']) -``` - -[Reference](https://developer.nexmo.com/verify/code-snippets/search-verify-request) - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') -​ -verify = Verify(client) -response = verify.search('69e2626cbc23451fbbc02f627a959677') -​ -if response is not None: - print(response['status']) -``` - -### Send verification code - -- Previous - -```python -response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc") - -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) - -``` - -[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request) - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') - -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -### Send verification code with workflow - -- Previous - -```python -response = client.start_verification(number=RECIPIENT_NUMBER, brand="AcmeInc", workflow_id=1) - -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) - -``` - -[Reference](https://developer.nexmo.com/verify/code-snippets/send-verify-request-with-workflow) - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) - -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - - -### Check verification code - - -- Previous - -```python -response = client.check_verification(REQUEST_ID, code=CODE) - -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -[Reference](https://developer.nexmo.com/verify/code-snippets/check-verify-request) - - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.check(REQUEST_ID, code=CODE) - -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -### Cancel Verification Request - -- Previous - -```python -response = client.cancel_verification(REQUEST_ID) - -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) -``` - -[Reference](https://developer.nexmo.com/verify/code-snippets/cancel-verify-request) - - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.cancel(REQUEST_ID) - -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) -``` - - -### Trigger next verification proccess - -- Previous - -```python -response = client.trigger_next_verification_event(REQUEST_ID) - -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) -``` - -[Reference](https://developer.nexmo.com/verify/code-snippets/trigger-next-verification-process) - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.trigger_next_event(REQUEST_ID) - -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) -``` - - -### Send payment authentication code - - -- Previous - -```python -response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) - -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -[Reference](https://gitlab.com/codeonrocks/client/nexmo-python-code-snippets/-/blob/psd2-request-snippet/verify/psd2_request.py) - -- New - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) - -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -### Send payment authentication code with workflow - -- Previous - -```python -response = client.start_psd2_verification_request(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) - -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - -- New - - -```python -client = Client(key='API_KEY', secret='API_SECRET') - -verify = Verify(client) -verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) - -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) -``` - ## Number Insight API ### Basic Number Insight @@ -807,6 +452,38 @@ print(client.api_host()) # returns api.nexmo.com client.api_host('myapi.nexmo.com') # rewrite the value of api_host ``` +## Frequently Asked Questions + +### Dropping support for Python 2.7 + +Back in 2014 when Guido van Rossum, Python's creator and principal author, made the announcement, January 1, 2020 seemed pretty far away. Python 2.7’s sunset has happened, after which there’ll be absolutely no more support from the core Python team. Many utilized projects pledge to drop Python 2 support in or before 2020. [(Official statement here)](https://www.python.org/doc/sunset-python-2/). + +Just because 2.7 isn’t going to be maintained past 2020 doesn’t mean your applications or libraries suddenly stop working but as of this moment we won't give official support for upcoming releases. Please read the official ["Porting Python 2 Code to Python 3" guide](https://docs.python.org/3/howto/pyporting.html). Please also read the [Python 3 Statement Practicalities](https://python3statement.org/practicalities/) for advice on sunsetting your Python 2 code. + +### Supported APIs + +The following is a list of Vonage APIs and whether the Python SDK provides support for them: + +| API | API Release Status | Supported? | +| --------------------- | :------------------: | :--------: | +| Account API | General Availability | ✅ | +| Alerts API | General Availability | ✅ | +| Application API | General Availability | ✅ | +| Audit API | Beta | ❌ | +| Conversation API | Beta | ❌ | +| Dispatch API | Beta | ❌ | +| External Accounts API | Beta | ❌ | +| Media API | Beta | ❌ | +| Messages API | Beta | ❌ | +| Number Insight API | General Availability | ✅ | +| Number Management API | General Availability | ✅ | +| Pricing API | General Availability | ✅ | +| Redact API | General Availability | ✅ | +| Reports API | Beta | ❌ | +| SMS API | General Availability | ✅ | +| Verify API | General Availability | ✅ | +| Voice API | General Availability | ✅ | + ## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! From 980b286d2f53235ac62e969969f58beb36f3d5c0 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 25 Aug 2020 14:25:04 -0400 Subject: [PATCH 068/401] Putting some changes back to reduce the diff --- README.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++++------ setup.py | 2 +- 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d7fe45d8..edb66d23 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,18 @@ This is the Python client library for Nexmo's API. To use it you'll need a Nexmo account. Sign up [for free at nexmo.com][signup]. -- [Installation](#installation) -- [Usage](#usage) -- [SMS API](#sms-api) -- [Voice API](#voice-api) -- [Verify API](#verify-api) -- [Number Insight API](#number-insight-api) -- [Number Management API](#number-management-api) -- [Managing Secrets](#managing-secrets) -- [Application API](#application-api) -- [Overriding API Attributes](#overriding-api-attributes) -- [Frequently Asked Questions](#frequently-asked-questions) -- [License](#license) +* [Installation](#installation) +* [Usage](#usage) +* [SMS API](#sms-api) +* [Voice API](#voice-api) +* [Verify API](#verify-api) +* [Number Insight API](#number-insight-api) +* [Number Management API](#number-management-api) +* [Managing Secrets](#managing-secrets) +* [Application API](#application-api) +* [Overriding API Attributes](#overriding-api-attributes) +* [Frequently Asked Questions](#frequently-asked-questions) +* [License](#license) ## Installation @@ -281,7 +281,7 @@ from nexmo import Verify from nexmo.verify import Verify ​ #Third valid way -import nexmo #then tou can use nexmo.Verify() to create an instance +import nexmo #then you can use nexmo.Verify() to create an instance ``` - **Create the instance** @@ -296,6 +296,100 @@ client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) verify = Verify(client) ``` +### Search for a Verification request + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.search('69e2626cbc23451fbbc02f627a959677') +if response is not None: + print(response['status']) +``` + +### Send verification code + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Send verification code with workflow + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +if response["status"] == "0": + print("Started verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Check verification code + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.check(REQUEST_ID, code=CODE) +if response["status"] == "0": + print("Verification successful, event_id is %s" % (response["event_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Cancel Verification Request + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.cancel(REQUEST_ID) +if response["status"] == "0": + print("Cancellation successful") +else: + print("Error: %s" % response["error_text"]) +``` + +### Trigger next verification proccess + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.trigger_next_event(REQUEST_ID) +if response["status"] == "0": + print("Next verification stage triggered") +else: + print("Error: %s" % response["error_text"]) +``` + +### Send payment authentication code + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + +### Send payment authentication code with workflow + +```python +client = Client(key='API_KEY', secret='API_SECRET') +verify = Verify(client) +verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +if response["status"] == "0": + print("Started PSD2 verification request_id is %s" % (response["request_id"])) +else: + print("Error: %s" % response["error_text"]) +``` + ## Number Insight API ### Basic Number Insight diff --git a/setup.py b/setup.py index 7176fa10..99add45f 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ description="Nexmo Client Library for Python", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/nexmo/nexmo-python", + url="https://github.com/Nexmo/nexmo-python", author="Nexmo", author_email="devrel@nexmo.com", license="MIT", From 0ac9be63a01cf3822aff42fdd33c23f10f30bbcd Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 25 Aug 2020 14:27:11 -0400 Subject: [PATCH 069/401] Cleaned up verify whitespace --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index edb66d23..166b372a 100644 --- a/README.md +++ b/README.md @@ -300,8 +300,10 @@ verify = Verify(client) ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.search('69e2626cbc23451fbbc02f627a959677') + if response is not None: print(response['status']) ``` @@ -310,8 +312,10 @@ if response is not None: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') + if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) else: @@ -322,8 +326,10 @@ else: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) + if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) else: @@ -334,8 +340,10 @@ else: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.check(REQUEST_ID, code=CODE) + if response["status"] == "0": print("Verification successful, event_id is %s" % (response["event_id"])) else: @@ -346,8 +354,10 @@ else: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.cancel(REQUEST_ID) + if response["status"] == "0": print("Cancellation successful") else: @@ -358,8 +368,10 @@ else: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.trigger_next_event(REQUEST_ID) + if response["status"] == "0": print("Next verification stage triggered") else: @@ -370,8 +382,10 @@ else: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) + if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) else: @@ -382,8 +396,10 @@ else: ```python client = Client(key='API_KEY', secret='API_SECRET') + verify = Verify(client) verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) + if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) else: From 3e5dadced119a840c6d32871cbe8189af07b84c5 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 25 Aug 2020 14:28:17 -0400 Subject: [PATCH 070/401] Fixed heading sizes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 166b372a..98a27e4d 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ environment variable). ## SMS API -## SMS Class +### SMS Class -### Creating an instance of the SMS class +#### Creating an instance of the SMS class To create an instance of the SMS class follow these steps: From d7fec48596069cd801c4300b609ca54edcb0dd6c Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 25 Aug 2020 18:51:48 -0400 Subject: [PATCH 071/401] adding tests and deprecated decorator --- CHANGES.md | 94 +-- requirements.txt | 2 +- setup.py | 9 +- src/nexmo/__init__.py | 1489 +++++++++++++++++++++-------------------- tests/test_sms.py | 153 +++-- tests/test_verify.py | 286 +++++--- tests/test_voice.py | 450 ++++++++----- 7 files changed, 1388 insertions(+), 1095 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0a9e8d51..567cc906 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,92 +1,106 @@ +# 2.5.x + +- Support for Independent SMS, Voice and Verify APIs with tests as well as current client methods +- Getters/Setters to extract/rewrite custom attributes +- PSD2 Verification support +- Dropping support for Python 2.7 +- Roadmap to better error handling +- Supporting Python 3.8 + # 2.4.0 -* Application V2 API added under `Client.application_v2` -* Existing application methods under `Client` are now deprecated. + +- Application V2 API added under `Client.application_v2` +- Existing application methods under `Client` are now deprecated. # 2.3.0 -* Explicit parameter list for the `nexmo.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** -* Secret Management -* Support for Authorization header authentication. + +- Explicit parameter list for the `nexmo.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** +- Secret Management +- Support for Authorization header authentication. # 2.2.0 -* Add support for `redact_transaction`. + +- Add support for `redact_transaction`. # 2.1.0 -* Add support for `get_recording` -* Add support for SMS conversion -* Add debug logging for most calls, under the 'nexmo' logger. -* Internal refactoring (affects only private methods.) + +- Add support for `get_recording` +- Add support for SMS conversion +- Add debug logging for most calls, under the 'nexmo' logger. +- Internal refactoring (affects only private methods.) # 2.0.0 -* Drop support for Python 3.3 (in line with the cryptography library we depend upon) -* Ensure timestamp is added the params list if signing requests -* Avoid value injection in signature auth. -* Add support for different hashes for signature generation (thanks @trancee!) -* Tests ported to pytest + +- Drop support for Python 3.3 (in line with the cryptography library we depend upon) +- Ensure timestamp is added the params list if signing requests +- Avoid value injection in signature auth. +- Add support for different hashes for signature generation (thanks @trancee!) +- Tests ported to pytest # 1.5.0 -* Add ability to provide a file path as private_key param no the nexmo.Client constructor +- Add ability to provide a file path as private_key param no the nexmo.Client constructor -* Add send/stop endpoints for audio/speech/dtmf +- Add send/stop endpoints for audio/speech/dtmf -* Add new number insight endpoints +- Add new number insight endpoints # 1.4.0 -* Add new Voice API call methods +- Add new Voice API call methods -* Add Application API methods +- Add Application API methods -* Add check_signature method for checking callback signatures +- Add check_signature method for checking callback signatures -* Deprecate old Verify API methods +- Deprecate old Verify API methods # 1.3.0 -* Add get_sms_pricing method +- Add get_sms_pricing method -* Add get_voice_pricing method +- Add get_voice_pricing method -* Add get_event_alert_numbers method to get opt-in/opt-out numbers +- Add get_event_alert_numbers method to get opt-in/opt-out numbers -* Add resubscribe_event_alert_number method to opt-in a number +- Add resubscribe_event_alert_number method to opt-in a number -* Add more clearly named methods for Verify API +- Add more clearly named methods for Verify API -* Add app_name and app_version options +- Add app_name and app_version options # 1.2.0 -* Add topup method +- Add topup method -* Add update_settings method +- Add update_settings method -* Add api_host attribute +- Add api_host attribute -* Add ClientError and ServerError classes +- Add ClientError and ServerError classes # 1.1.0 -* Move repository to https://github.com/Nexmo/nexmo-python +- Move repository to https://github.com/Nexmo/nexmo-python -* Add get_basic_number_insight method for Number Insight Basic API +- Add get_basic_number_insight method for Number Insight Basic API -* Add get_number_insight method for Number Insight Standard API +- Add get_number_insight method for Number Insight Standard API -* Add User-Agent header to requests +- Add User-Agent header to requests # 1.0.3 -* Change license from LGPL-3.0 to MIT +- Change license from LGPL-3.0 to MIT # 1.0.2 -* Remove merge helper function +- Remove merge helper function # 1.0.1 -* Python 3.x fixes +- Python 3.x fixes # 1.0.0 -* First version! +- First version! diff --git a/requirements.txt b/requirements.txt index 91203619..cf6661f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ pytest==3.1.2 pytest-cov==2.5.1 responses==0.5.1 coveralls -glom==18.3.1 +glom==18.3.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 99add45f..6a7a4da9 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="nexmo", - version="2.5.1", + version="2.5.2", description="Nexmo Client Library for Python", long_description=long_description, long_description_content_type="text/markdown", @@ -22,7 +22,12 @@ packages=find_packages(where="src"), package_dir={"": "src"}, platforms=["any"], - install_requires=["requests>=2.4.2", "PyJWT[crypto]>=1.6.4", "pytz>=2018.5"], + install_requires=[ + "requests>=2.4.2", + "PyJWT[crypto]>=1.6.4", + "pytz>=2018.5", + "Deprecated", + ], python_requires=">=3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", tests_require=["cryptography>=2.3.1"], classifiers=[ diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 9aa1c119..798cde79 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,736 +1,753 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param -from .errors import * -from .voice import * -from .sms import * -from .verify import * -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import sys -import time -from uuid import uuid4 -import warnings -import re - - -string_types = (str, bytes) -from urllib.parse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - - -__version__ = "2.4.0" - -logger = logging.getLogger("nexmo") - - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("NEXMO_API_KEY", None) - - self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "NEXMO_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "NEXMO_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" - - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for host') - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for api_host') - else: - self.__api_host = value - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#send_verification_request is deprecated (use #start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#check_verification_request is deprecated (use #check_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "nexmo.Client#get_verification_request is deprecated (use #get_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) - else: - raise ClientError("Error: Callback needed for async advanced number insight") - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "nexmo.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "nexmo.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "nexmo.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): - """ - Low-level method to make a post request to a Nexmo API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse( - host, self.session.delete(uri, params=params, headers=headers) - ) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - return jwt.encode(payload, self.private_key, algorithm="RS256") +from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .errors import * +from .voice import * +from .sms import * +from .verify import * +from datetime import datetime +import logging +from platform import python_version + +import base64 +import hashlib +import hmac +import jwt +import os +import pytz +import requests +import sys +import time +from uuid import uuid4 +import warnings +import re +from deprecated import deprecated + + +string_types = (str, bytes) +from urllib.parse import urlparse + +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + + +__version__ = "2.4.0" + +logger = logging.getLogger("nexmo") + + +class Client: + """ + Create a Client object to start making calls to Nexmo APIs. + + Most methods corresponding to Nexmo API calls are on this class itself, + although newer APIs are under namespaces like :attr:`Client.application_v2`. + + The credentials you provide when instantiating a Client determine which + methods can be called. Consult the `Nexmo API docs `_ for details of the + authentication used by the APIs you wish to use, and instantiate your + Client with the appropriate credentials. + + :param str key: Your Nexmo API key + :param str secret: Your Nexmo API secret. + :param str signature_secret: Your Nexmo API signature secret. + You may need to have this enabled by Nexmo support. It is only used for SMS authentication. + :param str signature_method: + The encryption method used for signature encryption. This must match the method + configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. + This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. + If you want to use a simple MD5 hash, leave this as `None`. + :param str application_id: Your application ID if calling methods which use JWT authentication. + :param str private_key: Your private key if calling methods which use JWT authentication. + This should either be a str containing the key in its PEM form, or a path to a private key file. + :param str app_name: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + :param str app_version: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + """ + + def __init__( + self, + key=None, + secret=None, + signature_secret=None, + signature_method=None, + application_id=None, + private_key=None, + app_name=None, + app_version=None, + ): + self.api_key = key or os.environ.get("NEXMO_API_KEY", None) + + self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) + + self.signature_secret = signature_secret or os.environ.get( + "NEXMO_SIGNATURE_SECRET", None + ) + + self.signature_method = signature_method or os.environ.get( + "NEXMO_SIGNATURE_METHOD", None + ) + + if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: + self.signature_method = getattr(hashlib, signature_method) + + self.application_id = application_id + + self.private_key = private_key + + if isinstance(self.private_key, string_types) and "\n" not in self.private_key: + with open(self.private_key, "rb") as key_file: + self.private_key = key_file.read() + + self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' + + self.__host = "rest.nexmo.com" + + self.__api_host = "api.nexmo.com" + + user_agent = "nexmo-python/{version} python/{python_version}".format( + version=__version__, python_version=python_version() + ) + + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) + + self.headers = {"User-Agent": user_agent} + + self.auth_params = {} + + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) + + self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for host') + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for api_host') + else: + self.__api_host = value + + def auth(self, params=None, **kwargs): + self.auth_params = params or kwargs + + @deprecated(reason="nexmo.Client#send_message is deprecated. Use Sms#send_message instead") + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_NEXMO_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + + def get_balance(self): + return self.get(self.host(), "/account/get-balance") + + def get_country_pricing(self, country_code): + return self.get( + self.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + def get_prefix_pricing(self, prefix): + return self.get( + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) + + def get_sms_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + def get_voice_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + def update_settings(self, params=None, **kwargs): + return self.post(self.host(), "/account/settings", params or kwargs) + + def topup(self, params=None, **kwargs): + return self.post(self.host(), "/account/top-up", params or kwargs) + + def get_account_numbers(self, params=None, **kwargs): + return self.get(self.host(), "/account/numbers", params or kwargs) + + def get_available_numbers(self, country_code, params=None, **kwargs): + return self.get( + self.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + def buy_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/buy", params or kwargs) + + def cancel_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/cancel", params or kwargs) + + def update_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/update", params or kwargs) + + def get_message(self, message_id): + return self.get(self.host(), "/search/message", {"id": message_id}) + + def get_message_rejections(self, params=None, **kwargs): + return self.get(self.host(), "/search/rejections", params or kwargs) + + def search_messages(self, params=None, **kwargs): + return self.get(self.host(), "/search/messages", params or kwargs) + + def send_ussd_push_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd/json", params or kwargs) + + def send_ussd_prompt_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + + def send_2fa_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) + + def send_event_alert_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + + def send_marketing_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + + def get_event_alert_numbers(self): + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) + + @deprecated(reason="nexmo.Client#start_verification is deprecated. Use Verify#start_verification instead") + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#send_verification_request is deprecated (use Verify#start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + @deprecated(reason="nexmo.Client#check_verification is deprecated. Use Verify#check instead") + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#check_verification_request is deprecated (use Verify#check instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + @deprecated(reason="nexmo.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead") + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) + + @deprecated(reason="nexmo.Client#get_verification is deprecated. Use Verify#search instead") + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "nexmo.Client#get_verification_request is deprecated (use Verify#search instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + @deprecated(reason="nexmo.Client#cancel_verification is deprecated. Use Verify#cancel instead") + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + @deprecated(reason="nexmo.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead") + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) + + def get_basic_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + + def get_standard_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) + else: + raise ClientError("Error: Callback needed for async advanced number insight") + + def get_advanced_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + + def request_number_insight(self, params=None, **kwargs): + return self.post(self.host(), "/ni/json", params or kwargs) + + def get_applications(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#get_applications is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get(self.api_host(), "/v1/applications", params or kwargs) + + def get_application(self, application_id): + warnings.warn( + "nexmo.Client#get_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + def create_application(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#create_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.post(self.api_host(), "/v1/applications", params or kwargs) + + def update_application(self, application_id, params=None, **kwargs): + warnings.warn( + "nexmo.Client#update_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.put( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + params or kwargs, + ) + + def delete_application(self, application_id): + warnings.warn( + "nexmo.Client#delete_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.delete( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + @deprecated(reason="nexmo.Client#create_call is deprecated. Use Voice#create_call instead") + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + @deprecated(reason="nexmo.Client#get_calls is deprecated. Use Voice#get_calls instead") + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + @deprecated(reason="nexmo.Client#get_call is deprecated. Use Voice#get_call instead") + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + @deprecated(reason="nexmo.Client#update_call is deprecated. Use Voice#update_call instead") + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="nexmo.Client#send_audio is deprecated. Use Voice#send_audio instead") + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="nexmo.Client#stop_audio is deprecated. Use Voice#stop_audio instead") + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + @deprecated(reason="nexmo.Client#send_speech is deprecated. Use Voice#send_speech instead") + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="nexmo.Client#stop_speech is deprecated. Use Voice#stop_speech instead") + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + @deprecated(reason="nexmo.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead") + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + def get_recording(self, url): + hostname = urlparse(url).hostname + return self.parse(hostname, self.session.get(url, headers=self._headers())) + + def redact_transaction(self, id, product, type=None): + params = {"id": id, "product": product} + if type is not None: + params["type"] = type + return self._post_json(self.api_host(), "/v1/redact/transaction", params) + + def list_secrets(self, api_key): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) + + def get_secret(self, api_key, secret_id): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._post_json( + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + ) + + def delete_secret(self, api_key, secret_id): + return self.delete( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def check_signature(self, params): + params = dict(params) + signature = params.pop("sig", "").lower() + return hmac.compare_digest(signature, self.signature(params)) + + def signature(self, params): + if self.signature_method: + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) + else: + hasher = hashlib.md5() + + # Add timestamp if not already present + if not params.get("timestamp"): + params["timestamp"] = int(time.time()) + + for key in sorted(params): + value = params[key] + + if isinstance(value, str): + value = value.replace("&", "_").replace("=", "_") + + hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) + + if self.signature_method is None: + hasher.update(self.signature_secret.encode()) + + return hasher.hexdigest() + + def get(self, host, request_uri, params=None, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) + logger.debug("GET to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.get(uri, params=params, headers=headers)) + + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): + """ + Low-level method to make a post request to a Nexmo API server. + This method automatically adds authentication, picking the first applicable authentication method from the following: + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. + - Otherwise the client's key and secret are appended to the post request's params. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if supports_signature_auth and self.signature_secret: + params["api_key"] = self.api_key + params["sig"] = self.signature(params) + elif header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("POST to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.post(uri, data=params, headers=headers)) + + def _post_json(self, host, request_uri, json): + """ + Post json to `request_uri`, using basic auth. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + auth = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) + return self.parse(host, self.session.post(uri, headers=headers, json=json)) + + def put(self, host, request_uri, params, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.put(uri, json=params, headers=headers)) + + def delete(self, host, request_uri, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + params = None + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = {"api_key": self.api_key, "api_secret": self.api_secret} + logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) + return self.parse( + host, self.session.delete(uri, params=params, headers=headers) + ) + + def parse(self, host, response): + logger.debug("Response headers %r", response.headers) + if response.status_code == 401: + raise AuthenticationError + elif response.status_code == 204: + return None + elif 200 <= response.status_code < 300: + + # Strip off any encoding from the content-type header: + content_mime = response.headers.get("content-type").split(";", 1)[0] + if content_mime == "application/json": + return response.json() + else: + return response.content + elif 400 <= response.status_code < 500: + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + + # Test for standard error format: + try: + error_data = response.json() + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): + message = "{title}: {detail} ({type})".format( + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], + ) + except JSONDecodeError: + pass + raise ClientError(message) + elif 500 <= response.status_code < 600: + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + raise ServerError(message) + + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.post(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.delete(uri, headers=self._headers()) + ) + + def _headers(self): + token = self.generate_application_jwt() + return dict(self.headers, Authorization=b"Bearer " + token) + + def generate_application_jwt(self, when=None): + iat = int(when if when is not None else time.time()) + + payload = dict(self.auth_params) + payload.setdefault("application_id", self.application_id) + payload.setdefault("iat", iat) + payload.setdefault("exp", iat + 60) + payload.setdefault("jti", str(uuid4())) + + return jwt.encode(payload, self.private_key, algorithm="RS256") diff --git a/tests/test_sms.py b/tests/test_sms.py index fda960e8..d20f32d4 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,52 +1,101 @@ -import nexmo -from util import * - - -@responses.activate -def test_send_message(sms, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(sms.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(nexmo.AuthenticationError): - sms.send_message({}) - - -@responses.activate -def test_client_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(nexmo.ClientError) as excinfo: - sms.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(nexmo.ServerError) as excinfo: - sms.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(sms): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - sms.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() +import nexmo +from util import * + + +@responses.activate +def test_send_message(sms, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(sms.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_authentication_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(nexmo.AuthenticationError): + sms.send_message({}) + + +@responses.activate +def test_client_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(nexmo.ClientError) as excinfo: + sms.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_server_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(nexmo.ServerError) as excinfo: + sms.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_submit_sms_conversion(sms): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + sms.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() + +@responses.activate +def test_deprecated_send_message(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(client.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_deprecated_authentication_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(nexmo.AuthenticationError): + client.send_message({}) + + +@responses.activate +def test_deprecated_client_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(nexmo.ClientError) as excinfo: + client.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_server_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(nexmo.ServerError) as excinfo: + client.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_submit_sms_conversion(client): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + client.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py index d35942e3..76cfc042 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,112 +1,176 @@ -from util import * - - -@responses.activate -def test_start_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_start_psd2_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_psd2_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() +from util import * + +@responses.activate +def test_start_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_check_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + verify.check("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_get_verification(verify, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(verify.search("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_cancel_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_trigger_next_verification_event(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_start_psd2_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.psd2(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + +@responses.activate +def test_deprecated_start_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_send_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.send_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_check_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_check_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.check_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_get_verification(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_get_verification_request(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification_request("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_cancel_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_trigger_next_verification_event(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_control_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.control_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_deprecated_start_psd2_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_psd2_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file diff --git a/tests/test_voice.py b/tests/test_voice.py index bae67983..a80c8377 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -1,153 +1,297 @@ -import os.path -import time - -import jwt - -import nexmo -from util import * - - -@responses.activate -def test_create_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(voice.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id +import os.path +import time + +import jwt + +import nexmo +from util import * + + +@responses.activate +def test_create_call(voice, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(voice.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_get_calls(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(voice.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_get_call(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_update_call(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_send_audio(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_stop_audio(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_speech(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_stop_speech(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_dtmf(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = nexmo.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_authorization_with_private_key_object(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_create_call(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(client.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_deprecated_get_calls(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(client.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_get_call(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_update_call(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_deprecated_send_audio(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_deprecated_stop_audio(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_speech(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_deprecated_stop_speech(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_dtmf(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_deprecated_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + client.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_deprecated_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = nexmo.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_authorization_with_private_key_object(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id From ce4bdaad4be94de863ba1e17e34f3c88ca429ae6 Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 26 Aug 2020 14:08:04 -0400 Subject: [PATCH 072/401] correcting ^M --- tests/test_sms.py | 202 +++++++-------- tests/test_verify.py | 350 ++++++++++++------------- tests/test_voice.py | 594 +++++++++++++++++++++---------------------- 3 files changed, 573 insertions(+), 573 deletions(-) diff --git a/tests/test_sms.py b/tests/test_sms.py index d20f32d4..005fcc56 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,101 +1,101 @@ -import nexmo -from util import * - - -@responses.activate -def test_send_message(sms, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(sms.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(nexmo.AuthenticationError): - sms.send_message({}) - - -@responses.activate -def test_client_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(nexmo.ClientError) as excinfo: - sms.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(nexmo.ServerError) as excinfo: - sms.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(sms): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - sms.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() - -@responses.activate -def test_deprecated_send_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(client.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_deprecated_authentication_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(nexmo.AuthenticationError): - client.send_message({}) - - -@responses.activate -def test_deprecated_client_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(nexmo.ClientError) as excinfo: - client.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_server_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(nexmo.ServerError) as excinfo: - client.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_submit_sms_conversion(client): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - client.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() +import nexmo +from util import * + + +@responses.activate +def test_send_message(sms, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(sms.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_authentication_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(nexmo.AuthenticationError): + sms.send_message({}) + + +@responses.activate +def test_client_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(nexmo.ClientError) as excinfo: + sms.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_server_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(nexmo.ServerError) as excinfo: + sms.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_submit_sms_conversion(sms): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + sms.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() + +@responses.activate +def test_deprecated_send_message(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(client.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_deprecated_authentication_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(nexmo.AuthenticationError): + client.send_message({}) + + +@responses.activate +def test_deprecated_client_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(nexmo.ClientError) as excinfo: + client.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_server_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(nexmo.ServerError) as excinfo: + client.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_submit_sms_conversion(client): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + client.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py index 76cfc042..8ec2ba54 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,176 +1,176 @@ -from util import * - -@responses.activate -def test_start_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - verify.check("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(verify, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(verify.search("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_start_psd2_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.psd2(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - -@responses.activate -def test_deprecated_start_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_check_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_get_verification(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_cancel_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_trigger_next_verification_event(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_deprecated_start_psd2_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_psd2_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() +from util import * + +@responses.activate +def test_start_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_check_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + verify.check("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_get_verification(verify, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(verify.search("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_cancel_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_trigger_next_verification_event(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_start_psd2_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.psd2(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + +@responses.activate +def test_deprecated_start_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_send_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.send_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_check_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_check_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.check_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_get_verification(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_get_verification_request(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification_request("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_cancel_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_trigger_next_verification_event(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_control_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.control_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_deprecated_start_psd2_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_psd2_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file diff --git a/tests/test_voice.py b/tests/test_voice.py index a80c8377..808484c8 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -1,297 +1,297 @@ -import os.path -import time - -import jwt - -import nexmo -from util import * - - -@responses.activate -def test_create_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(voice.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_create_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(client.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_deprecated_get_calls(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(client.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_get_call(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_update_call(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_deprecated_send_audio(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_deprecated_stop_audio(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_speech(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_deprecated_stop_speech(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_dtmf(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_deprecated_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_deprecated_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_authorization_with_private_key_object(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id +import os.path +import time + +import jwt + +import nexmo +from util import * + + +@responses.activate +def test_create_call(voice, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(voice.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_get_calls(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(voice.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_get_call(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_update_call(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_send_audio(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_stop_audio(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_speech(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_stop_speech(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_dtmf(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = nexmo.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + voice = nexmo.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_authorization_with_private_key_object(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_create_call(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(client.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_deprecated_get_calls(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(client.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_get_call(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_update_call(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_deprecated_send_audio(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_deprecated_stop_audio(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_speech(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_deprecated_stop_speech(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_dtmf(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_deprecated_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + client.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_deprecated_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = nexmo.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_authorization_with_private_key_object(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id From 4a252b4b495c81235293518c8392f6a2dbf2b3c9 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Wed, 26 Aug 2020 16:39:08 -0400 Subject: [PATCH 073/401] Removed CRLF --- src/nexmo/__init__.py | 1506 ++++++++++++++++++++--------------------- 1 file changed, 753 insertions(+), 753 deletions(-) diff --git a/src/nexmo/__init__.py b/src/nexmo/__init__.py index 798cde79..68b68e40 100644 --- a/src/nexmo/__init__.py +++ b/src/nexmo/__init__.py @@ -1,753 +1,753 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param -from .errors import * -from .voice import * -from .sms import * -from .verify import * -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import sys -import time -from uuid import uuid4 -import warnings -import re -from deprecated import deprecated - - -string_types = (str, bytes) -from urllib.parse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - - -__version__ = "2.4.0" - -logger = logging.getLogger("nexmo") - - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("NEXMO_API_KEY", None) - - self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "NEXMO_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "NEXMO_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" - - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for host') - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for api_host') - else: - self.__api_host = value - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - @deprecated(reason="nexmo.Client#send_message is deprecated. Use Sms#send_message instead") - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - @deprecated(reason="nexmo.Client#start_verification is deprecated. Use Verify#start_verification instead") - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated(reason="nexmo.Client#check_verification is deprecated. Use Verify#check instead") - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated(reason="nexmo.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead") - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated(reason="nexmo.Client#get_verification is deprecated. Use Verify#search instead") - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "nexmo.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated(reason="nexmo.Client#cancel_verification is deprecated. Use Verify#cancel instead") - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated(reason="nexmo.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead") - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) - else: - raise ClientError("Error: Callback needed for async advanced number insight") - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "nexmo.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "nexmo.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "nexmo.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - @deprecated(reason="nexmo.Client#create_call is deprecated. Use Voice#create_call instead") - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated(reason="nexmo.Client#get_calls is deprecated. Use Voice#get_calls instead") - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated(reason="nexmo.Client#get_call is deprecated. Use Voice#get_call instead") - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - @deprecated(reason="nexmo.Client#update_call is deprecated. Use Voice#update_call instead") - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - @deprecated(reason="nexmo.Client#send_audio is deprecated. Use Voice#send_audio instead") - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - @deprecated(reason="nexmo.Client#stop_audio is deprecated. Use Voice#stop_audio instead") - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - @deprecated(reason="nexmo.Client#send_speech is deprecated. Use Voice#send_speech instead") - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - @deprecated(reason="nexmo.Client#stop_speech is deprecated. Use Voice#stop_speech instead") - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - @deprecated(reason="nexmo.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead") - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): - """ - Low-level method to make a post request to a Nexmo API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse( - host, self.session.delete(uri, params=params, headers=headers) - ) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - return jwt.encode(payload, self.private_key, algorithm="RS256") +from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .errors import * +from .voice import * +from .sms import * +from .verify import * +from datetime import datetime +import logging +from platform import python_version + +import base64 +import hashlib +import hmac +import jwt +import os +import pytz +import requests +import sys +import time +from uuid import uuid4 +import warnings +import re +from deprecated import deprecated + + +string_types = (str, bytes) +from urllib.parse import urlparse + +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + + +__version__ = "2.4.0" + +logger = logging.getLogger("nexmo") + + +class Client: + """ + Create a Client object to start making calls to Nexmo APIs. + + Most methods corresponding to Nexmo API calls are on this class itself, + although newer APIs are under namespaces like :attr:`Client.application_v2`. + + The credentials you provide when instantiating a Client determine which + methods can be called. Consult the `Nexmo API docs `_ for details of the + authentication used by the APIs you wish to use, and instantiate your + Client with the appropriate credentials. + + :param str key: Your Nexmo API key + :param str secret: Your Nexmo API secret. + :param str signature_secret: Your Nexmo API signature secret. + You may need to have this enabled by Nexmo support. It is only used for SMS authentication. + :param str signature_method: + The encryption method used for signature encryption. This must match the method + configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. + This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. + If you want to use a simple MD5 hash, leave this as `None`. + :param str application_id: Your application ID if calling methods which use JWT authentication. + :param str private_key: Your private key if calling methods which use JWT authentication. + This should either be a str containing the key in its PEM form, or a path to a private key file. + :param str app_name: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + :param str app_version: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + """ + + def __init__( + self, + key=None, + secret=None, + signature_secret=None, + signature_method=None, + application_id=None, + private_key=None, + app_name=None, + app_version=None, + ): + self.api_key = key or os.environ.get("NEXMO_API_KEY", None) + + self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) + + self.signature_secret = signature_secret or os.environ.get( + "NEXMO_SIGNATURE_SECRET", None + ) + + self.signature_method = signature_method or os.environ.get( + "NEXMO_SIGNATURE_METHOD", None + ) + + if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: + self.signature_method = getattr(hashlib, signature_method) + + self.application_id = application_id + + self.private_key = private_key + + if isinstance(self.private_key, string_types) and "\n" not in self.private_key: + with open(self.private_key, "rb") as key_file: + self.private_key = key_file.read() + + self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' + + self.__host = "rest.nexmo.com" + + self.__api_host = "api.nexmo.com" + + user_agent = "nexmo-python/{version} python/{python_version}".format( + version=__version__, python_version=python_version() + ) + + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) + + self.headers = {"User-Agent": user_agent} + + self.auth_params = {} + + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) + + self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for host') + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for api_host') + else: + self.__api_host = value + + def auth(self, params=None, **kwargs): + self.auth_params = params or kwargs + + @deprecated(reason="nexmo.Client#send_message is deprecated. Use Sms#send_message instead") + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_NEXMO_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + + def get_balance(self): + return self.get(self.host(), "/account/get-balance") + + def get_country_pricing(self, country_code): + return self.get( + self.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + def get_prefix_pricing(self, prefix): + return self.get( + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) + + def get_sms_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + def get_voice_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + def update_settings(self, params=None, **kwargs): + return self.post(self.host(), "/account/settings", params or kwargs) + + def topup(self, params=None, **kwargs): + return self.post(self.host(), "/account/top-up", params or kwargs) + + def get_account_numbers(self, params=None, **kwargs): + return self.get(self.host(), "/account/numbers", params or kwargs) + + def get_available_numbers(self, country_code, params=None, **kwargs): + return self.get( + self.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + def buy_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/buy", params or kwargs) + + def cancel_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/cancel", params or kwargs) + + def update_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/update", params or kwargs) + + def get_message(self, message_id): + return self.get(self.host(), "/search/message", {"id": message_id}) + + def get_message_rejections(self, params=None, **kwargs): + return self.get(self.host(), "/search/rejections", params or kwargs) + + def search_messages(self, params=None, **kwargs): + return self.get(self.host(), "/search/messages", params or kwargs) + + def send_ussd_push_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd/json", params or kwargs) + + def send_ussd_prompt_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + + def send_2fa_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) + + def send_event_alert_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + + def send_marketing_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + + def get_event_alert_numbers(self): + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) + + @deprecated(reason="nexmo.Client#start_verification is deprecated. Use Verify#start_verification instead") + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#send_verification_request is deprecated (use Verify#start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + @deprecated(reason="nexmo.Client#check_verification is deprecated. Use Verify#check instead") + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#check_verification_request is deprecated (use Verify#check instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + @deprecated(reason="nexmo.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead") + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) + + @deprecated(reason="nexmo.Client#get_verification is deprecated. Use Verify#search instead") + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "nexmo.Client#get_verification_request is deprecated (use Verify#search instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + @deprecated(reason="nexmo.Client#cancel_verification is deprecated. Use Verify#cancel instead") + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + @deprecated(reason="nexmo.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead") + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) + + def get_basic_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + + def get_standard_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) + else: + raise ClientError("Error: Callback needed for async advanced number insight") + + def get_advanced_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + + def request_number_insight(self, params=None, **kwargs): + return self.post(self.host(), "/ni/json", params or kwargs) + + def get_applications(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#get_applications is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get(self.api_host(), "/v1/applications", params or kwargs) + + def get_application(self, application_id): + warnings.warn( + "nexmo.Client#get_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + def create_application(self, params=None, **kwargs): + warnings.warn( + "nexmo.Client#create_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.post(self.api_host(), "/v1/applications", params or kwargs) + + def update_application(self, application_id, params=None, **kwargs): + warnings.warn( + "nexmo.Client#update_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.put( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + params or kwargs, + ) + + def delete_application(self, application_id): + warnings.warn( + "nexmo.Client#delete_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.delete( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + @deprecated(reason="nexmo.Client#create_call is deprecated. Use Voice#create_call instead") + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + @deprecated(reason="nexmo.Client#get_calls is deprecated. Use Voice#get_calls instead") + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + @deprecated(reason="nexmo.Client#get_call is deprecated. Use Voice#get_call instead") + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + @deprecated(reason="nexmo.Client#update_call is deprecated. Use Voice#update_call instead") + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="nexmo.Client#send_audio is deprecated. Use Voice#send_audio instead") + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="nexmo.Client#stop_audio is deprecated. Use Voice#stop_audio instead") + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + @deprecated(reason="nexmo.Client#send_speech is deprecated. Use Voice#send_speech instead") + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="nexmo.Client#stop_speech is deprecated. Use Voice#stop_speech instead") + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + @deprecated(reason="nexmo.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead") + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + def get_recording(self, url): + hostname = urlparse(url).hostname + return self.parse(hostname, self.session.get(url, headers=self._headers())) + + def redact_transaction(self, id, product, type=None): + params = {"id": id, "product": product} + if type is not None: + params["type"] = type + return self._post_json(self.api_host(), "/v1/redact/transaction", params) + + def list_secrets(self, api_key): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) + + def get_secret(self, api_key, secret_id): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._post_json( + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + ) + + def delete_secret(self, api_key, secret_id): + return self.delete( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def check_signature(self, params): + params = dict(params) + signature = params.pop("sig", "").lower() + return hmac.compare_digest(signature, self.signature(params)) + + def signature(self, params): + if self.signature_method: + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) + else: + hasher = hashlib.md5() + + # Add timestamp if not already present + if not params.get("timestamp"): + params["timestamp"] = int(time.time()) + + for key in sorted(params): + value = params[key] + + if isinstance(value, str): + value = value.replace("&", "_").replace("=", "_") + + hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) + + if self.signature_method is None: + hasher.update(self.signature_secret.encode()) + + return hasher.hexdigest() + + def get(self, host, request_uri, params=None, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) + logger.debug("GET to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.get(uri, params=params, headers=headers)) + + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): + """ + Low-level method to make a post request to a Nexmo API server. + This method automatically adds authentication, picking the first applicable authentication method from the following: + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. + - Otherwise the client's key and secret are appended to the post request's params. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if supports_signature_auth and self.signature_secret: + params["api_key"] = self.api_key + params["sig"] = self.signature(params) + elif header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("POST to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.post(uri, data=params, headers=headers)) + + def _post_json(self, host, request_uri, json): + """ + Post json to `request_uri`, using basic auth. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + auth = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) + return self.parse(host, self.session.post(uri, headers=headers, json=json)) + + def put(self, host, request_uri, params, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.put(uri, json=params, headers=headers)) + + def delete(self, host, request_uri, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + params = None + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = {"api_key": self.api_key, "api_secret": self.api_secret} + logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) + return self.parse( + host, self.session.delete(uri, params=params, headers=headers) + ) + + def parse(self, host, response): + logger.debug("Response headers %r", response.headers) + if response.status_code == 401: + raise AuthenticationError + elif response.status_code == 204: + return None + elif 200 <= response.status_code < 300: + + # Strip off any encoding from the content-type header: + content_mime = response.headers.get("content-type").split(";", 1)[0] + if content_mime == "application/json": + return response.json() + else: + return response.content + elif 400 <= response.status_code < 500: + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + + # Test for standard error format: + try: + error_data = response.json() + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): + message = "{title}: {detail} ({type})".format( + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], + ) + except JSONDecodeError: + pass + raise ClientError(message) + elif 500 <= response.status_code < 600: + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + raise ServerError(message) + + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.post(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.delete(uri, headers=self._headers()) + ) + + def _headers(self): + token = self.generate_application_jwt() + return dict(self.headers, Authorization=b"Bearer " + token) + + def generate_application_jwt(self, when=None): + iat = int(when if when is not None else time.time()) + + payload = dict(self.auth_params) + payload.setdefault("application_id", self.application_id) + payload.setdefault("iat", iat) + payload.setdefault("exp", iat + 60) + payload.setdefault("jti", str(uuid4())) + + return jwt.encode(payload, self.private_key, algorithm="RS256") From a681a3562356c6fc86a27fcb1af5be5e1848cf94 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Fri, 4 Sep 2020 10:17:10 -0400 Subject: [PATCH 074/401] Updating LICENSE --- LICENSE.txt | 225 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 24 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 5feca115..d03c4f54 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,24 +1,201 @@ -The MIT License (MIT) - -Copyright (c) 2016 Nexmo Inc - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Vonage + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From fab157398c2711821e332df6801ccb5e6aba771f Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Fri, 4 Sep 2020 10:31:23 -0400 Subject: [PATCH 075/401] Update LICENSE.txt --- LICENSE.txt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index d03c4f54..3b44e590 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -175,16 +175,6 @@ Apache License END OF TERMS AND CONDITIONS - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. Copyright 2020 Vonage From 1113cead49847852d565b68782c37120040d7364 Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Sat, 5 Sep 2020 08:54:05 -0400 Subject: [PATCH 076/401] namespace changes --- .bumpversion.cfg | 2 +- .github/workflows/release.yml | 14 +- CHANGES.md | 8 +- CODE_OF_CONDUCT.md | 22 +- LICENSE.txt | 215 +++- README.md | 148 +-- docs/Makefile | 8 +- docs/conf.py | 18 +- docs/index.rst | 2 +- docs/make.bat | 4 +- docs/quickstart.rst | 64 +- docs/reference.rst | 8 +- setup.cfg | 2 +- setup.py | 12 +- src/{nexmo => vonage}/__init__.py | 1506 ++++++++++++++-------------- src/{nexmo => vonage}/_internal.py | 2 +- src/{nexmo => vonage}/errors.py | 28 +- src/{nexmo => vonage}/sms.py | 4 +- src/{nexmo => vonage}/verify.py | 100 +- src/{nexmo => vonage}/voice.py | 4 +- tests/conftest.py | 150 +-- tests/test_account.py | 460 ++++----- tests/test_applications_v2.py | 21 +- tests/test_nexmo.py | 22 +- tests/test_sms.py | 202 ++-- tests/test_verify.py | 350 +++---- tests/test_voice.py | 594 +++++------ 27 files changed, 2063 insertions(+), 1907 deletions(-) rename src/{nexmo => vonage}/__init__.py (89%) rename src/{nexmo => vonage}/_internal.py (99%) rename src/{nexmo => vonage}/errors.py (91%) rename src/{nexmo => vonage}/sms.py (96%) rename src/{nexmo => vonage}/verify.py (93%) rename src/{nexmo => vonage}/voice.py (97%) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0707c40f..e266f4fc 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,7 +3,7 @@ current_version = 2.4.0 commit = True tag = False -[bumpversion:file:nexmo/__init__.py] +[bumpversion:file:vonage/__init__.py] [bumpversion:file:setup.py] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e01b159d..055a97be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,10 +7,10 @@ jobs: name: Add Changelog runs-on: ubuntu-latest steps: - - name: Add Changelog - uses: nexmo/github-actions/nexmo-changelog@master - env: - CHANGELOG_AUTH_TOKEN: ${{ secrets.CHANGELOG_AUTH_TOKEN }} - CHANGELOG_CATEGORY: Server SDK - CHANGELOG_RELEASE_TITLE: nexmo-python - CHANGELOG_SUBCATEGORY: python + - name: Add Changelog + uses: vonage/github-actions/vonage-changelog@master + env: + CHANGELOG_AUTH_TOKEN: ${{ secrets.CHANGELOG_AUTH_TOKEN }} + CHANGELOG_CATEGORY: Server SDK + CHANGELOG_RELEASE_TITLE: vonage-python + CHANGELOG_SUBCATEGORY: python diff --git a/CHANGES.md b/CHANGES.md index 567cc906..52aef0b7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,7 +14,7 @@ # 2.3.0 -- Explicit parameter list for the `nexmo.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** +- Explicit parameter list for the `vonage.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** - Secret Management - Support for Authorization header authentication. @@ -26,7 +26,7 @@ - Add support for `get_recording` - Add support for SMS conversion -- Add debug logging for most calls, under the 'nexmo' logger. +- Add debug logging for most calls, under the 'vonage' logger. - Internal refactoring (affects only private methods.) # 2.0.0 @@ -39,7 +39,7 @@ # 1.5.0 -- Add ability to provide a file path as private_key param no the nexmo.Client constructor +- Add ability to provide a file path as private_key param no the vonage.Client constructor - Add send/stop endpoints for audio/speech/dtmf @@ -81,7 +81,7 @@ # 1.1.0 -- Move repository to https://github.com/Nexmo/nexmo-python +- Move repository to https://github.com/Vonage/vonage-python - Add get_basic_number_insight method for Number Insight Basic API diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fbf13047..003f9af2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,21 +14,21 @@ appearance, race, religion, or sexual identity and orientation. Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or +- The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@nexmo.com. All +reported by contacting the project team at support@vonage.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/LICENSE.txt b/LICENSE.txt index 5feca115..3b44e590 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,24 +1,191 @@ -The MIT License (MIT) - -Copyright (c) 2016 Nexmo Inc - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + + Copyright 2020 Vonage + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 98a27e4d..58375d09 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,55 @@ -# Nexmo Client Library for Python +# Vonage Client Library for Python -[![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) -[![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) -[![Coverage Status](https://coveralls.io/repos/github/Nexmo/nexmo-python/badge.svg?branch=master)](https://coveralls.io/github/Nexmo/nexmo-python?branch=master) -[![Python versions supported](https://img.shields.io/pypi/pyversions/nexmo.svg)](https://pypi.python.org/pypi/nexmo) +[![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) +[![Build Status](https://api.travis-ci.org/Vonage/vonage-python.svg?branch=master)](https://travis-ci.org/Vonage/vonage-python) +[![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python?branch=master) +[![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) -This is the Python client library for Nexmo's API. To use it you'll -need a Nexmo account. Sign up [for free at nexmo.com][signup]. - -* [Installation](#installation) -* [Usage](#usage) -* [SMS API](#sms-api) -* [Voice API](#voice-api) -* [Verify API](#verify-api) -* [Number Insight API](#number-insight-api) -* [Number Management API](#number-management-api) -* [Managing Secrets](#managing-secrets) -* [Application API](#application-api) -* [Overriding API Attributes](#overriding-api-attributes) -* [Frequently Asked Questions](#frequently-asked-questions) -* [License](#license) +This is the Python client library for Vonage's API. To use it you'll +need a Vonage account. Sign up [for free at vonage.com][signup]. + +- [Installation](#installation) +- [Usage](#usage) +- [SMS API](#sms-api) +- [Voice API](#voice-api) +- [Verify API](#verify-api) +- [Number Insight API](#number-insight-api) +- [Number Management API](#number-management-api) +- [Managing Secrets](#managing-secrets) +- [Application API](#application-api) +- [Overriding API Attributes](#overriding-api-attributes) +- [Frequently Asked Questions](#frequently-asked-questions) +- [License](#license) ## Installation To install the Python client library using pip: - pip install nexmo + pip install vonage To upgrade your installed client library using pip: - pip install nexmo --upgrade + pip install vonage --upgrade Alternatively, you can clone the repository via the command line: - git clone git@github.com:Nexmo/nexmo-python.git + git clone git@github.com:Vonage/vonage-python.git or by opening it on GitHub desktop. ## Usage -Begin by importing the `nexmo` module: +Begin by importing the `vonage` module: ```python -import nexmo +import vonage ``` Then construct a client object with your key and secret: ```python -client = nexmo.Client(key=api_key, secret=api_secret) +client = vonage.Client(key=api_key, secret=api_secret) ``` For production, you can specify the `NEXMO_API_KEY` and `NEXMO_API_SECRET` @@ -59,7 +59,7 @@ For newer endpoints that support JWT authentication such as the Voice API, you can also specify the `application_id` and `private_key` arguments: ```python -client = nexmo.Client(application_id=application_id, private_key=private_key) +client = vonage.Client(application_id=application_id, private_key=private_key) ``` To check signatures for incoming webhook requests, you'll also need @@ -78,13 +78,13 @@ To create an instance of the SMS class follow these steps: ```python #Option 1 -from nexmo import Sms +from vonage import Sms #Option 2 -from nexmo.sms import Sms +from vonage.sms import Sms #Option 3 -import nexmo #then you can use nexmo.Sms() to create an instance +import vonage #then you can use vonage.Sms() to create an instance ``` - Create an instance @@ -101,12 +101,12 @@ sms = Sms(client) ### Send an SMS ```python -from nexmo import Sms +from vonage import Sms sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) sms.send_message({ "from": NEXMO_BRAND_NAME, "to": TO_NUMBER, - "text": "A text message sent using the Nexmo SMS API", + "text": "A text message sent using the Vonage SMS API", }) ``` @@ -124,7 +124,7 @@ sms.send_message({ ### Submit SMS Conversion ```python -from nexmo import Client, Sms +from vonage import Client, Sms client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) sms = Sms(client) response = sms.send_message({ @@ -140,7 +140,7 @@ sms.submit_sms_conversion(response['message-id']) ### Make a call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) voice.create_all({ @@ -153,7 +153,7 @@ voice.create_all({ ### Retrieve a list of calls ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) voice.get_calls() @@ -162,7 +162,7 @@ voice.get_calls() ### Retrieve a single call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) voice.get_call(uuid) @@ -171,7 +171,7 @@ voice.get_call(uuid) ### Update a call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) response = voice.create_all({ @@ -185,10 +185,10 @@ voice.update_call(response['uuid'], action='hangup') ### Stream audio to a call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +stream_url = 'https://vonage-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' response = voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, @@ -200,10 +200,10 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) ### Stop streaming audio to a call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') voice = Voice(client) -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +stream_url = 'https://vonage-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' response = voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, @@ -216,7 +216,7 @@ voice.stop_audio(response['uuid']) ### Send a synthesized speech message to a call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) response = voice.create_call({ @@ -224,13 +224,13 @@ response = voice.create_call({ 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.send_speech(response['uuid'], text='Hello from nexmo') +voice.send_speech(response['uuid'], text='Hello from vonage') ``` ### Stop sending a synthesized speech message to a call ```python ->>> from nexmo import Client, Voice +>>> from vonage import Client, Voice >>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) >>> voice = Voice(client) >>> response = voice.create_call({ @@ -238,14 +238,14 @@ voice.send_speech(response['uuid'], text='Hello from nexmo') 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) ->>> voice.send_speech(response['uuid'], text='Hello from nexmo') +>>> voice.send_speech(response['uuid'], text='Hello from vonage') >>> voice.stop_speech(response['uuid']) ``` ### Send DTMF tones to a call ```python -from nexmo import Client, Voice +from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) response = voice.create_call({ @@ -275,13 +275,13 @@ To create an instance of the Verify class, Just follow the next steps: ```python #First way -from nexmo import Verify +from vonage import Verify ​ #Second way -from nexmo.verify import Verify +from vonage.verify import Verify ​ #Third valid way -import nexmo #then you can use nexmo.Verify() to create an instance +import vonage #then you can use vonage.Verify() to create an instance ``` - **Create the instance** @@ -414,7 +414,7 @@ else: client.get_basic_number_insight(number='447700900000') ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) +Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightBasic](https://developer.vonage.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) ### Standard Number Insight @@ -422,7 +422,7 @@ Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](htt client.get_standard_number_insight(number='447700900000') ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) +Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightStandard](https://developer.vonage.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) ### Advanced Number Insight @@ -430,7 +430,7 @@ Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard]( client.get_advanced_number_insight(number='447700900000') ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightAdvanced](https://developer.vonage.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) ## Managing Secrets @@ -466,7 +466,7 @@ client.delete_secret(API_KEY, 'my-secret-id') response = client.application_v2.create_application({name='Example App', type='voice'}) ``` -Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) +Docs: [https://developer.vonage.com/api/application.v2#createApplication](https://developer.vonage.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) ### Retrieve a list of applications @@ -474,7 +474,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https:/ response = client.application_v2.list_applications() ``` -Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) +Docs: [https://developer.vonage.com/api/application.v2#listApplication](https://developer.vonage.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) ### Retrieve a single application @@ -482,7 +482,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://d response = client.application_v2.get_application(uuid) ``` -Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) +Docs: [https://developer.vonage.com/api/application.v2#getApplication](https://developer.vonage.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) ### Update an application @@ -490,7 +490,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://de response = client.application_v2.update_application(uuid, answer_method='POST') ``` -Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) +Docs: [https://developer.vonage.com/api/application.v2#updateApplication](https://developer.vonage.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) ### Delete an application @@ -498,12 +498,12 @@ Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https:/ response = client.application_v2.delete_application(uuid) ``` -Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) +Docs: [https://developer.vonage.com/api/application.v2#deleteApplication](https://developer.vonage.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) ## Validate webhook signatures ```python -client = nexmo.Client(signature_secret='secret') +client = vonage.Client(signature_secret='secret') if client.check_signature(request.query): # valid signature @@ -511,9 +511,9 @@ else: # invalid signature ``` -Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://developer.nexmo.com/concepts/guides/signing-messages?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) +Docs: [https://developer.vonage.com/concepts/guides/signing-messages](https://developer.vonage.com/concepts/guides/signing-messages?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) -Note: you'll need to contact support@nexmo.com to enable message signing on +Note: you'll need to contact support@vonage.com to enable message signing on your account before you can validate webhook signatures. ## JWT parameters @@ -529,22 +529,22 @@ client.auth(nbf=nbf, exp=exp, jti=jti) ## Overriding API Attributes -In order to rewrite/get the value of variables used across all the Nexmo classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. +In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. An example using setters/getters with `Object references`: ```python -from nexmo import Client, Sms +from vonage import Client, Sms #Defines the client client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # using getter for host -- value returned: rest.nexmo.com +print(client.host()) # using getter for host -- value returned: rest.vonage.com #Define the sms instance sms = Sms(client) #Change the value in client -client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms +client.host('mio.vonage.com') #Change host to mio.vonage.com - this change will be available for sms ``` @@ -553,13 +553,13 @@ client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be These attributes are private in the client class and the only way to access them is using the getters/setters we provide. ```python -from nexmo import Client +from vonage import Client client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # return rest.nexmo.com -client.host('mio.nexmo.com') # rewrites the host value to mio.nexmo.com -print(client.api_host()) # returns api.nexmo.com -client.api_host('myapi.nexmo.com') # rewrite the value of api_host +print(client.host()) # return rest.vonage.com +client.host('mio.vonage.com') # rewrites the host value to mio.vonage.com +print(client.api_host()) # returns api.vonage.com +client.api_host('myapi.vonage.com') # rewrite the value of api_host ``` ## Frequently Asked Questions @@ -596,9 +596,9 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo ## Contributing -We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! +We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@vonage.com) first! -We recommend working on `nexmo-python` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: +We recommend working on `vonage-python` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: ```bash make install @@ -612,10 +612,10 @@ make test ## License -This library is released under the [MIT License][license]. +This library is released under the [Apache License][license]. [virtualenv]: https://virtualenv.pypa.io/en/stable/ -[report-a-bug]: https://github.com/Nexmo/nexmo-python/issues/new -[pull-request]: https://github.com/Nexmo/nexmo-python/pulls +[report-a-bug]: https://github.com/Vonage/vonage-python-sdk/issues/new +[pull-request]: https://github.com/Vonage/vonage-python-sdk/pulls [signup]: https://dashboard.nexmo.com/sign-up?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library [license]: LICENSE.txt diff --git a/docs/Makefile b/docs/Makefile index 46afce51..01db0284 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -94,9 +94,9 @@ qthelp: @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Nexmo.qhcp" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Vonage.qhcp" @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Nexmo.qhc" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Vonage.qhc" .PHONY: applehelp applehelp: @@ -113,8 +113,8 @@ devhelp: @echo @echo "Build finished." @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Nexmo" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Nexmo" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Vonage" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Vonage" @echo "# devhelp" .PHONY: epub diff --git a/docs/conf.py b/docs/conf.py index 3555201f..4364e0ce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Nexmo documentation build configuration file, created by +# Vonage documentation build configuration file, created by # sphinx-quickstart on Sun Sep 18 14:36:55 2016. # # This file is execfile()d with the current directory set to its @@ -57,7 +57,7 @@ master_doc = "index" # General information about the project. -project = u"Nexmo" +project = u"Vonage" copyright = u"{0}, Tim Craft".format(datetime.datetime.now().year) author = u"Tim Craft" @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Nexmo v1.4.0' +# html_title = u'Vonage v1.4.0' # A shorter title for the navigation bar. Default is the same as html_title. # @@ -243,7 +243,7 @@ # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = "Nexmodoc" +htmlhelp_basename = "Vonagedoc" # -- Options for LaTeX output --------------------------------------------- @@ -266,7 +266,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, "Nexmo.tex", u"Nexmo Documentation", u"Tim Craft", "manual") + (master_doc, "Vonage.tex", u"Vonage Documentation", u"Tim Craft", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -306,7 +306,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "nexmo", u"Nexmo Documentation", [author], 1)] +man_pages = [(master_doc, "vonage", u"Vonage Documentation", [author], 1)] # If true, show URL addresses after external links. # @@ -321,10 +321,10 @@ texinfo_documents = [ ( master_doc, - "Nexmo", - u"Nexmo Documentation", + "Vonage", + u"Vonage Documentation", author, - "Nexmo", + "Vonage", "One line description of project.", "Miscellaneous", ) diff --git a/docs/index.rst b/docs/index.rst index 109e4e3a..b7d930fd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,5 @@ -Welcome to Nexmo's documentation! +Welcome to Vonage's documentation! ================================= .. toctree:: diff --git a/docs/make.bat b/docs/make.bat index 8f65cffb..62035422 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -129,9 +129,9 @@ if "%1" == "qthelp" ( echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Nexmo.qhcp + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Vonage.qhcp echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Nexmo.ghc + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Vonage.ghc goto end ) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index ae7fa996..19380b6a 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -1,11 +1,11 @@ -Nexmo Client Library for Python +Vonage Client Library for Python =============================== |PyPI version| |Build Status| -This is the Python client library for Nexmo's API. To use it you'll need -a Nexmo account. Sign up `for free at -nexmo.com `__. +This is the Python client library for Vonage's API. To use it you'll need +a Vonage account. Sign up `for free at +vonage.com `__. - `Installation <#installation>`__ - `Usage <#usage>`__ @@ -23,28 +23,28 @@ To install the Python client library using pip: :: - pip install nexmo + pip install vonage Alternatively, you can clone the repository: :: - git clone git@github.com:Nexmo/nexmo-python.git + git clone git@github.com:Vonage/vonage-python.git Usage ----- -Begin by importing the nexmo module: +Begin by importing the vonage module: .. code:: python - import nexmo + import vonage Then construct a client object with your key and secret: .. code:: python - client = nexmo.Client(key=api_key, secret=api_secret) + client = vonage.Client(key=api_key, secret=api_secret) For production, you can specify the ``NEXMO_API_KEY`` and ``NEXMO_API_SECRET`` environment variables instead of specifying the key @@ -56,7 +56,7 @@ arguments: .. code:: python - client = nexmo.Client(application_id=application_id, private_key=private_key) + client = vonage.Client(application_id=application_id, private_key=private_key) In order to check signatures for incoming webhook requests, you'll also need to specify the ``signature_secret`` argument (or the @@ -86,7 +86,7 @@ Send a text message print('Error:', response['error-text']) Docs: -`https://docs.nexmo.com/messaging/sms-api/api-reference#request `__ +`https://docs.vonage.com/messaging/sms-api/api-reference#request `__ Voice API --------- @@ -103,7 +103,7 @@ Make a call }) Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_create `__ +`https://docs.vonage.com/voice/voice-api/api-reference#call\_create `__ Retrieve a list of calls ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -113,7 +113,7 @@ Retrieve a list of calls response = client.get_calls() Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve `__ +`https://docs.vonage.com/voice/voice-api/api-reference#call\_retrieve `__ Retrieve a single call ~~~~~~~~~~~~~~~~~~~~~~ @@ -123,7 +123,7 @@ Retrieve a single call response = client.get_call(uuid) Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve\_single `__ +`https://docs.vonage.com/voice/voice-api/api-reference#call\_retrieve\_single `__ Update a call ~~~~~~~~~~~~~ @@ -133,7 +133,7 @@ Update a call response = client.update_call(uuid, action='hangup') Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_modify\_single `__ +`https://docs.vonage.com/voice/voice-api/api-reference#call\_modify\_single `__ Verify API ---------- @@ -151,7 +151,7 @@ Start a verification print('Error:', response['error_text']) Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#vrequest `__ +`https://docs.vonage.com/verify/api-reference/api-reference#vrequest `__ The response contains a verification request id which you will need to store temporarily (in the session, database, url etc). @@ -169,7 +169,7 @@ Check a verification print('Error:', response['error_text']) Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#check `__ +`https://docs.vonage.com/verify/api-reference/api-reference#check `__ The verification request id comes from the call to the start\_verification method. The PIN code is entered into your @@ -183,7 +183,7 @@ Cancel a verification client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ +`https://docs.vonage.com/verify/api-reference/api-reference#control `__ Trigger next verification step ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -193,7 +193,7 @@ Trigger next verification step client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ +`https://docs.vonage.com/verify/api-reference/api-reference#control `__ Application API --------------- @@ -206,7 +206,7 @@ Create an application response = client.create_application(name='Example App', type='voice', answer_url=answer_url) Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#create `__ +`https://docs.vonage.com/tools/application-api/api-reference#create `__ Retrieve a list of applications ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -216,7 +216,7 @@ Retrieve a list of applications response = client.get_applications() Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#list `__ +`https://docs.vonage.com/tools/application-api/api-reference#list `__ Retrieve a single application ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -226,7 +226,7 @@ Retrieve a single application response = client.get_application(uuid) Docs: -`https://developer.nexmo.com/api/application#retrieve-an-application `__ +`https://developer.vonage.com/api/application#retrieve-an-application `__ Update an application ~~~~~~~~~~~~~~~~~~~~~ @@ -236,7 +236,7 @@ Update an application response = client.update_application(uuid, answer_method='POST') Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#update `__ +`https://docs.vonage.com/tools/application-api/api-reference#update `__ Delete an application ~~~~~~~~~~~~~~~~~~~~~ @@ -246,14 +246,14 @@ Delete an application response = client.delete_application(uuid) Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#delete `__ +`https://docs.vonage.com/tools/application-api/api-reference#delete `__ Validate webhook signatures --------------------------- .. code:: python - client = nexmo.Client(signature_secret='secret') + client = vonage.Client(signature_secret='secret') if client.check_signature(request.query): # valid signature @@ -263,7 +263,7 @@ Validate webhook signatures or by using signature method via POST: - client = nexmo.Client(signature_secret='secret', signature_method='sha256') + client = vonage.Client(signature_secret='secret', signature_method='sha256') if client.check_signature(request.body.decode()): # valid signature @@ -271,9 +271,9 @@ Validate webhook signatures # invalid signature Docs: -`https://docs.nexmo.com/messaging/signing-messages `__ +`https://docs.vonage.com/messaging/signing-messages `__ -Note: you'll need to contact support@nexmo.com to enable message signing +Note: you'll need to contact support@vonage.com to enable message signing on your account before you can validate webhook signatures. JWT parameters @@ -350,7 +350,7 @@ License This library is released under the `MIT License `__ -.. |PyPI version| image:: https://badge.fury.io/py/nexmo.svg - :target: https://badge.fury.io/py/nexmo -.. |Build Status| image:: https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master - :target: https://travis-ci.org/Nexmo/nexmo-python +.. |PyPI version| image:: https://badge.fury.io/py/vonage.svg + :target: https://badge.fury.io/py/vonage +.. |Build Status| image:: https://api.travis-ci.org/Vonage/vonage-python.svg?branch=master + :target: https://travis-ci.org/Vonage/vonage-python diff --git a/docs/reference.rst b/docs/reference.rst index c2334d7d..33e14019 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -1,14 +1,14 @@ API Reference ============= -.. autoclass:: nexmo.Client +.. autoclass:: vonage.Client :members: :undoc-members: .. attribute:: application_v2 - An instance of :class:`nexmo.ApplicationV2` for accessing the Application API. + An instance of :class:`vonage.ApplicationV2` for accessing the Application API. -.. autoclass:: nexmo.ApplicationV2 +.. autoclass:: vonage.ApplicationV2 :members: - :undoc-members: \ No newline at end of file + :undoc-members: diff --git a/setup.cfg b/setup.cfg index aae3689f..ae2a609b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ max-line-length=120 [coverage:run] # TODO: Change this to True: branch=False -source= nexmo +source= vonage [coverage:paths] source = diff --git a/setup.py b/setup.py index 6a7a4da9..3b9b521b 100644 --- a/setup.py +++ b/setup.py @@ -10,15 +10,15 @@ long_description = f.read() setup( - name="nexmo", + name="vonage", version="2.5.2", - description="Nexmo Client Library for Python", + description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/Nexmo/nexmo-python", - author="Nexmo", - author_email="devrel@nexmo.com", - license="MIT", + url="https://github.com/Vonage/vonage-python-sdk", + author="Vonage", + author_email="devrel@vonage.com", + license="Apache", packages=find_packages(where="src"), package_dir={"": "src"}, platforms=["any"], diff --git a/src/nexmo/__init__.py b/src/vonage/__init__.py similarity index 89% rename from src/nexmo/__init__.py rename to src/vonage/__init__.py index 68b68e40..6cd45131 100644 --- a/src/nexmo/__init__.py +++ b/src/vonage/__init__.py @@ -1,753 +1,753 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param -from .errors import * -from .voice import * -from .sms import * -from .verify import * -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import sys -import time -from uuid import uuid4 -import warnings -import re -from deprecated import deprecated - - -string_types = (str, bytes) -from urllib.parse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - - -__version__ = "2.4.0" - -logger = logging.getLogger("nexmo") - - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("NEXMO_API_KEY", None) - - self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "NEXMO_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "NEXMO_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" - - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for host') - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for api_host') - else: - self.__api_host = value - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - @deprecated(reason="nexmo.Client#send_message is deprecated. Use Sms#send_message instead") - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - @deprecated(reason="nexmo.Client#start_verification is deprecated. Use Verify#start_verification instead") - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated(reason="nexmo.Client#check_verification is deprecated. Use Verify#check instead") - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated(reason="nexmo.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead") - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated(reason="nexmo.Client#get_verification is deprecated. Use Verify#search instead") - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "nexmo.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated(reason="nexmo.Client#cancel_verification is deprecated. Use Verify#cancel instead") - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated(reason="nexmo.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead") - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) - else: - raise ClientError("Error: Callback needed for async advanced number insight") - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "nexmo.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "nexmo.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "nexmo.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - @deprecated(reason="nexmo.Client#create_call is deprecated. Use Voice#create_call instead") - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated(reason="nexmo.Client#get_calls is deprecated. Use Voice#get_calls instead") - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated(reason="nexmo.Client#get_call is deprecated. Use Voice#get_call instead") - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - @deprecated(reason="nexmo.Client#update_call is deprecated. Use Voice#update_call instead") - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - @deprecated(reason="nexmo.Client#send_audio is deprecated. Use Voice#send_audio instead") - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - @deprecated(reason="nexmo.Client#stop_audio is deprecated. Use Voice#stop_audio instead") - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - @deprecated(reason="nexmo.Client#send_speech is deprecated. Use Voice#send_speech instead") - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - @deprecated(reason="nexmo.Client#stop_speech is deprecated. Use Voice#stop_speech instead") - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - @deprecated(reason="nexmo.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead") - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): - """ - Low-level method to make a post request to a Nexmo API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse( - host, self.session.delete(uri, params=params, headers=headers) - ) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - return jwt.encode(payload, self.private_key, algorithm="RS256") +from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .errors import * +from .voice import * +from .sms import * +from .verify import * +from datetime import datetime +import logging +from platform import python_version + +import base64 +import hashlib +import hmac +import jwt +import os +import pytz +import requests +import sys +import time +from uuid import uuid4 +import warnings +import re +from deprecated import deprecated + + +string_types = (str, bytes) +from urllib.parse import urlparse + +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + + +__version__ = "2.4.0" + +logger = logging.getLogger("nexmo") + + +class Client: + """ + Create a Client object to start making calls to Nexmo APIs. + + Most methods corresponding to Nexmo API calls are on this class itself, + although newer APIs are under namespaces like :attr:`Client.application_v2`. + + The credentials you provide when instantiating a Client determine which + methods can be called. Consult the `Nexmo API docs `_ for details of the + authentication used by the APIs you wish to use, and instantiate your + Client with the appropriate credentials. + + :param str key: Your Nexmo API key + :param str secret: Your Nexmo API secret. + :param str signature_secret: Your Nexmo API signature secret. + You may need to have this enabled by Nexmo support. It is only used for SMS authentication. + :param str signature_method: + The encryption method used for signature encryption. This must match the method + configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. + This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. + If you want to use a simple MD5 hash, leave this as `None`. + :param str application_id: Your application ID if calling methods which use JWT authentication. + :param str private_key: Your private key if calling methods which use JWT authentication. + This should either be a str containing the key in its PEM form, or a path to a private key file. + :param str app_name: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + :param str app_version: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + """ + + def __init__( + self, + key=None, + secret=None, + signature_secret=None, + signature_method=None, + application_id=None, + private_key=None, + app_name=None, + app_version=None, + ): + self.api_key = key or os.environ.get("NEXMO_API_KEY", None) + + self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) + + self.signature_secret = signature_secret or os.environ.get( + "NEXMO_SIGNATURE_SECRET", None + ) + + self.signature_method = signature_method or os.environ.get( + "NEXMO_SIGNATURE_METHOD", None + ) + + if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: + self.signature_method = getattr(hashlib, signature_method) + + self.application_id = application_id + + self.private_key = private_key + + if isinstance(self.private_key, string_types) and "\n" not in self.private_key: + with open(self.private_key, "rb") as key_file: + self.private_key = key_file.read() + + self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' + + self.__host = "rest.nexmo.com" + + self.__api_host = "api.nexmo.com" + + user_agent = "nexmo-python/{version} python/{python_version}".format( + version=__version__, python_version=python_version() + ) + + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) + + self.headers = {"User-Agent": user_agent} + + self.auth_params = {} + + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) + + self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for host') + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern,value): + raise Exception('Error: Invalid format for api_host') + else: + self.__api_host = value + + def auth(self, params=None, **kwargs): + self.auth_params = params or kwargs + + @deprecated(reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead") + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_NEXMO_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + + def get_balance(self): + return self.get(self.host(), "/account/get-balance") + + def get_country_pricing(self, country_code): + return self.get( + self.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + def get_prefix_pricing(self, prefix): + return self.get( + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) + + def get_sms_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + def get_voice_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + def update_settings(self, params=None, **kwargs): + return self.post(self.host(), "/account/settings", params or kwargs) + + def topup(self, params=None, **kwargs): + return self.post(self.host(), "/account/top-up", params or kwargs) + + def get_account_numbers(self, params=None, **kwargs): + return self.get(self.host(), "/account/numbers", params or kwargs) + + def get_available_numbers(self, country_code, params=None, **kwargs): + return self.get( + self.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + def buy_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/buy", params or kwargs) + + def cancel_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/cancel", params or kwargs) + + def update_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/update", params or kwargs) + + def get_message(self, message_id): + return self.get(self.host(), "/search/message", {"id": message_id}) + + def get_message_rejections(self, params=None, **kwargs): + return self.get(self.host(), "/search/rejections", params or kwargs) + + def search_messages(self, params=None, **kwargs): + return self.get(self.host(), "/search/messages", params or kwargs) + + def send_ussd_push_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd/json", params or kwargs) + + def send_ussd_prompt_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + + def send_2fa_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) + + def send_event_alert_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + + def send_marketing_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + + def get_event_alert_numbers(self): + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) + + @deprecated(reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead") + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + @deprecated(reason="vonage.Client#check_verification is deprecated. Use Verify#check instead") + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + @deprecated(reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead") + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) + + @deprecated(reason="vonage.Client#get_verification is deprecated. Use Verify#search instead") + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + @deprecated(reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead") + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + @deprecated(reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead") + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) + + def get_basic_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + + def get_standard_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) + else: + raise ClientError("Error: Callback needed for async advanced number insight") + + def get_advanced_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + + def request_number_insight(self, params=None, **kwargs): + return self.post(self.host(), "/ni/json", params or kwargs) + + def get_applications(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get(self.api_host(), "/v1/applications", params or kwargs) + + def get_application(self, application_id): + warnings.warn( + "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + def create_application(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.post(self.api_host(), "/v1/applications", params or kwargs) + + def update_application(self, application_id, params=None, **kwargs): + warnings.warn( + "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.put( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + params or kwargs, + ) + + def delete_application(self, application_id): + warnings.warn( + "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.delete( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + @deprecated(reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead") + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + @deprecated(reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead") + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + @deprecated(reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead") + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + @deprecated(reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead") + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead") + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead") + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + @deprecated(reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead") + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + @deprecated(reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead") + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + @deprecated(reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead") + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + def get_recording(self, url): + hostname = urlparse(url).hostname + return self.parse(hostname, self.session.get(url, headers=self._headers())) + + def redact_transaction(self, id, product, type=None): + params = {"id": id, "product": product} + if type is not None: + params["type"] = type + return self._post_json(self.api_host(), "/v1/redact/transaction", params) + + def list_secrets(self, api_key): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) + + def get_secret(self, api_key, secret_id): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._post_json( + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + ) + + def delete_secret(self, api_key, secret_id): + return self.delete( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def check_signature(self, params): + params = dict(params) + signature = params.pop("sig", "").lower() + return hmac.compare_digest(signature, self.signature(params)) + + def signature(self, params): + if self.signature_method: + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) + else: + hasher = hashlib.md5() + + # Add timestamp if not already present + if not params.get("timestamp"): + params["timestamp"] = int(time.time()) + + for key in sorted(params): + value = params[key] + + if isinstance(value, str): + value = value.replace("&", "_").replace("=", "_") + + hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) + + if self.signature_method is None: + hasher.update(self.signature_secret.encode()) + + return hasher.hexdigest() + + def get(self, host, request_uri, params=None, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) + logger.debug("GET to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.get(uri, params=params, headers=headers)) + + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): + """ + Low-level method to make a post request to a Nexmo API server. + This method automatically adds authentication, picking the first applicable authentication method from the following: + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. + - Otherwise the client's key and secret are appended to the post request's params. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if supports_signature_auth and self.signature_secret: + params["api_key"] = self.api_key + params["sig"] = self.signature(params) + elif header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("POST to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.post(uri, data=params, headers=headers)) + + def _post_json(self, host, request_uri, json): + """ + Post json to `request_uri`, using basic auth. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + auth = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) + return self.parse(host, self.session.post(uri, headers=headers, json=json)) + + def put(self, host, request_uri, params, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.put(uri, json=params, headers=headers)) + + def delete(self, host, request_uri, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + params = None + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = {"api_key": self.api_key, "api_secret": self.api_secret} + logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) + return self.parse( + host, self.session.delete(uri, params=params, headers=headers) + ) + + def parse(self, host, response): + logger.debug("Response headers %r", response.headers) + if response.status_code == 401: + raise AuthenticationError + elif response.status_code == 204: + return None + elif 200 <= response.status_code < 300: + + # Strip off any encoding from the content-type header: + content_mime = response.headers.get("content-type").split(";", 1)[0] + if content_mime == "application/json": + return response.json() + else: + return response.content + elif 400 <= response.status_code < 500: + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + + # Test for standard error format: + try: + error_data = response.json() + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): + message = "{title}: {detail} ({type})".format( + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], + ) + except JSONDecodeError: + pass + raise ClientError(message) + elif 500 <= response.status_code < 600: + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + raise ServerError(message) + + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.post(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.delete(uri, headers=self._headers()) + ) + + def _headers(self): + token = self.generate_application_jwt() + return dict(self.headers, Authorization=b"Bearer " + token) + + def generate_application_jwt(self, when=None): + iat = int(when if when is not None else time.time()) + + payload = dict(self.auth_params) + payload.setdefault("application_id", self.application_id) + payload.setdefault("iat", iat) + payload.setdefault("exp", iat + 60) + payload.setdefault("jti", str(uuid4())) + + return jwt.encode(payload, self.private_key, algorithm="RS256") diff --git a/src/nexmo/_internal.py b/src/vonage/_internal.py similarity index 99% rename from src/nexmo/_internal.py rename to src/vonage/_internal.py index dee34ba2..08ca6620 100644 --- a/src/nexmo/_internal.py +++ b/src/vonage/_internal.py @@ -84,7 +84,7 @@ class ApplicationV2(object): """ Provides Application API v2 functionality. - Don't instantiate this class yourself, access it via :py:attr:`nexmo.Client.application_v2` + Don't instantiate this class yourself, access it via :py:attr:`vonage.Client.application_v2` """ def __init__(self, api_server): diff --git a/src/nexmo/errors.py b/src/vonage/errors.py similarity index 91% rename from src/nexmo/errors.py rename to src/vonage/errors.py index 88995700..ede0b9aa 100644 --- a/src/nexmo/errors.py +++ b/src/vonage/errors.py @@ -1,14 +1,14 @@ -class Error(Exception): - pass - - -class ClientError(Error): - pass - - -class ServerError(Error): - pass - - -class AuthenticationError(ClientError): - pass +class Error(Exception): + pass + + +class ClientError(Error): + pass + + +class ServerError(Error): + pass + + +class AuthenticationError(ClientError): + pass diff --git a/src/nexmo/sms.py b/src/vonage/sms.py similarity index 96% rename from src/nexmo/sms.py rename to src/vonage/sms.py index e0f3460c..0b8d27c5 100644 --- a/src/nexmo/sms.py +++ b/src/vonage/sms.py @@ -1,4 +1,4 @@ -import nexmo, pytz +import vonage, pytz from datetime import datetime from ._internal import _format_date_param @@ -15,7 +15,7 @@ def __init__( try: self._client = client if self._client is None: - self._client = nexmo.Client( + self._client = vonage.Client( key=key, secret=secret, signature_secret=signature_secret, diff --git a/src/nexmo/verify.py b/src/vonage/verify.py similarity index 93% rename from src/nexmo/verify.py rename to src/vonage/verify.py index b724fdbb..cf0e3f91 100644 --- a/src/nexmo/verify.py +++ b/src/vonage/verify.py @@ -1,51 +1,51 @@ -import nexmo -import warnings - -class Verify: - def __init__( - self, - client=None, - key=None, - secret=None - ): - try: - self._client = client - if self._client is None: - self._client = nexmo.Client( - key=key, - secret=secret - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def start_verification(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/verify/json", params or kwargs) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def psd2(self, params=None, **kwargs): +import vonage +import warnings + +class Verify: + def __init__( + self, + client=None, + key=None, + secret=None + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret + ) + except Exception as e: + print('Error: {error_message}'.format(error_message=str(e))) + + def start_verification(self, params=None, **kwargs): + return self._client.post(self._client.api_host(), "/verify/json", params or kwargs) + + def check(self, request_id, params=None, **kwargs): + return self._client.post( + self._client.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def search(self, request_id): + return self._client.get( + self._client.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def cancel(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_event(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def psd2(self, params=None, **kwargs): return self._client.post(self._client.api_host(), "/verify/psd2/json", params or kwargs) \ No newline at end of file diff --git a/src/nexmo/voice.py b/src/vonage/voice.py similarity index 97% rename from src/nexmo/voice.py rename to src/vonage/voice.py index b407009a..a83d1214 100644 --- a/src/nexmo/voice.py +++ b/src/vonage/voice.py @@ -1,4 +1,4 @@ -import nexmo +import vonage class Voice(): #application_id and private_key are needed for the calling methods @@ -13,7 +13,7 @@ def __init__( # Client is protected self._client = client if self._client is None: - self._client = nexmo.Client(application_id=application_id, private_key=private_key) + self._client = vonage.Client(application_id=application_id, private_key=private_key) except Exception as e: print('Error: {error_message}'.format(error_message=str(e))) diff --git a/tests/conftest.py b/tests/conftest.py index fe39dcf9..2f2b6bb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,75 +1,75 @@ -import os -import os.path -import platform - -import pytest - - -# Ensure our client isn't being configured with real values! -os.environ.clear() - - -def read_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as input_file: - return input_file.read() - - -class DummyData(object): - def __init__(self): - import nexmo - - self.api_key = "nexmo-api-key" - self.api_secret = "nexmo-api-secret" - self.signature_secret = "secret" - self.application_id = "nexmo-application-id" - self.private_key = read_file("data/private_key.txt") - self.public_key = read_file("data/public_key.txt") - self.user_agent = "nexmo-python/{} python/{}".format( - nexmo.__version__, platform.python_version() - ) - self.host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" - - -@pytest.fixture(scope="session") -def dummy_data(): - return DummyData() - - -@pytest.fixture -def client(dummy_data): - import nexmo - - return nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=dummy_data.private_key, - ) - -#Represents an instance of the Voice class for testing -@pytest.fixture -def voice(client, dummy_data): - import nexmo - - return nexmo.Voice( - client - ) - -#Represents an instance of the Sms class for testing -@pytest.fixture -def sms(client, dummy_data): - import nexmo - - return nexmo.Sms( - client - ) - -#Represents an instance of the Verify class for testing -@pytest.fixture -def verify(client, dummy_data): - import nexmo - - return nexmo.Verify( - client - ) +import os +import os.path +import platform + +import pytest + + +# Ensure our client isn't being configured with real values! +os.environ.clear() + + +def read_file(path): + with open(os.path.join(os.path.dirname(__file__), path)) as input_file: + return input_file.read() + + +class DummyData(object): + def __init__(self): + import vonage + + self.api_key = "nexmo-api-key" + self.api_secret = "nexmo-api-secret" + self.signature_secret = "secret" + self.application_id = "nexmo-application-id" + self.private_key = read_file("data/private_key.txt") + self.public_key = read_file("data/public_key.txt") + self.user_agent = "nexmo-python/{} python/{}".format( + vonage.__version__, platform.python_version() + ) + self.host = "rest.nexmo.com" + self.api_host = "api.nexmo.com" + + +@pytest.fixture(scope="session") +def dummy_data(): + return DummyData() + + +@pytest.fixture +def client(dummy_data): + import vonage + + return vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=dummy_data.private_key, + ) + +#Represents an instance of the Voice class for testing +@pytest.fixture +def voice(client, dummy_data): + import vonage + + return vonage.Voice( + client + ) + +#Represents an instance of the Sms class for testing +@pytest.fixture +def sms(client, dummy_data): + import vonage + + return vonage.Sms( + client + ) + +#Represents an instance of the Verify class for testing +@pytest.fixture +def verify(client, dummy_data): + import vonage + + return vonage.Verify( + client + ) diff --git a/tests/test_account.py b/tests/test_account.py index 07e94303..41ed2961 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,230 +1,230 @@ -import platform - -from glom import glom - -from util import * - -import nexmo - - -@responses.activate -def test_get_balance(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_application_info_options(dummy_data): - app_name, app_version = "ExampleApp", "X.Y.Z" - - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - app_name=app_name, - app_version=app_version, - ) - user_agent = "nexmo-python/{} python/{} {}/{}".format( - nexmo.__version__, - platform.python_version(), - app_name, - app_version, - ) - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == user_agent - - -@responses.activate -def test_get_country_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") - - assert isinstance(client.get_country_pricing("GB"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=GB" in request_query() - - -@responses.activate -def test_get_prefix_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") - - assert isinstance(client.get_prefix_pricing(44), dict) - assert request_user_agent() == dummy_data.user_agent - assert "prefix=44" in request_query() - - -@responses.activate -def test_get_sms_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") - - assert isinstance(client.get_sms_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_get_voice_pricing(client, dummy_data): - stub( - responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" - ) - - assert isinstance(client.get_voice_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_update_settings(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/settings") - - params = {"moCallBackUrl": "http://example.com/callback"} - - assert isinstance(client.update_settings(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() - - -@responses.activate -def test_topup(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/top-up") - - params = {"trx": "00X123456Y7890123Z"} - - assert isinstance(client.topup(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "trx=00X123456Y7890123Z" in request_body() - - -@responses.activate -def test_get_account_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") - - assert isinstance(client.get_account_numbers(size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] - - -@responses.activate -def test_list_secrets(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/list.json", - ) - - secrets = client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - glom(secrets, "_embedded.secrets.0.id") - == "ad6dc56f-07b5-46e1-a527-85530e625800" - ) - - -@responses.activate -def test_list_secrets_missing(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=404, - fixture_path="account/secret_management/missing.json", - ) - - with pytest.raises(nexmo.ClientError) as ce: - client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - """ClientError: Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - in str(ce) - ) - - -@responses.activate -def test_get_secret(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - fixture_path="account/secret_management/get.json", - ) - - secret = client.get_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_delete_secret(client): - stub( - responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" - ) - - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - - -@responses.activate -def test_delete_secret_last_secret(client): - stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - status_code=403, - fixture_path="account/secret_management/last-secret.json", - ) - with pytest.raises(nexmo.ClientError) as ce: - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - in str(ce) - ) - - -@responses.activate -def test_create_secret(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/create.json", - ) - - secret = client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret_max_secrets(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=403, - fixture_path="account/secret_management/max-secrets.json", - ) - - with pytest.raises(nexmo.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - in str(ce) - ) - - -@responses.activate -def test_create_secret_validation(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=400, - fixture_path="account/secret_management/create-validation.json", - ) - - with pytest.raises(nexmo.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - in str(ce) - ) +import platform + +from glom import glom + +from util import * + +import vonage + + +@responses.activate +def test_get_balance(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-balance") + + assert isinstance(client.get_balance(), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_application_info_options(dummy_data): + app_name, app_version = "ExampleApp", "X.Y.Z" + + stub(responses.GET, "https://rest.nexmo.com/account/get-balance") + + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + app_name=app_name, + app_version=app_version, + ) + user_agent = "nexmo-python/{} python/{} {}/{}".format( + vonage.__version__, + platform.python_version(), + app_name, + app_version, + ) + + assert isinstance(client.get_balance(), dict) + assert request_user_agent() == user_agent + + +@responses.activate +def test_get_country_pricing(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") + + assert isinstance(client.get_country_pricing("GB"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=GB" in request_query() + + +@responses.activate +def test_get_prefix_pricing(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") + + assert isinstance(client.get_prefix_pricing(44), dict) + assert request_user_agent() == dummy_data.user_agent + assert "prefix=44" in request_query() + + +@responses.activate +def test_get_sms_pricing(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") + + assert isinstance(client.get_sms_pricing("447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "phone=447525856424" in request_query() + + +@responses.activate +def test_get_voice_pricing(client, dummy_data): + stub( + responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" + ) + + assert isinstance(client.get_voice_pricing("447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "phone=447525856424" in request_query() + + +@responses.activate +def test_update_settings(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/account/settings") + + params = {"moCallBackUrl": "http://example.com/callback"} + + assert isinstance(client.update_settings(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() + + +@responses.activate +def test_topup(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/account/top-up") + + params = {"trx": "00X123456Y7890123Z"} + + assert isinstance(client.topup(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "trx=00X123456Y7890123Z" in request_body() + + +@responses.activate +def test_get_account_numbers(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/numbers") + + assert isinstance(client.get_account_numbers(size=25), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_params()["size"] == ["25"] + + +@responses.activate +def test_list_secrets(client): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets", + fixture_path="account/secret_management/list.json", + ) + + secrets = client.list_secrets("meaccountid") + assert_basic_auth() + assert ( + glom(secrets, "_embedded.secrets.0.id") + == "ad6dc56f-07b5-46e1-a527-85530e625800" + ) + + +@responses.activate +def test_list_secrets_missing(client): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=404, + fixture_path="account/secret_management/missing.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + client.list_secrets("meaccountid") + assert_basic_auth() + assert ( + """ClientError: Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" + in str(ce) + ) + + +@responses.activate +def test_get_secret(client): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + fixture_path="account/secret_management/get.json", + ) + + secret = client.get_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" + + +@responses.activate +def test_delete_secret(client): + stub( + responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" + ) + + client.delete_secret("meaccountid", "mahsecret") + assert_basic_auth() + + +@responses.activate +def test_delete_secret_last_secret(client): + stub( + responses.DELETE, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + status_code=403, + fixture_path="account/secret_management/last-secret.json", + ) + with pytest.raises(vonage.ClientError) as ce: + client.delete_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + """ClientError: Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" + in str(ce) + ) + + +@responses.activate +def test_create_secret(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + fixture_path="account/secret_management/create.json", + ) + + secret = client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" + + +@responses.activate +def test_create_secret_max_secrets(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=403, + fixture_path="account/secret_management/max-secrets.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + """ClientError: Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" + in str(ce) + ) + + +@responses.activate +def test_create_secret_validation(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=400, + fixture_path="account/secret_management/create-validation.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" + in str(ce) + ) diff --git a/tests/test_applications_v2.py b/tests/test_applications_v2.py index 7dcc6883..c39dd856 100644 --- a/tests/test_applications_v2.py +++ b/tests/test_applications_v2.py @@ -1,7 +1,7 @@ import json from util import * -import nexmo +import vonage @responses.activate @@ -93,7 +93,7 @@ def test_authentication_error(client): "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", status=401, ) - with pytest.raises(nexmo.AuthenticationError): + with pytest.raises(vonage.AuthenticationError): client.application_v2.delete_application("xx-xx-xx-xx") @@ -111,7 +111,7 @@ def test_client_error(client): } ), ) - with pytest.raises(nexmo.ClientError) as exc_info: + with pytest.raises(vonage.ClientError) as exc_info: client.application_v2.delete_application("xx-xx-xx-xx") assert ( str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" @@ -126,7 +126,7 @@ def test_client_error_no_decode(client): status=430, body="{this: isnot_json", ) - with pytest.raises(nexmo.ClientError) as exc_info: + with pytest.raises(vonage.ClientError) as exc_info: client.application_v2.delete_application("xx-xx-xx-xx") assert str(exc_info.value) == "430 response" @@ -138,16 +138,5 @@ def test_server_error(client): "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", status=500, ) - with pytest.raises(nexmo.ServerError): - client.application_v2.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_server_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(nexmo.ServerError): + with pytest.raises(vonage.ServerError): client.application_v2.delete_application("xx-xx-xx-xx") diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 3dd46c6f..850e995b 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -1,4 +1,4 @@ -import nexmo +import vonage from util import * import sys @@ -102,7 +102,7 @@ def test_check_signature(dummy_data): "sig": "6af838ef94998832dbfc29020b564830", } - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" ) @@ -111,7 +111,7 @@ def test_check_signature(dummy_data): def test_signature(client, dummy_data): params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" ) assert client.signature(params) == "6af838ef94998832dbfc29020b564830" @@ -120,7 +120,7 @@ def test_signature(client, dummy_data): def test_signature_adds_timestamp(dummy_data): params = {"a=7": "1", "b": "2 & 5"} - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" ) @@ -130,7 +130,7 @@ def test_signature_adds_timestamp(dummy_data): def test_signature_md5(dummy_data): params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret=dummy_data.signature_secret, @@ -141,7 +141,7 @@ def test_signature_md5(dummy_data): def test_signature_sha1(dummy_data): params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret=dummy_data.signature_secret, @@ -152,7 +152,7 @@ def test_signature_sha1(dummy_data): def test_signature_sha256(dummy_data): params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret=dummy_data.signature_secret, @@ -166,7 +166,7 @@ def test_signature_sha256(dummy_data): def test_signature_sha512(dummy_data): params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = nexmo.Client( + client = vonage.Client( key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret=dummy_data.signature_secret, @@ -179,7 +179,7 @@ def test_signature_sha512(dummy_data): def test_client_doesnt_require_api_key(): - client = nexmo.Client(application_id="myid", private_key="abc\nde") + client = vonage.Client(application_id="myid", private_key="abc\nde") assert client is not None assert client.api_key is None assert client.api_secret is None @@ -189,8 +189,8 @@ def test_client_doesnt_require_api_key(): def test_client_can_make_application_requests_without_api_key(dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") - client = nexmo.Client(application_id="myid", private_key=dummy_data.private_key) - voice = nexmo.Voice(client) + client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) + voice = vonage.Voice(client) voice.create_call("123455") diff --git a/tests/test_sms.py b/tests/test_sms.py index 005fcc56..8fb4e4fe 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,101 +1,101 @@ -import nexmo -from util import * - - -@responses.activate -def test_send_message(sms, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(sms.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(nexmo.AuthenticationError): - sms.send_message({}) - - -@responses.activate -def test_client_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(nexmo.ClientError) as excinfo: - sms.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(nexmo.ServerError) as excinfo: - sms.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(sms): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - sms.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() - -@responses.activate -def test_deprecated_send_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(client.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_deprecated_authentication_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(nexmo.AuthenticationError): - client.send_message({}) - - -@responses.activate -def test_deprecated_client_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(nexmo.ClientError) as excinfo: - client.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_server_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(nexmo.ServerError) as excinfo: - client.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_submit_sms_conversion(client): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - client.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() +import vonage +from util import * + + +@responses.activate +def test_send_message(sms, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(sms.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_authentication_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(vonage.AuthenticationError): + sms.send_message({}) + + +@responses.activate +def test_client_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(vonage.ClientError) as excinfo: + sms.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_server_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(vonage.ServerError) as excinfo: + sms.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_submit_sms_conversion(sms): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + sms.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() + +@responses.activate +def test_deprecated_send_message(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(client.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_deprecated_authentication_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(vonage.AuthenticationError): + client.send_message({}) + + +@responses.activate +def test_deprecated_client_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(vonage.ClientError) as excinfo: + client.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_server_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(vonage.ServerError) as excinfo: + client.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_submit_sms_conversion(client): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + client.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py index 8ec2ba54..76cfc042 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,176 +1,176 @@ -from util import * - -@responses.activate -def test_start_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - verify.check("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(verify, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(verify.search("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_start_psd2_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.psd2(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - -@responses.activate -def test_deprecated_start_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_check_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_get_verification(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_cancel_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_trigger_next_verification_event(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_deprecated_start_psd2_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_psd2_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() +from util import * + +@responses.activate +def test_start_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_check_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + verify.check("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_get_verification(verify, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(verify.search("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_cancel_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_trigger_next_verification_event(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_start_psd2_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.psd2(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + +@responses.activate +def test_deprecated_start_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_send_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.send_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_check_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_check_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.check_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_get_verification(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_get_verification_request(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification_request("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_cancel_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_trigger_next_verification_event(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_control_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.control_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + +@responses.activate +def test_deprecated_start_psd2_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_psd2_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() \ No newline at end of file diff --git a/tests/test_voice.py b/tests/test_voice.py index 808484c8..4e6a6bcf 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -1,297 +1,297 @@ -import os.path -import time - -import jwt - -import nexmo -from util import * - - -@responses.activate -def test_create_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(voice.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - voice = nexmo.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_create_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(client.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_deprecated_get_calls(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(client.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_get_call(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_update_call(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_deprecated_send_audio(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_deprecated_stop_audio(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_speech(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_deprecated_stop_speech(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_dtmf(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_deprecated_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_deprecated_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_authorization_with_private_key_object(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id +import os.path +import time + +import jwt + +import vonage +from util import * + + +@responses.activate +def test_create_call(voice, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(voice.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_get_calls(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(voice.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_get_call(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_update_call(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_send_audio(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_stop_audio(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_speech(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_stop_speech(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_dtmf(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + voice = vonage.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + voice = vonage.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_authorization_with_private_key_object(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_create_call(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(client.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_deprecated_get_calls(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(client.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_get_call(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_update_call(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_deprecated_send_audio(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_deprecated_stop_audio(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_speech(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_deprecated_stop_speech(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_dtmf(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_deprecated_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + client.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_deprecated_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_authorization_with_private_key_object(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id From a70553741405131e755e0c5d2982711e28b306f5 Mon Sep 17 00:00:00 2001 From: Michael Heap Date: Tue, 8 Sep 2020 16:35:20 +0100 Subject: [PATCH 077/401] Run pip using python -m pip This works around permissions errors on Windows when running via GitHub Actions --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 03df35cc..a4575e29 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,6 @@ install: requirements requirements: .requirements.txt .requirements.txt: requirements.txt - pip install --upgrade pip setuptools - pip install -r requirements.txt - pip freeze > .requirements.txt + python -m pip install --upgrade pip setuptools + python -m pip install -r requirements.txt + python -m pip freeze > .requirements.txt From b2670f561c223b847cabefbe705bed15ef4ff389 Mon Sep 17 00:00:00 2001 From: Michael Heap Date: Tue, 8 Sep 2020 16:35:58 +0100 Subject: [PATCH 078/401] Add GitHub Actions CI task --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5c8260b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python: ["3.4", "3.5", "3.6", "3.7", "3.8"] + os: ["ubuntu-latest", "macos-latest", "windows-latest"] + exclude: + - os: "windows-latest" + python: "3.4" + - os: "macos-latest" + python: "3.4" + steps: + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Clone repo + uses: actions/checkout@v2 + - name: Install dependencies + run: make install + - name: Run tests + run: make coverage + - name: Coveralls + run: coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} From 7789ec7b97c0d520d6042f01d9432cb5217d6799 Mon Sep 17 00:00:00 2001 From: Michael Heap Date: Tue, 8 Sep 2020 16:36:05 +0100 Subject: [PATCH 079/401] Remove Travis config --- .travis.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9b197592..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python - -python: - - "3.4" - - "3.5" - - "3.6" - - "3.7" -# Enable 3.8 without globally enabling sudo and dist: xenial for other build jobs -matrix: - include: - - python: 3.8 - dist: xenial - sudo: true - -install: - - make install - -script: - - make coverage - -after_success: - - coveralls From 6b18378febb63787f62b474109665fecdad5c52b Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Wed, 9 Sep 2020 11:26:22 -0400 Subject: [PATCH 080/401] corrections --- .travis.yml | 22 --------------------- CHANGES.md | 8 ++++---- CODE_OF_CONDUCT.md | 2 +- README.md | 48 ++++++++++++++++++++++----------------------- docs/conf.py | 4 ++-- docs/quickstart.rst | 44 +++++++++++++++++++++-------------------- 6 files changed, 54 insertions(+), 74 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9b197592..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python - -python: - - "3.4" - - "3.5" - - "3.6" - - "3.7" -# Enable 3.8 without globally enabling sudo and dist: xenial for other build jobs -matrix: - include: - - python: 3.8 - dist: xenial - sudo: true - -install: - - make install - -script: - - make coverage - -after_success: - - coveralls diff --git a/CHANGES.md b/CHANGES.md index 52aef0b7..10ce37b4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,7 +14,7 @@ # 2.3.0 -- Explicit parameter list for the `vonage.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** +- Explicit parameter list for the `nexmo.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** - Secret Management - Support for Authorization header authentication. @@ -26,7 +26,7 @@ - Add support for `get_recording` - Add support for SMS conversion -- Add debug logging for most calls, under the 'vonage' logger. +- Add debug logging for most calls, under the 'nexmo' logger. - Internal refactoring (affects only private methods.) # 2.0.0 @@ -39,7 +39,7 @@ # 1.5.0 -- Add ability to provide a file path as private_key param no the vonage.Client constructor +- Add ability to provide a file path as private_key param no the nexmo.Client constructor - Add send/stop endpoints for audio/speech/dtmf @@ -81,7 +81,7 @@ # 1.1.0 -- Move repository to https://github.com/Vonage/vonage-python +- Move repository to https://github.com/Vonage/nexmo-python - Add get_basic_number_insight method for Number Insight Basic API diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 003f9af2..6ff73cf8 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@vonage.com. All +reported by contacting the project team at support@nexmo.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/README.md b/README.md index 58375d09..bcb20599 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Vonage Client Library for Python +# Vonage Server SDK for Python [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) -[![Build Status](https://api.travis-ci.org/Vonage/vonage-python.svg?branch=master)](https://travis-ci.org/Vonage/vonage-python) -[![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python?branch=master) +[![Actions Status](https://github.com/Vonage/vonage-python-sdk-sdk/workflows/CI/badge.svg)](https://github.com/Vonage/vonage-python-sdk-sdk/actions) +[![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk-sdk?branch=master) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) -This is the Python client library for Vonage's API. To use it you'll +This is the Python server SDK for Vonage's API. To use it you'll need a Vonage account. Sign up [for free at vonage.com][signup]. - [Installation](#installation) @@ -34,7 +34,7 @@ To upgrade your installed client library using pip: Alternatively, you can clone the repository via the command line: - git clone git@github.com:Vonage/vonage-python.git + git clone git@github.com:Vonage/vonage-python-sdk-sdk.git or by opening it on GitHub desktop. @@ -188,7 +188,7 @@ voice.update_call(response['uuid'], action='hangup') from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) -stream_url = 'https://vonage-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' response = voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, @@ -203,7 +203,7 @@ voice.send_audio(response['uuid'],stream_url=[stream_url]) from vonage import Client, Voice client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') voice = Voice(client) -stream_url = 'https://vonage-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' response = voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, @@ -414,7 +414,7 @@ else: client.get_basic_number_insight(number='447700900000') ``` -Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightBasic](https://developer.vonage.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) +Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) ### Standard Number Insight @@ -422,7 +422,7 @@ Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightBasic](ht client.get_standard_number_insight(number='447700900000') ``` -Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightStandard](https://developer.vonage.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) +Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) ### Advanced Number Insight @@ -430,7 +430,7 @@ Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightStandard] client.get_advanced_number_insight(number='447700900000') ``` -Docs: [https://developer.vonage.com/api/number-insight#getNumberInsightAdvanced](https://developer.vonage.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) ## Managing Secrets @@ -466,7 +466,7 @@ client.delete_secret(API_KEY, 'my-secret-id') response = client.application_v2.create_application({name='Example App', type='voice'}) ``` -Docs: [https://developer.vonage.com/api/application.v2#createApplication](https://developer.vonage.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) +Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) ### Retrieve a list of applications @@ -474,7 +474,7 @@ Docs: [https://developer.vonage.com/api/application.v2#createApplication](https: response = client.application_v2.list_applications() ``` -Docs: [https://developer.vonage.com/api/application.v2#listApplication](https://developer.vonage.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) +Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) ### Retrieve a single application @@ -482,7 +482,7 @@ Docs: [https://developer.vonage.com/api/application.v2#listApplication](https:// response = client.application_v2.get_application(uuid) ``` -Docs: [https://developer.vonage.com/api/application.v2#getApplication](https://developer.vonage.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) +Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) ### Update an application @@ -490,7 +490,7 @@ Docs: [https://developer.vonage.com/api/application.v2#getApplication](https://d response = client.application_v2.update_application(uuid, answer_method='POST') ``` -Docs: [https://developer.vonage.com/api/application.v2#updateApplication](https://developer.vonage.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) +Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) ### Delete an application @@ -498,7 +498,7 @@ Docs: [https://developer.vonage.com/api/application.v2#updateApplication](https: response = client.application_v2.delete_application(uuid) ``` -Docs: [https://developer.vonage.com/api/application.v2#deleteApplication](https://developer.vonage.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) +Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) ## Validate webhook signatures @@ -511,9 +511,9 @@ else: # invalid signature ``` -Docs: [https://developer.vonage.com/concepts/guides/signing-messages](https://developer.vonage.com/concepts/guides/signing-messages?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) +Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://developer.nexmo.com/concepts/guides/signing-messages?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) -Note: you'll need to contact support@vonage.com to enable message signing on +Note: you'll need to contact support@nexmo.com to enable message signing on your account before you can validate webhook signatures. ## JWT parameters @@ -538,13 +538,13 @@ from vonage import Client, Sms #Defines the client client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # using getter for host -- value returned: rest.vonage.com +print(client.host()) # using getter for host -- value returned: rest.nexmo.com #Define the sms instance sms = Sms(client) #Change the value in client -client.host('mio.vonage.com') #Change host to mio.vonage.com - this change will be available for sms +client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms ``` @@ -556,8 +556,8 @@ These attributes are private in the client class and the only way to access them from vonage import Client client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # return rest.vonage.com -client.host('mio.vonage.com') # rewrites the host value to mio.vonage.com +print(client.host()) # return rest.nexmo.com +client.host('mio.nexmo.com') # rewrites the host value to mio.nexmo.com print(client.api_host()) # returns api.vonage.com client.api_host('myapi.vonage.com') # rewrite the value of api_host ``` @@ -598,7 +598,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@vonage.com) first! -We recommend working on `vonage-python` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: +We recommend working on `vonage-python-sdk` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: ```bash make install @@ -615,7 +615,7 @@ make test This library is released under the [Apache License][license]. [virtualenv]: https://virtualenv.pypa.io/en/stable/ -[report-a-bug]: https://github.com/Vonage/vonage-python-sdk/issues/new -[pull-request]: https://github.com/Vonage/vonage-python-sdk/pulls +[report-a-bug]: https://github.com/Vonage/vonage-python-sdk-sdk/issues/new +[pull-request]: https://github.com/Vonage/vonage-python-sdk-sdk/pulls [signup]: https://dashboard.nexmo.com/sign-up?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library [license]: LICENSE.txt diff --git a/docs/conf.py b/docs/conf.py index 4364e0ce..82b20c2f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,8 +58,8 @@ # General information about the project. project = u"Vonage" -copyright = u"{0}, Tim Craft".format(datetime.datetime.now().year) -author = u"Tim Craft" +copyright = u"{0}, Vonage".format(datetime.datetime.now().year) +author = u"Vonage" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 19380b6a..50bc3b88 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -5,7 +5,7 @@ Vonage Client Library for Python This is the Python client library for Vonage's API. To use it you'll need a Vonage account. Sign up `for free at -vonage.com `__. +vonage.com `__. - `Installation <#installation>`__ - `Usage <#usage>`__ @@ -19,7 +19,7 @@ vonage.com `__ +`https://docs.nexmo.com/messaging/sms-api/api-reference#request `__ Voice API --------- @@ -103,7 +103,7 @@ Make a call }) Docs: -`https://docs.vonage.com/voice/voice-api/api-reference#call\_create `__ +`https://docs.nexmo.com/voice/voice-api/api-reference#call\_create `__ Retrieve a list of calls ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -113,7 +113,7 @@ Retrieve a list of calls response = client.get_calls() Docs: -`https://docs.vonage.com/voice/voice-api/api-reference#call\_retrieve `__ +`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve `__ Retrieve a single call ~~~~~~~~~~~~~~~~~~~~~~ @@ -123,7 +123,7 @@ Retrieve a single call response = client.get_call(uuid) Docs: -`https://docs.vonage.com/voice/voice-api/api-reference#call\_retrieve\_single `__ +`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve\_single `__ Update a call ~~~~~~~~~~~~~ @@ -133,7 +133,7 @@ Update a call response = client.update_call(uuid, action='hangup') Docs: -`https://docs.vonage.com/voice/voice-api/api-reference#call\_modify\_single `__ +`https://docs.nexmo.com/voice/voice-api/api-reference#call\_modify\_single `__ Verify API ---------- @@ -151,7 +151,7 @@ Start a verification print('Error:', response['error_text']) Docs: -`https://docs.vonage.com/verify/api-reference/api-reference#vrequest `__ +`https://docs.nexmo.com/verify/api-reference/api-reference#vrequest `__ The response contains a verification request id which you will need to store temporarily (in the session, database, url etc). @@ -169,7 +169,7 @@ Check a verification print('Error:', response['error_text']) Docs: -`https://docs.vonage.com/verify/api-reference/api-reference#check `__ +`https://docs.nexmo.com/verify/api-reference/api-reference#check `__ The verification request id comes from the call to the start\_verification method. The PIN code is entered into your @@ -183,7 +183,7 @@ Cancel a verification client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') Docs: -`https://docs.vonage.com/verify/api-reference/api-reference#control `__ +`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ Trigger next verification step ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -193,7 +193,7 @@ Trigger next verification step client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') Docs: -`https://docs.vonage.com/verify/api-reference/api-reference#control `__ +`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ Application API --------------- @@ -206,7 +206,7 @@ Create an application response = client.create_application(name='Example App', type='voice', answer_url=answer_url) Docs: -`https://docs.vonage.com/tools/application-api/api-reference#create `__ +`https://docs.nexmo.com/tools/application-api/api-reference#create `__ Retrieve a list of applications ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -216,7 +216,7 @@ Retrieve a list of applications response = client.get_applications() Docs: -`https://docs.vonage.com/tools/application-api/api-reference#list `__ +`https://docs.nexmo.com/tools/application-api/api-reference#list `__ Retrieve a single application ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -226,7 +226,7 @@ Retrieve a single application response = client.get_application(uuid) Docs: -`https://developer.vonage.com/api/application#retrieve-an-application `__ +`https://developer.nexmo.com/api/application#retrieve-an-application `__ Update an application ~~~~~~~~~~~~~~~~~~~~~ @@ -236,7 +236,7 @@ Update an application response = client.update_application(uuid, answer_method='POST') Docs: -`https://docs.vonage.com/tools/application-api/api-reference#update `__ +`https://docs.nexmo.com/tools/application-api/api-reference#update `__ Delete an application ~~~~~~~~~~~~~~~~~~~~~ @@ -246,7 +246,7 @@ Delete an application response = client.delete_application(uuid) Docs: -`https://docs.vonage.com/tools/application-api/api-reference#delete `__ +`https://docs.nexmo.com/tools/application-api/api-reference#delete `__ Validate webhook signatures --------------------------- @@ -271,9 +271,9 @@ Validate webhook signatures # invalid signature Docs: -`https://docs.vonage.com/messaging/signing-messages `__ +`https://docs.nexmo.com/messaging/signing-messages `__ -Note: you'll need to contact support@vonage.com to enable message signing +Note: you'll need to contact support@nexmo.com to enable message signing on your account before you can validate webhook signatures. JWT parameters @@ -352,5 +352,7 @@ This library is released under the `MIT License `__ .. |PyPI version| image:: https://badge.fury.io/py/vonage.svg :target: https://badge.fury.io/py/vonage -.. |Build Status| image:: https://api.travis-ci.org/Vonage/vonage-python.svg?branch=master - :target: https://travis-ci.org/Vonage/vonage-python +.. |Build Status| image:: https://github.com/Vonage/vonage-python-sdk/workflows/CI/badge.svg + :target: https://github.com/Vonage/vonage-python-sdk/actions + + From 998308046c80768a08e458324af4380d6330622d Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Wed, 9 Sep 2020 11:30:28 -0400 Subject: [PATCH 081/401] updating release.yml --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 055a97be..d10c2521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Add Changelog - uses: vonage/github-actions/vonage-changelog@master + uses: nexmo/github-actions/nexmo-changelog@master env: CHANGELOG_AUTH_TOKEN: ${{ secrets.CHANGELOG_AUTH_TOKEN }} CHANGELOG_CATEGORY: Server SDK - CHANGELOG_RELEASE_TITLE: vonage-python + CHANGELOG_RELEASE_TITLE: vonage-python-sdk CHANGELOG_SUBCATEGORY: python From de7dbf8ead135f17cb08c2238c20d39da3d169de Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 9 Sep 2020 11:36:21 -0400 Subject: [PATCH 082/401] Update and rename ci.yml to build.yml --- .github/workflows/{ci.yml => build.yml} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{ci.yml => build.yml} (92%) diff --git a/.github/workflows/ci.yml b/.github/workflows/build.yml similarity index 92% rename from .github/workflows/ci.yml rename to .github/workflows/build.yml index 5c8260b7..c4f798a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: CI +name: Build on: push: branches: @@ -17,6 +17,8 @@ jobs: exclude: - os: "windows-latest" python: "3.4" + - os: "windows-latest" + python: "3.8" - os: "macos-latest" python: "3.4" steps: From ef1ea893100bf589f6237de069594afb9a1e2f1d Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Wed, 9 Sep 2020 11:38:11 -0400 Subject: [PATCH 083/401] updating actions changes --- README.md | 2 +- docs/quickstart.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bcb20599..92fc8db7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Vonage Server SDK for Python [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) -[![Actions Status](https://github.com/Vonage/vonage-python-sdk-sdk/workflows/CI/badge.svg)](https://github.com/Vonage/vonage-python-sdk-sdk/actions) +[![Actions Status](https://github.com/Vonage/vonage-python-sdk-sdk/workflows/build/badge.svg)](https://github.com/Vonage/vonage-python-sdk-sdk/actions) [![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk-sdk?branch=master) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 50bc3b88..947f90e9 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -352,7 +352,7 @@ This library is released under the `MIT License `__ .. |PyPI version| image:: https://badge.fury.io/py/vonage.svg :target: https://badge.fury.io/py/vonage -.. |Build Status| image:: https://github.com/Vonage/vonage-python-sdk/workflows/CI/badge.svg +.. |Build Status| image:: https://github.com/Vonage/vonage-python-sdk/workflows/build/badge.svg :target: https://github.com/Vonage/vonage-python-sdk/actions From e2559ed4e7e43a005f447a22eebea2cdc85980a7 Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Wed, 9 Sep 2020 11:40:29 -0400 Subject: [PATCH 084/401] correcting typo --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 92fc8db7..231024f7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Vonage Server SDK for Python [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) -[![Actions Status](https://github.com/Vonage/vonage-python-sdk-sdk/workflows/build/badge.svg)](https://github.com/Vonage/vonage-python-sdk-sdk/actions) -[![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk-sdk?branch=master) +[![Actions Status](https://github.com/Vonage/vonage-python-sdk/workflows/build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) +[![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk?branch=master) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) @@ -34,7 +34,7 @@ To upgrade your installed client library using pip: Alternatively, you can clone the repository via the command line: - git clone git@github.com:Vonage/vonage-python-sdk-sdk.git + git clone git@github.com:Vonage/vonage-python-sdk.git or by opening it on GitHub desktop. @@ -615,7 +615,7 @@ make test This library is released under the [Apache License][license]. [virtualenv]: https://virtualenv.pypa.io/en/stable/ -[report-a-bug]: https://github.com/Vonage/vonage-python-sdk-sdk/issues/new -[pull-request]: https://github.com/Vonage/vonage-python-sdk-sdk/pulls +[report-a-bug]: https://github.com/Vonage/vonage-python-sdk/issues/new +[pull-request]: https://github.com/Vonage/vonage-python-sdk/pulls [signup]: https://dashboard.nexmo.com/sign-up?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library [license]: LICENSE.txt From 3d0b330e276f33232a2cc83d9c495d6504ad6e3b Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Wed, 9 Sep 2020 11:46:13 -0400 Subject: [PATCH 085/401] Configuring CI Badge --- README.md | 2 +- docs/quickstart.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 231024f7..b492ca6f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Vonage Server SDK for Python [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) -[![Actions Status](https://github.com/Vonage/vonage-python-sdk/workflows/build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) +[![Actions Status](<(https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)>)](https://github.com/Vonage/vonage-python-sdk/actions) [![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk?branch=master) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 947f90e9..3cd0faa2 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -352,7 +352,7 @@ This library is released under the `MIT License `__ .. |PyPI version| image:: https://badge.fury.io/py/vonage.svg :target: https://badge.fury.io/py/vonage -.. |Build Status| image:: https://github.com/Vonage/vonage-python-sdk/workflows/build/badge.svg +.. |Build Status| image:: (https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg) :target: https://github.com/Vonage/vonage-python-sdk/actions From f2d9af8867c3c8d8398513b34f375475797876a2 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 9 Sep 2020 12:03:19 -0400 Subject: [PATCH 086/401] Update LICENSE.txt --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index 3b44e590..e5a4bbe3 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -176,7 +176,7 @@ Apache License END OF TERMS AND CONDITIONS - Copyright 2020 Vonage + Copyright (c) 2020 Vonage Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 51b73f533bbbe9d454517556accf4d5ab67c72ef Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 9 Sep 2020 12:03:19 -0400 Subject: [PATCH 087/401] Revert "Update LICENSE.txt" This reverts commit f2d9af8867c3c8d8398513b34f375475797876a2. --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index e5a4bbe3..3b44e590 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -176,7 +176,7 @@ Apache License END OF TERMS AND CONDITIONS - Copyright (c) 2020 Vonage + Copyright 2020 Vonage Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From f11467c53b12497a590fcc941d1a0dbf6c9465a1 Mon Sep 17 00:00:00 2001 From: alphacentauri82 Date: Wed, 9 Sep 2020 14:30:44 -0400 Subject: [PATCH 088/401] additional namespace changes --- README.md | 22 ++++----- docs/quickstart.rst | 6 +-- src/vonage/__init__.py | 107 ++++++++++++++++++++++++++++------------- 3 files changed, 87 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index b492ca6f..e15f6a17 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Then construct a client object with your key and secret: client = vonage.Client(key=api_key, secret=api_secret) ``` -For production, you can specify the `NEXMO_API_KEY` and `NEXMO_API_SECRET` +For production, you can specify the `VONAGE_API_KEY` and `VONAGE_API_SECRET` environment variables instead of specifying the key and secret explicitly. For newer endpoints that support JWT authentication such as the Voice API, @@ -63,7 +63,7 @@ client = vonage.Client(application_id=application_id, private_key=private_key) ``` To check signatures for incoming webhook requests, you'll also need -to specify the `signature_secret` argument (or the `NEXMO_SIGNATURE_SECRET` +to specify the `signature_secret` argument (or the `VONAGE_SIGNATURE_SECRET` environment variable). ## SMS API @@ -91,10 +91,10 @@ import vonage #then you can use vonage.Sms() to create an instance ```python #Option 1 - pass key and secret to the constructor -sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +sms = Sms(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) #Option 2 - Create a client instance and then pass the client to the Sms instance -client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +client = Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) sms = Sms(client) ``` @@ -102,9 +102,9 @@ sms = Sms(client) ```python from vonage import Sms -sms = Sms(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +sms = Sms(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) sms.send_message({ - "from": NEXMO_BRAND_NAME, + "from": VONAGE_BRAND_NAME, "to": TO_NUMBER, "text": "A text message sent using the Vonage SMS API", }) @@ -114,7 +114,7 @@ sms.send_message({ ```python sms.send_message({ - 'from': NEXMO_BRAND_NAME, + 'from': VONAGE_BRAND_NAME, 'to': TO_NUMBER, 'text': 'こんにちは世界', 'type': 'unicode', @@ -125,10 +125,10 @@ sms.send_message({ ```python from vonage import Client, Sms -client = Client(key=NEXMO_API_KEY, secret=NEXMO_SECRET) +client = Client(key=VONAGE_API_KEY, secret=VONAGE_SECRET) sms = Sms(client) response = sms.send_message({ - 'from': NEXMO_BRAND_NAME, + 'from': VONAGE_BRAND_NAME, 'to': TO_NUMBER, 'text': 'Hi from Vonage' }) @@ -289,10 +289,10 @@ import vonage #then you can use vonage.Verify() to create an instance ```python #First way - pass key and secret to the constructor -verify = Verify(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +verify = Verify(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) ​ #Second way - Create a client instance and then pass the client to the Verify contructor -client = Client(key=NEXMO_API_KEY, secret=NEXMO_API_SECRET) +client = Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) verify = Verify(client) ``` diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 3cd0faa2..534099c4 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -46,8 +46,8 @@ Then construct a client object with your key and secret: client = vonage.Client(key=api_key, secret=api_secret) -For production, you can specify the ``NEXMO_API_KEY`` and -``NEXMO_API_SECRET`` environment variables instead of specifying the key +For production, you can specify the ``VONAGE_API_KEY`` and +``VONAGE_API_SECRET`` environment variables instead of specifying the key and secret explicitly. For newer endpoints that support JWT authentication such as the Voice @@ -60,7 +60,7 @@ arguments: In order to check signatures for incoming webhook requests, you'll also need to specify the ``signature_secret`` argument (or the -``NEXMO_SIGNATURE_SECRET`` environment variable). +``VONAGE_SIGNATURE_SECRET`` environment variable). If the argument ``signature_method`` is omitted, it will default to the md5 hash algorithm. Otherwise, it will use the selected method as in md5, sha1, sha256 or diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 6cd45131..a30e5318 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -77,16 +77,16 @@ def __init__( app_name=None, app_version=None, ): - self.api_key = key or os.environ.get("NEXMO_API_KEY", None) + self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) + self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) self.signature_secret = signature_secret or os.environ.get( - "NEXMO_SIGNATURE_SECRET", None + "VONAGE_SIGNATURE_SECRET", None ) self.signature_method = signature_method or os.environ.get( - "NEXMO_SIGNATURE_METHOD", None + "VONAGE_SIGNATURE_METHOD", None ) if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: @@ -99,8 +99,8 @@ def __init__( if isinstance(self.private_key, string_types) and "\n" not in self.private_key: with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() - - self.__host_pattern = '^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$' + + self.__host_pattern = "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" self.__host = "rest.nexmo.com" @@ -128,29 +128,31 @@ def __init__( self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() - + # Get and Set __host attribute def host(self, value=None): if value is None: return self.__host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for host') + elif not re.match(self.__host_pattern, value): + raise Exception("Error: Invalid format for host") else: self.__host = value - + # Gets And sets __api_host attribute def api_host(self, value=None): if value is None: return self.__api_host - elif not re.match(self.__host_pattern,value): - raise Exception('Error: Invalid format for api_host') + elif not re.match(self.__host_pattern, value): + raise Exception("Error: Invalid format for api_host") else: self.__api_host = value def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - @deprecated(reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead") + @deprecated( + reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" + ) def send_message(self, params): """ Send an SMS message. @@ -158,7 +160,7 @@ def send_message(self, params): :: client.send_message({ "to": MY_CELLPHONE, - "from": MY_NEXMO_NUMBER, + "from": MY_VONAGE_NUMBER, "text": "Hello From Nexmo!", }) :param dict params: A dict of values described at `Send an SMS `_ @@ -257,7 +259,9 @@ def get_event_alert_numbers(self): return self.get(self.host(), "/sc/us/alert/opt-in/query/json") def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs) + return self.post( + self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs + ) def initiate_call(self, params=None, **kwargs): return self.post(self.host(), "/call/json", params or kwargs) @@ -268,7 +272,9 @@ def initiate_tts_call(self, params=None, **kwargs): def initiate_tts_prompt_call(self, params=None, **kwargs): return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - @deprecated(reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead") + @deprecated( + reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" + ) def start_verification(self, params=None, **kwargs): return self.post(self.api_host(), "/verify/json", params or kwargs) @@ -281,7 +287,9 @@ def send_verification_request(self, params=None, **kwargs): return self.post(self.api_host(), "/verify/json", params or kwargs) - @deprecated(reason="vonage.Client#check_verification is deprecated. Use Verify#check instead") + @deprecated( + reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" + ) def check_verification(self, request_id, params=None, **kwargs): return self.post( self.api_host(), @@ -297,12 +305,16 @@ def check_verification_request(self, params=None, **kwargs): ) return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated(reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead") + + @deprecated( + reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" + ) def start_psd2_verification_request(self, params=None, **kwargs): return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - @deprecated(reason="vonage.Client#get_verification is deprecated. Use Verify#search instead") + @deprecated( + reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" + ) def get_verification(self, request_id): return self.get( self.api_host(), "/verify/search/json", {"request_id": request_id} @@ -319,7 +331,9 @@ def get_verification_request(self, request_id): self.api_host(), "/verify/search/json", {"request_id": request_id} ) - @deprecated(reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead") + @deprecated( + reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" + ) def cancel_verification(self, request_id): return self.post( self.api_host(), @@ -327,7 +341,9 @@ def cancel_verification(self, request_id): {"request_id": request_id, "cmd": "cancel"}, ) - @deprecated(reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead") + @deprecated( + reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" + ) def trigger_next_verification_event(self, request_id): return self.post( self.api_host(), @@ -362,9 +378,13 @@ def get_number_insight(self, params=None, **kwargs): def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams: - return self.get(self.api_host(), "/ni/advanced/async/json", params or kwargs) + return self.get( + self.api_host(), "/ni/advanced/async/json", params or kwargs + ) else: - raise ClientError("Error: Callback needed for async advanced number insight") + raise ClientError( + "Error: Callback needed for async advanced number insight" + ) def get_advanced_number_insight(self, params=None, **kwargs): return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) @@ -422,45 +442,63 @@ def delete_application(self, application_id): "/v1/applications/{application_id}".format(application_id=application_id), ) - @deprecated(reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead") + @deprecated( + reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" + ) def create_call(self, params=None, **kwargs): return self._jwt_signed_post("/v1/calls", params or kwargs) - @deprecated(reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead") + @deprecated( + reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" + ) def get_calls(self, params=None, **kwargs): return self._jwt_signed_get("/v1/calls", params or kwargs) - @deprecated(reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead") + @deprecated( + reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" + ) def get_call(self, uuid): return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - @deprecated(reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead") + @deprecated( + reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" + ) def update_call(self, uuid, params=None, **kwargs): return self._jwt_signed_put( "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs ) - @deprecated(reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead") + @deprecated( + reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" + ) def send_audio(self, uuid, params=None, **kwargs): return self._jwt_signed_put( "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs ) - @deprecated(reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead") + @deprecated( + reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" + ) def stop_audio(self, uuid): return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - @deprecated(reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead") + @deprecated( + reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" + ) def send_speech(self, uuid, params=None, **kwargs): return self._jwt_signed_put( "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs ) - @deprecated(reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead") + @deprecated( + reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" + ) def stop_speech(self, uuid): return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - @deprecated(reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead") + @deprecated( + reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" + ) def send_dtmf(self, uuid, params=None, **kwargs): return self._jwt_signed_put( "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs @@ -716,7 +754,8 @@ def _jwt_signed_post(self, request_uri, params): ) return self.parse( - self.api_host(), self.session.post(uri, json=params, headers=self._headers()) + self.api_host(), + self.session.post(uri, json=params, headers=self._headers()), ) def _jwt_signed_put(self, request_uri, params): From d2e16c1cc5b419a895f80aee612bb173e32ae99f Mon Sep 17 00:00:00 2001 From: superdiana Date: Fri, 11 Sep 2020 23:12:00 -0400 Subject: [PATCH 089/401] CRLF to LF --- src/vonage/__init__.py | 1584 ++++++++++++++++++++-------------------- src/vonage/errors.py | 28 +- src/vonage/verify.py | 100 ++- tests/conftest.py | 147 ++-- tests/test_account.py | 457 ++++++------ tests/test_sms.py | 203 ++--- tests/test_verify.py | 352 ++++----- tests/test_voice.py | 594 +++++++-------- 8 files changed, 1729 insertions(+), 1736 deletions(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index a30e5318..f2a107b2 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,792 +1,792 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param -from .errors import * -from .voice import * -from .sms import * -from .verify import * -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import sys -import time -from uuid import uuid4 -import warnings -import re -from deprecated import deprecated - - -string_types = (str, bytes) -from urllib.parse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - - -__version__ = "2.4.0" - -logger = logging.getLogger("nexmo") - - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - - self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "VONAGE_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "VONAGE_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.__host_pattern = "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" - - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for host") - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for api_host") - else: - self.__api_host = value - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - @deprecated( - reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" - ) - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_VONAGE_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post( - self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs - ) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" - ) - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated( - reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" - ) - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" - ) - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" - ) - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated( - reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" - ) - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated( - reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" - ) - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get( - self.api_host(), "/ni/advanced/async/json", params or kwargs - ) - else: - raise ClientError( - "Error: Callback needed for async advanced number insight" - ) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - @deprecated( - reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" - ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" - ) - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" - ) - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" - ) - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" - ) - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" - ) - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" - ) - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" - ) - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" - ) - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): - """ - Low-level method to make a post request to a Nexmo API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse( - host, self.session.delete(uri, params=params, headers=headers) - ) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.post(uri, json=params, headers=self._headers()), - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - return jwt.encode(payload, self.private_key, algorithm="RS256") +from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .errors import * +from .voice import * +from .sms import * +from .verify import * +from datetime import datetime +import logging +from platform import python_version + +import base64 +import hashlib +import hmac +import jwt +import os +import pytz +import requests +import sys +import time +from uuid import uuid4 +import warnings +import re +from deprecated import deprecated + + +string_types = (str, bytes) +from urllib.parse import urlparse + +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + + +__version__ = "2.4.0" + +logger = logging.getLogger("nexmo") + + +class Client: + """ + Create a Client object to start making calls to Nexmo APIs. + + Most methods corresponding to Nexmo API calls are on this class itself, + although newer APIs are under namespaces like :attr:`Client.application_v2`. + + The credentials you provide when instantiating a Client determine which + methods can be called. Consult the `Nexmo API docs `_ for details of the + authentication used by the APIs you wish to use, and instantiate your + Client with the appropriate credentials. + + :param str key: Your Nexmo API key + :param str secret: Your Nexmo API secret. + :param str signature_secret: Your Nexmo API signature secret. + You may need to have this enabled by Nexmo support. It is only used for SMS authentication. + :param str signature_method: + The encryption method used for signature encryption. This must match the method + configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. + This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. + If you want to use a simple MD5 hash, leave this as `None`. + :param str application_id: Your application ID if calling methods which use JWT authentication. + :param str private_key: Your private key if calling methods which use JWT authentication. + This should either be a str containing the key in its PEM form, or a path to a private key file. + :param str app_name: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + :param str app_version: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + """ + + def __init__( + self, + key=None, + secret=None, + signature_secret=None, + signature_method=None, + application_id=None, + private_key=None, + app_name=None, + app_version=None, + ): + self.api_key = key or os.environ.get("VONAGE_API_KEY", None) + + self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) + + self.signature_secret = signature_secret or os.environ.get( + "VONAGE_SIGNATURE_SECRET", None + ) + + self.signature_method = signature_method or os.environ.get( + "VONAGE_SIGNATURE_METHOD", None + ) + + if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: + self.signature_method = getattr(hashlib, signature_method) + + self.application_id = application_id + + self.private_key = private_key + + if isinstance(self.private_key, string_types) and "\n" not in self.private_key: + with open(self.private_key, "rb") as key_file: + self.private_key = key_file.read() + + self.__host_pattern = "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" + + self.__host = "rest.nexmo.com" + + self.__api_host = "api.nexmo.com" + + user_agent = "nexmo-python/{version} python/{python_version}".format( + version=__version__, python_version=python_version() + ) + + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) + + self.headers = {"User-Agent": user_agent} + + self.auth_params = {} + + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) + + self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern, value): + raise Exception("Error: Invalid format for host") + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern, value): + raise Exception("Error: Invalid format for api_host") + else: + self.__api_host = value + + def auth(self, params=None, **kwargs): + self.auth_params = params or kwargs + + @deprecated( + reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" + ) + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_VONAGE_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + + def get_balance(self): + return self.get(self.host(), "/account/get-balance") + + def get_country_pricing(self, country_code): + return self.get( + self.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + def get_prefix_pricing(self, prefix): + return self.get( + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) + + def get_sms_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + def get_voice_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + def update_settings(self, params=None, **kwargs): + return self.post(self.host(), "/account/settings", params or kwargs) + + def topup(self, params=None, **kwargs): + return self.post(self.host(), "/account/top-up", params or kwargs) + + def get_account_numbers(self, params=None, **kwargs): + return self.get(self.host(), "/account/numbers", params or kwargs) + + def get_available_numbers(self, country_code, params=None, **kwargs): + return self.get( + self.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + def buy_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/buy", params or kwargs) + + def cancel_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/cancel", params or kwargs) + + def update_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/update", params or kwargs) + + def get_message(self, message_id): + return self.get(self.host(), "/search/message", {"id": message_id}) + + def get_message_rejections(self, params=None, **kwargs): + return self.get(self.host(), "/search/rejections", params or kwargs) + + def search_messages(self, params=None, **kwargs): + return self.get(self.host(), "/search/messages", params or kwargs) + + def send_ussd_push_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd/json", params or kwargs) + + def send_ussd_prompt_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + + def send_2fa_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) + + def send_event_alert_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + + def send_marketing_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + + def get_event_alert_numbers(self): + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self.post( + self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs + ) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) + + @deprecated( + reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" + ) + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + @deprecated( + reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" + ) + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + @deprecated( + reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" + ) + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) + + @deprecated( + reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" + ) + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + @deprecated( + reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" + ) + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + @deprecated( + reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" + ) + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) + + def get_basic_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + + def get_standard_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get( + self.api_host(), "/ni/advanced/async/json", params or kwargs + ) + else: + raise ClientError( + "Error: Callback needed for async advanced number insight" + ) + + def get_advanced_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + + def request_number_insight(self, params=None, **kwargs): + return self.post(self.host(), "/ni/json", params or kwargs) + + def get_applications(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get(self.api_host(), "/v1/applications", params or kwargs) + + def get_application(self, application_id): + warnings.warn( + "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + def create_application(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.post(self.api_host(), "/v1/applications", params or kwargs) + + def update_application(self, application_id, params=None, **kwargs): + warnings.warn( + "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.put( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + params or kwargs, + ) + + def delete_application(self, application_id): + warnings.warn( + "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.delete( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + @deprecated( + reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" + ) + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + @deprecated( + reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" + ) + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + @deprecated( + reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" + ) + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + @deprecated( + reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" + ) + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + @deprecated( + reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" + ) + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + @deprecated( + reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" + ) + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + @deprecated( + reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" + ) + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + @deprecated( + reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" + ) + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + @deprecated( + reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" + ) + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + def get_recording(self, url): + hostname = urlparse(url).hostname + return self.parse(hostname, self.session.get(url, headers=self._headers())) + + def redact_transaction(self, id, product, type=None): + params = {"id": id, "product": product} + if type is not None: + params["type"] = type + return self._post_json(self.api_host(), "/v1/redact/transaction", params) + + def list_secrets(self, api_key): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) + + def get_secret(self, api_key, secret_id): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._post_json( + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + ) + + def delete_secret(self, api_key, secret_id): + return self.delete( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def check_signature(self, params): + params = dict(params) + signature = params.pop("sig", "").lower() + return hmac.compare_digest(signature, self.signature(params)) + + def signature(self, params): + if self.signature_method: + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) + else: + hasher = hashlib.md5() + + # Add timestamp if not already present + if not params.get("timestamp"): + params["timestamp"] = int(time.time()) + + for key in sorted(params): + value = params[key] + + if isinstance(value, str): + value = value.replace("&", "_").replace("=", "_") + + hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) + + if self.signature_method is None: + hasher.update(self.signature_secret.encode()) + + return hasher.hexdigest() + + def get(self, host, request_uri, params=None, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) + logger.debug("GET to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.get(uri, params=params, headers=headers)) + + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): + """ + Low-level method to make a post request to a Nexmo API server. + This method automatically adds authentication, picking the first applicable authentication method from the following: + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. + - Otherwise the client's key and secret are appended to the post request's params. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if supports_signature_auth and self.signature_secret: + params["api_key"] = self.api_key + params["sig"] = self.signature(params) + elif header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("POST to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.post(uri, data=params, headers=headers)) + + def _post_json(self, host, request_uri, json): + """ + Post json to `request_uri`, using basic auth. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + auth = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) + return self.parse(host, self.session.post(uri, headers=headers, json=json)) + + def put(self, host, request_uri, params, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.put(uri, json=params, headers=headers)) + + def delete(self, host, request_uri, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + params = None + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = {"api_key": self.api_key, "api_secret": self.api_secret} + logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) + return self.parse( + host, self.session.delete(uri, params=params, headers=headers) + ) + + def parse(self, host, response): + logger.debug("Response headers %r", response.headers) + if response.status_code == 401: + raise AuthenticationError + elif response.status_code == 204: + return None + elif 200 <= response.status_code < 300: + + # Strip off any encoding from the content-type header: + content_mime = response.headers.get("content-type").split(";", 1)[0] + if content_mime == "application/json": + return response.json() + else: + return response.content + elif 400 <= response.status_code < 500: + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + + # Test for standard error format: + try: + error_data = response.json() + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): + message = "{title}: {detail} ({type})".format( + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], + ) + except JSONDecodeError: + pass + raise ClientError(message) + elif 500 <= response.status_code < 600: + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + raise ServerError(message) + + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.post(uri, json=params, headers=self._headers()), + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.delete(uri, headers=self._headers()) + ) + + def _headers(self): + token = self.generate_application_jwt() + return dict(self.headers, Authorization=b"Bearer " + token) + + def generate_application_jwt(self, when=None): + iat = int(when if when is not None else time.time()) + + payload = dict(self.auth_params) + payload.setdefault("application_id", self.application_id) + payload.setdefault("iat", iat) + payload.setdefault("exp", iat + 60) + payload.setdefault("jti", str(uuid4())) + + return jwt.encode(payload, self.private_key, algorithm="RS256") diff --git a/src/vonage/errors.py b/src/vonage/errors.py index ede0b9aa..88995700 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -1,14 +1,14 @@ -class Error(Exception): - pass - - -class ClientError(Error): - pass - - -class ServerError(Error): - pass - - -class AuthenticationError(ClientError): - pass +class Error(Exception): + pass + + +class ClientError(Error): + pass + + +class ServerError(Error): + pass + + +class AuthenticationError(ClientError): + pass diff --git a/src/vonage/verify.py b/src/vonage/verify.py index cf0e3f91..5f368b9f 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -1,51 +1,49 @@ -import vonage -import warnings - -class Verify: - def __init__( - self, - client=None, - key=None, - secret=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def start_verification(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/verify/json", params or kwargs) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def psd2(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/verify/psd2/json", params or kwargs) \ No newline at end of file +import vonage +import warnings + + +class Verify: + def __init__(self, client=None, key=None, secret=None): + try: + self._client = client + if self._client is None: + self._client = vonage.Client(key=key, secret=secret) + except Exception as e: + print("Error: {error_message}".format(error_message=str(e))) + + def start_verification(self, params=None, **kwargs): + return self._client.post( + self._client.api_host(), "/verify/json", params or kwargs + ) + + def check(self, request_id, params=None, **kwargs): + return self._client.post( + self._client.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def search(self, request_id): + return self._client.get( + self._client.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def cancel(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + def trigger_next_event(self, request_id): + return self._client.post( + self._client.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def psd2(self, params=None, **kwargs): + return self._client.post( + self._client.api_host(), "/verify/psd2/json", params or kwargs + ) + diff --git a/tests/conftest.py b/tests/conftest.py index 2f2b6bb1..0cc8d7bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,75 +1,72 @@ -import os -import os.path -import platform - -import pytest - - -# Ensure our client isn't being configured with real values! -os.environ.clear() - - -def read_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as input_file: - return input_file.read() - - -class DummyData(object): - def __init__(self): - import vonage - - self.api_key = "nexmo-api-key" - self.api_secret = "nexmo-api-secret" - self.signature_secret = "secret" - self.application_id = "nexmo-application-id" - self.private_key = read_file("data/private_key.txt") - self.public_key = read_file("data/public_key.txt") - self.user_agent = "nexmo-python/{} python/{}".format( - vonage.__version__, platform.python_version() - ) - self.host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" - - -@pytest.fixture(scope="session") -def dummy_data(): - return DummyData() - - -@pytest.fixture -def client(dummy_data): - import vonage - - return vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=dummy_data.private_key, - ) - -#Represents an instance of the Voice class for testing -@pytest.fixture -def voice(client, dummy_data): - import vonage - - return vonage.Voice( - client - ) - -#Represents an instance of the Sms class for testing -@pytest.fixture -def sms(client, dummy_data): - import vonage - - return vonage.Sms( - client - ) - -#Represents an instance of the Verify class for testing -@pytest.fixture -def verify(client, dummy_data): - import vonage - - return vonage.Verify( - client - ) +import os +import os.path +import platform + +import pytest + + +# Ensure our client isn't being configured with real values! +os.environ.clear() + + +def read_file(path): + with open(os.path.join(os.path.dirname(__file__), path)) as input_file: + return input_file.read() + + +class DummyData(object): + def __init__(self): + import vonage + + self.api_key = "nexmo-api-key" + self.api_secret = "nexmo-api-secret" + self.signature_secret = "secret" + self.application_id = "nexmo-application-id" + self.private_key = read_file("data/private_key.txt") + self.public_key = read_file("data/public_key.txt") + self.user_agent = "nexmo-python/{} python/{}".format( + vonage.__version__, platform.python_version() + ) + self.host = "rest.nexmo.com" + self.api_host = "api.nexmo.com" + + +@pytest.fixture(scope="session") +def dummy_data(): + return DummyData() + + +@pytest.fixture +def client(dummy_data): + import vonage + + return vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=dummy_data.private_key, + ) + + +# Represents an instance of the Voice class for testing +@pytest.fixture +def voice(client, dummy_data): + import vonage + + return vonage.Voice(client) + + +# Represents an instance of the Sms class for testing +@pytest.fixture +def sms(client, dummy_data): + import vonage + + return vonage.Sms(client) + + +# Represents an instance of the Verify class for testing +@pytest.fixture +def verify(client, dummy_data): + import vonage + + return vonage.Verify(client) diff --git a/tests/test_account.py b/tests/test_account.py index 41ed2961..a39d3607 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,230 +1,227 @@ -import platform - -from glom import glom - -from util import * - -import vonage - - -@responses.activate -def test_get_balance(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_application_info_options(dummy_data): - app_name, app_version = "ExampleApp", "X.Y.Z" - - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - app_name=app_name, - app_version=app_version, - ) - user_agent = "nexmo-python/{} python/{} {}/{}".format( - vonage.__version__, - platform.python_version(), - app_name, - app_version, - ) - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == user_agent - - -@responses.activate -def test_get_country_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") - - assert isinstance(client.get_country_pricing("GB"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=GB" in request_query() - - -@responses.activate -def test_get_prefix_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") - - assert isinstance(client.get_prefix_pricing(44), dict) - assert request_user_agent() == dummy_data.user_agent - assert "prefix=44" in request_query() - - -@responses.activate -def test_get_sms_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") - - assert isinstance(client.get_sms_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_get_voice_pricing(client, dummy_data): - stub( - responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" - ) - - assert isinstance(client.get_voice_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_update_settings(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/settings") - - params = {"moCallBackUrl": "http://example.com/callback"} - - assert isinstance(client.update_settings(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() - - -@responses.activate -def test_topup(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/top-up") - - params = {"trx": "00X123456Y7890123Z"} - - assert isinstance(client.topup(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "trx=00X123456Y7890123Z" in request_body() - - -@responses.activate -def test_get_account_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") - - assert isinstance(client.get_account_numbers(size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] - - -@responses.activate -def test_list_secrets(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/list.json", - ) - - secrets = client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - glom(secrets, "_embedded.secrets.0.id") - == "ad6dc56f-07b5-46e1-a527-85530e625800" - ) - - -@responses.activate -def test_list_secrets_missing(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=404, - fixture_path="account/secret_management/missing.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - """ClientError: Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - in str(ce) - ) - - -@responses.activate -def test_get_secret(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - fixture_path="account/secret_management/get.json", - ) - - secret = client.get_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_delete_secret(client): - stub( - responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" - ) - - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - - -@responses.activate -def test_delete_secret_last_secret(client): - stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - status_code=403, - fixture_path="account/secret_management/last-secret.json", - ) - with pytest.raises(vonage.ClientError) as ce: - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - in str(ce) - ) - - -@responses.activate -def test_create_secret(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/create.json", - ) - - secret = client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret_max_secrets(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=403, - fixture_path="account/secret_management/max-secrets.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - in str(ce) - ) - - -@responses.activate -def test_create_secret_validation(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=400, - fixture_path="account/secret_management/create-validation.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - in str(ce) - ) +import platform + +from glom import glom + +from util import * + +import vonage + + +@responses.activate +def test_get_balance(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-balance") + + assert isinstance(client.get_balance(), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_application_info_options(dummy_data): + app_name, app_version = "ExampleApp", "X.Y.Z" + + stub(responses.GET, "https://rest.nexmo.com/account/get-balance") + + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + app_name=app_name, + app_version=app_version, + ) + user_agent = "nexmo-python/{} python/{} {}/{}".format( + vonage.__version__, platform.python_version(), app_name, app_version, + ) + + assert isinstance(client.get_balance(), dict) + assert request_user_agent() == user_agent + + +@responses.activate +def test_get_country_pricing(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") + + assert isinstance(client.get_country_pricing("GB"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=GB" in request_query() + + +@responses.activate +def test_get_prefix_pricing(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") + + assert isinstance(client.get_prefix_pricing(44), dict) + assert request_user_agent() == dummy_data.user_agent + assert "prefix=44" in request_query() + + +@responses.activate +def test_get_sms_pricing(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") + + assert isinstance(client.get_sms_pricing("447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "phone=447525856424" in request_query() + + +@responses.activate +def test_get_voice_pricing(client, dummy_data): + stub( + responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" + ) + + assert isinstance(client.get_voice_pricing("447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "phone=447525856424" in request_query() + + +@responses.activate +def test_update_settings(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/account/settings") + + params = {"moCallBackUrl": "http://example.com/callback"} + + assert isinstance(client.update_settings(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() + + +@responses.activate +def test_topup(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/account/top-up") + + params = {"trx": "00X123456Y7890123Z"} + + assert isinstance(client.topup(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "trx=00X123456Y7890123Z" in request_body() + + +@responses.activate +def test_get_account_numbers(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/numbers") + + assert isinstance(client.get_account_numbers(size=25), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_params()["size"] == ["25"] + + +@responses.activate +def test_list_secrets(client): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets", + fixture_path="account/secret_management/list.json", + ) + + secrets = client.list_secrets("meaccountid") + assert_basic_auth() + assert ( + glom(secrets, "_embedded.secrets.0.id") + == "ad6dc56f-07b5-46e1-a527-85530e625800" + ) + + +@responses.activate +def test_list_secrets_missing(client): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=404, + fixture_path="account/secret_management/missing.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + client.list_secrets("meaccountid") + assert_basic_auth() + assert ( + """ClientError: Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" + in str(ce) + ) + + +@responses.activate +def test_get_secret(client): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + fixture_path="account/secret_management/get.json", + ) + + secret = client.get_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" + + +@responses.activate +def test_delete_secret(client): + stub( + responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" + ) + + client.delete_secret("meaccountid", "mahsecret") + assert_basic_auth() + + +@responses.activate +def test_delete_secret_last_secret(client): + stub( + responses.DELETE, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + status_code=403, + fixture_path="account/secret_management/last-secret.json", + ) + with pytest.raises(vonage.ClientError) as ce: + client.delete_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + """ClientError: Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" + in str(ce) + ) + + +@responses.activate +def test_create_secret(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + fixture_path="account/secret_management/create.json", + ) + + secret = client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" + + +@responses.activate +def test_create_secret_max_secrets(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=403, + fixture_path="account/secret_management/max-secrets.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + """ClientError: Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" + in str(ce) + ) + + +@responses.activate +def test_create_secret_validation(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=400, + fixture_path="account/secret_management/create-validation.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" + in str(ce) + ) diff --git a/tests/test_sms.py b/tests/test_sms.py index 8fb4e4fe..9584c068 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,101 +1,102 @@ -import vonage -from util import * - - -@responses.activate -def test_send_message(sms, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(sms.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(vonage.AuthenticationError): - sms.send_message({}) - - -@responses.activate -def test_client_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(vonage.ClientError) as excinfo: - sms.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(vonage.ServerError) as excinfo: - sms.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(sms): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - sms.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() - -@responses.activate -def test_deprecated_send_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(client.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_deprecated_authentication_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(vonage.AuthenticationError): - client.send_message({}) - - -@responses.activate -def test_deprecated_client_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(vonage.ClientError) as excinfo: - client.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_server_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(vonage.ServerError) as excinfo: - client.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_submit_sms_conversion(client): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - client.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() +import vonage +from util import * + + +@responses.activate +def test_send_message(sms, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(sms.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_authentication_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(vonage.AuthenticationError): + sms.send_message({}) + + +@responses.activate +def test_client_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(vonage.ClientError) as excinfo: + sms.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_server_error(sms): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(vonage.ServerError) as excinfo: + sms.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_submit_sms_conversion(sms): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + sms.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() + + +@responses.activate +def test_deprecated_send_message(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sms/json") + + params = {"from": "Python", "to": "447525856424", "text": "Hey!"} + + assert isinstance(client.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=Python" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hey%21" in request_body() + + +@responses.activate +def test_deprecated_authentication_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) + + with pytest.raises(vonage.AuthenticationError): + client.send_message({}) + + +@responses.activate +def test_deprecated_client_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) + + with pytest.raises(vonage.ClientError) as excinfo: + client.send_message({}) + excinfo.match(r"400 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_server_error(client): + responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) + + with pytest.raises(vonage.ServerError) as excinfo: + client.send_message({}) + excinfo.match(r"500 response from rest.nexmo.com") + + +@responses.activate +def test_deprecated_submit_sms_conversion(client): + responses.add( + responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" + ) + + client.submit_sms_conversion("a-message-id") + assert "message-id=a-message-id" in request_body() + assert "timestamp" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py index 76cfc042..cdf70298 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,176 +1,176 @@ -from util import * - -@responses.activate -def test_start_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - verify.check("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(verify, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(verify.search("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_start_psd2_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.psd2(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - -@responses.activate -def test_deprecated_start_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_check_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_get_verification(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_cancel_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_trigger_next_verification_event(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - -@responses.activate -def test_deprecated_start_psd2_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_psd2_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() \ No newline at end of file +from util import * + + +@responses.activate +def test_start_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_check_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance(verify.check("8g88g88eg8g8gg9g90", code="123445"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_get_verification(verify, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(verify.search("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_cancel_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_trigger_next_verification_event(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_start_psd2_verification(verify, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(verify.psd2(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_start_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_verification(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_send_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.send_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + + +@responses.activate +def test_deprecated_check_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + assert isinstance( + client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_check_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/check/json") + + params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.check_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "code=123445" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_get_verification(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_get_verification_request(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/verify/search/json") + + assert isinstance(client.get_verification_request("xxx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "request_id=xxx" in request_query() + + +@responses.activate +def test_deprecated_cancel_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_trigger_next_verification_event(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + assert isinstance( + client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=trigger_next_event" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_control_verification_request(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/control/json") + + params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} + + assert isinstance(client.control_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "cmd=cancel" in request_body() + assert "request_id=8g88g88eg8g8gg9g90" in request_body() + + +@responses.activate +def test_deprecated_start_psd2_verification(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") + + params = {"number": "447525856424", "brand": "MyApp"} + + assert isinstance(client.start_psd2_verification_request(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() diff --git a/tests/test_voice.py b/tests/test_voice.py index 4e6a6bcf..bbcf580d 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -1,297 +1,297 @@ -import os.path -import time - -import jwt - -import vonage -from util import * - - -@responses.activate -def test_create_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(voice.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - voice = vonage.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - voice = vonage.Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_create_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(client.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_deprecated_get_calls(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(client.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_get_call(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_update_call(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_deprecated_send_audio(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_deprecated_stop_audio(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_speech(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_deprecated_stop_speech(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_dtmf(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_deprecated_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_deprecated_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_authorization_with_private_key_object(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id +import os.path +import time + +import jwt + +import vonage +from util import * + + +@responses.activate +def test_create_call(voice, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(voice.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_get_calls(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(voice.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_get_call(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_update_call(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_send_audio(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_stop_audio(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_speech(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_stop_speech(voice, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_send_dtmf(voice, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + voice = vonage.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + voice = vonage.Voice(client) + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_authorization_with_private_key_object(voice, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + voice.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_create_call(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "from": {"type": "phone", "number": "14843335555"}, + "answer_url": ["https://example.com/answer"], + } + + assert isinstance(client.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + +@responses.activate +def test_deprecated_get_calls(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + + assert isinstance(client.get_calls(), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_get_call(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.get_call("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + assert_re(r"\ABearer ", request_authorization()) + + +@responses.activate +def test_deprecated_update_call(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"action": "hangup"}' + + +@responses.activate +def test_deprecated_send_audio(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance( + client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), + dict, + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' + + +@responses.activate +def test_deprecated_stop_audio(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") + + assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_speech(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"text": "Hello"}' + + +@responses.activate +def test_deprecated_stop_speech(client, dummy_data): + stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") + + assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_send_dtmf(client, dummy_data): + stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") + + assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert request_body() == b'{"digits": "1234"}' + + +@responses.activate +def test_deprecated_user_provided_authorization(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + application_id = "different-nexmo-application-id" + nbf = int(time.time()) + exp = nbf + 3600 + + client.auth(application_id=application_id, nbf=nbf, exp=exp) + client.get_call("xx-xx-xx-xx") + + token = request_authorization().split()[1] + + token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + + assert token["application_id"] == application_id + assert token["nbf"] == nbf + assert token["exp"] == exp + + +@responses.activate +def test_deprecated_authorization_with_private_key_path(dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") + + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + application_id=dummy_data.application_id, + private_key=private_key, + ) + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id + + +@responses.activate +def test_deprecated_authorization_with_private_key_object(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") + + client.get_call("xx-xx-xx-xx") + + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + ) + assert token["application_id"] == dummy_data.application_id From 3cf50d58c6788802774020f48027fb82d3c77b70 Mon Sep 17 00:00:00 2001 From: superdiana Date: Fri, 11 Sep 2020 23:15:05 -0400 Subject: [PATCH 090/401] Fixing Build status badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e15f6a17..80aa24df 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Vonage Server SDK for Python [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) -[![Actions Status](<(https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)>)](https://github.com/Vonage/vonage-python-sdk/actions) +[![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) [![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk?branch=master) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) From 2876d2a5c922fb11964e105f09c7fe36b690f4e4 Mon Sep 17 00:00:00 2001 From: superdiana Date: Sat, 12 Sep 2020 11:14:05 -0400 Subject: [PATCH 091/401] adding coveralls --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 889abafc..6b9e56b9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ htmlcov/ .tox/ .coverage .coverage.* +.coveralls.* .cache nosetests.xml coverage.xml @@ -101,4 +102,4 @@ ENV* /site .requirements.txt -*_quickstart* \ No newline at end of file +*_quickstart* From e7bde491d35df23496544fbc7740eb94e7769b2d Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Mon, 14 Sep 2020 15:05:58 -0400 Subject: [PATCH 092/401] Update CHANGES.md --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 10ce37b4..c1268d6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -81,7 +81,7 @@ # 1.1.0 -- Move repository to https://github.com/Vonage/nexmo-python +- Move repository to https://github.com/nexmo/nexmo-python - Add get_basic_number_insight method for Number Insight Basic API From b7773a9fd78884e0e1b8dbec7316acd6a2f57a31 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Wed, 16 Sep 2020 11:43:50 -0400 Subject: [PATCH 093/401] adding codecov --- .github/workflows/build.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4f798a6..a2f341e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,5 @@ jobs: run: make install - name: Run tests run: make coverage - - name: Coveralls - run: coveralls - env: - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + - name: Run codecov + uses: codecov/codecov-action@v1 From faffc9221a57ea83522e1b3f911d4eda3926f0fc Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 16 Sep 2020 11:58:22 -0400 Subject: [PATCH 094/401] adding codecov --- codecov.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..36d95711 --- /dev/null +++ b/codecov.yml @@ -0,0 +1 @@ +secret:TQv+70MO2TqSNLEjdr8xzi3HXRxXAWkmg+S02SgWlHIDrMj9rNOjPV/C6+Ou97XmyyruLrI+FdX6/6oVcT+DGdB5HHtK5frAk2YP8HVMDTc= From 0b6d7836d2aabd0ce851fbf6743bb18d3aa4421f Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 16 Sep 2020 12:00:59 -0400 Subject: [PATCH 095/401] adding codecov badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80aa24df..a84818e1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) [![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) -[![Coverage Status](https://coveralls.io/repos/github/Vonage/vonage-python-sdk/badge.svg?branch=master)](https://coveralls.io/github/Vonage/vonage-python-sdk?branch=master) +[![codecov](https://codecov.io/gh/Vonage/vonage-python-sdk/branch/master/graph/badge.svg)](https://codecov.io/gh/Vonage/vonage-python-sdk) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) From d4720ba975c7dd39066bb6cc487b4e9208dd3341 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Fri, 18 Sep 2020 15:45:38 -0400 Subject: [PATCH 096/401] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2f341e3..f251d5a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,4 +32,4 @@ jobs: - name: Run tests run: make coverage - name: Run codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v1 From 2d57b0180ecccba47c80bff93a82007f57cb47e0 Mon Sep 17 00:00:00 2001 From: R Srinath <47494475+srinath1412001@users.noreply.github.com> Date: Thu, 1 Oct 2020 07:42:00 +0530 Subject: [PATCH 097/401] Added link to documentation Solves #171 Added a link to Documentation page. It helps contributors to better understand the concepts behind Vonage's APIs --- CONTRIBUTING.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0a1767e..18f23c1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,10 @@ # Getting Involved Thanks for your interest in the project, we'd love to have you involved! Check out the sections below to find out more about what to do next... - + +## Documentation +Check out our [Documentaion](https://developer.nexmo.com/documentation) to get familiar with concepts common to Vonage's APIs and products. + ## Opening an Issue We always welcome issues, if you've seen something that isn't quite right or you have a suggestion for a new feature, please go ahead and open an issue in this project. Include as much information as you have, it really helps. @@ -11,5 +14,6 @@ We always welcome issues, if you've seen something that isn't quite right or you We're always open to pull requests, but these should be small and clearly described so that we can understand what you're trying to do. Feel free to open an issue first and get some discussion going. When you're ready to start coding, fork this repository to your own GitHub account and make your changes in a new branch. Once you're happy, open a pull request and explain what the change is and why you think we should include it in our project. - + + From cd0a62a5fd763e3103533277d3cf5b36b7f25db4 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Thu, 1 Oct 2020 10:53:33 -0400 Subject: [PATCH 098/401] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18f23c1c..5b653254 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ Thanks for your interest in the project, we'd love to have you involved! Check out the sections below to find out more about what to do next... ## Documentation -Check out our [Documentaion](https://developer.nexmo.com/documentation) to get familiar with concepts common to Vonage's APIs and products. +Check out our [Documentaion](https://developer.nexmo.com/documentation) ## Opening an Issue From b9b8423a9ff2dedcd9022b2c7cb56cfd173108b4 Mon Sep 17 00:00:00 2001 From: Raihan Nismara <31585789+raihan71@users.noreply.github.com> Date: Mon, 5 Oct 2020 16:55:03 +0700 Subject: [PATCH 099/401] Update README.md Using logo Voyage in README.md file --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a84818e1..46e28b6e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Vonage Server SDK for Python +Nexmo is now known as Vonage + [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) [![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) [![codecov](https://codecov.io/gh/Vonage/vonage-python-sdk/branch/master/graph/badge.svg)](https://codecov.io/gh/Vonage/vonage-python-sdk) From 473086f2ecd9de3aacd37c91f9370064e1ae8a55 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 6 Oct 2020 18:36:16 -0400 Subject: [PATCH 100/401] Signposting Asyncio Support --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 46e28b6e..0251cf48 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,12 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Verify API | General Availability | ✅ | | Voice API | General Availability | ✅ | +## asyncio Support + +[asyncio](https://docs.python.org/3/library/asyncio.html) is a library to write **concurrent** code using the **async/await** syntax. + +We don't currently support asyncio in the Python SDK but we are planning to do so in upcoming releases. + ## Contributing We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@vonage.com) first! From c6ed38810d55e22c022bd6fd0233bdf187a8c377 Mon Sep 17 00:00:00 2001 From: Atharv Attri <48738128+Atharv-Attri@users.noreply.github.com> Date: Tue, 6 Oct 2020 22:11:16 -0700 Subject: [PATCH 101/401] Update README.md --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 46e28b6e..9a507df1 100644 --- a/README.md +++ b/README.md @@ -232,16 +232,16 @@ voice.send_speech(response['uuid'], text='Hello from vonage') ### Stop sending a synthesized speech message to a call ```python ->>> from vonage import Client, Voice ->>> client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) ->>> voice = Voice(client) ->>> response = voice.create_call({ +from vonage import Client, Voice +client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) +voice = Voice(client) +response = voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) ->>> voice.send_speech(response['uuid'], text='Hello from vonage') ->>> voice.stop_speech(response['uuid']) +voice.send_speech(response['uuid'], text='Hello from vonage') +voice.stop_speech(response['uuid']) ``` ### Send DTMF tones to a call From aaeb6e90db5161795ec5d6619d67a17fd4adb84b Mon Sep 17 00:00:00 2001 From: raviprakash-dev <72661847+raviprakash-dev@users.noreply.github.com> Date: Sat, 10 Oct 2020 21:08:02 +0530 Subject: [PATCH 102/401] Wrong Method Name Fixed a typo in Voice API from "create_all" to "create_call" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a507df1..e75d76a0 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ sms.submit_sms_conversion(response['message-id']) from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) -voice.create_all({ +voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] From 13e240d90557c0b7816980d87a61fb2686dc8eeb Mon Sep 17 00:00:00 2001 From: raviprakash-dev <72661847+raviprakash-dev@users.noreply.github.com> Date: Sat, 10 Oct 2020 21:09:21 +0530 Subject: [PATCH 103/401] Update Call typo correction Corrected typo for update call from "create_all" to "create_call" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e75d76a0..772f445d 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ voice.get_call(uuid) from vonage import Client, Voice client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) voice = Voice(client) -response = voice.create_all({ +response = voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] From f7a6c5bc23d58a2c82c308293dc308f33e57d72c Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 2 Dec 2020 15:23:39 -0500 Subject: [PATCH 104/401] updating to latest version and user agent corrections --- .bumpversion.cfg | 2 +- .pre-commit-config.yaml | 20 ++++++++++---------- docs/conf.py | 6 +++--- setup.py | 2 +- tests/test_account.py | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e266f4fc..d5d9bc81 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.4.0 +current_version = 2.5.4 commit = True tag = False diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd92b0ba..6f3393de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v1.4.0 - hooks: - - id: trailing-whitespace - language_version: python3.6 -- repo: https://github.com/ambv/black - rev: 18.6b4 - hooks: - - id: black - language_version: python3.6 \ No newline at end of file + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.4 + hooks: + - id: trailing-whitespace + language_version: python3.6 + - repo: https://github.com/ambv/black + rev: 18.6b4 + hooks: + - id: black + language_version: python3.6 diff --git a/docs/conf.py b/docs/conf.py index 82b20c2f..6f1b7519 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = u"2.3.0" +version = u"2.5.4" # The full version, including alpha/beta/rc tags. -release = u"2.3.0" +release = u"2.5.4" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v1.4.0' +# html_title = u'Vonage v2.5.4' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index 3b9b521b..e02fb8df 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.5.2", + version="2.5.4", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_account.py b/tests/test_account.py index a39d3607..ac0f3ec1 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -27,7 +27,7 @@ def test_application_info_options(dummy_data): app_name=app_name, app_version=app_version, ) - user_agent = "nexmo-python/{} python/{} {}/{}".format( + user_agent = "vonage-python/{} python/{} {}/{}".format( vonage.__version__, platform.python_version(), app_name, app_version, ) From 891d0a56b969fc84063c3a2293f9bbeb26e94e16 Mon Sep 17 00:00:00 2001 From: superdiana Date: Wed, 2 Dec 2020 15:30:17 -0500 Subject: [PATCH 105/401] add vonage to user agent --- src/vonage/__init__.py | 2 +- tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index f2a107b2..179e50e7 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -106,7 +106,7 @@ def __init__( self.__api_host = "api.nexmo.com" - user_agent = "nexmo-python/{version} python/{python_version}".format( + user_agent = "vonage-python/{version} python/{python_version}".format( version=__version__, python_version=python_version() ) diff --git a/tests/conftest.py b/tests/conftest.py index 0cc8d7bc..fc0c202f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ def __init__(self): self.application_id = "nexmo-application-id" self.private_key = read_file("data/private_key.txt") self.public_key = read_file("data/public_key.txt") - self.user_agent = "nexmo-python/{} python/{}".format( + self.user_agent = "vonage-python/{} python/{}".format( vonage.__version__, platform.python_version() ) self.host = "rest.nexmo.com" From 7eb962c0031ec9d2d5737ea932d55552c3023563 Mon Sep 17 00:00:00 2001 From: Diana Rodriguez <61435963+superdiana@users.noreply.github.com> Date: Mon, 4 Jan 2021 09:34:59 -0500 Subject: [PATCH 106/401] Fixing concatenating byte to string bug --- src/vonage/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 179e50e7..d73a5f7c 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -778,7 +778,7 @@ def _jwt_signed_delete(self, request_uri): def _headers(self): token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) + return dict(self.headers, Authorization=b"Bearer " + bytes(token, 'utf-8')) def generate_application_jwt(self, when=None): iat = int(when if when is not None else time.time()) From 797ac26711732bd2752d32885afabdbdf8853fd8 Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 4 Jan 2021 13:10:42 -0500 Subject: [PATCH 107/401] fixing tests and adding better implementation of fix --- src/vonage/__init__.py | 4 +++- tests/test_voice.py | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index d73a5f7c..efbc96e0 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -778,7 +778,9 @@ def _jwt_signed_delete(self, request_uri): def _headers(self): token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + bytes(token, 'utf-8')) + if(type(token) is bytes): + token = token.decode('utf-8') + return dict(self.headers, Authorization=bytes("Bearer " + token, 'utf-8')) def generate_application_jwt(self, when=None): iat = int(when if when is not None else time.time()) diff --git a/tests/test_voice.py b/tests/test_voice.py index bbcf580d..ed46ba6e 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -113,7 +113,7 @@ def test_user_provided_authorization(client, dummy_data): token = request_authorization().split()[1] - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") assert token["application_id"] == application_id assert token["nbf"] == nbf @@ -136,7 +136,7 @@ def test_authorization_with_private_key_path(dummy_data): voice.get_call("xx-xx-xx-xx") token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" ) assert token["application_id"] == dummy_data.application_id @@ -148,7 +148,7 @@ def test_authorization_with_private_key_object(voice, dummy_data): voice.get_call("xx-xx-xx-xx") token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" ) assert token["application_id"] == dummy_data.application_id @@ -258,7 +258,7 @@ def test_deprecated_user_provided_authorization(client, dummy_data): token = request_authorization().split()[1] - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") + token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") assert token["application_id"] == application_id assert token["nbf"] == nbf @@ -280,7 +280,7 @@ def test_deprecated_authorization_with_private_key_path(dummy_data): client.get_call("xx-xx-xx-xx") token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" ) assert token["application_id"] == dummy_data.application_id @@ -292,6 +292,6 @@ def test_deprecated_authorization_with_private_key_object(client, dummy_data): client.get_call("xx-xx-xx-xx") token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" + request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" ) assert token["application_id"] == dummy_data.application_id From a84e943fe42f6698bfd4ba88021c1e846737da3e Mon Sep 17 00:00:00 2001 From: superdiana Date: Mon, 4 Jan 2021 15:46:57 -0500 Subject: [PATCH 108/401] preserving BC --- .DS_Store | Bin 0 -> 8196 bytes .gitignore | 4 ++++ src/vonage/__init__.py | 12 ++++++++---- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..aaedb4594372223b4c559aa2cda251213a4e7d13 GIT binary patch literal 8196 zcmeHM%We}f6unMTI!Qo{P$kL&X*TStDutGc1yY&^5D5w*0TzJTyl6+HGa;EYln|6X ze*hA{z<;nudYb-B+!)v<_GY&ZPtV zesGZ)3mT^i<*NgQ3;}>CoR$f7!~s&{YAk4+DwI<6siFs=r9y`oLdntYG96gZI8~_R zB$S+l9$DxJMaa>ib0wWbL7^?J1J;3A2RL@mP>Jr-Htj^?_i8t2HM>C*?fgh>Bd`6I z2{oe2&^)3t;{9FVU6g#hgELQ2tE)W#qmOS!A6aTqfSrPl#3N{qv2)OktHd=!wWLR? z(-W#v5#!Z?eC=}I!BBrP)G3S?V7!6B`(^O5^Z>nS->YztcTU(hkNmhQSu#{<%)A0!8EU>V?6$-ea<#rjR_h}5 zaZPV&h~Bw&i0orVh6}_i6xHSC=}z}Uu2oc@rU6C@F>Viv7F^knU#Z~ri7K3!qDRLD zpVukMPlDg4R-7f*r_9&g>=Ty<~cZ%$U-@p^t^Yt_BAu`!vXoh#SZ?^O2=kKd0!Og?_bjFE)? z@X?YwJ}JMTrd>Fy_5E((^Igg81vuYIFM9xSmF4;GJpU749wI{!8%|anCE~ Date: Mon, 4 Jan 2021 15:48:05 -0500 Subject: [PATCH 109/401] removing clutter --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index aaedb4594372223b4c559aa2cda251213a4e7d13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM%We}f6unMTI!Qo{P$kL&X*TStDutGc1yY&^5D5w*0TzJTyl6+HGa;EYln|6X ze*hA{z<;nudYb-B+!)v<_GY&ZPtV zesGZ)3mT^i<*NgQ3;}>CoR$f7!~s&{YAk4+DwI<6siFs=r9y`oLdntYG96gZI8~_R zB$S+l9$DxJMaa>ib0wWbL7^?J1J;3A2RL@mP>Jr-Htj^?_i8t2HM>C*?fgh>Bd`6I z2{oe2&^)3t;{9FVU6g#hgELQ2tE)W#qmOS!A6aTqfSrPl#3N{qv2)OktHd=!wWLR? z(-W#v5#!Z?eC=}I!BBrP)G3S?V7!6B`(^O5^Z>nS->YztcTU(hkNmhQSu#{<%)A0!8EU>V?6$-ea<#rjR_h}5 zaZPV&h~Bw&i0orVh6}_i6xHSC=}z}Uu2oc@rU6C@F>Viv7F^knU#Z~ri7K3!qDRLD zpVukMPlDg4R-7f*r_9&g>=Ty<~cZ%$U-@p^t^Yt_BAu`!vXoh#SZ?^O2=kKd0!Og?_bjFE)? z@X?YwJ}JMTrd>Fy_5E((^Igg81vuYIFM9xSmF4;GJpU749wI{!8%|anCE~ Date: Tue, 5 Jan 2021 15:37:52 -0500 Subject: [PATCH 110/401] bump 2.5.5 --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d5d9bc81..ecc24d78 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.5.4 +current_version = 2.5.5 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index 6f1b7519..b312c09b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = u"2.5.4" +version = u"2.5.5" # The full version, including alpha/beta/rc tags. -release = u"2.5.4" +release = u"2.5.5" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.5.4' +# html_title = u'Vonage v2.5.5' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index e02fb8df..6af24560 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.5.4", + version="2.5.5", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", From 33ab3f7470808572ee8eb2e29ed6d2792efb0c09 Mon Sep 17 00:00:00 2001 From: superdiana Date: Tue, 5 Jan 2021 15:48:32 -0500 Subject: [PATCH 111/401] adding pypirc to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 78e40f58..fdc9093c 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ ENV* .DS_Store .vscode .idea +.pypirc \ No newline at end of file From 4cfa8abcd56aa7454e48b4e795c27d3afb88c82b Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Mon, 1 Mar 2021 15:30:59 -0500 Subject: [PATCH 112/401] Moved GHA to run on all pushes and pull requests --- .github/workflows/build.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f251d5a5..6994c9d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,5 @@ name: Build -on: - push: - branches: - - master - pull_request: - types: [opened, synchronize, reopened] +on: [push, pull_request] jobs: test: name: Test From fb39a295b56f177fe73efc7a0fbff6a1a36db16e Mon Sep 17 00:00:00 2001 From: Tonya Camille Date: Mon, 28 Jun 2021 16:12:54 -0500 Subject: [PATCH 113/401] added random_from_number to voice.py and added new test (#198) * added random_from_number to voice.py and added new test * Adding changes to the test * added dictionary in voice.py and fixed test * Remove Python version 3.4 --- .github/workflows/build.yml | 2 +- src/vonage/voice.py | 19 ++++++++++++++++++- tests/test_voice.py | 17 ++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6994c9d6..68eb95fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.4", "3.5", "3.6", "3.7", "3.8"] + python: ["3.5", "3.6", "3.7", "3.8"] os: ["ubuntu-latest", "macos-latest", "windows-latest"] exclude: - os: "windows-latest" diff --git a/src/vonage/voice.py b/src/vonage/voice.py index a83d1214..839d7290 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -18,7 +18,24 @@ def __init__( print('Error: {error_message}'.format(error_message=str(e))) # Creates a new call session - def create_call(self, params=None, **kwargs): + def create_call(self, params, **kwargs): + """ + Adding Random From Number Feature for the Voice API, + if set to `True`, the from number will be randomly selected + from the pool of numbers available to the application making + the call. + + :param params is a dictionry that holds the 'from' and 'random_from_number' + + """ + if not params: + params = kwargs + + key = 'from' + if key not in params: + params['random_from_number'] = True + + return self._jwt_signed_post("/v1/calls", params or kwargs) # Get call history paginated. Pass start and end dates to filter the retrieved information diff --git a/tests/test_voice.py b/tests/test_voice.py index ed46ba6e..4b23882a 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -2,6 +2,7 @@ import time import jwt +import requests import vonage from util import * @@ -14,7 +15,21 @@ def test_create_call(voice, dummy_data): params = { "to": [{"type": "phone", "number": "14843331234"}], "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], + "answer_url": ["https://example.com/answer"] + } + + assert isinstance(voice.create_call(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + +@responses.activate +def test_params_with_random_number(voice, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + params = { + "to": [{"type": "phone", "number": "14843331234"}], + "random_from_number":True, + "answer_url": ["https://example.com/answer"] } assert isinstance(voice.create_call(params), dict) From f78f77354cfccca9f22874bc0c3841230591c2e1 Mon Sep 17 00:00:00 2001 From: Martin Thorsen Ranang Date: Sat, 6 Nov 2021 18:33:43 +0100 Subject: [PATCH 114/401] Updated __version__ to match the release. --- src/vonage/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index deab92c9..1713f5b1 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -31,7 +31,7 @@ JSONDecodeError = ValueError -__version__ = "2.4.0" +__version__ = "2.5.5" logger = logging.getLogger("nexmo") From e29574d1dc992ee759ce1b3c55dcd40887c34e24 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Apr 2022 19:34:43 +0100 Subject: [PATCH 115/401] removed old make target --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a4575e29..a6533e10 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean test dist coverage install requirements release release-test +.PHONY: clean test dist coverage install requirements release clean: rm -rf dist build From 979c80272046a290c402cd6122166aa05b918108 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Apr 2022 21:31:57 +0100 Subject: [PATCH 116/401] updated deps in requirements.txt, changed tests to fit new pytest ExceptionInfo format --- requirements.txt | 8 ++++---- tests/test_account.py | 12 ++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index cf6661f8..543a3a3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -e . -pytest==3.1.2 -pytest-cov==2.5.1 -responses==0.5.1 +pytest==7.1.1 +pytest-cov==3.0.0 +responses==0.20.0 coveralls -glom==18.3.1 \ No newline at end of file +glom==22.1.0 \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index ac0f3ec1..8e066e53 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -133,8 +133,7 @@ def test_list_secrets_missing(client): client.list_secrets("meaccountid") assert_basic_auth() assert ( - """ClientError: Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - in str(ce) + str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" ) @@ -173,8 +172,7 @@ def test_delete_secret_last_secret(client): client.delete_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - """ClientError: Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - in str(ce) + str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" ) @@ -204,8 +202,7 @@ def test_create_secret_max_secrets(client): client.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - """ClientError: Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - in str(ce) + str(ce.value) == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" ) @@ -222,6 +219,5 @@ def test_create_secret_validation(client): client.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - in str(ce) + str(ce.value) == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" ) From 87c982db8fc06cdc69412daa8de3994438af75d0 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Apr 2022 21:48:02 +0100 Subject: [PATCH 117/401] testing on currently supported Python versions --- .github/workflows/build.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68eb95fa..df4738d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,15 +7,12 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.5", "3.6", "3.7", "3.8"] + python: ["3.7", "3.8", "3.9", "3.10"] os: ["ubuntu-latest", "macos-latest", "windows-latest"] exclude: - - os: "windows-latest" - python: "3.4" - os: "windows-latest" python: "3.8" - - os: "macos-latest" - python: "3.4" + steps: - uses: actions/setup-python@v2 with: From 8e1c8e82aa424eaa00e331392dca606d32dcca4e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Apr 2022 22:26:19 +0100 Subject: [PATCH 118/401] using raw string format for regex string --- src/vonage/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 1713f5b1..4e7ac2f5 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -100,7 +100,7 @@ def __init__( with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() - self.__host_pattern = "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" + self.__host_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" self.__host = "rest.nexmo.com" From d5e4621850439e4f4c381213c0702de7c7481a1e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 8 Apr 2022 12:20:14 +0100 Subject: [PATCH 119/401] removing windows test run from github actions --- .github/workflows/build.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df4738d3..535cace2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,10 +8,7 @@ jobs: fail-fast: false matrix: python: ["3.7", "3.8", "3.9", "3.10"] - os: ["ubuntu-latest", "macos-latest", "windows-latest"] - exclude: - - os: "windows-latest" - python: "3.8" + os: ["ubuntu-latest", "macos-latest"] steps: - uses: actions/setup-python@v2 From bee1a90a82a852dc3c92a9595698c5e292064d0f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 8 Apr 2022 12:22:40 +0100 Subject: [PATCH 120/401] removed python 2 check from test utils code --- tests/util.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/util.py b/tests/util.py index aa8511fb..06bbf992 100644 --- a/tests/util.py +++ b/tests/util.py @@ -3,11 +3,7 @@ import pytest - -try: - from urllib.parse import urlparse, parse_qs -except ImportError: - from urlparse import urlparse, parse_qs +from urllib.parse import urlparse, parse_qs import responses From 66af36a87be8bd748eb46822af42bda8f611c159 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 8 Apr 2022 12:42:59 +0100 Subject: [PATCH 121/401] require python version >=3.7 when installing from PyPI --- .bumpversion.cfg | 2 +- setup.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ecc24d78..5f0ce6ef 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,7 +3,7 @@ current_version = 2.5.5 commit = True tag = False -[bumpversion:file:vonage/__init__.py] +[bumpversion:file:src/vonage/__init__.py] [bumpversion:file:setup.py] diff --git a/setup.py b/setup.py index 6af24560..434370c2 100644 --- a/setup.py +++ b/setup.py @@ -28,15 +28,14 @@ "pytz>=2018.5", "Deprecated", ], - python_requires=">=3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", + python_requires=">=3.7", tests_require=["cryptography>=2.3.1"], classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], ) From 555ae91e01f17d7edec0cc927e4ce912433f2721 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 8 Apr 2022 13:06:33 +0100 Subject: [PATCH 122/401] updated bumpversion --- .bumpversion.cfg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ecc24d78..feb00e0b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,7 +3,8 @@ current_version = 2.5.5 commit = True tag = False -[bumpversion:file:vonage/__init__.py] +[bumpversion:file:src/vonage/__init__.py] [bumpversion:file:setup.py] +[bumpversion:file:docs/conf.py] From 3ad5b4d538d3cfafaebb013c5ebc234d3acbe8e4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 8 Apr 2022 13:06:38 +0100 Subject: [PATCH 123/401] =?UTF-8?q?Bump=20version:=202.5.5=20=E2=86=92=202?= =?UTF-8?q?.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index feb00e0b..3f675ce2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.5.5 +current_version = 2.6.0 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index b312c09b..424b07c2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = u"2.5.5" +version = u"2.6.0" # The full version, including alpha/beta/rc tags. -release = u"2.5.5" +release = u"2.6.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.5.5' +# html_title = u'Vonage v2.6.0' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index 6af24560..1ac13413 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.5.5", + version="2.6.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 4e7ac2f5..92f1e3ec 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -31,7 +31,7 @@ JSONDecodeError = ValueError -__version__ = "2.5.5" +__version__ = "2.6.0" logger = logging.getLogger("nexmo") From 90808500806394446491e4d0b630516cc311b06f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 16:16:09 +0100 Subject: [PATCH 124/401] put Client class in own module, cleaned up __init__.py --- src/vonage/__init__.py | 793 +---------------------------------------- src/vonage/client.py | 793 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 794 insertions(+), 792 deletions(-) create mode 100644 src/vonage/client.py diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 92f1e3ec..b6d6fa14 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,798 +1,7 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .client import * from .errors import * from .voice import * from .sms import * from .verify import * -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import sys -import time -from uuid import uuid4 -import warnings -import re -from deprecated import deprecated - - -string_types = (str, bytes) -from urllib.parse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - __version__ = "2.6.0" - -logger = logging.getLogger("nexmo") - - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - - self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "VONAGE_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "VONAGE_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.__host_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" - - user_agent = "vonage-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for host") - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for api_host") - else: - self.__api_host = value - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - @deprecated( - reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" - ) - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_VONAGE_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post( - self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs - ) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" - ) - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated( - reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" - ) - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" - ) - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" - ) - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated( - reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" - ) - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated( - reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" - ) - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get( - self.api_host(), "/ni/advanced/async/json", params or kwargs - ) - else: - raise ClientError( - "Error: Callback needed for async advanced number insight" - ) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - @deprecated( - reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" - ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" - ) - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" - ) - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" - ) - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" - ) - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" - ) - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" - ) - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" - ) - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" - ) - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): - """ - Low-level method to make a post request to a Nexmo API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse( - host, self.session.delete(uri, params=params, headers=headers) - ) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.post(uri, json=params, headers=self._headers()), - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - token = jwt.encode(payload, self.private_key, algorithm="RS256") - - # If token is string transform it to byte type - if(type(token) is str): - token = bytes(token, 'utf-8') - - return token diff --git a/src/vonage/client.py b/src/vonage/client.py new file mode 100644 index 00000000..f2d2d269 --- /dev/null +++ b/src/vonage/client.py @@ -0,0 +1,793 @@ +from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .errors import * +from .voice import * +from .sms import * +from .verify import * +from datetime import datetime +import logging +from platform import python_version + +import base64 +import hashlib +import hmac +import jwt +import os +import pytz +import requests +import time +from uuid import uuid4 +import warnings +import re +from deprecated import deprecated + + +string_types = (str, bytes) +from urllib.parse import urlparse + +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + +logger = logging.getLogger("nexmo") + +class Client: + """ + Create a Client object to start making calls to Nexmo APIs. + + Most methods corresponding to Nexmo API calls are on this class itself, + although newer APIs are under namespaces like :attr:`Client.application_v2`. + + The credentials you provide when instantiating a Client determine which + methods can be called. Consult the `Nexmo API docs `_ for details of the + authentication used by the APIs you wish to use, and instantiate your + Client with the appropriate credentials. + + :param str key: Your Nexmo API key + :param str secret: Your Nexmo API secret. + :param str signature_secret: Your Nexmo API signature secret. + You may need to have this enabled by Nexmo support. It is only used for SMS authentication. + :param str signature_method: + The encryption method used for signature encryption. This must match the method + configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. + This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. + If you want to use a simple MD5 hash, leave this as `None`. + :param str application_id: Your application ID if calling methods which use JWT authentication. + :param str private_key: Your private key if calling methods which use JWT authentication. + This should either be a str containing the key in its PEM form, or a path to a private key file. + :param str app_name: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + :param str app_version: This optional value is added to the user-agent header + provided by this library and can be used by Nexmo to track your app statistics. + """ + + def __init__( + self, + key=None, + secret=None, + signature_secret=None, + signature_method=None, + application_id=None, + private_key=None, + app_name=None, + app_version=None, + ): + self.api_key = key or os.environ.get("VONAGE_API_KEY", None) + + self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) + + self.signature_secret = signature_secret or os.environ.get( + "VONAGE_SIGNATURE_SECRET", None + ) + + self.signature_method = signature_method or os.environ.get( + "VONAGE_SIGNATURE_METHOD", None + ) + + if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: + self.signature_method = getattr(hashlib, signature_method) + + self.application_id = application_id + + self.private_key = private_key + + if isinstance(self.private_key, string_types) and "\n" not in self.private_key: + with open(self.private_key, "rb") as key_file: + self.private_key = key_file.read() + + self.__host_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" + + self.__host = "rest.nexmo.com" + + self.__api_host = "api.nexmo.com" + + user_agent = "vonage-python/{version} python/{python_version}".format( + version=vonage.__version__, python_version=python_version() + ) + + if app_name and app_version: + user_agent += " {app_name}/{app_version}".format( + app_name=app_name, app_version=app_version + ) + + self.headers = {"User-Agent": user_agent} + + self.auth_params = {} + + api_server = BasicAuthenticatedServer( + "https://api.nexmo.com", + user_agent=user_agent, + api_key=self.api_key, + api_secret=self.api_secret, + ) + self.application_v2 = ApplicationV2(api_server) + + self.session = requests.Session() + + # Get and Set __host attribute + def host(self, value=None): + if value is None: + return self.__host + elif not re.match(self.__host_pattern, value): + raise Exception("Error: Invalid format for host") + else: + self.__host = value + + # Gets And sets __api_host attribute + def api_host(self, value=None): + if value is None: + return self.__api_host + elif not re.match(self.__host_pattern, value): + raise Exception("Error: Invalid format for api_host") + else: + self.__api_host = value + + def auth(self, params=None, **kwargs): + self.auth_params = params or kwargs + + @deprecated( + reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" + ) + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_VONAGE_NUMBER, + "text": "Hello From Nexmo!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + + def get_balance(self): + return self.get(self.host(), "/account/get-balance") + + def get_country_pricing(self, country_code): + return self.get( + self.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + def get_prefix_pricing(self, prefix): + return self.get( + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) + + def get_sms_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + def get_voice_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + def update_settings(self, params=None, **kwargs): + return self.post(self.host(), "/account/settings", params or kwargs) + + def topup(self, params=None, **kwargs): + return self.post(self.host(), "/account/top-up", params or kwargs) + + def get_account_numbers(self, params=None, **kwargs): + return self.get(self.host(), "/account/numbers", params or kwargs) + + def get_available_numbers(self, country_code, params=None, **kwargs): + return self.get( + self.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + def buy_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/buy", params or kwargs) + + def cancel_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/cancel", params or kwargs) + + def update_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/update", params or kwargs) + + def get_message(self, message_id): + return self.get(self.host(), "/search/message", {"id": message_id}) + + def get_message_rejections(self, params=None, **kwargs): + return self.get(self.host(), "/search/rejections", params or kwargs) + + def search_messages(self, params=None, **kwargs): + return self.get(self.host(), "/search/messages", params or kwargs) + + def send_ussd_push_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd/json", params or kwargs) + + def send_ussd_prompt_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + + def send_2fa_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Nexmo that an SMS was successfully received. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) + + def send_event_alert_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + + def send_marketing_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + + def get_event_alert_numbers(self): + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self.post( + self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs + ) + + def initiate_call(self, params=None, **kwargs): + return self.post(self.host(), "/call/json", params or kwargs) + + def initiate_tts_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts/json", params or kwargs) + + def initiate_tts_prompt_call(self, params=None, **kwargs): + return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) + + @deprecated( + reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" + ) + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + @deprecated( + reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" + ) + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + @deprecated( + reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" + ) + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) + + @deprecated( + reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" + ) + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + @deprecated( + reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" + ) + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + @deprecated( + reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" + ) + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) + + def get_basic_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + + def get_standard_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get( + self.api_host(), "/ni/advanced/async/json", params or kwargs + ) + else: + raise ClientError( + "Error: Callback needed for async advanced number insight" + ) + + def get_advanced_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + + def request_number_insight(self, params=None, **kwargs): + return self.post(self.host(), "/ni/json", params or kwargs) + + def get_applications(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get(self.api_host(), "/v1/applications", params or kwargs) + + def get_application(self, application_id): + warnings.warn( + "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + def create_application(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.post(self.api_host(), "/v1/applications", params or kwargs) + + def update_application(self, application_id, params=None, **kwargs): + warnings.warn( + "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.put( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + params or kwargs, + ) + + def delete_application(self, application_id): + warnings.warn( + "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.delete( + self.api_host(), + "/v1/applications/{application_id}".format(application_id=application_id), + ) + + @deprecated( + reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" + ) + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + @deprecated( + reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" + ) + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + @deprecated( + reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" + ) + def get_call(self, uuid): + return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + + @deprecated( + reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" + ) + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + ) + + @deprecated( + reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" + ) + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + ) + + @deprecated( + reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" + ) + def stop_audio(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + + @deprecated( + reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" + ) + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + ) + + @deprecated( + reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" + ) + def stop_speech(self, uuid): + return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + + @deprecated( + reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" + ) + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + ) + + def get_recording(self, url): + hostname = urlparse(url).hostname + return self.parse(hostname, self.session.get(url, headers=self._headers())) + + def redact_transaction(self, id, product, type=None): + params = {"id": id, "product": product} + if type is not None: + params["type"] = type + return self._post_json(self.api_host(), "/v1/redact/transaction", params) + + def list_secrets(self, api_key): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets".format(api_key=api_key), + header_auth=True, + ) + + def get_secret(self, api_key, secret_id): + return self.get( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._post_json( + self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + ) + + def delete_secret(self, api_key, secret_id): + return self.delete( + self.api_host(), + "/accounts/{api_key}/secrets/{secret_id}".format( + api_key=api_key, secret_id=secret_id + ), + header_auth=True, + ) + + def check_signature(self, params): + params = dict(params) + signature = params.pop("sig", "").lower() + return hmac.compare_digest(signature, self.signature(params)) + + def signature(self, params): + if self.signature_method: + hasher = hmac.new( + self.signature_secret.encode(), digestmod=self.signature_method + ) + else: + hasher = hashlib.md5() + + # Add timestamp if not already present + if not params.get("timestamp"): + params["timestamp"] = int(time.time()) + + for key in sorted(params): + value = params[key] + + if isinstance(value, str): + value = value.replace("&", "_").replace("=", "_") + + hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) + + if self.signature_method is None: + hasher.update(self.signature_secret.encode()) + + return hasher.hexdigest() + + def get(self, host, request_uri, params=None, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) + logger.debug("GET to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.get(uri, params=params, headers=headers)) + + def post( + self, + host, + request_uri, + params, + supports_signature_auth=False, + header_auth=False, + ): + """ + Low-level method to make a post request to a Nexmo API server. + This method automatically adds authentication, picking the first applicable authentication method from the following: + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. + - Otherwise the client's key and secret are appended to the post request's params. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + headers = self.headers + if supports_signature_auth and self.signature_secret: + params["api_key"] = self.api_key + params["sig"] = self.signature(params) + elif header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("POST to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.post(uri, data=params, headers=headers)) + + def _post_json(self, host, request_uri, json): + """ + Post json to `request_uri`, using basic auth. + """ + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + auth = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + headers = dict( + self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + ) + logger.debug( + "POST to %r with body: %r, headers: %r", request_uri, json, headers + ) + return self.parse(host, self.session.post(uri, headers=headers, json=json)) + + def put(self, host, request_uri, params, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) + return self.parse(host, self.session.put(uri, json=params, headers=headers)) + + def delete(self, host, request_uri, header_auth=False): + uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + + params = None + headers = self.headers + if header_auth: + h = base64.b64encode( + ( + "{api_key}:{api_secret}".format( + api_key=self.api_key, api_secret=self.api_secret + ).encode("utf-8") + ) + ).decode("ascii") + # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: + headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + else: + params = {"api_key": self.api_key, "api_secret": self.api_secret} + logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) + return self.parse( + host, self.session.delete(uri, params=params, headers=headers) + ) + + def parse(self, host, response): + logger.debug("Response headers %r", response.headers) + if response.status_code == 401: + raise AuthenticationError + elif response.status_code == 204: + return None + elif 200 <= response.status_code < 300: + + # Strip off any encoding from the content-type header: + content_mime = response.headers.get("content-type").split(";", 1)[0] + if content_mime == "application/json": + return response.json() + else: + return response.content + elif 400 <= response.status_code < 500: + logger.warning( + "Client error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + + # Test for standard error format: + try: + error_data = response.json() + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): + message = "{title}: {detail} ({type})".format( + title=error_data["title"], + detail=error_data["detail"], + type=error_data["type"], + ) + except JSONDecodeError: + pass + raise ClientError(message) + elif 500 <= response.status_code < 600: + logger.warning( + "Server error: %s %r", response.status_code, response.content + ) + message = "{code} response from {host}".format( + code=response.status_code, host=host + ) + raise ServerError(message) + + def _jwt_signed_get(self, request_uri, params=None): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.get(uri, params=params or {}, headers=self._headers()), + ) + + def _jwt_signed_post(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), + self.session.post(uri, json=params, headers=self._headers()), + ) + + def _jwt_signed_put(self, request_uri, params): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.put(uri, json=params, headers=self._headers()) + ) + + def _jwt_signed_delete(self, request_uri): + uri = "https://{api_host}{request_uri}".format( + api_host=self.api_host(), request_uri=request_uri + ) + + return self.parse( + self.api_host(), self.session.delete(uri, headers=self._headers()) + ) + + def _headers(self): + token = self.generate_application_jwt() + return dict(self.headers, Authorization=b"Bearer " + token) + + def generate_application_jwt(self, when=None): + iat = int(when if when is not None else time.time()) + + payload = dict(self.auth_params) + payload.setdefault("application_id", self.application_id) + payload.setdefault("iat", iat) + payload.setdefault("exp", iat + 60) + payload.setdefault("jti", str(uuid4())) + + token = jwt.encode(payload, self.private_key, algorithm="RS256") + + # If token is string transform it to byte type + if(type(token) is str): + token = bytes(token, 'utf-8') + + return token From a863f8944ec67fe3dcc03da03c33daf6eb71ca45 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 17:26:31 +0100 Subject: [PATCH 125/401] restrucuring package --- .bumpversion.cfg | 2 +- setup.cfg | 2 +- setup.py | 4 ++-- {src => vonage}/vonage/__init__.py | 0 {src => vonage}/vonage/_internal.py | 0 {src => vonage}/vonage/client.py | 0 {src => vonage}/vonage/errors.py | 0 {src => vonage}/vonage/sms.py | 0 {src => vonage}/vonage/verify.py | 0 {src => vonage}/vonage/voice.py | 0 10 files changed, 4 insertions(+), 4 deletions(-) rename {src => vonage}/vonage/__init__.py (100%) rename {src => vonage}/vonage/_internal.py (100%) rename {src => vonage}/vonage/client.py (100%) rename {src => vonage}/vonage/errors.py (100%) rename {src => vonage}/vonage/sms.py (100%) rename {src => vonage}/vonage/verify.py (100%) rename {src => vonage}/vonage/voice.py (100%) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3f675ce2..ef8fe9c7 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,7 +3,7 @@ current_version = 2.6.0 commit = True tag = False -[bumpversion:file:src/vonage/__init__.py] +[bumpversion:file:vonage/__init__.py] [bumpversion:file:setup.py] diff --git a/setup.cfg b/setup.cfg index ae2a609b..82c04e77 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,7 @@ source= vonage [coverage:paths] source = - src + vonage .tox/*/site-packages [bdist_wheel] diff --git a/setup.py b/setup.py index 81517d55..2f302986 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,8 @@ author="Vonage", author_email="devrel@vonage.com", license="Apache", - packages=find_packages(where="src"), - package_dir={"": "src"}, + packages=find_packages(where="vonage"), + package_dir={"": "vonage"}, platforms=["any"], install_requires=[ "requests>=2.4.2", diff --git a/src/vonage/__init__.py b/vonage/vonage/__init__.py similarity index 100% rename from src/vonage/__init__.py rename to vonage/vonage/__init__.py diff --git a/src/vonage/_internal.py b/vonage/vonage/_internal.py similarity index 100% rename from src/vonage/_internal.py rename to vonage/vonage/_internal.py diff --git a/src/vonage/client.py b/vonage/vonage/client.py similarity index 100% rename from src/vonage/client.py rename to vonage/vonage/client.py diff --git a/src/vonage/errors.py b/vonage/vonage/errors.py similarity index 100% rename from src/vonage/errors.py rename to vonage/vonage/errors.py diff --git a/src/vonage/sms.py b/vonage/vonage/sms.py similarity index 100% rename from src/vonage/sms.py rename to vonage/vonage/sms.py diff --git a/src/vonage/verify.py b/vonage/vonage/verify.py similarity index 100% rename from src/vonage/verify.py rename to vonage/vonage/verify.py diff --git a/src/vonage/voice.py b/vonage/vonage/voice.py similarity index 100% rename from src/vonage/voice.py rename to vonage/vonage/voice.py From 1f534bca4eb103a96d5b57064123aacecbe4c823 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 17:26:31 +0100 Subject: [PATCH 126/401] restructuring package --- .bumpversion.cfg | 2 +- setup.cfg | 2 +- setup.py | 4 ++-- {src/vonage => vonage}/__init__.py | 0 {src/vonage => vonage}/_internal.py | 0 {src/vonage => vonage}/client.py | 0 {src/vonage => vonage}/errors.py | 0 {src/vonage => vonage}/sms.py | 0 {src/vonage => vonage}/verify.py | 0 {src/vonage => vonage}/voice.py | 0 10 files changed, 4 insertions(+), 4 deletions(-) rename {src/vonage => vonage}/__init__.py (100%) rename {src/vonage => vonage}/_internal.py (100%) rename {src/vonage => vonage}/client.py (100%) rename {src/vonage => vonage}/errors.py (100%) rename {src/vonage => vonage}/sms.py (100%) rename {src/vonage => vonage}/verify.py (100%) rename {src/vonage => vonage}/voice.py (100%) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3f675ce2..ef8fe9c7 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,7 +3,7 @@ current_version = 2.6.0 commit = True tag = False -[bumpversion:file:src/vonage/__init__.py] +[bumpversion:file:vonage/__init__.py] [bumpversion:file:setup.py] diff --git a/setup.cfg b/setup.cfg index ae2a609b..82c04e77 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,7 @@ source= vonage [coverage:paths] source = - src + vonage .tox/*/site-packages [bdist_wheel] diff --git a/setup.py b/setup.py index 81517d55..35c4b700 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,8 @@ author="Vonage", author_email="devrel@vonage.com", license="Apache", - packages=find_packages(where="src"), - package_dir={"": "src"}, + packages=find_packages(where="vonage"), + package_dir={"": "."}, platforms=["any"], install_requires=[ "requests>=2.4.2", diff --git a/src/vonage/__init__.py b/vonage/__init__.py similarity index 100% rename from src/vonage/__init__.py rename to vonage/__init__.py diff --git a/src/vonage/_internal.py b/vonage/_internal.py similarity index 100% rename from src/vonage/_internal.py rename to vonage/_internal.py diff --git a/src/vonage/client.py b/vonage/client.py similarity index 100% rename from src/vonage/client.py rename to vonage/client.py diff --git a/src/vonage/errors.py b/vonage/errors.py similarity index 100% rename from src/vonage/errors.py rename to vonage/errors.py diff --git a/src/vonage/sms.py b/vonage/sms.py similarity index 100% rename from src/vonage/sms.py rename to vonage/sms.py diff --git a/src/vonage/verify.py b/vonage/verify.py similarity index 100% rename from src/vonage/verify.py rename to vonage/verify.py diff --git a/src/vonage/voice.py b/vonage/voice.py similarity index 100% rename from src/vonage/voice.py rename to vonage/voice.py From 411ab0e8f7255a799785575076dec832d3b0fe82 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 17:49:00 +0100 Subject: [PATCH 127/401] removed coverage path from setup.cfg --- setup.cfg | 1 - vonage/vonage/__init__.py | 7 - vonage/vonage/_internal.py | 176 -------- vonage/vonage/client.py | 793 ------------------------------------- vonage/vonage/errors.py | 14 - vonage/vonage/sms.py | 51 --- vonage/vonage/verify.py | 49 --- vonage/vonage/voice.py | 134 ------- 8 files changed, 1225 deletions(-) delete mode 100644 vonage/vonage/__init__.py delete mode 100644 vonage/vonage/_internal.py delete mode 100644 vonage/vonage/client.py delete mode 100644 vonage/vonage/errors.py delete mode 100644 vonage/vonage/sms.py delete mode 100644 vonage/vonage/verify.py delete mode 100644 vonage/vonage/voice.py diff --git a/setup.cfg b/setup.cfg index 82c04e77..37ff610d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,7 +13,6 @@ source= vonage [coverage:paths] source = - vonage .tox/*/site-packages [bdist_wheel] diff --git a/vonage/vonage/__init__.py b/vonage/vonage/__init__.py deleted file mode 100644 index b6d6fa14..00000000 --- a/vonage/vonage/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .client import * -from .errors import * -from .voice import * -from .sms import * -from .verify import * - -__version__ = "2.6.0" diff --git a/vonage/vonage/_internal.py b/vonage/vonage/_internal.py deleted file mode 100644 index 08ca6620..00000000 --- a/vonage/vonage/_internal.py +++ /dev/null @@ -1,176 +0,0 @@ -import logging - -from requests.sessions import Session - -from .errors import AuthenticationError, ClientError, ServerError - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("nexmo") - - -class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret, timeout=None): - self._host = host - self._session = session = Session() - self.timeout = None - session.auth = (api_key, api_secret) # Basic authentication. - session.headers.update({"User-Agent": user_agent}) - - def _uri(self, path): - return "{host}{path}".format(host=self._host, path=path) - - def get(self, path, params=None, headers=None): - return self._parse( - self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) - ) - - def post(self, path, body=None, headers=None): - return self._parse( - self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def put(self, path, body=None, headers=None): - return self._parse( - self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def delete(self, path, body=None, headers=None): - return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def _parse(self, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError() - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - return response.json() - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response".format(code=response.status_code) - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response".format(code=response.status_code) - raise ServerError(message) - - -class ApplicationV2(object): - """ - Provides Application API v2 functionality. - - Don't instantiate this class yourself, access it via :py:attr:`vonage.Client.application_v2` - """ - - def __init__(self, api_server): - self._api_server = api_server - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application_v2.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.nexmo.com/api/application.v2#createApplication - """ - return self._api_server.post("/v2/applications", application_data) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.nexmo.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._api_server.get( - "/v2/applications/{application_id}".format(application_id=application_id), - headers={"content-type": "application/json"}, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._api_server.put( - "/v2/applications/{application_id}".format(application_id=application_id), - params, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._api_server.delete( - "/v2/applications/{application_id}".format(application_id=application_id), - headers={"content-type": "application/json"}, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._api_server.get( - "/v2/applications", - params=params, - headers={"content-type": "application/json"}, - ) - - -def _filter_none_values(d): - return {k: v for k, v in d.items() if v is not None} - - -def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): - """ - Utility function to convert datetime values to strings. - - If the value is already a str, or is not in the dict, no change is made. - - :param params: A `dict` of params that may contain a `datetime` value. - :param key: The datetime value to be converted to a `str` - :param format: The `strftime` format to be used to format the date. The default value is '%Y-%m-%d %H:%M:%S' - """ - if key in params: - param = params[key] - if hasattr(param, "strftime"): - params[key] = param.strftime(format) diff --git a/vonage/vonage/client.py b/vonage/vonage/client.py deleted file mode 100644 index f2d2d269..00000000 --- a/vonage/vonage/client.py +++ /dev/null @@ -1,793 +0,0 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param -from .errors import * -from .voice import * -from .sms import * -from .verify import * -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import time -from uuid import uuid4 -import warnings -import re -from deprecated import deprecated - - -string_types = (str, bytes) -from urllib.parse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("nexmo") - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - - self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "VONAGE_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "VONAGE_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.__host_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" - - user_agent = "vonage-python/{version} python/{python_version}".format( - version=vonage.__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - # Get and Set __host attribute - def host(self, value=None): - if value is None: - return self.__host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for host") - else: - self.__host = value - - # Gets And sets __api_host attribute - def api_host(self, value=None): - if value is None: - return self.__api_host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for api_host") - else: - self.__api_host = value - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - @deprecated( - reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" - ) - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_VONAGE_NUMBER, - "text": "Hello From Nexmo!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post( - self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs - ) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" - ) - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated( - reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" - ) - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" - ) - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" - ) - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated( - reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" - ) - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated( - reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" - ) - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get( - self.api_host(), "/ni/advanced/async/json", params or kwargs - ) - else: - raise ClientError( - "Error: Callback needed for async advanced number insight" - ) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - @deprecated( - reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" - ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" - ) - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" - ) - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" - ) - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" - ) - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" - ) - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" - ) - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" - ) - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - @deprecated( - reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" - ) - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - ): - """ - Low-level method to make a post request to a Nexmo API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse( - host, self.session.delete(uri, params=params, headers=headers) - ) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), - self.session.post(uri, json=params, headers=self._headers()), - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - token = jwt.encode(payload, self.private_key, algorithm="RS256") - - # If token is string transform it to byte type - if(type(token) is str): - token = bytes(token, 'utf-8') - - return token diff --git a/vonage/vonage/errors.py b/vonage/vonage/errors.py deleted file mode 100644 index 88995700..00000000 --- a/vonage/vonage/errors.py +++ /dev/null @@ -1,14 +0,0 @@ -class Error(Exception): - pass - - -class ClientError(Error): - pass - - -class ServerError(Error): - pass - - -class AuthenticationError(ClientError): - pass diff --git a/vonage/vonage/sms.py b/vonage/vonage/sms.py deleted file mode 100644 index 0b8d27c5..00000000 --- a/vonage/vonage/sms.py +++ /dev/null @@ -1,51 +0,0 @@ -import vonage, pytz -from datetime import datetime -from ._internal import _format_date_param - -class Sms: - #To init Sms class pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc) - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self._client.post(self._client.api_host(), "/conversions/sms", params) diff --git a/vonage/vonage/verify.py b/vonage/vonage/verify.py deleted file mode 100644 index 5f368b9f..00000000 --- a/vonage/vonage/verify.py +++ /dev/null @@ -1,49 +0,0 @@ -import vonage -import warnings - - -class Verify: - def __init__(self, client=None, key=None, secret=None): - try: - self._client = client - if self._client is None: - self._client = vonage.Client(key=key, secret=secret) - except Exception as e: - print("Error: {error_message}".format(error_message=str(e))) - - def start_verification(self, params=None, **kwargs): - return self._client.post( - self._client.api_host(), "/verify/json", params or kwargs - ) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def psd2(self, params=None, **kwargs): - return self._client.post( - self._client.api_host(), "/verify/psd2/json", params or kwargs - ) - diff --git a/vonage/vonage/voice.py b/vonage/vonage/voice.py deleted file mode 100644 index 839d7290..00000000 --- a/vonage/vonage/voice.py +++ /dev/null @@ -1,134 +0,0 @@ -import vonage - -class Voice(): - #application_id and private_key are needed for the calling methods - #Passing a Nexmo Client is also possible - def __init__( - self, - client=None, - application_id=None, - private_key=None, - ): - try: - # Client is protected - self._client = client - if self._client is None: - self._client = vonage.Client(application_id=application_id, private_key=private_key) - except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) - - # Creates a new call session - def create_call(self, params, **kwargs): - """ - Adding Random From Number Feature for the Voice API, - if set to `True`, the from number will be randomly selected - from the pool of numbers available to the application making - the call. - - :param params is a dictionry that holds the 'from' and 'random_from_number' - - """ - if not params: - params = kwargs - - key = 'from' - if key not in params: - params['random_from_number'] = True - - - return self._jwt_signed_post("/v1/calls", params or kwargs) - - # Get call history paginated. Pass start and end dates to filter the retrieved information - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - # Get a single call record by identifier - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - # Update call data using custom ncco - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - # Plays audio streaming into call in progress - stream_url parameter is required - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - # Play an speech into specified call - text parameter (text to speech) is required - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - # plays DTMF tones into the specified call - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - # Stops audio recently played into specified call - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - # Stop a speech recently played into specified call - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - # Deprecated section - # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory - def initiate_call(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) - # End deprecated section - - # Utils methods - # _jwt_signed_post private method that Allows developer perform signed post request - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - # Uses the client session to perform the call action with api - return self._client.parse( - self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) - ) - - # _jwt_signed_post private method that Allows developer perform signed get request - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), - self._client.session.get(uri, params=params or {}, headers=self._client._headers()), - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) - - return self._client.parse( - self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) - ) From 51cfdcad73f5d3fdf4a35a3ebf8ae06b701783ff Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 18:01:09 +0100 Subject: [PATCH 128/401] updated tox.ini to run on supported python versions --- .gitignore | 3 ++- tox.ini | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fdc9093c..9f75e4d4 100644 --- a/.gitignore +++ b/.gitignore @@ -107,4 +107,5 @@ ENV* .DS_Store .vscode .idea -.pypirc \ No newline at end of file +.pypirc +.pytest_cache \ No newline at end of file diff --git a/tox.ini b/tox.ini index 40208908..e8e90f72 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] -envlist = py36,coverage-report +envlist = py3.7, py3.10, coverage-report [testenv] -deps = -rrequirements.txt +deps = -r requirements.txt commands = coverage run --parallel -m pytest tests [testenv:coverage-report] From 3f59d7231f6b470eb49b73cca5cf0c01a7a98f36 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 18:33:43 +0100 Subject: [PATCH 129/401] using updated setup-python and checkout github actions --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 535cace2..21842f86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,11 +11,11 @@ jobs: os: ["ubuntu-latest", "macos-latest"] steps: - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} - name: Clone repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install dependencies run: make install - name: Run tests From d846239007874b0ac370a5d48840c08dbe318380 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Apr 2022 19:15:03 +0100 Subject: [PATCH 130/401] replaced references to nexmo with vonage in documentation URLs etc --- .gitignore | 2 +- CODE_OF_CONDUCT.md | 2 +- vonage/_internal.py | 6 +++--- vonage/client.py | 30 +++++++++++++++--------------- vonage/sms.py | 4 ++-- vonage/voice.py | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 9f75e4d4..d96fba92 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,4 @@ ENV* .vscode .idea .pypirc -.pytest_cache \ No newline at end of file +.pytest_cache diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6ff73cf8..003f9af2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@nexmo.com. All +reported by contacting the project team at support@vonage.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/vonage/_internal.py b/vonage/_internal.py index 08ca6620..7dd94c45 100644 --- a/vonage/_internal.py +++ b/vonage/_internal.py @@ -9,7 +9,7 @@ except ImportError: JSONDecodeError = ValueError -logger = logging.getLogger("nexmo") +logger = logging.getLogger("vonage") class BasicAuthenticatedServer(object): @@ -98,7 +98,7 @@ def create_application(self, application_data): >>> client.application_v2.create_application({ 'name': 'My Cool App!' }) - Details of the `application_data` dict are described at https://developer.nexmo.com/api/application.v2#createApplication + Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication """ return self._api_server.post("/v2/applications", application_data) @@ -106,7 +106,7 @@ def get_application(self, application_id): """ Get application details for the application with `application_id`. - The format of the returned dict is described at https://developer.nexmo.com/api/application.v2#getApplication + The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication :param str application_id: The application ID. :rtype: dict diff --git a/vonage/client.py b/vonage/client.py index f2d2d269..de06edbc 100644 --- a/vonage/client.py +++ b/vonage/client.py @@ -29,36 +29,36 @@ except ImportError: JSONDecodeError = ValueError -logger = logging.getLogger("nexmo") +logger = logging.getLogger("vonage") class Client: """ - Create a Client object to start making calls to Nexmo APIs. + Create a Client object to start making calls to Vonage/Nexmo APIs. - Most methods corresponding to Nexmo API calls are on this class itself, + Most methods corresponding to Vonage API calls are on this class itself, although newer APIs are under namespaces like :attr:`Client.application_v2`. The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the + methods can be called. Consult the `Vonage API docs `_ for details of the authentication used by the APIs you wish to use, and instantiate your Client with the appropriate credentials. - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. + :param str key: Your Vonage API key + :param str secret: Your Vonage API secret. + :param str signature_secret: Your Vonage API signature secret. + You may need to have this enabled by Vonage support. It is only used for SMS authentication. :param str signature_method: The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. + configured in the Vonage Dashboard. We recommend `sha256` or `sha512`. This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. If you want to use a simple MD5 hash, leave this as `None`. :param str application_id: Your application ID if calling methods which use JWT authentication. :param str private_key: Your private key if calling methods which use JWT authentication. This should either be a str containing the key in its PEM form, or a path to a private key file. :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. + provided by this library and can be used by Vonage to track your app statistics. :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. + provided by this library and can be used by Vonage to track your app statistics. """ def __init__( @@ -156,9 +156,9 @@ def send_message(self, params): client.send_message({ "to": MY_CELLPHONE, "from": MY_VONAGE_NUMBER, - "text": "Hello From Nexmo!", + "text": "Hello From Vonage!", }) - :param dict params: A dict of values described at `Send an SMS `_ + :param dict params: A dict of values described at `Send an SMS `_ """ return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) @@ -228,7 +228,7 @@ def send_2fa_message(self, params=None, **kwargs): def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ - Notify Nexmo that an SMS was successfully received. + Notify Vonage that an SMS was successfully received. :param message_id: The `message-id` str returned by the send_message call. :param delivered: A `bool` indicating that the message was or was not successfully delivered. @@ -598,7 +598,7 @@ def post( header_auth=False, ): """ - Low-level method to make a post request to a Nexmo API server. + Low-level method to make a post request to a Vonage API server, which may have a Nexmo url. This method automatically adds authentication, picking the first applicable authentication method from the following: - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. diff --git a/vonage/sms.py b/vonage/sms.py index 0b8d27c5..6deae4e6 100644 --- a/vonage/sms.py +++ b/vonage/sms.py @@ -28,13 +28,13 @@ def send_message(self, params): """ Send an SMS message. Requires a client initialized with `key` and either `secret` or `signature_secret`. - :param dict params: A dict of values described at `Send an SMS `_ + :param dict params: A dict of values described at `Send an SMS `_ """ return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ - Notify Nexmo that an SMS was successfully received. + Notify Vonage that an SMS was successfully received. :param message_id: The `message-id` str returned by the send_message call. :param delivered: A `bool` indicating that the message was or was not successfully delivered. diff --git a/vonage/voice.py b/vonage/voice.py index 839d7290..35f22ebd 100644 --- a/vonage/voice.py +++ b/vonage/voice.py @@ -2,7 +2,7 @@ class Voice(): #application_id and private_key are needed for the calling methods - #Passing a Nexmo Client is also possible + #Passing a Vonage Client is also possible def __init__( self, client=None, From 23ad0ab58b9fcd4c2a9ef504b8e157b8fc7840d6 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 12 Apr 2022 13:35:25 +0100 Subject: [PATCH 131/401] added deprecation notice to client class docstring --- vonage/client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vonage/client.py b/vonage/client.py index de06edbc..91ed8d34 100644 --- a/vonage/client.py +++ b/vonage/client.py @@ -35,6 +35,11 @@ class Client: """ Create a Client object to start making calls to Vonage/Nexmo APIs. + Note on deprecations: most public-facing APIs that are called directly from this class (e.g. voice, + sms, number insight) have been deprecated and will instead be called from modules that house + the relevant classes (e.g. `voice.py`, `sms.py`). Change your code to call these classes directly + as they will be removed in a later release! + Most methods corresponding to Vonage API calls are on this class itself, although newer APIs are under namespaces like :attr:`Client.application_v2`. From a85216d633771921d22c3952fe0abb80df0be4c8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 12 Apr 2022 13:35:25 +0100 Subject: [PATCH 132/401] added deprecation notice to client class docstring --- vonage/client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vonage/client.py b/vonage/client.py index de06edbc..48da7a86 100644 --- a/vonage/client.py +++ b/vonage/client.py @@ -35,8 +35,12 @@ class Client: """ Create a Client object to start making calls to Vonage/Nexmo APIs. - Most methods corresponding to Vonage API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. + Note on deprecations: most public-facing APIs that are called directly from this class (e.g. voice, + sms, number insight) have been deprecated and will instead be called from modules that house + the relevant classes (e.g. `voice.py`, `sms.py`). Change your code to call these classes directly + as they will be removed in a later release! + + Newer APIs are under namespaces like :attr:`Client.application_v2`. The credentials you provide when instantiating a Client determine which methods can be called. Consult the `Vonage API docs `_ for details of the From 632db49847815cd399c67a86460a9dd0242a453f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 13 Apr 2022 01:19:37 +0100 Subject: [PATCH 133/401] changed strings to f-strings in line with Python 3.6 and above --- docs/conf.py | 16 ++--- docs/quickstart.rst | 4 +- tests/conftest.py | 4 +- tests/test_account.py | 4 +- tests/util.py | 2 +- vonage/_internal.py | 25 ++++---- vonage/client.py | 140 +++++++++++++++--------------------------- vonage/sms.py | 2 +- vonage/verify.py | 2 +- vonage/voice.py | 32 ++++------ 10 files changed, 88 insertions(+), 143 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 424b07c2..d2b3da73 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,18 +57,18 @@ master_doc = "index" # General information about the project. -project = u"Vonage" -copyright = u"{0}, Vonage".format(datetime.datetime.now().year) -author = u"Vonage" +project = "Vonage" +copyright = f"{datetime.datetime.now().year}, Vonage" +author = "Vonage" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u"2.6.0" +version = "2.6.0" # The full version, including alpha/beta/rc tags. -release = u"2.6.0" +release = "2.6.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -266,7 +266,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, "Vonage.tex", u"Vonage Documentation", u"Tim Craft", "manual") + (master_doc, "Vonage.tex", "Vonage Documentation", "developer@vonage.com", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -306,7 +306,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "vonage", u"Vonage Documentation", [author], 1)] +man_pages = [(master_doc, "vonage", "Vonage Documentation", [author], 1)] # If true, show URL addresses after external links. # @@ -322,7 +322,7 @@ ( master_doc, "Vonage", - u"Vonage Documentation", + "Vonage Documentation", author, "Vonage", "One line description of project.", diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 534099c4..39f8ccd6 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -146,7 +146,7 @@ Start a verification response = client.start_verification(number='441632960960', brand='MyApp') if response['status'] == '0': - print 'Started verification request_id={request_id}'.format(request_id=response['request_id']) + print f'Started verification request_id={response['request_id']}' else: print('Error:', response['error_text']) @@ -164,7 +164,7 @@ Check a verification response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') if response['status'] == '0': - print 'Verification complete, event_id={event_id}'.format(event_id=response['event_id']) + print 'Verification complete, event_id={response['event_id']}' else: print('Error:', response['error_text']) diff --git a/tests/conftest.py b/tests/conftest.py index fc0c202f..c1d2e8fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,9 +24,7 @@ def __init__(self): self.application_id = "nexmo-application-id" self.private_key = read_file("data/private_key.txt") self.public_key = read_file("data/public_key.txt") - self.user_agent = "vonage-python/{} python/{}".format( - vonage.__version__, platform.python_version() - ) + self.user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()}" self.host = "rest.nexmo.com" self.api_host = "api.nexmo.com" diff --git a/tests/test_account.py b/tests/test_account.py index 8e066e53..1f7cf193 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -27,9 +27,7 @@ def test_application_info_options(dummy_data): app_name=app_name, app_version=app_version, ) - user_agent = "vonage-python/{} python/{} {}/{}".format( - vonage.__version__, platform.python_version(), app_name, app_version, - ) + user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()} {app_name}/{app_version}" assert isinstance(client.get_balance(), dict) assert request_user_agent() == user_agent diff --git a/tests/util.py b/tests/util.py index 06bbf992..55f917e5 100644 --- a/tests/util.py +++ b/tests/util.py @@ -51,7 +51,7 @@ def stub_bytes(method, url): def assert_re(pattern, string): __tracebackhide__ = True if not re.search(pattern, string): - pytest.fail("Cannot find pattern {!r} in {!r}".format(pattern, string)) + pytest.fail(f"Cannot find pattern {repr(pattern)} in {repr(string)}") def assert_basic_auth(): diff --git a/vonage/_internal.py b/vonage/_internal.py index 7dd94c45..fc4a9df5 100644 --- a/vonage/_internal.py +++ b/vonage/_internal.py @@ -21,7 +21,7 @@ def __init__(self, host, user_agent, api_key, api_secret, timeout=None): session.headers.update({"User-Agent": user_agent}) def _uri(self, path): - return "{host}{path}".format(host=self._host, path=path) + return f"{self._host}{path}" def get(self, path, params=None, headers=None): return self._parse( @@ -53,9 +53,9 @@ def _parse(self, response): return response.json() elif 400 <= response.status_code < 500: logger.warning( - "Client error: %s %r", response.status_code, response.content + f"Client error: {response.status_code} {repr(response.content)}" ) - message = "{code} response".format(code=response.status_code) + message = f"{response.status_code} response" # Test for standard error format: try: error_data = response.json() @@ -64,19 +64,18 @@ def _parse(self, response): and "title" in error_data and "detail" in error_data ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) + title=error_data["title"] + detail=error_data["detail"] + type=error_data["type"] + message = f"{title}: {detail} ({type})" except JSONDecodeError: pass raise ClientError(message) elif 500 <= response.status_code < 600: logger.warning( - "Server error: %s %r", response.status_code, response.content + f"Server error: {response.status_code} {repr(response.content)}" ) - message = "{code} response".format(code=response.status_code) + message = f"{response.status_code} response" raise ServerError(message) @@ -113,7 +112,7 @@ def get_application(self, application_id): """ return self._api_server.get( - "/v2/applications/{application_id}".format(application_id=application_id), + f"/v2/applications/{application_id}", headers={"content-type": "application/json"}, ) @@ -124,7 +123,7 @@ def update_application(self, application_id, params): """ return self._api_server.put( - "/v2/applications/{application_id}".format(application_id=application_id), + f"/v2/applications/{application_id}", params, ) @@ -134,7 +133,7 @@ def delete_application(self, application_id): """ self._api_server.delete( - "/v2/applications/{application_id}".format(application_id=application_id), + f"/v2/applications/{application_id}", headers={"content-type": "application/json"}, ) diff --git a/vonage/client.py b/vonage/client.py index 48da7a86..93b2114c 100644 --- a/vonage/client.py +++ b/vonage/client.py @@ -105,14 +105,10 @@ def __init__( self.__api_host = "api.nexmo.com" - user_agent = "vonage-python/{version} python/{python_version}".format( - version=vonage.__version__, python_version=python_version() - ) + user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) + user_agent += f" {app_name}/{app_version}" self.headers = {"User-Agent": user_agent} @@ -407,7 +403,7 @@ def get_application(self, application_id): ) return self.get( self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), + f"/v1/applications/{application_id}", ) def create_application(self, params=None, **kwargs): @@ -426,7 +422,7 @@ def update_application(self, application_id, params=None, **kwargs): ) return self.put( self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), + f"/v1/applications/{application_id}", params or kwargs, ) @@ -438,7 +434,7 @@ def delete_application(self, application_id): ) return self.delete( self.api_host(), - "/v1/applications/{application_id}".format(application_id=application_id), + f"/v1/applications/{application_id}" ) @deprecated( @@ -457,14 +453,14 @@ def get_calls(self, params=None, **kwargs): reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" ) def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + return self._jwt_signed_get(f"/v1/calls/{uuid}") @deprecated( reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" ) def update_call(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}", params or kwargs ) @deprecated( @@ -472,35 +468,35 @@ def update_call(self, uuid, params=None, **kwargs): ) def send_audio(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}/stream", params or kwargs ) @deprecated( reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" ) def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + return self._jwt_signed_delete(f"/v1/calls/{uuid}/stream") @deprecated( reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" ) def send_speech(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}/talk", params or kwargs ) @deprecated( reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" ) def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") @deprecated( reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" ) def send_dtmf(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}/dtmf", params or kwargs ) def get_recording(self, url): @@ -516,31 +512,27 @@ def redact_transaction(self, id, product, type=None): def list_secrets(self, api_key): return self.get( self.api_host(), - "/accounts/{api_key}/secrets".format(api_key=api_key), + f"/accounts/{api_key}/secrets", header_auth=True, ) def get_secret(self, api_key, secret_id): return self.get( self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), + f"/accounts/{api_key}/secrets/{secret_id}", header_auth=True, ) def create_secret(self, api_key, secret): body = {"secret": secret} return self._post_json( - self.api_host(), "/accounts/{api_key}/secrets".format(api_key=api_key), body + self.api_host(), f"/accounts/{api_key}/secrets", body ) def delete_secret(self, api_key, secret_id): return self.delete( self.api_host(), - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), + f"/accounts/{api_key}/secrets/{secret_id}", header_auth=True, ) @@ -567,7 +559,7 @@ def signature(self, params): if isinstance(value, str): value = value.replace("&", "_").replace("=", "_") - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) + hasher.update(f"&{key}={value}".encode("utf-8")) if self.signature_method is None: hasher.update(self.signature_secret.encode()) @@ -575,17 +567,13 @@ def signature(self, params): return hasher.hexdigest() def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + uri = f"https://{host}{request_uri}" headers = self.headers if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) + hash = base64.b64encode( + f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = dict( params or {}, api_key=self.api_key, api_secret=self.api_secret @@ -610,20 +598,16 @@ def post( :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + uri = f"https://{host}{request_uri}" headers = self.headers if supports_signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) elif header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) + hash = base64.b64encode( + f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) logger.debug("POST to %r with params %r, headers %r", uri, params, headers) @@ -633,16 +617,12 @@ def _post_json(self, host, request_uri, json): """ Post json to `request_uri`, using basic auth. """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + uri = f"https://{host}{request_uri}" auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) + f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) + self.headers or {}, Authorization=f"Basic {auth}" ) logger.debug( "POST to %r with body: %r, headers: %r", request_uri, json, headers @@ -650,39 +630,31 @@ def _post_json(self, host, request_uri, json): return self.parse(host, self.session.post(uri, headers=headers, json=json)) def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + uri = f"https://{host}{request_uri}" headers = self.headers if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) + hash = base64.b64encode( + f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) return self.parse(host, self.session.put(uri, json=params, headers=headers)) def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) + uri = f"https://{host}{request_uri}" params = None headers = self.headers if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) + hash = base64.b64encode( + f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) + headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = {"api_key": self.api_key, "api_secret": self.api_secret} logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) @@ -706,11 +678,9 @@ def parse(self, host, response): return response.content elif 400 <= response.status_code < 500: logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host + f"Client error: {response.status_code} {repr(response.content)}" ) + message = f"{response.status_code} response from {host}" # Test for standard error format: try: @@ -720,27 +690,21 @@ def parse(self, host, response): and "title" in error_data and "detail" in error_data ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) + title=error_data["title"] + detail=error_data["detail"] + type=error_data["type"] + message = f"{title}: {detail} ({type})" + except JSONDecodeError: pass raise ClientError(message) elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) + logger.warning(f"Server error: {response.status_code} {repr(response.content)}") + message = f"{response.status_code} response from {host}" raise ServerError(message) def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) + uri = f"https://{self.api_host()}{request_uri}" return self.parse( self.api_host(), @@ -748,9 +712,7 @@ def _jwt_signed_get(self, request_uri, params=None): ) def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) + uri = f"https://{self.api_host()}{request_uri}" return self.parse( self.api_host(), @@ -758,18 +720,14 @@ def _jwt_signed_post(self, request_uri, params): ) def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) + uri = f"https://{self.api_host()}{request_uri}" return self.parse( self.api_host(), self.session.put(uri, json=params, headers=self._headers()) ) def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host(), request_uri=request_uri - ) + uri = f"https://{self.api_host()}{request_uri}" return self.parse( self.api_host(), self.session.delete(uri, headers=self._headers()) diff --git a/vonage/sms.py b/vonage/sms.py index 6deae4e6..224510b4 100644 --- a/vonage/sms.py +++ b/vonage/sms.py @@ -22,7 +22,7 @@ def __init__( signature_method=signature_method ) except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) + print(f'Error: {str(e)}') def send_message(self, params): """ diff --git a/vonage/verify.py b/vonage/verify.py index 5f368b9f..daf42a75 100644 --- a/vonage/verify.py +++ b/vonage/verify.py @@ -9,7 +9,7 @@ def __init__(self, client=None, key=None, secret=None): if self._client is None: self._client = vonage.Client(key=key, secret=secret) except Exception as e: - print("Error: {error_message}".format(error_message=str(e))) + print(f"Error: {str(e)}") def start_verification(self, params=None, **kwargs): return self._client.post( diff --git a/vonage/voice.py b/vonage/voice.py index 35f22ebd..144b10e4 100644 --- a/vonage/voice.py +++ b/vonage/voice.py @@ -15,7 +15,7 @@ def __init__( if self._client is None: self._client = vonage.Client(application_id=application_id, private_key=private_key) except Exception as e: - print('Error: {error_message}'.format(error_message=str(e))) + print(f'Error: {str(e)}') # Creates a new call session def create_call(self, params, **kwargs): @@ -44,39 +44,39 @@ def get_calls(self, params=None, **kwargs): # Get a single call record by identifier def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) + return self._jwt_signed_get(f"/v1/calls/{uuid}") # Update call data using custom ncco def update_call(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}", params or kwargs ) # Plays audio streaming into call in progress - stream_url parameter is required def send_audio(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}/stream", params or kwargs ) # Play an speech into specified call - text parameter (text to speech) is required def send_speech(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}/talk", params or kwargs ) # plays DTMF tones into the specified call def send_dtmf(self, uuid, params=None, **kwargs): return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs + f"/v1/calls/{uuid}/dtmf", params or kwargs ) # Stops audio recently played into specified call def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) + return self._jwt_signed_delete(f"/v1/calls/{uuid}/stream") # Stop a speech recently played into specified call def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) + return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") # Deprecated section # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory @@ -93,9 +93,7 @@ def initiate_tts_prompt_call(self, params=None, **kwargs): # Utils methods # _jwt_signed_post private method that Allows developer perform signed post request def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) + uri = f"https://{self._client.api_host()}{request_uri}" # Uses the client session to perform the call action with api return self._client.parse( @@ -104,9 +102,7 @@ def _jwt_signed_post(self, request_uri, params): # _jwt_signed_post private method that Allows developer perform signed get request def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) + uri = f"https://{self._client.api_host()}{request_uri}" return self._client.parse( self._client.api_host(), @@ -115,9 +111,7 @@ def _jwt_signed_get(self, request_uri, params=None): # _jwt_signed_put private method that Allows developer perform signed put request def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) + uri = f"https://{self._client.api_host()}{request_uri}" return self._client.parse( self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) @@ -125,9 +119,7 @@ def _jwt_signed_put(self, request_uri, params): # _jwt_signed_put private method that Allows developer perform signed put request def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self._client.api_host(), request_uri=request_uri - ) + uri = f"https://{self._client.api_host()}{request_uri}" return self._client.parse( self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) From 622857905a7aae37d5031d73c06a8c1b82cbf8cb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 13 Apr 2022 01:49:03 +0100 Subject: [PATCH 134/401] replaced string formatting % operators with f-strings --- vonage/_internal.py | 2 +- vonage/client.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/vonage/_internal.py b/vonage/_internal.py index fc4a9df5..6f329fb8 100644 --- a/vonage/_internal.py +++ b/vonage/_internal.py @@ -44,7 +44,7 @@ def delete(self, path, body=None, headers=None): ) def _parse(self, response): - logger.debug("Response headers %r", response.headers) + logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: raise AuthenticationError() elif response.status_code == 204: diff --git a/vonage/client.py b/vonage/client.py index 93b2114c..75557dd8 100644 --- a/vonage/client.py +++ b/vonage/client.py @@ -578,7 +578,7 @@ def get(self, host, request_uri, params=None, header_auth=False): params = dict( params or {}, api_key=self.api_key, api_secret=self.api_secret ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) + logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") return self.parse(host, self.session.get(uri, params=params, headers=headers)) def post( @@ -610,7 +610,9 @@ def post( headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) + logger.debug( + f"POST to {repr(uri)} with params {repr(params)}, headers {repr(headers)}" + ) return self.parse(host, self.session.post(uri, data=params, headers=headers)) def _post_json(self, host, request_uri, json): @@ -625,7 +627,7 @@ def _post_json(self, host, request_uri, json): self.headers or {}, Authorization=f"Basic {auth}" ) logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers + f"POST to %{repr(request_uri)} with body: {repr(json)}, headers: {repr(headers)}" ) return self.parse(host, self.session.post(uri, headers=headers, json=json)) @@ -641,7 +643,7 @@ def put(self, host, request_uri, params, header_auth=False): headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) + logger.debug(f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") return self.parse(host, self.session.put(uri, json=params, headers=headers)) def delete(self, host, request_uri, header_auth=False): @@ -657,13 +659,13 @@ def delete(self, host, request_uri, header_auth=False): headers = dict(headers or {}, Authorization=f"Basic {hash}") else: params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) + logger.debug(f"DELETE to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") return self.parse( host, self.session.delete(uri, params=params, headers=headers) ) def parse(self, host, response): - logger.debug("Response headers %r", response.headers) + logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: raise AuthenticationError elif response.status_code == 204: From 4aef91c96bdddef58694b0ff95b5a207421b9a6c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 15:57:20 +0100 Subject: [PATCH 135/401] content-type change --- vonage/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage/client.py b/vonage/client.py index 75557dd8..f4807559 100644 --- a/vonage/client.py +++ b/vonage/client.py @@ -110,7 +110,7 @@ def __init__( if app_name and app_version: user_agent += f" {app_name}/{app_version}" - self.headers = {"User-Agent": user_agent} + self.headers = {"User-Agent": user_agent, "Content-Type": "application/json"} self.auth_params = {} From f605884d2d1b589cd72afdb6495640646a00e864 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 16:01:01 +0100 Subject: [PATCH 136/401] updated CHANGES.md --- CHANGES.md | 6 ++++++ vonage/_internal.py | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c1268d6a..ecdffe41 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,9 @@ +# 2.6.0 + +- Dropped support for Python 3.6 and below +- Now supporting currently supported stable versions of Python, i.e. Python 3.7-3.10 +- Internal refactoring and enhancements + # 2.5.x - Support for Independent SMS, Voice and Verify APIs with tests as well as current client methods diff --git a/vonage/_internal.py b/vonage/_internal.py index 6f329fb8..c9d53d32 100644 --- a/vonage/_internal.py +++ b/vonage/_internal.py @@ -113,7 +113,7 @@ def get_application(self, application_id): return self._api_server.get( f"/v2/applications/{application_id}", - headers={"content-type": "application/json"}, + headers={"Content-Type": "application/json"}, ) def update_application(self, application_id, params): @@ -134,7 +134,7 @@ def delete_application(self, application_id): self._api_server.delete( f"/v2/applications/{application_id}", - headers={"content-type": "application/json"}, + headers={"Content-Type": "application/json"}, ) def list_applications(self, page_size=None, page=None): @@ -151,7 +151,7 @@ def list_applications(self, page_size=None, page=None): return self._api_server.get( "/v2/applications", params=params, - headers={"content-type": "application/json"}, + headers={"Content-Type": "application/json"}, ) From 7a6b7f0b69a2d228dc4a28d7fa4cbdc365293a30 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 17:37:21 +0100 Subject: [PATCH 137/401] =?UTF-8?q?Bump=20version:=202.6.0=20=E2=86=92=202?= =?UTF-8?q?.6.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ef8fe9c7..ed11e739 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.6.0 +current_version = 2.6.1 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index d2b3da73..eb155612 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.6.0" +version = "2.6.1" # The full version, including alpha/beta/rc tags. -release = "2.6.0" +release = "2.6.1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.6.0' +# html_title = u'Vonage v2.6.1' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index 35c4b700..2589fe45 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.6.0", + version="2.6.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/vonage/__init__.py b/vonage/__init__.py index b6d6fa14..61261fe1 100644 --- a/vonage/__init__.py +++ b/vonage/__init__.py @@ -4,4 +4,4 @@ from .sms import * from .verify import * -__version__ = "2.6.0" +__version__ = "2.6.1" From b0afb7d954c65bcf13ec1e29e1e89ce4780b0725 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 18:47:07 +0100 Subject: [PATCH 138/401] changed package structure --- .bumpversion.cfg | 4 ++-- docs/conf.py | 6 +++--- setup.cfg | 2 +- setup.py | 6 +++--- {vonage => src/vonage}/__init__.py | 2 +- {vonage => src/vonage}/_internal.py | 0 {vonage => src/vonage}/client.py | 0 {vonage => src/vonage}/errors.py | 0 {vonage => src/vonage}/sms.py | 0 {vonage => src/vonage}/verify.py | 0 {vonage => src/vonage}/voice.py | 0 11 files changed, 10 insertions(+), 10 deletions(-) rename {vonage => src/vonage}/__init__.py (82%) rename {vonage => src/vonage}/_internal.py (100%) rename {vonage => src/vonage}/client.py (100%) rename {vonage => src/vonage}/errors.py (100%) rename {vonage => src/vonage}/sms.py (100%) rename {vonage => src/vonage}/verify.py (100%) rename {vonage => src/vonage}/voice.py (100%) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ed11e739..e3c190d6 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,9 +1,9 @@ [bumpversion] -current_version = 2.6.1 +current_version = 2.6.2 commit = True tag = False -[bumpversion:file:vonage/__init__.py] +[bumpversion:file:src/vonage/__init__.py] [bumpversion:file:setup.py] diff --git a/docs/conf.py b/docs/conf.py index eb155612..b95da16e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.6.1" +version = "2.6.2" # The full version, including alpha/beta/rc tags. -release = "2.6.1" +release = "2.6.2" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.6.1' +# html_title = u'Vonage v2.6.2' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.cfg b/setup.cfg index 37ff610d..45444f0b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ max-line-length=120 [coverage:run] # TODO: Change this to True: branch=False -source= vonage +source=src [coverage:paths] source = diff --git a/setup.py b/setup.py index 2589fe45..65086d05 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.6.1", + version="2.6.2", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", @@ -19,8 +19,8 @@ author="Vonage", author_email="devrel@vonage.com", license="Apache", - packages=find_packages(where="vonage"), - package_dir={"": "."}, + packages=find_packages(where="src"), + package_dir={"": "src"}, platforms=["any"], install_requires=[ "requests>=2.4.2", diff --git a/vonage/__init__.py b/src/vonage/__init__.py similarity index 82% rename from vonage/__init__.py rename to src/vonage/__init__.py index 61261fe1..d89bcf11 100644 --- a/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -4,4 +4,4 @@ from .sms import * from .verify import * -__version__ = "2.6.1" +__version__ = "2.6.2" diff --git a/vonage/_internal.py b/src/vonage/_internal.py similarity index 100% rename from vonage/_internal.py rename to src/vonage/_internal.py diff --git a/vonage/client.py b/src/vonage/client.py similarity index 100% rename from vonage/client.py rename to src/vonage/client.py diff --git a/vonage/errors.py b/src/vonage/errors.py similarity index 100% rename from vonage/errors.py rename to src/vonage/errors.py diff --git a/vonage/sms.py b/src/vonage/sms.py similarity index 100% rename from vonage/sms.py rename to src/vonage/sms.py diff --git a/vonage/verify.py b/src/vonage/verify.py similarity index 100% rename from vonage/verify.py rename to src/vonage/verify.py diff --git a/vonage/voice.py b/src/vonage/voice.py similarity index 100% rename from vonage/voice.py rename to src/vonage/voice.py From ecdceab55ceae265c1198444640d06c976913c4d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 18:48:24 +0100 Subject: [PATCH 139/401] =?UTF-8?q?Bump=20version:=202.6.2=20=E2=86=92=202?= =?UTF-8?q?.6.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e3c190d6..362ca5f8 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.6.2 +current_version = 2.6.3 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index b95da16e..1a2ae070 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.6.2" +version = "2.6.3" # The full version, including alpha/beta/rc tags. -release = "2.6.2" +release = "2.6.3" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.6.2' +# html_title = u'Vonage v2.6.3' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index 65086d05..63bed902 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.6.2", + version="2.6.3", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index d89bcf11..b041a7e2 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -4,4 +4,4 @@ from .sms import * from .verify import * -__version__ = "2.6.2" +__version__ = "2.6.3" From d0cb668b183c66fdc2e20cb2620709aa48daa0f4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 18:58:43 +0100 Subject: [PATCH 140/401] adding "Accept" header to requests --- src/vonage/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index f4807559..09ecde16 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -110,7 +110,7 @@ def __init__( if app_name and app_version: user_agent += f" {app_name}/{app_version}" - self.headers = {"User-Agent": user_agent, "Content-Type": "application/json"} + self.headers = {"User-Agent": user_agent, "Accept": "application/json"} self.auth_params = {} From e0b0cbe66dbb16cdde27266dabe4c929c5e1b1da Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 19:00:39 +0100 Subject: [PATCH 141/401] updating build target in Makefile --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index a6533e10..feff4c69 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean test dist coverage install requirements release +.PHONY: clean test build coverage install requirements release clean: rm -rf dist build @@ -10,8 +10,8 @@ coverage: test: pytest -v -dist: - python setup.py sdist --formats zip,gztar bdist_wheel +build: + python -m build release: twine upload dist/* From 90474747ce37291397a611192b07d33d2fd853e1 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 19:05:02 +0100 Subject: [PATCH 142/401] changelog versions --- CHANGES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ecdffe41..c343e509 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,10 @@ -# 2.6.0 +# 2.6.x - Dropped support for Python 3.6 and below - Now supporting currently supported stable versions of Python, i.e. Python 3.7-3.10 - Internal refactoring and enhancements -# 2.5.x +# 2.5.5 - Support for Independent SMS, Voice and Verify APIs with tests as well as current client methods - Getters/Setters to extract/rewrite custom attributes From a35296610db20df24380d370a91d509ac68d2a38 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Apr 2022 19:11:57 +0100 Subject: [PATCH 143/401] changed make target --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index feff4c69..5bad49a6 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ build: python -m build release: - twine upload dist/* + python -m twine upload dist/* install: requirements From 0c7b78e058c8d102424dac2df75661341a221b23 Mon Sep 17 00:00:00 2001 From: Nathaniel Bern Date: Tue, 26 Apr 2022 09:50:33 +0200 Subject: [PATCH 144/401] set default max retries to 3 and add optional arguments for max retries, pool connections and pool sizes --- src/vonage/_internal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py index c9d53d32..1ec46bb7 100644 --- a/src/vonage/_internal.py +++ b/src/vonage/_internal.py @@ -1,5 +1,6 @@ import logging +from requests.adapters import HTTPAdapter from requests.sessions import Session from .errors import AuthenticationError, ClientError, ServerError @@ -13,10 +14,13 @@ class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret, timeout=None): + def __init__(self, host, user_agent, api_key, api_secret, timeout=None, pool_connections=10, pool_maxsize=10, max_retries=3): self._host = host self._session = session = Session() self.timeout = None + adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries) + self._session.mount("https://", adapter) + self._session.mount("http://", adapter) session.auth = (api_key, api_secret) # Basic authentication. session.headers.update({"User-Agent": user_agent}) From 93470c8988d632b4eced54b08c7e4e9948d8bd13 Mon Sep 17 00:00:00 2001 From: Nicolas Blanc Date: Tue, 26 Apr 2022 10:26:46 +0200 Subject: [PATCH 145/401] Correct Doc, replace request method by start_verification --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd44a8ca..8a1ccec2 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ if response is not None: client = Client(key='API_KEY', secret='API_SECRET') verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc') +response = verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc') if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) @@ -330,7 +330,7 @@ else: client = Client(key='API_KEY', secret='API_SECRET') verify = Verify(client) -response = verify.request(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +response = verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) From 7bf2ef7f00233b200267ba8e738125cc9f6cdbd2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 27 Apr 2022 15:52:50 +0100 Subject: [PATCH 146/401] set timeout from init method argument --- src/vonage/_internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py index 1ec46bb7..d9a5dc87 100644 --- a/src/vonage/_internal.py +++ b/src/vonage/_internal.py @@ -17,7 +17,7 @@ class BasicAuthenticatedServer(object): def __init__(self, host, user_agent, api_key, api_secret, timeout=None, pool_connections=10, pool_maxsize=10, max_retries=3): self._host = host self._session = session = Session() - self.timeout = None + self.timeout = timeout adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries) self._session.mount("https://", adapter) self._session.mount("http://", adapter) From bd7e2f6b5f0c4d5551a361ee9dc44ac874d97522 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 27 Apr 2022 16:10:58 +0100 Subject: [PATCH 147/401] =?UTF-8?q?Bump=20version:=202.6.3=20=E2=86=92=202?= =?UTF-8?q?.6.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 362ca5f8..20d802d7 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.6.3 +current_version = 2.6.4 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index 1a2ae070..3e114eb3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.6.3" +version = "2.6.4" # The full version, including alpha/beta/rc tags. -release = "2.6.3" +release = "2.6.4" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.6.3' +# html_title = u'Vonage v2.6.4' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index 63bed902..a1ec3a40 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.6.3", + version="2.6.4", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index b041a7e2..2fbb290f 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -4,4 +4,4 @@ from .sms import * from .verify import * -__version__ = "2.6.3" +__version__ = "2.6.4" From 14d3cb285f7d329217b9cfc8a04e6d7265f1558d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 27 Apr 2022 16:14:39 +0100 Subject: [PATCH 148/401] updated changelog --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index c343e509..c68f7149 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ - Dropped support for Python 3.6 and below - Now supporting currently supported stable versions of Python, i.e. Python 3.7-3.10 - Internal refactoring and enhancements +- Adding default `max_retries` option to the `BasicAuthenticationServer` constructor, specifying optional parameters # 2.5.5 From 927ae0c15cee34176431f8b2b65f15dd6b987e0d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 20 May 2022 03:05:41 +0100 Subject: [PATCH 149/401] moved application_v2 from _internal, renamed to be application, started new classes for messages api --- README.md | 10 +- docs/reference.rst | 4 +- src/vonage/_internal.py | 165 --------------------------------- src/vonage/application.py | 166 ++++++++++++++++++++++++++++++++++ src/vonage/client.py | 19 ++-- src/vonage/message_classes.py | 18 ++++ src/vonage/messages.py | 6 ++ src/vonage/verify.py | 2 - tests/test_applications_v2.py | 18 ++-- 9 files changed, 216 insertions(+), 192 deletions(-) create mode 100644 src/vonage/application.py create mode 100644 src/vonage/message_classes.py create mode 100644 src/vonage/messages.py diff --git a/README.md b/README.md index 8a1ccec2..10710dd3 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ client.delete_secret(API_KEY, 'my-secret-id') ### Create an application ```python -response = client.application_v2.create_application({name='Example App', type='voice'}) +response = client.application.create_application({name='Example App', type='voice'}) ``` Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) @@ -473,7 +473,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https:/ ### Retrieve a list of applications ```python -response = client.application_v2.list_applications() +response = client.application.list_applications() ``` Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) @@ -481,7 +481,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://d ### Retrieve a single application ```python -response = client.application_v2.get_application(uuid) +response = client.application.get_application(uuid) ``` Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) @@ -489,7 +489,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://de ### Update an application ```python -response = client.application_v2.update_application(uuid, answer_method='POST') +response = client.application.update_application(uuid, answer_method='POST') ``` Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) @@ -497,7 +497,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https:/ ### Delete an application ```python -response = client.application_v2.delete_application(uuid) +response = client.application.delete_application(uuid) ``` Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) diff --git a/docs/reference.rst b/docs/reference.rst index 33e14019..f7d5a4b8 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -5,9 +5,9 @@ API Reference :members: :undoc-members: - .. attribute:: application_v2 + .. attribute:: application - An instance of :class:`vonage.ApplicationV2` for accessing the Application API. + An instance of :class:`vonage.Application` for accessing the Application API. .. autoclass:: vonage.ApplicationV2 :members: diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py index d9a5dc87..d701224a 100644 --- a/src/vonage/_internal.py +++ b/src/vonage/_internal.py @@ -1,168 +1,3 @@ -import logging - -from requests.adapters import HTTPAdapter -from requests.sessions import Session - -from .errors import AuthenticationError, ClientError, ServerError - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("vonage") - - -class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret, timeout=None, pool_connections=10, pool_maxsize=10, max_retries=3): - self._host = host - self._session = session = Session() - self.timeout = timeout - adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries) - self._session.mount("https://", adapter) - self._session.mount("http://", adapter) - session.auth = (api_key, api_secret) # Basic authentication. - session.headers.update({"User-Agent": user_agent}) - - def _uri(self, path): - return f"{self._host}{path}" - - def get(self, path, params=None, headers=None): - return self._parse( - self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) - ) - - def post(self, path, body=None, headers=None): - return self._parse( - self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def put(self, path, body=None, headers=None): - return self._parse( - self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def delete(self, path, body=None, headers=None): - return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def _parse(self, response): - logger.debug(f"Response headers {repr(response.headers)}") - if response.status_code == 401: - raise AuthenticationError() - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - return response.json() - elif 400 <= response.status_code < 500: - logger.warning( - f"Client error: {response.status_code} {repr(response.content)}" - ) - message = f"{response.status_code} response" - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - title=error_data["title"] - detail=error_data["detail"] - type=error_data["type"] - message = f"{title}: {detail} ({type})" - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - f"Server error: {response.status_code} {repr(response.content)}" - ) - message = f"{response.status_code} response" - raise ServerError(message) - - -class ApplicationV2(object): - """ - Provides Application API v2 functionality. - - Don't instantiate this class yourself, access it via :py:attr:`vonage.Client.application_v2` - """ - - def __init__(self, api_server): - self._api_server = api_server - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application_v2.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication - """ - return self._api_server.post("/v2/applications", application_data) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._api_server.get( - f"/v2/applications/{application_id}", - headers={"Content-Type": "application/json"}, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._api_server.put( - f"/v2/applications/{application_id}", - params, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._api_server.delete( - f"/v2/applications/{application_id}", - headers={"Content-Type": "application/json"}, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._api_server.get( - "/v2/applications", - params=params, - headers={"Content-Type": "application/json"}, - ) - - -def _filter_none_values(d): - return {k: v for k, v in d.items() if v is not None} - - def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): """ Utility function to convert datetime values to strings. diff --git a/src/vonage/application.py b/src/vonage/application.py new file mode 100644 index 00000000..c490fadd --- /dev/null +++ b/src/vonage/application.py @@ -0,0 +1,166 @@ +import logging + +from requests.adapters import HTTPAdapter +from requests.sessions import Session + +from .errors import AuthenticationError, ClientError, ServerError + +from deprecated import deprecated + +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + +logger = logging.getLogger("vonage") + + +class BasicAuthenticatedServer(object): + def __init__(self, host, user_agent, api_key, api_secret, timeout=None, pool_connections=10, pool_maxsize=10, max_retries=3): + self._host = host + self._session = session = Session() + self.timeout = timeout + adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries) + self._session.mount("https://", adapter) + self._session.mount("http://", adapter) + session.auth = (api_key, api_secret) # Basic authentication. + session.headers.update({"User-Agent": user_agent}) + + def _uri(self, path): + return f"{self._host}{path}" + + def get(self, path, params=None, headers=None): + return self._parse( + self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) + ) + + def post(self, path, body=None, headers=None): + return self._parse( + self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) + ) + + def put(self, path, body=None, headers=None): + return self._parse( + self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) + ) + + def delete(self, path, body=None, headers=None): + return self._parse( + self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) + ) + + def _parse(self, response): + logger.debug(f"Response headers {repr(response.headers)}") + if response.status_code == 401: + raise AuthenticationError() + elif response.status_code == 204: + return None + elif 200 <= response.status_code < 300: + return response.json() + elif 400 <= response.status_code < 500: + logger.warning( + f"Client error: {response.status_code} {repr(response.content)}" + ) + message = f"{response.status_code} response" + # Test for standard error format: + try: + error_data = response.json() + if ( + "type" in error_data + and "title" in error_data + and "detail" in error_data + ): + title=error_data["title"] + detail=error_data["detail"] + type=error_data["type"] + message = f"{title}: {detail} ({type})" + except JSONDecodeError: + pass + raise ClientError(message) + elif 500 <= response.status_code < 600: + logger.warning( + f"Server error: {response.status_code} {repr(response.content)}" + ) + message = f"{response.status_code} response" + raise ServerError(message) + + +class Application(object): + """ + Provides Application API v2 functionality. + + Don't instantiate this class yourself, access it via :py:attr:`vonage.Client.application` + """ + + def __init__(self, api_server): + self._api_server = api_server + + def create_application(self, application_data): + """ + Create an application using the provided `application_data`. + + :param dict application_data: A JSON-style dict describing the application to be created. + + >>> client.application.create_application({ 'name': 'My Cool App!' }) + + Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication + """ + return self._api_server.post("/v2/applications", application_data) + + def get_application(self, application_id): + """ + Get application details for the application with `application_id`. + + The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication + + :param str application_id: The application ID. + :rtype: dict + """ + + return self._api_server.get( + f"/v2/applications/{application_id}", + headers={"Content-Type": "application/json"}, + ) + + def update_application(self, application_id, params): + """ + Update the application with `application_id` using the values provided in `params`. + + + """ + return self._api_server.put( + f"/v2/applications/{application_id}", + params, + ) + + def delete_application(self, application_id): + """ + Delete the application with `application_id`. + """ + + self._api_server.delete( + f"/v2/applications/{application_id}", + headers={"Content-Type": "application/json"}, + ) + + def list_applications(self, page_size=None, page=None): + """ + List all applications for your account. + + Results are paged, so each page will need to be requested to see all applications. + + :param int page_size: The number of items in the page to be returned + :param int page: The page number of the page to be returned. + """ + params = _filter_none_values({"page_size": page_size, "page": page}) + + return self._api_server.get( + "/v2/applications", + params=params, + headers={"Content-Type": "application/json"}, + ) + + +def _filter_none_values(d): + return {k: v for k, v in d.items() if v is not None} + diff --git a/src/vonage/client.py b/src/vonage/client.py index 09ecde16..c169e392 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,4 +1,5 @@ -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param +from .application import Application, BasicAuthenticatedServer +from ._internal import _format_date_param from .errors import * from .voice import * from .sms import * @@ -16,7 +17,7 @@ import requests import time from uuid import uuid4 -import warnings +import warnings import re from deprecated import deprecated @@ -40,7 +41,7 @@ class Client: the relevant classes (e.g. `voice.py`, `sms.py`). Change your code to call these classes directly as they will be removed in a later release! - Newer APIs are under namespaces like :attr:`Client.application_v2`. + Newer APIs are under namespaces like :attr:`Client.application`. The credentials you provide when instantiating a Client determine which methods can be called. Consult the `Vonage API docs `_ for details of the @@ -120,7 +121,7 @@ def __init__( api_key=self.api_key, api_secret=self.api_secret, ) - self.application_v2 = ApplicationV2(api_server) + self.application = Application(api_server) self.session = requests.Session() @@ -389,7 +390,7 @@ def request_number_insight(self, params=None, **kwargs): def get_applications(self, params=None, **kwargs): warnings.warn( - "vonage.Client#get_applications is deprecated (use methods from #application_v2 instead)", + "vonage.Client#get_applications is deprecated (use methods from #application instead)", DeprecationWarning, stacklevel=2, ) @@ -397,7 +398,7 @@ def get_applications(self, params=None, **kwargs): def get_application(self, application_id): warnings.warn( - "vonage.Client#get_application is deprecated (use methods from #application_v2 instead)", + "vonage.Client#get_application is deprecated (use methods from #application instead)", DeprecationWarning, stacklevel=2, ) @@ -408,7 +409,7 @@ def get_application(self, application_id): def create_application(self, params=None, **kwargs): warnings.warn( - "vonage.Client#create_application is deprecated (use methods from #application_v2 instead)", + "vonage.Client#create_application is deprecated (use methods from #application instead)", DeprecationWarning, stacklevel=2, ) @@ -416,7 +417,7 @@ def create_application(self, params=None, **kwargs): def update_application(self, application_id, params=None, **kwargs): warnings.warn( - "vonage.Client#update_application is deprecated (use methods from #application_v2 instead)", + "vonage.Client#update_application is deprecated (use methods from #application instead)", DeprecationWarning, stacklevel=2, ) @@ -428,7 +429,7 @@ def update_application(self, application_id, params=None, **kwargs): def delete_application(self, application_id): warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from #application_v2 instead)", + "vonage.Client#delete_application is deprecated (use methods from #application instead)", DeprecationWarning, stacklevel=2, ) diff --git a/src/vonage/message_classes.py b/src/vonage/message_classes.py new file mode 100644 index 00000000..e0cf36c9 --- /dev/null +++ b/src/vonage/message_classes.py @@ -0,0 +1,18 @@ +class MessagesObject(object): + pass + +class SmsMessage(MessagesObject): + def __init__(self): + pass + +class MmsMessage(MessagesObject): + pass + +class WhatsAppMessage(MessagesObject): + pass + +class MessengerMessage(MessagesObject): + pass + +class ViberMessage(MessagesObject): + pass \ No newline at end of file diff --git a/src/vonage/messages.py b/src/vonage/messages.py new file mode 100644 index 00000000..a0802d7e --- /dev/null +++ b/src/vonage/messages.py @@ -0,0 +1,6 @@ +import vonage + +def send_message(message): + return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) + + diff --git a/src/vonage/verify.py b/src/vonage/verify.py index daf42a75..ded81482 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -1,6 +1,4 @@ import vonage -import warnings - class Verify: def __init__(self, client=None, key=None, secret=None): diff --git a/tests/test_applications_v2.py b/tests/test_applications_v2.py index c39dd856..c9b2d270 100644 --- a/tests/test_applications_v2.py +++ b/tests/test_applications_v2.py @@ -12,7 +12,7 @@ def test_list_applications(client, dummy_data): fixture_path="applications_v2/list_applications.json", ) - apps = client.application_v2.list_applications() + apps = client.application.list_applications() assert_basic_auth() assert isinstance(apps, dict) assert apps["total_items"] == 30 @@ -27,7 +27,7 @@ def test_get_application(client, dummy_data): fixture_path="applications_v2/get_application.json", ) - app = client.application_v2.get_application("xx-xx-xx-xx") + app = client.application.get_application("xx-xx-xx-xx") assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -44,7 +44,7 @@ def test_create_application(client, dummy_data): params = {"name": "Example App", "type": "voice"} - app = client.application_v2.create_application(params) + app = client.application.create_application(params) assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -63,7 +63,7 @@ def test_update_application(client, dummy_data): params = {"answer_url": "https://example.com/ncco"} - app = client.application_v2.update_application("xx-xx-xx-xx", params) + app = client.application.update_application("xx-xx-xx-xx", params) assert_basic_auth() assert isinstance(app, dict) assert request_user_agent() == dummy_data.user_agent @@ -81,7 +81,7 @@ def test_delete_application(client, dummy_data): status=204, ) - assert client.application_v2.delete_application("xx-xx-xx-xx") is None + assert client.application.delete_application("xx-xx-xx-xx") is None assert_basic_auth() assert request_user_agent() == dummy_data.user_agent @@ -94,7 +94,7 @@ def test_authentication_error(client): status=401, ) with pytest.raises(vonage.AuthenticationError): - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") @responses.activate @@ -112,7 +112,7 @@ def test_client_error(client): ), ) with pytest.raises(vonage.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") assert ( str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" ) @@ -127,7 +127,7 @@ def test_client_error_no_decode(client): body="{this: isnot_json", ) with pytest.raises(vonage.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") assert str(exc_info.value) == "430 response" @@ -139,4 +139,4 @@ def test_server_error(client): status=500, ) with pytest.raises(vonage.ServerError): - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") From 8ed98796eface47bec9b1d07d33f931a948ccbcb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 20 May 2022 03:07:17 +0100 Subject: [PATCH 150/401] renamed test file --- tests/{test_applications_v2.py => test_application.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_applications_v2.py => test_application.py} (100%) diff --git a/tests/test_applications_v2.py b/tests/test_application.py similarity index 100% rename from tests/test_applications_v2.py rename to tests/test_application.py From 0a7f1684ed6d57afbb0f2535a3533d0f6a69e2a8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 20 May 2022 04:10:52 +0100 Subject: [PATCH 151/401] added new NumberInsight class, deprecated methods in client class, refactoring --- src/vonage/client.py | 535 +++++++++--------- src/vonage/errors.py | 6 + src/vonage/number_insight.py | 43 ++ tests/conftest.py | 12 +- .../create_application.json | 0 .../get_application.json | 0 .../list_applications.json | 0 .../update_application.json | 0 tests/test_application.py | 8 +- tests/test_insight.py | 49 -- tests/test_number_insight.py | 83 +++ 11 files changed, 428 insertions(+), 308 deletions(-) create mode 100644 src/vonage/number_insight.py rename tests/data/{applications_v2 => applications}/create_application.json (100%) rename tests/data/{applications_v2 => applications}/get_application.json (100%) rename tests/data/{applications_v2 => applications}/list_applications.json (100%) rename tests/data/{applications_v2 => applications}/update_application.json (100%) delete mode 100644 tests/test_insight.py create mode 100644 tests/test_number_insight.py diff --git a/src/vonage/client.py b/src/vonage/client.py index c169e392..c63ae8ea 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,11 +1,13 @@ -from .application import Application, BasicAuthenticatedServer from ._internal import _format_date_param +from .application import Application, BasicAuthenticatedServer from .errors import * -from .voice import * +from .number_insight import * from .sms import * +from .voice import * from .verify import * -from datetime import datetime + import logging +from datetime import datetime from platform import python_version import base64 @@ -146,23 +148,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - @deprecated( - reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" - ) - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_VONAGE_NUMBER, - "text": "Hello From Vonage!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - def get_balance(self): return self.get(self.host(), "/account/get-balance") @@ -268,238 +253,7 @@ def initiate_tts_call(self, params=None, **kwargs): def initiate_tts_prompt_call(self, params=None, **kwargs): return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - @deprecated( - reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" - ) - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated( - reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" - ) - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" - ) - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" - ) - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated( - reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" - ) - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated( - reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" - ) - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get( - self.api_host(), "/ni/advanced/async/json", params or kwargs - ) - else: - raise ClientError( - "Error: Callback needed for async advanced number insight" - ) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_applications is deprecated (use methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "vonage.Client#get_application is deprecated (use methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - f"/v1/applications/{application_id}", - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#create_application is deprecated (use methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "vonage.Client#update_application is deprecated (use methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - f"/v1/applications/{application_id}", - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - f"/v1/applications/{application_id}" - ) - - @deprecated( - reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" - ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" - ) - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" - ) - def get_call(self, uuid): - return self._jwt_signed_get(f"/v1/calls/{uuid}") - - @deprecated( - reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" - ) - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}", params or kwargs - ) - - @deprecated( - reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" - ) - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/stream", params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" - ) - def stop_audio(self, uuid): - return self._jwt_signed_delete(f"/v1/calls/{uuid}/stream") - - @deprecated( - reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" - ) - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/talk", params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" - ) - def stop_speech(self, uuid): - return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") - - @deprecated( - reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" - ) - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/dtmf", params or kwargs - ) - + def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -687,6 +441,7 @@ def parse(self, host, response): # Test for standard error format: try: + error_data = response.json() if ( "type" in error_data @@ -756,3 +511,279 @@ def generate_application_jwt(self, when=None): token = bytes(token, 'utf-8') return token + + + + + + + # Deprecated methods that will be removed soon + ######################################################### + ######################################################### + ######################################################### + + @deprecated( + reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" + ) + def send_message(self, params): + """ + Send an SMS message. + Requires a client initialized with `key` and either `secret` or `signature_secret`. + :: + client.send_message({ + "to": MY_CELLPHONE, + "from": MY_VONAGE_NUMBER, + "text": "Hello From Vonage!", + }) + :param dict params: A dict of values described at `Send an SMS `_ + """ + return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + + + @deprecated( + reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" + ) + def start_verification(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/json", params or kwargs) + + def send_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/json", params or kwargs) + + @deprecated( + reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" + ) + def check_verification(self, request_id, params=None, **kwargs): + return self.post( + self.api_host(), + "/verify/check/json", + dict(params or kwargs, request_id=request_id), + ) + + def check_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/check/json", params or kwargs) + + @deprecated( + reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" + ) + def start_psd2_verification_request(self, params=None, **kwargs): + return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) + + @deprecated( + reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" + ) + def get_verification(self, request_id): + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + def get_verification_request(self, request_id): + warnings.warn( + "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get( + self.api_host(), "/verify/search/json", {"request_id": request_id} + ) + + @deprecated( + reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" + ) + def cancel_verification(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "cancel"}, + ) + + @deprecated( + reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" + ) + def trigger_next_verification_event(self, request_id): + return self.post( + self.api_host(), + "/verify/control/json", + {"request_id": request_id, "cmd": "trigger_next_event"}, + ) + + def control_verification_request(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#control_verification_request is deprecated", + DeprecationWarning, + stacklevel=2, + ) + + return self.post(self.api_host(), "/verify/control/json", params or kwargs) + + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + + + def get_applications(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_applications is deprecated (use methods from #application instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get(self.api_host(), "/v1/applications", params or kwargs) + + def get_application(self, application_id): + warnings.warn( + "vonage.Client#get_application is deprecated (use methods from #application instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.get( + self.api_host(), + f"/v1/applications/{application_id}", + ) + + def create_application(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#create_application is deprecated (use methods from #application instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.post(self.api_host(), "/v1/applications", params or kwargs) + + def update_application(self, application_id, params=None, **kwargs): + warnings.warn( + "vonage.Client#update_application is deprecated (use methods from #application instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.put( + self.api_host(), + f"/v1/applications/{application_id}", + params or kwargs, + ) + + def delete_application(self, application_id): + warnings.warn( + "vonage.Client#delete_application is deprecated (use methods from #application instead)", + DeprecationWarning, + stacklevel=2, + ) + return self.delete( + self.api_host(), + f"/v1/applications/{application_id}" + ) + + @deprecated( + reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" + ) + def create_call(self, params=None, **kwargs): + return self._jwt_signed_post("/v1/calls", params or kwargs) + + @deprecated( + reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" + ) + def get_calls(self, params=None, **kwargs): + return self._jwt_signed_get("/v1/calls", params or kwargs) + + @deprecated( + reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" + ) + def get_call(self, uuid): + return self._jwt_signed_get(f"/v1/calls/{uuid}") + + @deprecated( + reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" + ) + def update_call(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + f"/v1/calls/{uuid}", params or kwargs + ) + + @deprecated( + reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" + ) + def send_audio(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + f"/v1/calls/{uuid}/stream", params or kwargs + ) + + @deprecated( + reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" + ) + def stop_audio(self, uuid): + return self._jwt_signed_delete(f"/v1/calls/{uuid}/stream") + + @deprecated( + reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" + ) + def send_speech(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + f"/v1/calls/{uuid}/talk", params or kwargs + ) + + @deprecated( + reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" + ) + def stop_speech(self, uuid): + return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") + + @deprecated( + reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" + ) + def send_dtmf(self, uuid, params=None, **kwargs): + return self._jwt_signed_put( + f"/v1/calls/{uuid}/dtmf", params or kwargs + ) + + @deprecated( + reason="vonage.Client#get_basic_number_insight is deprecated. Use NumberInsight#get_basic_number_insight instead" + ) + def get_basic_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/basic/json", params or kwargs) + + @deprecated( + reason="vonage.Client#get_standard_number_insight is deprecated. Use NumberInsight#get_standard_number_insight instead" + ) + def get_standard_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/standard/json", params or kwargs) + + @deprecated( + reason="vonage.Client#get_async_advanced_number_insight is deprecated. Use NumberInsight#get_async_advanced_number_insight instead" + ) + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self.get( + self.api_host(), "/ni/advanced/async/json", params or kwargs + ) + else: + raise ClientError( + "Error: Callback needed for async advanced number insight" + ) + + @deprecated( + reason="vonage.Client#get_advanced_number_insight is deprecated. Use NumberInsight#get_advanced_number_insight instead" + ) + def get_advanced_number_insight(self, params=None, **kwargs): + return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) + + @deprecated( + reason="vonage.Client#request_number_insight is deprecated. Use NumberInsight#request_number_insight instead" + ) + def request_number_insight(self, params=None, **kwargs): + return self.post(self.host(), "/ni/json", params or kwargs) diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 88995700..d8f6a389 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -12,3 +12,9 @@ class ServerError(Error): class AuthenticationError(ClientError): pass + + +class CallbackRequiredError(Error): + """ + Indicates a callback is required but was not present. + """ \ No newline at end of file diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py new file mode 100644 index 00000000..7773e3f2 --- /dev/null +++ b/src/vonage/number_insight.py @@ -0,0 +1,43 @@ +import vonage +from .errors import CallbackRequiredError + +class NumberInsight: + # To init NumberInsight class, pass a client reference or a key and secret + def __init__( + self, + client=None, + key=None, + secret=None, + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret + ) + except Exception as e: + print(f'Error: {str(e)}') + + def get_basic_number_insight(self, params=None, **kwargs): + return self._client.get(self._client.api_host(), "/ni/basic/json", params or kwargs) + + def get_standard_number_insight(self, params=None, **kwargs): + return self._client.get(self._client.api_host(), "/ni/standard/json", params or kwargs) + + def get_advanced_number_insight(self, params=None, **kwargs): + return self._client.get(self._client.api_host(), "/ni/advanced/json", params or kwargs) + + def get_async_advanced_number_insight(self, params=None, **kwargs): + argoparams = params or kwargs + if "callback" in argoparams: + return self._client.get( + self._client.api_host(), "/ni/advanced/async/json", params or kwargs + ) + else: + raise CallbackRequiredError( + "A callback is needed for async advanced number insight" + ) + + def request_number_insight(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/ni/json", params or kwargs) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index c1d2e8fa..0f490ce7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,7 +48,7 @@ def client(dummy_data): # Represents an instance of the Voice class for testing @pytest.fixture -def voice(client, dummy_data): +def voice(client): import vonage return vonage.Voice(client) @@ -56,7 +56,7 @@ def voice(client, dummy_data): # Represents an instance of the Sms class for testing @pytest.fixture -def sms(client, dummy_data): +def sms(client): import vonage return vonage.Sms(client) @@ -64,7 +64,13 @@ def sms(client, dummy_data): # Represents an instance of the Verify class for testing @pytest.fixture -def verify(client, dummy_data): +def verify(client): import vonage return vonage.Verify(client) + +@pytest.fixture +def number_insight(client): + import vonage + + return vonage.NumberInsight(client) \ No newline at end of file diff --git a/tests/data/applications_v2/create_application.json b/tests/data/applications/create_application.json similarity index 100% rename from tests/data/applications_v2/create_application.json rename to tests/data/applications/create_application.json diff --git a/tests/data/applications_v2/get_application.json b/tests/data/applications/get_application.json similarity index 100% rename from tests/data/applications_v2/get_application.json rename to tests/data/applications/get_application.json diff --git a/tests/data/applications_v2/list_applications.json b/tests/data/applications/list_applications.json similarity index 100% rename from tests/data/applications_v2/list_applications.json rename to tests/data/applications/list_applications.json diff --git a/tests/data/applications_v2/update_application.json b/tests/data/applications/update_application.json similarity index 100% rename from tests/data/applications_v2/update_application.json rename to tests/data/applications/update_application.json diff --git a/tests/test_application.py b/tests/test_application.py index c9b2d270..33498f72 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -9,7 +9,7 @@ def test_list_applications(client, dummy_data): stub( responses.GET, "https://api.nexmo.com/v2/applications", - fixture_path="applications_v2/list_applications.json", + fixture_path="applications/list_applications.json", ) apps = client.application.list_applications() @@ -24,7 +24,7 @@ def test_get_application(client, dummy_data): stub( responses.GET, "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications_v2/get_application.json", + fixture_path="applications/get_application.json", ) app = client.application.get_application("xx-xx-xx-xx") @@ -39,7 +39,7 @@ def test_create_application(client, dummy_data): stub( responses.POST, "https://api.nexmo.com/v2/applications", - fixture_path="applications_v2/create_application.json", + fixture_path="applications/create_application.json", ) params = {"name": "Example App", "type": "voice"} @@ -58,7 +58,7 @@ def test_update_application(client, dummy_data): stub( responses.PUT, "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications_v2/update_application.json", + fixture_path="applications/update_application.json", ) params = {"answer_url": "https://example.com/ncco"} diff --git a/tests/test_insight.py b/tests/test_insight.py deleted file mode 100644 index d4089a10..00000000 --- a/tests/test_insight.py +++ /dev/null @@ -1,49 +0,0 @@ -from util import * - - -@responses.activate -def test_get_basic_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/basic/json") - - assert isinstance(client.get_basic_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_standard_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/standard/json") - - assert isinstance(client.get_standard_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/number/lookup/json") - - assert isinstance(client.get_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_advanced_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") - - assert isinstance(client.get_advanced_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_request_number_insight(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ni/json") - - params = {"number": "447525856424", "callback": "https://example.com"} - - assert isinstance(client.request_number_insight(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "callback=https%3A%2F%2Fexample.com" in request_body() diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py new file mode 100644 index 00000000..5eb0946d --- /dev/null +++ b/tests/test_number_insight.py @@ -0,0 +1,83 @@ +from util import * + + +@responses.activate +def test_deprecated_get_basic_number_insight(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/basic/json") + + assert isinstance(client.get_basic_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + +@responses.activate +def test_get_basic_number_insight(number_insight, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/basic/json") + + assert isinstance(number_insight.get_basic_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + + +@responses.activate +def test_deprecated_get_standard_number_insight(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/standard/json") + + assert isinstance(client.get_standard_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + +@responses.activate +def test_get_standard_number_insight(number_insight, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/standard/json") + + assert isinstance(number_insight.get_standard_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + +@responses.activate +def test_deprecated_get_number_insight(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/number/lookup/json") + + assert isinstance(client.get_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + + +@responses.activate +def test_deprecated_get_advanced_number_insight(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") + + assert isinstance(client.get_advanced_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + +@responses.activate +def test_get_advanced_number_insight(number_insight, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") + + assert isinstance(number_insight.get_advanced_number_insight(number="447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + + +@responses.activate +def test_deprecated_request_number_insight(client, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/ni/json") + + params = {"number": "447525856424", "callback": "https://example.com"} + + assert isinstance(client.request_number_insight(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "callback=https%3A%2F%2Fexample.com" in request_body() + +@responses.activate +def test_request_number_insight(number_insight, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/ni/json") + + params = {"number": "447525856424", "callback": "https://example.com"} + + assert isinstance(number_insight.request_number_insight(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "callback=https%3A%2F%2Fexample.com" in request_body() From 0842c617bf20e40ea35ceb564758365bfcf0cfd8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 20 May 2022 04:46:51 +0100 Subject: [PATCH 152/401] adding Account class, deprecating old methods --- src/vonage/account.py | 35 +++++++++++++++++++++++++++++++++++ src/vonage/client.py | 11 +++++++++++ tests/conftest.py | 8 +++++++- tests/test_account.py | 26 +++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 src/vonage/account.py diff --git a/src/vonage/account.py b/src/vonage/account.py new file mode 100644 index 00000000..8ce0c9f5 --- /dev/null +++ b/src/vonage/account.py @@ -0,0 +1,35 @@ +import vonage + +class Account: + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print(f'Error: {str(e)}') + + def get_balance(self): + return self._client.get(self._client.host(), "/account/get-balance") + + def get_country_pricing(self, country_code): + return self._client.get( + self._client.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + def get_prefix_pricing(self, prefix): + return self._client.get( + self._client.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) \ No newline at end of file diff --git a/src/vonage/client.py b/src/vonage/client.py index c63ae8ea..4491991b 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,4 +1,5 @@ from ._internal import _format_date_param +from .account import * from .application import Application, BasicAuthenticatedServer from .errors import * from .number_insight import * @@ -148,14 +149,24 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs + + @deprecated( + reason="vonage.Client#get_balance is deprecated. Use Account#get_balance instead" + ) def get_balance(self): return self.get(self.host(), "/account/get-balance") + @deprecated( + reason="vonage.Client#get_country_pricing is deprecated. Use Account#get_country_pricing instead" + ) def get_country_pricing(self, country_code): return self.get( self.host(), "/account/get-pricing/outbound", {"country": country_code} ) + @deprecated( + reason="vonage.Client#get_prefix_pricing is deprecated. Use Account#get_prefix_pricing instead" + ) def get_prefix_pricing(self, prefix): return self.get( self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} diff --git a/tests/conftest.py b/tests/conftest.py index 0f490ce7..8179abd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,4 +73,10 @@ def verify(client): def number_insight(client): import vonage - return vonage.NumberInsight(client) \ No newline at end of file + return vonage.NumberInsight(client) + +@pytest.fixture +def account(client): + import vonage + + return vonage.Account(client) \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index 1f7cf193..ad25e0e1 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -8,12 +8,18 @@ @responses.activate -def test_get_balance(client, dummy_data): +def test_deprecated_get_balance(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-balance") assert isinstance(client.get_balance(), dict) assert request_user_agent() == dummy_data.user_agent +@responses.activate +def test_get_balance(account, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-balance") + + assert isinstance(account.get_balance(), dict) + assert request_user_agent() == dummy_data.user_agent @responses.activate def test_application_info_options(dummy_data): @@ -34,22 +40,36 @@ def test_application_info_options(dummy_data): @responses.activate -def test_get_country_pricing(client, dummy_data): +def test_deprecated_get_country_pricing(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") assert isinstance(client.get_country_pricing("GB"), dict) assert request_user_agent() == dummy_data.user_agent assert "country=GB" in request_query() +@responses.activate +def test_get_country_pricing(account, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") + + assert isinstance(account.get_country_pricing("GB"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=GB" in request_query() @responses.activate -def test_get_prefix_pricing(client, dummy_data): +def test_deprecated_get_prefix_pricing(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") assert isinstance(client.get_prefix_pricing(44), dict) assert request_user_agent() == dummy_data.user_agent assert "prefix=44" in request_query() +@responses.activate +def test_get_prefix_pricing(account, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") + + assert isinstance(account.get_prefix_pricing(44), dict) + assert request_user_agent() == dummy_data.user_agent + assert "prefix=44" in request_query() @responses.activate def test_get_sms_pricing(client, dummy_data): From 8af76f2455764fde4b3570dd04bfd59054244a37 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 21 May 2022 02:08:53 +0100 Subject: [PATCH 153/401] refactored account and numbers methods into their own classes, deprecations --- src/vonage/account.py | 18 ++++- src/vonage/client.py | 169 +++++++++++++++++++++++++----------------- src/vonage/numbers.py | 39 ++++++++++ tests/conftest.py | 8 +- tests/test_account.py | 44 ++++++++--- tests/test_numbers.py | 63 +++++++++++++++- 6 files changed, 257 insertions(+), 84 deletions(-) create mode 100644 src/vonage/numbers.py diff --git a/src/vonage/account.py b/src/vonage/account.py index 8ce0c9f5..1e0cc1d4 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -24,6 +24,9 @@ def __init__( def get_balance(self): return self._client.get(self._client.host(), "/account/get-balance") + def topup(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/account/top-up", params or kwargs) + def get_country_pricing(self, country_code): return self._client.get( self._client.host(), "/account/get-pricing/outbound", {"country": country_code} @@ -32,4 +35,17 @@ def get_country_pricing(self, country_code): def get_prefix_pricing(self, prefix): return self._client.get( self._client.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) \ No newline at end of file + ) + + def get_sms_pricing(self, number): + return self._client.get( + self._client.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + def get_voice_pricing(self, number): + return self._client.get( + self._client.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + def update_default_sms_webhook(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/account/settings", params or kwargs) \ No newline at end of file diff --git a/src/vonage/client.py b/src/vonage/client.py index 4491991b..527d9ecb 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -3,6 +3,7 @@ from .application import Application, BasicAuthenticatedServer from .errors import * from .number_insight import * +from .numbers import * from .sms import * from .voice import * from .verify import * @@ -149,62 +150,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - - @deprecated( - reason="vonage.Client#get_balance is deprecated. Use Account#get_balance instead" - ) - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - @deprecated( - reason="vonage.Client#get_country_pricing is deprecated. Use Account#get_country_pricing instead" - ) - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - @deprecated( - reason="vonage.Client#get_prefix_pricing is deprecated. Use Account#get_prefix_pricing instead" - ) - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - def get_message(self, message_id): return self.get(self.host(), "/search/message", {"id": message_id}) @@ -525,14 +470,12 @@ def generate_application_jwt(self, when=None): - - - # Deprecated methods that will be removed soon ######################################################### ######################################################### ######################################################### + # SMS API @deprecated( reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" ) @@ -551,6 +494,7 @@ def send_message(self, params): return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) + # Verfiy API @deprecated( reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" ) @@ -639,16 +583,7 @@ def control_verification_request(self, params=None, **kwargs): return self.post(self.api_host(), "/verify/control/json", params or kwargs) - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - + # Application API def get_applications(self, params=None, **kwargs): warnings.warn( "vonage.Client#get_applications is deprecated (use methods from #application instead)", @@ -699,6 +634,7 @@ def delete_application(self, application_id): f"/v1/applications/{application_id}" ) + # Voice API @deprecated( reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" ) @@ -761,6 +697,16 @@ def send_dtmf(self, uuid, params=None, **kwargs): f"/v1/calls/{uuid}/dtmf", params or kwargs ) + # Number Insight API + def get_number_insight(self, params=None, **kwargs): + warnings.warn( + "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", + DeprecationWarning, + stacklevel=2, + ) + + return self.get(self.api_host(), "/number/lookup/json", params or kwargs) + @deprecated( reason="vonage.Client#get_basic_number_insight is deprecated. Use NumberInsight#get_basic_number_insight instead" ) @@ -798,3 +744,88 @@ def get_advanced_number_insight(self, params=None, **kwargs): ) def request_number_insight(self, params=None, **kwargs): return self.post(self.host(), "/ni/json", params or kwargs) + + # Account API + @deprecated( + reason="vonage.Client#get_balance is deprecated. Use Account#get_balance instead" + ) + def get_balance(self): + return self.get(self.host(), "/account/get-balance") + + @deprecated( + reason="vonage.Client#get_country_pricing is deprecated. Use Account#get_country_pricing instead" + ) + def get_country_pricing(self, country_code): + return self.get( + self.host(), "/account/get-pricing/outbound", {"country": country_code} + ) + + @deprecated( + reason="vonage.Client#get_prefix_pricing is deprecated. Use Account#get_prefix_pricing instead" + ) + def get_prefix_pricing(self, prefix): + return self.get( + self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + ) + + @deprecated( + reason="vonage.Client#get_sms_pricing is deprecated. Use Account#get_sms_pricing instead" + ) + def get_sms_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + ) + + @deprecated( + reason="vonage.Client#get_voice_pricing is deprecated. Use Account#get_voice_pricing instead" + ) + def get_voice_pricing(self, number): + return self.get( + self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + ) + + @deprecated( + reason="vonage.Client#update_settings is deprecated. Use Account#update_default_sms_webhook instead" + ) + def update_settings(self, params=None, **kwargs): + return self.post(self.host(), "/account/settings", params or kwargs) + + @deprecated( + reason="vonage.Client#topup is deprecated. Use Account#topup instead" + ) + def topup(self, params=None, **kwargs): + return self.post(self.host(), "/account/top-up", params or kwargs) + + # Numbers API + @deprecated( + reason="vonage.Client#get_account_numbers is deprecated. Use Numbers#get_account_numbers instead" + ) + def get_account_numbers(self, params=None, **kwargs): + return self.get(self.host(), "/account/numbers", params or kwargs) + + @deprecated( + reason="vonage.Client#get_available_numbers is deprecated. Use Numbers#get_available_numbers instead" + ) + def get_available_numbers(self, country_code, params=None, **kwargs): + return self.get( + self.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + @deprecated( + reason="vonage.Client#buy_number is deprecated. Use Numbers#buy_number instead" + ) + def buy_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/buy", params or kwargs) + + @deprecated( + reason="vonage.Client#cancel_number is deprecated. Use Numbers#cancel_number instead" + ) + def cancel_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/cancel", params or kwargs) + + @deprecated( + reason="vonage.Client#update_number is deprecated. Use Numbers#update_number instead" + ) + def update_number(self, params=None, **kwargs): + return self.post(self.host(), "/number/update", params or kwargs) + \ No newline at end of file diff --git a/src/vonage/numbers.py b/src/vonage/numbers.py new file mode 100644 index 00000000..bf19c4e3 --- /dev/null +++ b/src/vonage/numbers.py @@ -0,0 +1,39 @@ +import vonage + +class Numbers: + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print(f'Error: {str(e)}') + + def get_account_numbers(self, params=None, **kwargs): + return self._client.get(self._client.host(), "/account/numbers", params or kwargs) + + def get_available_numbers(self, country_code, params=None, **kwargs): + return self._client.get( + self._client.host(), "/number/search", dict(params or kwargs, country=country_code) + ) + + def buy_number(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/number/buy", params or kwargs) + + def cancel_number(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/number/cancel", params or kwargs) + + def update_number(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/number/update", params or kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 8179abd8..7b63dd04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,4 +79,10 @@ def number_insight(client): def account(client): import vonage - return vonage.Account(client) \ No newline at end of file + return vonage.Account(client) + +@pytest.fixture +def numbers(client): + import vonage + + return vonage.Numbers(client) \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index ad25e0e1..716d775a 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -72,16 +72,23 @@ def test_get_prefix_pricing(account, dummy_data): assert "prefix=44" in request_query() @responses.activate -def test_get_sms_pricing(client, dummy_data): +def test_deprecated_get_sms_pricing(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") assert isinstance(client.get_sms_pricing("447525856424"), dict) assert request_user_agent() == dummy_data.user_agent assert "phone=447525856424" in request_query() +@responses.activate +def test_get_sms_pricing(account, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") + + assert isinstance(account.get_sms_pricing("447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "phone=447525856424" in request_query() @responses.activate -def test_get_voice_pricing(client, dummy_data): +def test_deprecated_get_voice_pricing(client, dummy_data): stub( responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" ) @@ -90,9 +97,18 @@ def test_get_voice_pricing(client, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "phone=447525856424" in request_query() +@responses.activate +def test_get_voice_pricing(account, dummy_data): + stub( + responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" + ) + + assert isinstance(account.get_voice_pricing("447525856424"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "phone=447525856424" in request_query() @responses.activate -def test_update_settings(client, dummy_data): +def test_deprecated_update_settings(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/account/settings") params = {"moCallBackUrl": "http://example.com/callback"} @@ -101,9 +117,18 @@ def test_update_settings(client, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() +@responses.activate +def test_update_default_sms_webhook(account, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/account/settings") + + params = {"moCallBackUrl": "http://example.com/callback"} + + assert isinstance(account.update_default_sms_webhook(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() @responses.activate -def test_topup(client, dummy_data): +def test_deprecated_topup(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/account/top-up") params = {"trx": "00X123456Y7890123Z"} @@ -112,14 +137,15 @@ def test_topup(client, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "trx=00X123456Y7890123Z" in request_body() - @responses.activate -def test_get_account_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") +def test_topup(account, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/account/top-up") - assert isinstance(client.get_account_numbers(size=25), dict) + params = {"trx": "00X123456Y7890123Z"} + + assert isinstance(account.topup(params), dict) assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] + assert "trx=00X123456Y7890123Z" in request_body() @responses.activate diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 7f4a4c99..02e9fc67 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -1,8 +1,23 @@ from util import * +@responses.activate +def test_deprecated_get_account_numbers(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/numbers") + + assert isinstance(client.get_account_numbers(size=25), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_params()["size"] == ["25"] + +@responses.activate +def test_get_account_numbers(numbers, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/numbers") + + assert isinstance(numbers.get_account_numbers(size=25), dict) + assert request_user_agent() == dummy_data.user_agent + assert request_params()["size"] == ["25"] @responses.activate -def test_get_available_numbers(client, dummy_data): +def test_deprecated_get_available_numbers(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/number/search") assert isinstance(client.get_available_numbers("CA", size=25), dict) @@ -10,9 +25,17 @@ def test_get_available_numbers(client, dummy_data): assert "country=CA" in request_query() assert "size=25" in request_query() +@responses.activate +def test_get_available_numbers(numbers, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/number/search") + + assert isinstance(numbers.get_available_numbers("CA", size=25), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=CA" in request_query() + assert "size=25" in request_query() @responses.activate -def test_buy_number(client, dummy_data): +def test_deprecated_buy_number(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/number/buy") params = {"country": "US", "msisdn": "number"} @@ -22,9 +45,19 @@ def test_buy_number(client, dummy_data): assert "country=US" in request_body() assert "msisdn=number" in request_body() +@responses.activate +def test_buy_number(numbers, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/number/buy") + + params = {"country": "US", "msisdn": "number"} + + assert isinstance(numbers.buy_number(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=US" in request_body() + assert "msisdn=number" in request_body() @responses.activate -def test_cancel_number(client, dummy_data): +def test_deprecated_cancel_number(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/number/cancel") params = {"country": "US", "msisdn": "number"} @@ -34,9 +67,19 @@ def test_cancel_number(client, dummy_data): assert "country=US" in request_body() assert "msisdn=number" in request_body() +@responses.activate +def test_cancel_number(numbers, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/number/cancel") + + params = {"country": "US", "msisdn": "number"} + + assert isinstance(numbers.cancel_number(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=US" in request_body() + assert "msisdn=number" in request_body() @responses.activate -def test_update_number(client, dummy_data): +def test_deprecated_update_number(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/number/update") params = {"country": "US", "msisdn": "number", "moHttpUrl": "callback"} @@ -46,3 +89,15 @@ def test_update_number(client, dummy_data): assert "country=US" in request_body() assert "msisdn=number" in request_body() assert "moHttpUrl=callback" in request_body() + +@responses.activate +def test_update_number(numbers, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/number/update") + + params = {"country": "US", "msisdn": "number", "moHttpUrl": "callback"} + + assert isinstance(numbers.update_number(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "country=US" in request_body() + assert "msisdn=number" in request_body() + assert "moHttpUrl=callback" in request_body() From 528d9dd0f51266df8fdcb084ecf3aa6530878cca Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 21 May 2022 02:37:35 +0100 Subject: [PATCH 154/401] added MessageSearch class, deprecated client methods, renamed test_misc.py to test_rest_calls.py --- src/vonage/client.py | 29 ++++++--- src/vonage/message_search.py | 31 +++++++++ tests/conftest.py | 8 ++- tests/test_message_search.py | 76 ++++++++++++++++++++++ tests/{test_misc.py => test_rest_calls.py} | 0 tests/test_search.py | 42 ------------ 6 files changed, 134 insertions(+), 52 deletions(-) create mode 100644 src/vonage/message_search.py create mode 100644 tests/test_message_search.py rename tests/{test_misc.py => test_rest_calls.py} (100%) delete mode 100644 tests/test_search.py diff --git a/src/vonage/client.py b/src/vonage/client.py index 527d9ecb..e11db2c2 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -2,6 +2,7 @@ from .account import * from .application import Application, BasicAuthenticatedServer from .errors import * +from .message_search import * from .number_insight import * from .numbers import * from .sms import * @@ -150,14 +151,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) def send_ussd_push_message(self, params=None, **kwargs): return self.post(self.host(), "/ussd/json", params or kwargs) @@ -828,4 +821,22 @@ def cancel_number(self, params=None, **kwargs): ) def update_number(self, params=None, **kwargs): return self.post(self.host(), "/number/update", params or kwargs) - \ No newline at end of file + + # Message Search API + @deprecated( + reason="vonage.Client#get_message is deprecated. Use MessageSearch#get_message instead" + ) + def get_message(self, message_id): + return self.get(self.host(), "/search/message", {"id": message_id}) + + @deprecated( + reason="vonage.Client#search_messages is deprecated. Use MessageSearch#search_messages instead" + ) + def search_messages(self, params=None, **kwargs): + return self.get(self.host(), "/search/messages", params or kwargs) + + @deprecated( + reason="vonage.Client#get_message_rejections is deprecated. Use MessageSearch#get_message_rejections instead" + ) + def get_message_rejections(self, params=None, **kwargs): + return self.get(self.host(), "/search/rejections", params or kwargs) \ No newline at end of file diff --git a/src/vonage/message_search.py b/src/vonage/message_search.py new file mode 100644 index 00000000..26efe9d8 --- /dev/null +++ b/src/vonage/message_search.py @@ -0,0 +1,31 @@ +import vonage + +class MessageSearch: + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print(f'Error: {str(e)}') + + def get_message(self, message_id): + return self._client.get(self._client.host(), "/search/message", {"id": message_id}) + + def search_messages(self, params=None, **kwargs): + return self._client.get(self._client.host(), "/search/messages", params or kwargs) + + def get_message_rejections(self, params=None, **kwargs): + return self._client.get(self._client.host(), "/search/rejections", params or kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 7b63dd04..b020dab7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,4 +85,10 @@ def account(client): def numbers(client): import vonage - return vonage.Numbers(client) \ No newline at end of file + return vonage.Numbers(client) + +@pytest.fixture +def message_search(client): + import vonage + + return vonage.MessageSearch(client) diff --git a/tests/test_message_search.py b/tests/test_message_search.py new file mode 100644 index 00000000..11463422 --- /dev/null +++ b/tests/test_message_search.py @@ -0,0 +1,76 @@ +from util import * + + +@responses.activate +def test_deprecated_get_message(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/message") + + assert isinstance(client.get_message("00A0B0C0"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "id=00A0B0C0" in request_query() + +@responses.activate +def test_get_message(message_search, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/message") + + assert isinstance(message_search.get_message("00A0B0C0"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "id=00A0B0C0" in request_query() + +@responses.activate +def test_deprecated_search_messages(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/messages") + + assert isinstance(client.search_messages(to="1234567890", date="YYYY-MM-DD"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "date=YYYY-MM-DD" in request_query() + assert "to=1234567890" in request_query() + +@responses.activate +def test_search_messages(message_search, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/messages") + + assert isinstance(message_search.search_messages(to="1234567890", date="YYYY-MM-DD"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "date=YYYY-MM-DD" in request_query() + assert "to=1234567890" in request_query() + +@responses.activate +def test_deprecated_search_messages_by_ids(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/messages") + + assert isinstance( + client.search_messages(ids=["00A0B0C0", "00A0B0C1", "00A0B0C2"]), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "ids=00A0B0C0" in request_query() + assert "ids=00A0B0C1" in request_query() + assert "ids=00A0B0C2" in request_query() + +@responses.activate +def test_search_messages_by_ids(message_search, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/messages") + + assert isinstance( + message_search.search_messages(ids=["00A0B0C0", "00A0B0C1", "00A0B0C2"]), dict + ) + assert request_user_agent() == dummy_data.user_agent + assert "ids=00A0B0C0" in request_query() + assert "ids=00A0B0C1" in request_query() + assert "ids=00A0B0C2" in request_query() + +@responses.activate +def test_deprecated_get_message_rejections(client, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/rejections") + + assert isinstance(client.get_message_rejections(date="YYYY-MM-DD"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "date=YYYY-MM-DD" in request_query() + +@responses.activate +def test_get_message_rejections(message_search, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/search/rejections") + + assert isinstance(message_search.get_message_rejections(date="YYYY-MM-DD"), dict) + assert request_user_agent() == dummy_data.user_agent + assert "date=YYYY-MM-DD" in request_query() diff --git a/tests/test_misc.py b/tests/test_rest_calls.py similarity index 100% rename from tests/test_misc.py rename to tests/test_rest_calls.py diff --git a/tests/test_search.py b/tests/test_search.py deleted file mode 100644 index e36afaa8..00000000 --- a/tests/test_search.py +++ /dev/null @@ -1,42 +0,0 @@ -from util import * - - -@responses.activate -def test_get_message(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/message") - - assert isinstance(client.get_message("00A0B0C0"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "id=00A0B0C0" in request_query() - - -@responses.activate -def test_get_message_rejections(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/rejections") - - assert isinstance(client.get_message_rejections(date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - - -@responses.activate -def test_search_messages(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance(client.search_messages(to="1234567890", date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - assert "to=1234567890" in request_query() - - -@responses.activate -def test_search_messages_by_ids(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance( - client.search_messages(ids=["00A0B0C0", "00A0B0C1", "00A0B0C2"]), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "ids=00A0B0C0" in request_query() - assert "ids=00A0B0C1" in request_query() - assert "ids=00A0B0C2" in request_query() From b887d4c58f7a7bfa028834e8d7a96d6b475e5a25 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 24 May 2022 01:14:59 +0100 Subject: [PATCH 155/401] added Ussd and ShortCodes classes/modules, deprecated old methods and added tests --- src/vonage/client.py | 119 ++++++++++++++++++++++++-------------- src/vonage/short_codes.py | 41 +++++++++++++ src/vonage/sms.py | 3 + src/vonage/ussd.py | 29 ++++++++++ tests/conftest.py | 12 ++++ tests/test_nexmo.py | 17 +++--- tests/test_short_codes.py | 63 ++++++++++++++++++++ tests/test_ussd.py | 25 ++++++++ 8 files changed, 255 insertions(+), 54 deletions(-) create mode 100644 src/vonage/short_codes.py create mode 100644 src/vonage/ussd.py create mode 100644 tests/test_short_codes.py create mode 100644 tests/test_ussd.py diff --git a/src/vonage/client.py b/src/vonage/client.py index e11db2c2..515751f8 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -5,7 +5,9 @@ from .message_search import * from .number_insight import * from .numbers import * +from .short_codes import * from .sms import * +from .ussd import * from .voice import * from .verify import * @@ -151,48 +153,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Vonage that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post( - self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs - ) - def initiate_call(self, params=None, **kwargs): return self.post(self.host(), "/call/json", params or kwargs) @@ -202,7 +162,6 @@ def initiate_tts_call(self, params=None, **kwargs): def initiate_tts_prompt_call(self, params=None, **kwargs): return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - def get_recording(self, url): hostname = urlparse(url).hostname return self.parse(hostname, self.session.get(url, headers=self._headers())) @@ -839,4 +798,76 @@ def search_messages(self, params=None, **kwargs): reason="vonage.Client#get_message_rejections is deprecated. Use MessageSearch#get_message_rejections instead" ) def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) \ No newline at end of file + return self.get(self.host(), "/search/rejections", params or kwargs) + + # SMS Conversion API + @deprecated( + reason="vonage.Client#submit_sms_conversion is deprecated. Use Sms#submit_sms_conversion instead" + ) + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): + """ + Notify Vonage that an SMS was successfully received. + + If you are using the Verify API for 2FA, this information is sent to Vonage automatically + so you do not need to use this method to submit conversion data about 2FA messages. + + :param message_id: The `message-id` str returned by the send_message call. + :param delivered: A `bool` indicating that the message was or was not successfully delivered. + :param timestamp: A `datetime` object containing the time the SMS arrived. + :return: The parsed response from the server. On success, the bytestring b'OK' + """ + params = { + "message-id": message_id, + "delivered": delivered, + "timestamp": timestamp or datetime.now(pytz.utc), + } + # Ensure timestamp is a string: + _format_date_param(params, "timestamp") + return self.post(self.api_host(), "/conversions/sms", params) + + # Ussd API + @deprecated( + reason="vonage.Client#send_ussd_push_message is deprecated. Use Ussd#send_ussd_push_message instead" + ) + def send_ussd_push_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd/json", params or kwargs) + + @deprecated( + reason="vonage.Client#send_ussd_prompt_message is deprecated. Use Ussd#send_ussd_prompt_message instead" + ) + def send_ussd_prompt_message(self, params=None, **kwargs): + return self.post(self.host(), "/ussd-prompt/json", params or kwargs) + + # Short Codes API + @deprecated( + reason="vonage.Client#send_2fa_message is deprecated. Use ShortCodes#send_2fa_message instead" + ) + def send_2fa_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) + + @deprecated( + reason="vonage.Client#send_event_alert_message is deprecated. Use ShortCodes#send_event_alert_message instead" + ) + def send_event_alert_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/alert/json", params or kwargs) + + @deprecated( + reason="vonage.Client#send_marketing_message is deprecated. Use ShortCodes#send_marketing_message instead" + ) + def send_marketing_message(self, params=None, **kwargs): + return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) + + @deprecated( + reason="vonage.Client#get_event_alert_numbers is deprecated. Use ShortCodes#get_event_alert_numbers instead" + ) + def get_event_alert_numbers(self): + return self.get(self.host(), "/sc/us/alert/opt-in/query/json") + + @deprecated( + reason="vonage.Client#resubscribe_event_alert_number is deprecated. Use ShortCodes#resubscribe_event_alert_number instead" + ) + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self.post( + self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs + ) + \ No newline at end of file diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py new file mode 100644 index 00000000..757ad141 --- /dev/null +++ b/src/vonage/short_codes.py @@ -0,0 +1,41 @@ +import vonage + +class ShortCodes: + #To init Sms class pass a client reference or a key and secret + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print(f'Error: {str(e)}') + + def send_2fa_message(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/sc/us/2fa/json", params or kwargs) + + def send_event_alert_message(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/sc/us/alert/json", params or kwargs) + + def send_marketing_message(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/sc/us/marketing/json", params or kwargs) + + def get_event_alert_numbers(self): + return self._client.get(self._client.host(), "/sc/us/alert/opt-in/query/json") + + def resubscribe_event_alert_number(self, params=None, **kwargs): + return self._client.post( + self._client.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs + ) + \ No newline at end of file diff --git a/src/vonage/sms.py b/src/vonage/sms.py index 224510b4..5882bc14 100644 --- a/src/vonage/sms.py +++ b/src/vonage/sms.py @@ -36,6 +36,9 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ Notify Vonage that an SMS was successfully received. + If you are using the Verify API for 2FA, this information is sent to Vonage automatically + so you do not need to use this method to submit conversion data about 2FA messages. + :param message_id: The `message-id` str returned by the send_message call. :param delivered: A `bool` indicating that the message was or was not successfully delivered. :param timestamp: A `datetime` object containing the time the SMS arrived. diff --git a/src/vonage/ussd.py b/src/vonage/ussd.py new file mode 100644 index 00000000..fb0eb9af --- /dev/null +++ b/src/vonage/ussd.py @@ -0,0 +1,29 @@ +import vonage + +class Ussd: + #To init Sms class pass a client reference or a key and secret + def __init__( + self, + client=None, + key=None, + secret=None, + signature_secret=None, + signature_method=None + ): + try: + self._client = client + if self._client is None: + self._client = vonage.Client( + key=key, + secret=secret, + signature_secret=signature_secret, + signature_method=signature_method + ) + except Exception as e: + print(f'Error: {str(e)}') + + def send_ussd_push_message(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/ussd/json", params or kwargs) + + def send_ussd_prompt_message(self, params=None, **kwargs): + return self._client.post(self._client.host(), "/ussd-prompt/json", params or kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index b020dab7..38bfc6e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,3 +92,15 @@ def message_search(client): import vonage return vonage.MessageSearch(client) + +@pytest.fixture +def ussd(client): + import vonage + + return vonage.Ussd(client) + +@pytest.fixture +def short_codes(client): + import vonage + + return vonage.ShortCodes(client) \ No newline at end of file diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 850e995b..0d1d3b88 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -1,12 +1,10 @@ import vonage from util import * -import sys - bytes_type = bytes @responses.activate -def test_send_ussd_push_message(client, dummy_data): +def test_deprecated_send_ussd_push_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd/json") params = {"from": "MyCompany20", "to": "447525856424", "text": "Hello"} @@ -17,9 +15,8 @@ def test_send_ussd_push_message(client, dummy_data): assert "to=447525856424" in request_body() assert "text=Hello" in request_body() - @responses.activate -def test_send_ussd_prompt_message(client, dummy_data): +def test_deprecated_send_ussd_prompt_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") params = {"from": "long-virtual-number", "to": "447525856424", "text": "Hello"} @@ -32,7 +29,7 @@ def test_send_ussd_prompt_message(client, dummy_data): @responses.activate -def test_send_2fa_message(client, dummy_data): +def test_deprecated_send_2fa_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") params = {"to": "16365553226", "pin": "1234"} @@ -44,7 +41,7 @@ def test_send_2fa_message(client, dummy_data): @responses.activate -def test_send_event_alert_message(client, dummy_data): +def test_deprecated_send_event_alert_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/json") params = {"to": "16365553226", "server": "host", "link": "http://example.com/"} @@ -57,7 +54,7 @@ def test_send_event_alert_message(client, dummy_data): @responses.activate -def test_send_marketing_message(client, dummy_data): +def test_deprecated_send_marketing_message(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sc/us/marketing/json") params = { @@ -76,7 +73,7 @@ def test_send_marketing_message(client, dummy_data): @responses.activate -def test_get_event_alert_numbers(client, dummy_data): +def test_deprecated_get_event_alert_numbers(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/sc/us/alert/opt-in/query/json") assert isinstance(client.get_event_alert_numbers(), dict) @@ -84,7 +81,7 @@ def test_get_event_alert_numbers(client, dummy_data): @responses.activate -def test_resubscribe_event_alert_number(client, dummy_data): +def test_deprecated_resubscribe_event_alert_number(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/opt-in/manage/json") params = {"msisdn": "441632960960"} diff --git a/tests/test_short_codes.py b/tests/test_short_codes.py new file mode 100644 index 00000000..6abf751b --- /dev/null +++ b/tests/test_short_codes.py @@ -0,0 +1,63 @@ +from util import * + +@responses.activate +def test_send_2fa_message(short_codes, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") + + params = {"to": "16365553226", "pin": "1234"} + + assert isinstance(short_codes.send_2fa_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "to=16365553226" in request_body() + assert "pin=1234" in request_body() + + +@responses.activate +def test_send_event_alert_message(short_codes, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/json") + + params = {"to": "16365553226", "server": "host", "link": "http://example.com/"} + + assert isinstance(short_codes.send_event_alert_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "to=16365553226" in request_body() + assert "server=host" in request_body() + assert "link=http%3A%2F%2Fexample.com%2F" in request_body() + + +@responses.activate +def test_send_marketing_message(short_codes, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sc/us/marketing/json") + + params = { + "from": "short-code", + "to": "16365553226", + "keyword": "NEXMO", + "text": "Hello", + } + + assert isinstance(short_codes.send_marketing_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=short-code" in request_body() + assert "to=16365553226" in request_body() + assert "keyword=NEXMO" in request_body() + assert "text=Hello" in request_body() + + +@responses.activate +def test_get_event_alert_numbers(short_codes, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/sc/us/alert/opt-in/query/json") + + assert isinstance(short_codes.get_event_alert_numbers(), dict) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_resubscribe_event_alert_number(short_codes, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/opt-in/manage/json") + + params = {"msisdn": "441632960960"} + + assert isinstance(short_codes.resubscribe_event_alert_number(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "msisdn=441632960960" in request_body() \ No newline at end of file diff --git a/tests/test_ussd.py b/tests/test_ussd.py new file mode 100644 index 00000000..9eb4db00 --- /dev/null +++ b/tests/test_ussd.py @@ -0,0 +1,25 @@ +from util import * + +@responses.activate +def test_send_ussd_push_message(ussd, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/ussd/json") + + params = {"from": "MyCompany20", "to": "447525856424", "text": "Hello"} + + assert isinstance(ussd.send_ussd_push_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=MyCompany20" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hello" in request_body() + +@responses.activate +def test_send_ussd_prompt_message(ussd, dummy_data): + stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") + + params = {"from": "long-virtual-number", "to": "447525856424", "text": "Hello"} + + assert isinstance(ussd.send_ussd_prompt_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "from=long-virtual-number" in request_body() + assert "to=447525856424" in request_body() + assert "text=Hello" in request_body() \ No newline at end of file From 9873e0fdf07b92ded1b478c7849feb4a096e8404 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 24 May 2022 01:44:47 +0100 Subject: [PATCH 156/401] added account secret management into Account class, deprecated methods in Client and added new tests --- src/vonage/account.py | 29 ++++++++- src/vonage/client.py | 69 +++++++++++--------- tests/test_account.py | 148 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 197 insertions(+), 49 deletions(-) diff --git a/src/vonage/account.py b/src/vonage/account.py index 1e0cc1d4..5df2051a 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -48,4 +48,31 @@ def get_voice_pricing(self, number): ) def update_default_sms_webhook(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/account/settings", params or kwargs) \ No newline at end of file + return self._client.post(self._client.host(), "/account/settings", params or kwargs) + + def list_secrets(self, api_key): + return self._client.get( + self._client.api_host(), + f"/accounts/{api_key}/secrets", + header_auth=True, + ) + + def get_secret(self, api_key, secret_id): + return self._client.get( + self._client.api_host(), + f"/accounts/{api_key}/secrets/{secret_id}", + header_auth=True, + ) + + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._client._post_json( + self._client.api_host(), f"/accounts/{api_key}/secrets", body + ) + + def revoke_secret(self, api_key, secret_id): + return self._client.delete( + self._client.api_host(), + f"/accounts/{api_key}/secrets/{secret_id}", + header_auth=True, + ) diff --git a/src/vonage/client.py b/src/vonage/client.py index 515751f8..c45b98e8 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -47,7 +47,7 @@ class Client: sms, number insight) have been deprecated and will instead be called from modules that house the relevant classes (e.g. `voice.py`, `sms.py`). Change your code to call these classes directly as they will be removed in a later release! - + Newer APIs are under namespaces like :attr:`Client.application`. The credentials you provide when instantiating a Client determine which @@ -172,33 +172,6 @@ def redact_transaction(self, id, product, type=None): params["type"] = type return self._post_json(self.api_host(), "/v1/redact/transaction", params) - def list_secrets(self, api_key): - return self.get( - self.api_host(), - f"/accounts/{api_key}/secrets", - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), f"/accounts/{api_key}/secrets", body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True, - ) - def check_signature(self, params): params = dict(params) signature = params.pop("sig", "").lower() @@ -748,6 +721,45 @@ def update_settings(self, params=None, **kwargs): def topup(self, params=None, **kwargs): return self.post(self.host(), "/account/top-up", params or kwargs) + @deprecated( + reason="vonage.Client#list_secrets is deprecated. Use Account#list_secrets instead" + ) + def list_secrets(self, api_key): + return self.get( + self.api_host(), + f"/accounts/{api_key}/secrets", + header_auth=True, + ) + + @deprecated( + reason="vonage.Client#get_secret is deprecated. Use Account#get_secret instead" + ) + def get_secret(self, api_key, secret_id): + return self.get( + self.api_host(), + f"/accounts/{api_key}/secrets/{secret_id}", + header_auth=True, + ) + + @deprecated( + reason="vonage.Client#create_secret is deprecated. Use Account#create_secret instead" + ) + def create_secret(self, api_key, secret): + body = {"secret": secret} + return self._post_json( + self.api_host(), f"/accounts/{api_key}/secrets", body + ) + + @deprecated( + reason="vonage.Client#delete_secret is deprecated. Use Account#revoke_secret instead" + ) + def delete_secret(self, api_key, secret_id): + return self.delete( + self.api_host(), + f"/accounts/{api_key}/secrets/{secret_id}", + header_auth=True, + ) + # Numbers API @deprecated( reason="vonage.Client#get_account_numbers is deprecated. Use Numbers#get_account_numbers instead" @@ -870,4 +882,3 @@ def resubscribe_event_alert_number(self, params=None, **kwargs): return self.post( self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs ) - \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index 716d775a..c97ed46e 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -149,7 +149,7 @@ def test_topup(account, dummy_data): @responses.activate -def test_list_secrets(client): +def test_deprecated_list_secrets(client): stub( responses.GET, "https://api.nexmo.com/accounts/meaccountid/secrets", @@ -163,9 +163,23 @@ def test_list_secrets(client): == "ad6dc56f-07b5-46e1-a527-85530e625800" ) +@responses.activate +def test_list_secrets(account): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets", + fixture_path="account/secret_management/list.json", + ) + + secrets = account.list_secrets("meaccountid") + assert_basic_auth() + assert ( + glom(secrets, "_embedded.secrets.0.id") + == "ad6dc56f-07b5-46e1-a527-85530e625800" + ) @responses.activate -def test_list_secrets_missing(client): +def test_deprecated_list_secrets_missing(client): stub( responses.GET, "https://api.nexmo.com/accounts/meaccountid/secrets", @@ -180,9 +194,24 @@ def test_list_secrets_missing(client): str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" ) +@responses.activate +def test_list_secrets_missing(account): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=404, + fixture_path="account/secret_management/missing.json", + ) + + with pytest.raises(vonage.ClientError) as ce: + account.list_secrets("meaccountid") + assert_basic_auth() + assert ( + str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" + ) @responses.activate -def test_get_secret(client): +def test_deprecated_get_secret(client): stub( responses.GET, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", @@ -193,48 +222,79 @@ def test_get_secret(client): assert_basic_auth() assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" +@responses.activate +def test_get_secret(account): + stub( + responses.GET, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + fixture_path="account/secret_management/get.json", + ) + + secret = account.get_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" + @responses.activate -def test_delete_secret(client): +def test_deprecated_create_secret(client): stub( - responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + fixture_path="account/secret_management/create.json", ) - client.delete_secret("meaccountid", "mahsecret") + secret = client.create_secret("meaccountid", "mahsecret") assert_basic_auth() + assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" @responses.activate -def test_delete_secret_last_secret(client): +def test_deprecated_create_secret_max_secrets(client): stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", status_code=403, - fixture_path="account/secret_management/last-secret.json", + fixture_path="account/secret_management/max-secrets.json", ) + with pytest.raises(vonage.ClientError) as ce: - client.delete_secret("meaccountid", "mahsecret") + client.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" + str(ce.value) == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" + ) + +@responses.activate +def test_deprecated_create_secret_validation(client): + stub( + responses.POST, + "https://api.nexmo.com/accounts/meaccountid/secrets", + status_code=400, + fixture_path="account/secret_management/create-validation.json", ) + with pytest.raises(vonage.ClientError) as ce: + client.create_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + str(ce.value) == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" + ) @responses.activate -def test_create_secret(client): +def test_create_secret(account): stub( responses.POST, "https://api.nexmo.com/accounts/meaccountid/secrets", fixture_path="account/secret_management/create.json", ) - secret = client.create_secret("meaccountid", "mahsecret") + secret = account.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" @responses.activate -def test_create_secret_max_secrets(client): +def test_create_secret_max_secrets(account): stub( responses.POST, "https://api.nexmo.com/accounts/meaccountid/secrets", @@ -243,15 +303,14 @@ def test_create_secret_max_secrets(client): ) with pytest.raises(vonage.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") + account.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( str(ce.value) == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" ) - @responses.activate -def test_create_secret_validation(client): +def test_create_secret_validation(account): stub( responses.POST, "https://api.nexmo.com/accounts/meaccountid/secrets", @@ -260,8 +319,59 @@ def test_create_secret_validation(client): ) with pytest.raises(vonage.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") + account.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( str(ce.value) == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" ) + +@responses.activate +def test_deprecated_delete_secret(client): + stub( + responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" + ) + + client.delete_secret("meaccountid", "mahsecret") + assert_basic_auth() + + +@responses.activate +def test_deprecated_delete_secret_last_secret(client): + stub( + responses.DELETE, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + status_code=403, + fixture_path="account/secret_management/last-secret.json", + ) + with pytest.raises(vonage.ClientError) as ce: + client.delete_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" + ) + +@responses.activate +def test_delete_secret(account): + stub( + responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" + ) + + account.revoke_secret("meaccountid", "mahsecret") + assert_basic_auth() + + +@responses.activate +def test_delete_secret_last_secret(account): + stub( + responses.DELETE, + "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", + status_code=403, + fixture_path="account/secret_management/last-secret.json", + ) + with pytest.raises(vonage.ClientError) as ce: + account.revoke_secret("meaccountid", "mahsecret") + assert_basic_auth() + assert ( + str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" + ) + From 0bc61e5f9650594e47e811599b60a59909674796 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 25 May 2022 17:12:00 +0100 Subject: [PATCH 157/401] renamed Application -> ApplicationV2 for backwards compatibility --- README.md | 10 +++++----- docs/reference.rst | 4 ++-- src/vonage/application.py | 2 +- src/vonage/client.py | 16 ++++++++-------- tests/test_application.py | 18 +++++++++--------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 10710dd3..8a1ccec2 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ client.delete_secret(API_KEY, 'my-secret-id') ### Create an application ```python -response = client.application.create_application({name='Example App', type='voice'}) +response = client.application_v2.create_application({name='Example App', type='voice'}) ``` Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) @@ -473,7 +473,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https:/ ### Retrieve a list of applications ```python -response = client.application.list_applications() +response = client.application_v2.list_applications() ``` Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) @@ -481,7 +481,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://d ### Retrieve a single application ```python -response = client.application.get_application(uuid) +response = client.application_v2.get_application(uuid) ``` Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) @@ -489,7 +489,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://de ### Update an application ```python -response = client.application.update_application(uuid, answer_method='POST') +response = client.application_v2.update_application(uuid, answer_method='POST') ``` Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) @@ -497,7 +497,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https:/ ### Delete an application ```python -response = client.application.delete_application(uuid) +response = client.application_v2.delete_application(uuid) ``` Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) diff --git a/docs/reference.rst b/docs/reference.rst index f7d5a4b8..33e14019 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -5,9 +5,9 @@ API Reference :members: :undoc-members: - .. attribute:: application + .. attribute:: application_v2 - An instance of :class:`vonage.Application` for accessing the Application API. + An instance of :class:`vonage.ApplicationV2` for accessing the Application API. .. autoclass:: vonage.ApplicationV2 :members: diff --git a/src/vonage/application.py b/src/vonage/application.py index c490fadd..4344be11 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -85,7 +85,7 @@ def _parse(self, response): raise ServerError(message) -class Application(object): +class ApplicationV2(object): """ Provides Application API v2 functionality. diff --git a/src/vonage/client.py b/src/vonage/client.py index c45b98e8..7cb04efc 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,6 +1,6 @@ from ._internal import _format_date_param from .account import * -from .application import Application, BasicAuthenticatedServer +from .application import ApplicationV2, BasicAuthenticatedServer from .errors import * from .message_search import * from .number_insight import * @@ -48,7 +48,7 @@ class Client: the relevant classes (e.g. `voice.py`, `sms.py`). Change your code to call these classes directly as they will be removed in a later release! - Newer APIs are under namespaces like :attr:`Client.application`. + Newer APIs are under namespaces like :attr:`Client.application_v2`. The credentials you provide when instantiating a Client determine which methods can be called. Consult the `Vonage API docs `_ for details of the @@ -128,7 +128,7 @@ def __init__( api_key=self.api_key, api_secret=self.api_secret, ) - self.application = Application(api_server) + self.application_v2 = ApplicationV2(api_server) self.session = requests.Session() @@ -511,7 +511,7 @@ def control_verification_request(self, params=None, **kwargs): # Application API def get_applications(self, params=None, **kwargs): warnings.warn( - "vonage.Client#get_applications is deprecated (use methods from #application instead)", + "vonage.Client#get_applications is deprecated (use v2 methods from #application instead)", DeprecationWarning, stacklevel=2, ) @@ -519,7 +519,7 @@ def get_applications(self, params=None, **kwargs): def get_application(self, application_id): warnings.warn( - "vonage.Client#get_application is deprecated (use methods from #application instead)", + "vonage.Client#get_application is deprecated (use v2 methods from #application instead)", DeprecationWarning, stacklevel=2, ) @@ -530,7 +530,7 @@ def get_application(self, application_id): def create_application(self, params=None, **kwargs): warnings.warn( - "vonage.Client#create_application is deprecated (use methods from #application instead)", + "vonage.Client#create_application is deprecated (use methods from v2 #application instead)", DeprecationWarning, stacklevel=2, ) @@ -538,7 +538,7 @@ def create_application(self, params=None, **kwargs): def update_application(self, application_id, params=None, **kwargs): warnings.warn( - "vonage.Client#update_application is deprecated (use methods from #application instead)", + "vonage.Client#update_application is deprecated (use methods from v2 #application instead)", DeprecationWarning, stacklevel=2, ) @@ -550,7 +550,7 @@ def update_application(self, application_id, params=None, **kwargs): def delete_application(self, application_id): warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from #application instead)", + "vonage.Client#delete_application is deprecated (use methods from v2 #application instead)", DeprecationWarning, stacklevel=2, ) diff --git a/tests/test_application.py b/tests/test_application.py index 33498f72..635f11f9 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -12,7 +12,7 @@ def test_list_applications(client, dummy_data): fixture_path="applications/list_applications.json", ) - apps = client.application.list_applications() + apps = client.application_v2.list_applications() assert_basic_auth() assert isinstance(apps, dict) assert apps["total_items"] == 30 @@ -27,7 +27,7 @@ def test_get_application(client, dummy_data): fixture_path="applications/get_application.json", ) - app = client.application.get_application("xx-xx-xx-xx") + app = client.application_v2.get_application("xx-xx-xx-xx") assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -44,7 +44,7 @@ def test_create_application(client, dummy_data): params = {"name": "Example App", "type": "voice"} - app = client.application.create_application(params) + app = client.application_v2.create_application(params) assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -63,7 +63,7 @@ def test_update_application(client, dummy_data): params = {"answer_url": "https://example.com/ncco"} - app = client.application.update_application("xx-xx-xx-xx", params) + app = client.application_v2.update_application("xx-xx-xx-xx", params) assert_basic_auth() assert isinstance(app, dict) assert request_user_agent() == dummy_data.user_agent @@ -81,7 +81,7 @@ def test_delete_application(client, dummy_data): status=204, ) - assert client.application.delete_application("xx-xx-xx-xx") is None + assert client.application_v2.delete_application("xx-xx-xx-xx") is None assert_basic_auth() assert request_user_agent() == dummy_data.user_agent @@ -94,7 +94,7 @@ def test_authentication_error(client): status=401, ) with pytest.raises(vonage.AuthenticationError): - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") @responses.activate @@ -112,7 +112,7 @@ def test_client_error(client): ), ) with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") assert ( str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" ) @@ -127,7 +127,7 @@ def test_client_error_no_decode(client): body="{this: isnot_json", ) with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") assert str(exc_info.value) == "430 response" @@ -139,4 +139,4 @@ def test_server_error(client): status=500, ) with pytest.raises(vonage.ServerError): - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") From 478df7638d4b516c14aab71553fa69ed828f940f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 25 May 2022 17:24:33 +0100 Subject: [PATCH 158/401] added changelog, removed messages code for this release --- CHANGES.md | 5 +++++ src/vonage/message_classes.py | 18 ------------------ src/vonage/messages.py | 6 ------ 3 files changed, 5 insertions(+), 24 deletions(-) delete mode 100644 src/vonage/message_classes.py delete mode 100644 src/vonage/messages.py diff --git a/CHANGES.md b/CHANGES.md index c68f7149..e5cb21c4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +# 2.7.0 +- Moved some client methods into their own classes: `account.py, application.py, +message_search.py, number_insight.py, numbers.py, short_codes.py, ussd.py` +- Deprecated the corresponding client methods. These will be removed in a major release that's coming soon. + # 2.6.x - Dropped support for Python 3.6 and below diff --git a/src/vonage/message_classes.py b/src/vonage/message_classes.py deleted file mode 100644 index e0cf36c9..00000000 --- a/src/vonage/message_classes.py +++ /dev/null @@ -1,18 +0,0 @@ -class MessagesObject(object): - pass - -class SmsMessage(MessagesObject): - def __init__(self): - pass - -class MmsMessage(MessagesObject): - pass - -class WhatsAppMessage(MessagesObject): - pass - -class MessengerMessage(MessagesObject): - pass - -class ViberMessage(MessagesObject): - pass \ No newline at end of file diff --git a/src/vonage/messages.py b/src/vonage/messages.py deleted file mode 100644 index a0802d7e..00000000 --- a/src/vonage/messages.py +++ /dev/null @@ -1,6 +0,0 @@ -import vonage - -def send_message(message): - return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) - - From acb33ebab58a263cf604f42f539a12bacb5db93f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 26 May 2022 15:27:25 +0100 Subject: [PATCH 159/401] instantiating each API class when Client class is created, simplifying new class __init__ methods --- src/vonage/account.py | 23 ++--------------------- src/vonage/client.py | 12 +++++++++++- src/vonage/message_search.py | 23 ++--------------------- src/vonage/number_insight.py | 19 ++----------------- src/vonage/numbers.py | 23 +++-------------------- src/vonage/short_codes.py | 24 ++---------------------- src/vonage/ussd.py | 24 ++---------------------- 7 files changed, 24 insertions(+), 124 deletions(-) diff --git a/src/vonage/account.py b/src/vonage/account.py index 5df2051a..64ffdcb2 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -1,25 +1,6 @@ -import vonage - class Account: - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client def get_balance(self): return self._client.get(self._client.host(), "/account/get-balance") diff --git a/src/vonage/client.py b/src/vonage/client.py index 7cb04efc..ad52cded 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -3,7 +3,7 @@ from .application import ApplicationV2, BasicAuthenticatedServer from .errors import * from .message_search import * -from .number_insight import * +from .number_insight import NumberInsight from .numbers import * from .short_codes import * from .sms import * @@ -130,6 +130,16 @@ def __init__( ) self.application_v2 = ApplicationV2(api_server) + self.account = Account(self) + self.message_search = MessageSearch(self) + self.number_insight = NumberInsight(self) + self.numbers = Numbers(self) + self.short_codes = ShortCodes(self) + self.sms = Sms(self) + self.ussd = Ussd(self) + self.verify = Verify(self) + self.voice = Voice(self) + self.session = requests.Session() # Get and Set __host attribute diff --git a/src/vonage/message_search.py b/src/vonage/message_search.py index 26efe9d8..13fe0fe9 100644 --- a/src/vonage/message_search.py +++ b/src/vonage/message_search.py @@ -1,25 +1,6 @@ -import vonage - class MessageSearch: - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client def get_message(self, message_id): return self._client.get(self._client.host(), "/search/message", {"id": message_id}) diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py index 7773e3f2..8245b223 100644 --- a/src/vonage/number_insight.py +++ b/src/vonage/number_insight.py @@ -1,23 +1,8 @@ -import vonage from .errors import CallbackRequiredError class NumberInsight: - # To init NumberInsight class, pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret - ) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client def get_basic_number_insight(self, params=None, **kwargs): return self._client.get(self._client.api_host(), "/ni/basic/json", params or kwargs) diff --git a/src/vonage/numbers.py b/src/vonage/numbers.py index bf19c4e3..14b736f5 100644 --- a/src/vonage/numbers.py +++ b/src/vonage/numbers.py @@ -1,26 +1,9 @@ import vonage class Numbers: - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print(f'Error: {str(e)}') - + def __init__(self, client): + self._client = client + def get_account_numbers(self, params=None, **kwargs): return self._client.get(self._client.host(), "/account/numbers", params or kwargs) diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py index 757ad141..a641b700 100644 --- a/src/vonage/short_codes.py +++ b/src/vonage/short_codes.py @@ -1,26 +1,6 @@ -import vonage - class ShortCodes: - #To init Sms class pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client def send_2fa_message(self, params=None, **kwargs): return self._client.post(self._client.host(), "/sc/us/2fa/json", params or kwargs) diff --git a/src/vonage/ussd.py b/src/vonage/ussd.py index fb0eb9af..893fb242 100644 --- a/src/vonage/ussd.py +++ b/src/vonage/ussd.py @@ -1,26 +1,6 @@ -import vonage - class Ussd: - #To init Sms class pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client def send_ussd_push_message(self, params=None, **kwargs): return self._client.post(self._client.host(), "/ussd/json", params or kwargs) From af777b4f5a303f003830d529d7e96e8bbffdf615 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 26 May 2022 15:29:30 +0100 Subject: [PATCH 160/401] =?UTF-8?q?Bump=20version:=202.6.4=20=E2=86=92=202?= =?UTF-8?q?.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 20d802d7..24cd4f14 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.6.4 +current_version = 2.7.0 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index 3e114eb3..cb4addc4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.6.4" +version = "2.7.0" # The full version, including alpha/beta/rc tags. -release = "2.6.4" +release = "2.7.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.6.4' +# html_title = u'Vonage v2.7.0' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index a1ec3a40..b80ef54b 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.6.4", + version="2.7.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 2fbb290f..5b24f447 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -4,4 +4,4 @@ from .sms import * from .verify import * -__version__ = "2.6.4" +__version__ = "2.7.0" From 148a382a22c9667a0e62cf6868cf54bb5d9e296d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 26 May 2022 16:36:27 +0100 Subject: [PATCH 161/401] updated CHANGES.md and README.md --- CHANGES.md | 15 ++++ README.md | 211 +++++++++++++++++++---------------------------------- 2 files changed, 92 insertions(+), 134 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e5cb21c4..3d66a75d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,21 @@ - Moved some client methods into their own classes: `account.py, application.py, message_search.py, number_insight.py, numbers.py, short_codes.py, ussd.py` - Deprecated the corresponding client methods. These will be removed in a major release that's coming soon. +- Client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` +instantiates instances of `Account`, `Sms`, `NumberInsight` etc. These instances can now be called directly from `Client`, e.g. +``` +client = vonage.Client(key="mykey", secret="mysecret") + +print(f"Account balance is: {client.account.get_balance()}") + +print("Sending an SMS") +client.sms.send_message( + "from": "Vonage", + "to": "SOME_PHONE_NUMBER", + "text": "Hello from Vonage's SMS API" +) + +``` # 2.6.x diff --git a/README.md b/README.md index 8a1ccec2..3a93f3e9 100644 --- a/README.md +++ b/README.md @@ -68,41 +68,43 @@ To check signatures for incoming webhook requests, you'll also need to specify the `signature_secret` argument (or the `VONAGE_SIGNATURE_SECRET` environment variable). -## SMS API - -### SMS Class - -#### Creating an instance of the SMS class - -To create an instance of the SMS class follow these steps: +## Simplified structure for calling API Methods -- Import the class +The client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` +instantiates instances of `Account`, `Sms`, `NumberInsight` etc. These instances can now be called directly from `Client`, e.g. ```python -#Option 1 -from vonage import Sms +client = vonage.Client(key="mykey", secret="mysecret") -#Option 2 -from vonage.sms import Sms +print(f"Account balance is: {client.account.get_balance()}") -#Option 3 -import vonage #then you can use vonage.Sms() to create an instance +print("Sending an SMS") +client.sms.send_message( + "from": "Vonage", + "to": "SOME_PHONE_NUMBER", + "text": "Hello from Vonage's SMS API" +) ``` -- Create an instance - +This means you don't have to create a separate instance of each class to use its API methods. Instead, you can access class methods from the client instance with ```python -#Option 1 - pass key and secret to the constructor -sms = Sms(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) - -#Option 2 - Create a client instance and then pass the client to the Sms instance -client = Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -sms = Sms(client) +client.CLASS_NAME.CLASS_METHOD ``` +## SMS API + ### Send an SMS ```python +# New way +client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) +client.sms.send_message({ + "from": VONAGE_BRAND_NAME, + "to": TO_NUMBER, + "text": "A text message sent using the Vonage SMS API", +}) + +# Old way from vonage import Sms sms = Sms(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) sms.send_message({ @@ -115,7 +117,8 @@ sms.send_message({ ### Send SMS with unicode ```python -sms.send_message({ +client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) +client.sms.send_message({ 'from': VONAGE_BRAND_NAME, 'to': TO_NUMBER, 'text': 'こんにちは世界', @@ -126,15 +129,13 @@ sms.send_message({ ### Submit SMS Conversion ```python -from vonage import Client, Sms -client = Client(key=VONAGE_API_KEY, secret=VONAGE_SECRET) -sms = Sms(client) -response = sms.send_message({ +client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_SECRET) +response = client.sms.send_message({ 'from': VONAGE_BRAND_NAME, 'to': TO_NUMBER, 'text': 'Hi from Vonage' }) -sms.submit_sms_conversion(response['message-id']) +client.sms.submit_sms_conversion(response['message-id']) ``` ## Voice API @@ -142,10 +143,8 @@ sms.submit_sms_conversion(response['message-id']) ### Make a call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -voice.create_call({ +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] @@ -155,107 +154,91 @@ voice.create_call({ ### Retrieve a list of calls ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -voice.get_calls() +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +client.voice.get_calls() ``` ### Retrieve a single call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -voice.get_call(uuid) +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +client.voice.get_call(uuid) ``` ### Update a call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -response = voice.create_call({ +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +response = client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.update_call(response['uuid'], action='hangup') +client.voice.update_call(response['uuid'], action='hangup') ``` ### Stream audio to a call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = voice.create_call({ +response = client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.send_audio(response['uuid'],stream_url=[stream_url]) +client.voice.send_audio(response['uuid'],stream_url=[stream_url]) ``` ### Stop streaming audio to a call ```python -from vonage import Client, Voice -client = Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') -voice = Voice(client) +client = vonage.Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = voice.create_call({ +response = client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.send_audio(response['uuid'],stream_url=[stream_url]) -voice.stop_audio(response['uuid']) +client.voice.send_audio(response['uuid'],stream_url=[stream_url]) +client.voice.stop_audio(response['uuid']) ``` ### Send a synthesized speech message to a call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -response = voice.create_call({ +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +response = client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.send_speech(response['uuid'], text='Hello from vonage') +client.voice.send_speech(response['uuid'], text='Hello from vonage') ``` ### Stop sending a synthesized speech message to a call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) -voice = Voice(client) -response = voice.create_call({ +client = vonage.Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) +response = client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.send_speech(response['uuid'], text='Hello from vonage') -voice.stop_speech(response['uuid']) +client.voice.send_speech(response['uuid'], text='Hello from vonage') +client.voice.stop_speech(response['uuid']) ``` ### Send DTMF tones to a call ```python -from vonage import Client, Voice -client = Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -voice = Voice(client) -response = voice.create_call({ +client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) +response = client.voice.create_call({ 'to': [{'type': 'phone', 'number': '14843331234'}], 'from': {'type': 'phone', 'number': '14843335555'}, 'answer_url': ['https://example.com/answer'] }) -voice.send_dtmf(response['uuid'], digits='1234') +client.voice.send_dtmf(response['uuid'], digits='1234') ``` ### Get recording @@ -266,45 +249,12 @@ response = client.get_recording(RECORDING_URL) ## Verify API -### Verify Class - -#### Creating an instance of the class - -To create an instance of the Verify class, Just follow the next steps: -​ - -- **Import the class from module** (3 different ways) - -```python -#First way -from vonage import Verify -​ -#Second way -from vonage.verify import Verify -​ -#Third valid way -import vonage #then you can use vonage.Verify() to create an instance -``` - -- **Create the instance** - ​ - -```python -#First way - pass key and secret to the constructor -verify = Verify(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -​ -#Second way - Create a client instance and then pass the client to the Verify contructor -client = Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -verify = Verify(client) -``` - ### Search for a Verification request ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.search('69e2626cbc23451fbbc02f627a959677') +response = client.verify.search('69e2626cbc23451fbbc02f627a959677') if response is not None: print(response['status']) @@ -313,10 +263,9 @@ if response is not None: ### Send verification code ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc') +response = client.verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc') if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) @@ -327,10 +276,9 @@ else: ### Send verification code with workflow ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +response = client.verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) if response["status"] == "0": print("Started verification request_id is %s" % (response["request_id"])) @@ -341,10 +289,9 @@ else: ### Check verification code ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.check(REQUEST_ID, code=CODE) +response = client.verify.check(REQUEST_ID, code=CODE) if response["status"] == "0": print("Verification successful, event_id is %s" % (response["event_id"])) @@ -355,10 +302,9 @@ else: ### Cancel Verification Request ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.cancel(REQUEST_ID) +response = client.verify.cancel(REQUEST_ID) if response["status"] == "0": print("Cancellation successful") @@ -369,10 +315,9 @@ else: ### Trigger next verification proccess ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.trigger_next_event(REQUEST_ID) +response = client.verify.trigger_next_event(REQUEST_ID) if response["status"] == "0": print("Next verification stage triggered") @@ -383,10 +328,9 @@ else: ### Send payment authentication code ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -response = verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +response = client.verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) @@ -397,10 +341,9 @@ else: ### Send payment authentication code with workflow ```python -client = Client(key='API_KEY', secret='API_SECRET') +client = vonage.Client(key='API_KEY', secret='API_SECRET') -verify = Verify(client) -verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +client.verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) if response["status"] == "0": print("Started PSD2 verification request_id is %s" % (response["request_id"])) @@ -413,7 +356,7 @@ else: ### Basic Number Insight ```python -client.get_basic_number_insight(number='447700900000') +client.number_insight.get_basic_number_insight(number='447700900000') ``` Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) @@ -421,7 +364,7 @@ Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](htt ### Standard Number Insight ```python -client.get_standard_number_insight(number='447700900000') +client.number_insight.get_standard_number_insight(number='447700900000') ``` Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) @@ -429,7 +372,7 @@ Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard]( ### Advanced Number Insight ```python -client.get_advanced_number_insight(number='447700900000') +client.number_insight.get_advanced_number_insight(number='447700900000') ``` Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) @@ -441,7 +384,7 @@ An API is provided to allow you to rotate your API secrets. You can create a new ### List Secrets ```python -secrets = client.list_secrets(API_KEY) +secrets = client.account.list_secrets(API_KEY) ``` ### Create A New Secret @@ -449,7 +392,7 @@ secrets = client.list_secrets(API_KEY) Create a new secret (the created dates will help you know which is which): ```python -client.create_secret(API_KEY, 'awes0meNewSekret!!;'); +client.account.create_secret(API_KEY, 'awes0meNewSekret!!;'); ``` ### Delete A Secret @@ -457,7 +400,7 @@ client.create_secret(API_KEY, 'awes0meNewSekret!!;'); Delete the old secret (any application still using these credentials will stop working): ```python -client.delete_secret(API_KEY, 'my-secret-id') +client.account.delete_secret(API_KEY, 'my-secret-id') ``` ## Application API From 49a09c0d43abce9d51073864ab0058f843f66a84 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 27 May 2022 12:13:04 +0100 Subject: [PATCH 162/401] added messages.py and message classes for Messages API implementation --- src/vonage/__init__.py | 4 ---- src/vonage/client.py | 23 +++++++++++-------- src/vonage/errors.py | 13 ++++++++++- src/vonage/message_classes.py | 42 +++++++++++++++++++++++++++++++++++ src/vonage/messages.py | 7 ++++++ src/vonage/voice.py | 2 +- tests/conftest.py | 8 ++++++- tests/test_messages.py | 24 ++++++++++++++++++++ 8 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 src/vonage/message_classes.py create mode 100644 src/vonage/messages.py create mode 100644 tests/test_messages.py diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 5b24f447..2b32e0fa 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,7 +1,3 @@ from .client import * -from .errors import * -from .voice import * -from .sms import * -from .verify import * __version__ = "2.7.0" diff --git a/src/vonage/client.py b/src/vonage/client.py index ad52cded..0e922c67 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,15 +1,19 @@ +import vonage + from ._internal import _format_date_param -from .account import * +from .account import Account from .application import ApplicationV2, BasicAuthenticatedServer from .errors import * -from .message_search import * +from .message_search import MessageSearch +from .message_classes import * +from .messages import Messages from .number_insight import NumberInsight -from .numbers import * -from .short_codes import * -from .sms import * -from .ussd import * -from .voice import * -from .verify import * +from .numbers import Numbers +from .short_codes import ShortCodes +from .sms import Sms +from .ussd import Ussd +from .voice import Voice +from .verify import Verify import logging from datetime import datetime @@ -129,9 +133,10 @@ def __init__( api_secret=self.api_secret, ) self.application_v2 = ApplicationV2(api_server) - + self.account = Account(self) self.message_search = MessageSearch(self) + self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) self.short_codes = ShortCodes(self) diff --git a/src/vonage/errors.py b/src/vonage/errors.py index d8f6a389..eb425fd4 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -17,4 +17,15 @@ class AuthenticationError(ClientError): class CallbackRequiredError(Error): """ Indicates a callback is required but was not present. - """ \ No newline at end of file + """ + +class MessagesApiError(Error): + """ + Indicates an error related to the Messages API. + """ + +class InvalidMessageTypeError(Error): + """ + Indicates that the supplied 'message_type' was invalid. + """ + diff --git a/src/vonage/message_classes.py b/src/vonage/message_classes.py new file mode 100644 index 00000000..bff68035 --- /dev/null +++ b/src/vonage/message_classes.py @@ -0,0 +1,42 @@ +from .errors import InvalidMessageTypeError + +class BaseMessage(object): + def __init__(self, to, sender, channel, message_type): + self.to = to + self.sender = sender + self.channel = channel + self.message_type = message_type + +class SmsMessage(BaseMessage): + valid_message_types = {'text'} + def __init__(self, message_type='text'): + if message_type not in self.valid_message_types: + raise InvalidMessageTypeError + +class MmsMessage(BaseMessage): + valid_message_types = {'image', 'vcard', 'audio', 'video'} + def __init__(self, message_type): + if message_type not in self.valid_message_types: + raise InvalidMessageTypeError + self.message_type = message_type + +class WhatsAppMessage(BaseMessage): + def __init__(self, message_type): + self.valid_message_types = {'text', 'image', 'audio', 'video', 'file', 'template', 'custom'} + if message_type not in self.valid_message_types: + raise InvalidMessageTypeError + self.message_type = message_type + +class MessengerMessage(BaseMessage): + def __init__(self, message_type): + self.valid_message_types = {'text', 'image', 'audio', 'video', 'file'} + if message_type not in self.valid_message_types: + raise InvalidMessageTypeError + self.message_type = message_type + +class ViberMessage(BaseMessage): + def __init__(self, message_type): + self.valid_message_types = {'text', 'image'} + if message_type not in self.valid_message_types: + raise InvalidMessageTypeError + self.message_type = message_type diff --git a/src/vonage/messages.py b/src/vonage/messages.py new file mode 100644 index 00000000..d53fca86 --- /dev/null +++ b/src/vonage/messages.py @@ -0,0 +1,7 @@ +class Messages: + def __init__(self, client): + self._client = client + + def send_message(self, params): + return self._client.post(self._client.api_host(), "/v1/messages", params, header_auth=True) + \ No newline at end of file diff --git a/src/vonage/voice.py b/src/vonage/voice.py index 144b10e4..4533defb 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -1,6 +1,6 @@ import vonage -class Voice(): +class Voice: #application_id and private_key are needed for the calling methods #Passing a Vonage Client is also possible def __init__( diff --git a/tests/conftest.py b/tests/conftest.py index 38bfc6e8..348dd4f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,4 +103,10 @@ def ussd(client): def short_codes(client): import vonage - return vonage.ShortCodes(client) \ No newline at end of file + return vonage.ShortCodes(client) + +@pytest.fixture +def messages(client): + import vonage + + return vonage.Messages(client) diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 00000000..44bf010c --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,24 @@ +from util import * +from vonage.errors import InvalidMessageTypeError +from vonage.message_classes import * + +def test_invalid_sms_message_type(): + with pytest.raises(InvalidMessageTypeError): + SmsMessage('image') + +def test_invalid_mms_message_type(): + with pytest.raises(InvalidMessageTypeError): + MmsMessage('text') + +def test_invalid_whatsapp_message_type(): + with pytest.raises(InvalidMessageTypeError): + WhatsAppMessage('vcard') + +def test_invalid_messenger_message_type(): + with pytest.raises(InvalidMessageTypeError): + MessengerMessage('template') + +def test_invalid_viber_message_type(): + with pytest.raises(InvalidMessageTypeError): + ViberMessage('audio') + From 4f779c1863b383a3252c9d385855506f02a18d55 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 29 May 2022 03:06:32 +0100 Subject: [PATCH 163/401] not using inheritance-based approach, adding input validation to Messages class, testing --- src/vonage/errors.py | 4 +-- src/vonage/message_classes.py | 42 ---------------------- src/vonage/messages.py | 68 +++++++++++++++++++++++++++++++++-- tests/test_messages.py | 47 ++++++++++++++++-------- 4 files changed, 99 insertions(+), 62 deletions(-) delete mode 100644 src/vonage/message_classes.py diff --git a/src/vonage/errors.py b/src/vonage/errors.py index eb425fd4..d56a8a05 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -19,9 +19,9 @@ class CallbackRequiredError(Error): Indicates a callback is required but was not present. """ -class MessagesApiError(Error): +class MessagesError(Error): """ - Indicates an error related to the Messages API. + Indicates an error related to the Messages class, that calls the Vonage Messages API. """ class InvalidMessageTypeError(Error): diff --git a/src/vonage/message_classes.py b/src/vonage/message_classes.py deleted file mode 100644 index bff68035..00000000 --- a/src/vonage/message_classes.py +++ /dev/null @@ -1,42 +0,0 @@ -from .errors import InvalidMessageTypeError - -class BaseMessage(object): - def __init__(self, to, sender, channel, message_type): - self.to = to - self.sender = sender - self.channel = channel - self.message_type = message_type - -class SmsMessage(BaseMessage): - valid_message_types = {'text'} - def __init__(self, message_type='text'): - if message_type not in self.valid_message_types: - raise InvalidMessageTypeError - -class MmsMessage(BaseMessage): - valid_message_types = {'image', 'vcard', 'audio', 'video'} - def __init__(self, message_type): - if message_type not in self.valid_message_types: - raise InvalidMessageTypeError - self.message_type = message_type - -class WhatsAppMessage(BaseMessage): - def __init__(self, message_type): - self.valid_message_types = {'text', 'image', 'audio', 'video', 'file', 'template', 'custom'} - if message_type not in self.valid_message_types: - raise InvalidMessageTypeError - self.message_type = message_type - -class MessengerMessage(BaseMessage): - def __init__(self, message_type): - self.valid_message_types = {'text', 'image', 'audio', 'video', 'file'} - if message_type not in self.valid_message_types: - raise InvalidMessageTypeError - self.message_type = message_type - -class ViberMessage(BaseMessage): - def __init__(self, message_type): - self.valid_message_types = {'text', 'image'} - if message_type not in self.valid_message_types: - raise InvalidMessageTypeError - self.message_type = message_type diff --git a/src/vonage/messages.py b/src/vonage/messages.py index d53fca86..c7103c5b 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -1,7 +1,69 @@ +import string +from .errors import MessagesError, InvalidMessageTypeError + +import re + class Messages: + valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber'} + valid_message_types = { + 'sms': {'text'}, + 'mms': {'image', 'vcard', 'audio', 'video'}, + 'whatsapp': {'text', 'image', 'audio', 'video', 'file', 'template', 'custom'}, + 'messenger': {'text', 'image', 'audio', 'video', 'file'}, + 'viber': {'text', 'image'} + } + def __init__(self, client): self._client = client - def send_message(self, params): - return self._client.post(self._client.api_host(), "/v1/messages", params, header_auth=True) - \ No newline at end of file + def send_message( + self, + channel=None, + message_type=None, + to=None, + frm=None, + opts=None + ): + + self._channel = channel + self._message_type = message_type + self._to = to + self._frm = frm + self._opts = opts + + self._validate_send_message_input() + + self._build_request_string() + + # return self._client.post(self._client.api_host(), "/v1/messages", header_auth=True) + + def _validate_send_message_input(self): + self._check_valid_message_channel() + self._check_valid_message_type() + self._check_valid_recipient() + self._check_sender_string() + + def _check_valid_message_channel(self): + if self._channel not in Messages.valid_message_channels: + raise MessagesError(f""" + '{self._channel}' is an invalid message channel. + Must be one of the following types: {self.valid_message_channels}' + """) + + def _check_valid_message_type(self): + if self._message_type not in self.valid_message_types[self._channel]: + raise InvalidMessageTypeError(f""" + "{self._message_type}" is not a valid message type for channel "{self._channel}". + Must be one of the following types: {self.valid_message_types[self._channel]} + """) + + def _check_valid_recipient(self): + if not re.search(r'^[1-9]\d{6,14}$', self._to): + raise MessagesError(f'Message recipient ("to={self._to}") details not in a valid format.') + + def _check_sender_string(self): + if not isinstance(self._frm, str) or self._frm == "": + raise MessagesError(f'Message sender ("frm={self._frm}") set incorrectly. Set a valid name for the sender.') + + def _build_request_string(self): + pass \ No newline at end of file diff --git a/tests/test_messages.py b/tests/test_messages.py index 44bf010c..b69eb077 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,24 +1,41 @@ from util import * -from vonage.errors import InvalidMessageTypeError +from vonage.errors import InvalidMessageTypeError, MessagesError from vonage.message_classes import * +from vonage.messages import Messages -def test_invalid_sms_message_type(): - with pytest.raises(InvalidMessageTypeError): - SmsMessage('image') +def test_invalid_message_channel(messages): + with pytest.raises(MessagesError): + messages.send_message(channel='carrier_pigeon') -def test_invalid_mms_message_type(): +def test_invalid_message_type(messages): with pytest.raises(InvalidMessageTypeError): - MmsMessage('text') + messages.send_message(channel='sms', message_type='video') -def test_invalid_whatsapp_message_type(): - with pytest.raises(InvalidMessageTypeError): - WhatsAppMessage('vcard') +def test_invalid_recipient(messages): + with pytest.raises(MessagesError): + messages.send_message(channel='sms', message_type='text', to='+441234567890') -def test_invalid_messenger_message_type(): - with pytest.raises(InvalidMessageTypeError): - MessengerMessage('template') +def test_invalid_sender(messages): + with pytest.raises(MessagesError): + messages.send_message(channel='sms', message_type='text', to='441234567890', frm=1234) -def test_invalid_viber_message_type(): - with pytest.raises(InvalidMessageTypeError): - ViberMessage('audio') +# def test_invalid_sms_message_type(): +# with pytest.raises(InvalidMessageTypeError): +# SmsMessage('image') + +# def test_invalid_mms_message_type(): +# with pytest.raises(InvalidMessageTypeError): +# MmsMessage('text') + +# def test_invalid_whatsapp_message_type(): +# with pytest.raises(InvalidMessageTypeError): +# WhatsAppMessage('vcard') + +# def test_invalid_messenger_message_type(): +# with pytest.raises(InvalidMessageTypeError): +# MessengerMessage('template') + +# def test_invalid_viber_message_type(): +# with pytest.raises(InvalidMessageTypeError): +# ViberMessage('audio') From 26663c597c77ee781df680c51afe3c089401e938 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 29 May 2022 03:15:48 +0100 Subject: [PATCH 164/401] removing references to message_classes module --- src/vonage/client.py | 1 - src/vonage/messages.py | 7 +++---- tests/test_messages.py | 23 ----------------------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index 0e922c67..91b8056b 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -5,7 +5,6 @@ from .application import ApplicationV2, BasicAuthenticatedServer from .errors import * from .message_search import MessageSearch -from .message_classes import * from .messages import Messages from .number_insight import NumberInsight from .numbers import Numbers diff --git a/src/vonage/messages.py b/src/vonage/messages.py index c7103c5b..7128d64a 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -1,4 +1,3 @@ -import string from .errors import MessagesError, InvalidMessageTypeError import re @@ -41,7 +40,7 @@ def _validate_send_message_input(self): self._check_valid_message_channel() self._check_valid_message_type() self._check_valid_recipient() - self._check_sender_string() + self._check_valid_sender() def _check_valid_message_channel(self): if self._channel not in Messages.valid_message_channels: @@ -61,9 +60,9 @@ def _check_valid_recipient(self): if not re.search(r'^[1-9]\d{6,14}$', self._to): raise MessagesError(f'Message recipient ("to={self._to}") details not in a valid format.') - def _check_sender_string(self): + def _check_valid_sender(self): if not isinstance(self._frm, str) or self._frm == "": - raise MessagesError(f'Message sender ("frm={self._frm}") set incorrectly. Set a valid name for the sender.') + raise MessagesError(f'Message sender ("frm={self._frm}") set incorrectly. Set a valid name or number for the sender.') def _build_request_string(self): pass \ No newline at end of file diff --git a/tests/test_messages.py b/tests/test_messages.py index b69eb077..fad8a743 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,7 +1,5 @@ from util import * from vonage.errors import InvalidMessageTypeError, MessagesError -from vonage.message_classes import * -from vonage.messages import Messages def test_invalid_message_channel(messages): with pytest.raises(MessagesError): @@ -18,24 +16,3 @@ def test_invalid_recipient(messages): def test_invalid_sender(messages): with pytest.raises(MessagesError): messages.send_message(channel='sms', message_type='text', to='441234567890', frm=1234) - -# def test_invalid_sms_message_type(): -# with pytest.raises(InvalidMessageTypeError): -# SmsMessage('image') - -# def test_invalid_mms_message_type(): -# with pytest.raises(InvalidMessageTypeError): -# MmsMessage('text') - -# def test_invalid_whatsapp_message_type(): -# with pytest.raises(InvalidMessageTypeError): -# WhatsAppMessage('vcard') - -# def test_invalid_messenger_message_type(): -# with pytest.raises(InvalidMessageTypeError): -# MessengerMessage('template') - -# def test_invalid_viber_message_type(): -# with pytest.raises(InvalidMessageTypeError): -# ViberMessage('audio') - From 66162291698470a49023788fbdd967199b3b5f17 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 29 May 2022 22:37:10 +0100 Subject: [PATCH 165/401] new messages validation test --- src/vonage/messages.py | 18 ++++++++++++++---- tests/test_messages.py | 10 +++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/vonage/messages.py b/src/vonage/messages.py index 7128d64a..95bc706b 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -21,14 +21,16 @@ def send_message( message_type=None, to=None, frm=None, - opts=None + client_ref=None, + message_contents=None ): self._channel = channel self._message_type = message_type self._to = to self._frm = frm - self._opts = opts + self._client_ref = client_ref + self._message_contents = message_contents self._validate_send_message_input() @@ -57,12 +59,20 @@ def _check_valid_message_type(self): """) def _check_valid_recipient(self): - if not re.search(r'^[1-9]\d{6,14}$', self._to): - raise MessagesError(f'Message recipient ("to={self._to}") details not in a valid format.') + if not isinstance(self._frm, str): + raise MessagesError(f'Message recipient ("to={self._to}") not in a valid format.') + elif self._channel != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', self._to): + raise MessagesError(f'Message recipient number ("to={self._to}") not in a valid format.') + elif not 0 < len(self._frm) < 50: + raise MessagesError(f'Message recipient ID ("to={self._to}") not in a valid format.') def _check_valid_sender(self): if not isinstance(self._frm, str) or self._frm == "": raise MessagesError(f'Message sender ("frm={self._frm}") set incorrectly. Set a valid name or number for the sender.') + + + + def _build_request_string(self): pass \ No newline at end of file diff --git a/tests/test_messages.py b/tests/test_messages.py index fad8a743..38edd935 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -9,10 +9,18 @@ def test_invalid_message_type(messages): with pytest.raises(InvalidMessageTypeError): messages.send_message(channel='sms', message_type='video') -def test_invalid_recipient(messages): +def test_invalid_recipient_not_string(messages): + with pytest.raises(MessagesError): + messages.send_message(channel='sms', message_type='text', to=441234567890) + +def test_invalid_recipient_number(messages): with pytest.raises(MessagesError): messages.send_message(channel='sms', message_type='text', to='+441234567890') +def test_invalid_messenger_recipient(messages): + with pytest.raises(MessagesError): + messages.send_message(channel='messenger', message_type='text', to='441234567890') + def test_invalid_sender(messages): with pytest.raises(MessagesError): messages.send_message(channel='sms', message_type='text', to='441234567890', frm=1234) From c8a99a748d4778f7d93ba9976b2cd5d88d8eee88 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 29 May 2022 23:07:25 +0100 Subject: [PATCH 166/401] Fixing typo in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a93f3e9..ad1e71d7 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,11 @@ client = vonage.Client(key="mykey", secret="mysecret") print(f"Account balance is: {client.account.get_balance()}") print("Sending an SMS") -client.sms.send_message( +client.sms.send_message({ "from": "Vonage", "to": "SOME_PHONE_NUMBER", "text": "Hello from Vonage's SMS API" -) +}) ``` This means you don't have to create a separate instance of each class to use its API methods. Instead, you can access class methods from the client instance with From ab2776282dc11fa569ba59178657fc2c3e3ca216 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 31 May 2022 03:08:16 +0100 Subject: [PATCH 167/401] now setting and validating instance attributes, more tests --- README.md | 2 + src/vonage/errors.py | 6 -- src/vonage/messages.py | 70 ++++++++------- tests/test_messages.py | 26 ------ tests/test_messages_validate_input.py | 123 ++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 62 deletions(-) delete mode 100644 tests/test_messages.py create mode 100644 tests/test_messages_validate_input.py diff --git a/README.md b/README.md index ad1e71d7..4cfab55f 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ To check signatures for incoming webhook requests, you'll also need to specify the `signature_secret` argument (or the `VONAGE_SIGNATURE_SECRET` environment variable). +To use the SDK to call Vonage APIs, pass in dicts with the required options to methods like `Sms.send_message()`. Examples of this are given below. + ## Simplified structure for calling API Methods The client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` diff --git a/src/vonage/errors.py b/src/vonage/errors.py index d56a8a05..3218329f 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -23,9 +23,3 @@ class MessagesError(Error): """ Indicates an error related to the Messages class, that calls the Vonage Messages API. """ - -class InvalidMessageTypeError(Error): - """ - Indicates that the supplied 'message_type' was invalid. - """ - diff --git a/src/vonage/messages.py b/src/vonage/messages.py index 95bc706b..c2fe176a 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -1,49 +1,62 @@ -from .errors import MessagesError, InvalidMessageTypeError +from .errors import MessagesError import re class Messages: - valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber'} + valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber_service'} valid_message_types = { 'sms': {'text'}, 'mms': {'image', 'vcard', 'audio', 'video'}, 'whatsapp': {'text', 'image', 'audio', 'video', 'file', 'template', 'custom'}, 'messenger': {'text', 'image', 'audio', 'video', 'file'}, - 'viber': {'text', 'image'} + 'viber_service': {'text', 'image'} } def __init__(self, client): self._client = client - def send_message( - self, - channel=None, - message_type=None, - to=None, - frm=None, - client_ref=None, - message_contents=None - ): - - self._channel = channel - self._message_type = message_type - self._to = to - self._frm = frm - self._client_ref = client_ref - self._message_contents = message_contents - + def send_message(self, params: dict): + self._set_instance_attributes(params) self._validate_send_message_input() self._build_request_string() # return self._client.post(self._client.api_host(), "/v1/messages", header_auth=True) + def _set_instance_attributes(self, params): + try: + self._channel = params['channel'] + self._message_type = params['message_type'] + self._to = params['to'] + self._from = params['from'] + + # Message specific checks + # e.g. for sms this will be an object called 'text': 'hello' + # and for mms will be something like 'image': {'url': 'myurl.com', 'caption' my photo'} + self._message = params[self._message_type] + + # Channel specific checks + if self._channel == 'whatsapp' and self._message_type == 'template': + self._whatsapp = params['whatsapp'] + if self._channel == 'messenger' and 'messenger' in params: + self._messenger = params['messenger'] + if self._channel == 'viber_service': + self._viber_service = params['viber_service'] + except (KeyError, TypeError): + raise MessagesError('You must specify all required properties for this channel and message type.') + + if 'client_ref' in params: + if len(params['client_ref']) <= 40: + self._client_ref = params['client_ref'] + else: + raise MessagesError('client_ref can be a maximum of 40 characters.') + def _validate_send_message_input(self): self._check_valid_message_channel() self._check_valid_message_type() self._check_valid_recipient() self._check_valid_sender() - + def _check_valid_message_channel(self): if self._channel not in Messages.valid_message_channels: raise MessagesError(f""" @@ -53,26 +66,23 @@ def _check_valid_message_channel(self): def _check_valid_message_type(self): if self._message_type not in self.valid_message_types[self._channel]: - raise InvalidMessageTypeError(f""" + raise MessagesError(f""" "{self._message_type}" is not a valid message type for channel "{self._channel}". Must be one of the following types: {self.valid_message_types[self._channel]} """) def _check_valid_recipient(self): - if not isinstance(self._frm, str): + if not isinstance(self._to, str): raise MessagesError(f'Message recipient ("to={self._to}") not in a valid format.') elif self._channel != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', self._to): raise MessagesError(f'Message recipient number ("to={self._to}") not in a valid format.') - elif not 0 < len(self._frm) < 50: + elif self._channel == 'messenger' and not 0 < len(self._to) < 50: raise MessagesError(f'Message recipient ID ("to={self._to}") not in a valid format.') def _check_valid_sender(self): - if not isinstance(self._frm, str) or self._frm == "": - raise MessagesError(f'Message sender ("frm={self._frm}") set incorrectly. Set a valid name or number for the sender.') - - - + if not isinstance(self._from, str) or self._from == "": + raise MessagesError(f'Message sender ("frm={self._from}") set incorrectly. Set a valid name or number for the sender.') def _build_request_string(self): - pass \ No newline at end of file + pass diff --git a/tests/test_messages.py b/tests/test_messages.py deleted file mode 100644 index 38edd935..00000000 --- a/tests/test_messages.py +++ /dev/null @@ -1,26 +0,0 @@ -from util import * -from vonage.errors import InvalidMessageTypeError, MessagesError - -def test_invalid_message_channel(messages): - with pytest.raises(MessagesError): - messages.send_message(channel='carrier_pigeon') - -def test_invalid_message_type(messages): - with pytest.raises(InvalidMessageTypeError): - messages.send_message(channel='sms', message_type='video') - -def test_invalid_recipient_not_string(messages): - with pytest.raises(MessagesError): - messages.send_message(channel='sms', message_type='text', to=441234567890) - -def test_invalid_recipient_number(messages): - with pytest.raises(MessagesError): - messages.send_message(channel='sms', message_type='text', to='+441234567890') - -def test_invalid_messenger_recipient(messages): - with pytest.raises(MessagesError): - messages.send_message(channel='messenger', message_type='text', to='441234567890') - -def test_invalid_sender(messages): - with pytest.raises(MessagesError): - messages.send_message(channel='sms', message_type='text', to='441234567890', frm=1234) diff --git a/tests/test_messages_validate_input.py b/tests/test_messages_validate_input.py new file mode 100644 index 00000000..43c39a64 --- /dev/null +++ b/tests/test_messages_validate_input.py @@ -0,0 +1,123 @@ +from util import * +from vonage.errors import MessagesError + +def test_invalid_send_message_params_object(messages): + with pytest.raises(MessagesError): + messages.send_message('hi') + +def test_invalid_message_channel(messages): + with pytest.raises(MessagesError): + messages.send_message({ + 'channel': 'carrier_pigeon', + 'message_type': 'text', + 'to': '12345678', + 'from': 'vonage', + 'text': 'my important message' + }) + +def test_invalid_message_type(messages): + with pytest.raises(MessagesError): + messages.send_message({ + 'channel': 'sms', + 'message_type': 'video', + 'to': '12345678', + 'from': 'vonage', + 'video': 'my_url.com' + }) + +def test_invalid_recipient_not_string(messages): + with pytest.raises(MessagesError): + messages.send_message({ + 'channel': 'sms', + 'message_type': 'text', + 'to': 12345678, + 'from': 'vonage', + 'text': 'my important message' + }) + +def test_invalid_recipient_number(messages): + with pytest.raises(MessagesError): + messages.send_message({ + 'channel': 'sms', + 'message_type': 'text', + 'to': '+441234567890', + 'from': 'vonage', + 'text': 'my important message' + }) + +def test_invalid_messenger_recipient(messages): + with pytest.raises(MessagesError): + messages.send_message({ + 'channel': 'messenger', + 'message_type': 'text', + 'to': '', + 'from': 'vonage', + 'text': 'my important message' + }) + +def test_invalid_sender(messages): + with pytest.raises(MessagesError): + messages.send_message({ + 'channel': 'sms', + 'message_type': 'text', + 'to': '441234567890', + 'from': 1234, + 'text': 'my important message' + }) + +def test_set_client_ref(messages): + messages._set_instance_attributes({ + 'channel': 'sms', + 'message_type': 'text', + 'to': '441234567890', + 'from': 'vonage', + 'text': 'my important message', + 'client_ref': 'my client reference' + }) + assert messages._client_ref == 'my client reference' + +def test_invalid_client_ref(messages): + with pytest.raises(MessagesError): + messages._set_instance_attributes({ + 'channel': 'sms', + 'message_type': 'text', + 'to': '441234567890', + 'from': 'vonage', + 'text': 'my important message', + 'client_ref': 'my client reference that is far longer than the 40 character limit' + }) + +def test_set_whatsapp_template(messages): + messages._set_instance_attributes({ + 'channel': 'whatsapp', + 'message_type': 'template', + 'to': '', + 'from': 'vonage', + 'template': {'name': 'namespace:mytemplate'}, + 'whatsapp': {'policy': 'deterministic', 'locale': 'en-GB'} + }) + + assert messages._channel == 'whatsapp' + assert messages._whatsapp == {'policy': 'deterministic', 'locale': 'en-GB'} + +def test_set_messenger_optional_attribute(messages): + messages._set_instance_attributes({ + 'channel': 'messenger', + 'message_type': 'text', + 'to': 'user_messenger_id', + 'from': 'vonage', + 'text': 'my important message', + 'messenger': {'category': 'response', 'tag': 'ACCOUNT_UPDATE'} + }) + assert messages._messenger == {'category': 'response', 'tag': 'ACCOUNT_UPDATE'} + +def test_set_viber_service_optional_attribute(messages): + messages._set_instance_attributes({ + 'channel': 'viber_service', + 'message_type': 'text', + 'to': '44123456789', + 'from': 'vonage', + 'text': 'my important message', + 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'text'} + }) + assert messages._viber_service == {'category': 'transaction', 'ttl': 30, 'type': 'text'} From 31f84ef033a2d86ce96f4cf202223e8639179931 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 13:55:49 +0100 Subject: [PATCH 168/401] tidied up messages api implementation and added tests --- README.md | 7 ++ src/vonage/client.py | 8 +- src/vonage/messages.py | 116 +++++++++++++------------- tests/test_messages_send_message.py | 38 +++++++++ tests/test_messages_validate_input.py | 31 ++++--- 5 files changed, 128 insertions(+), 72 deletions(-) create mode 100644 tests/test_messages_send_message.py diff --git a/README.md b/README.md index 4cfab55f..5b787ffa 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,13 @@ client.voice.send_dtmf(response['uuid'], digits='1234') response = client.get_recording(RECORDING_URL) ``` +## Messages API + +The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. + + + + ## Verify API ### Search for a Verification request diff --git a/src/vonage/client.py b/src/vonage/client.py index 91b8056b..01371d85 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -238,6 +238,7 @@ def post( params, supports_signature_auth=False, header_auth=False, + additional_headers=None ): """ Low-level method to make a post request to a Vonage API server, which may have a Nexmo url. @@ -249,7 +250,12 @@ def post( :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = f"https://{host}{request_uri}" - headers = self.headers + + if not additional_headers: + headers = {**self.headers} + else: + headers = {**self.headers, **additional_headers} + if supports_signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) diff --git a/src/vonage/messages.py b/src/vonage/messages.py index c2fe176a..e1c2e30e 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -1,6 +1,7 @@ from .errors import MessagesError import re +import json class Messages: valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber_service'} @@ -15,74 +16,73 @@ class Messages: def __init__(self, client): self._client = client - def send_message(self, params: dict): - self._set_instance_attributes(params) - self._validate_send_message_input() + def send_message(self, params: dict, header_auth=False): + self.validate_send_message_input(params) - self._build_request_string() + json_formatted_params = json.dumps(params) + if header_auth: # Using base64 encoded API key/secret pair + return self._client.post( + self._client.api_host(), + "/v1/messages", + json_formatted_params, + header_auth=header_auth, + additional_headers={'Content-Type': 'application/json'}) + else: # If using jwt auth + return self._client._jwt_signed_post( + "/v1/messages", + params) - # return self._client.post(self._client.api_host(), "/v1/messages", header_auth=True) - - def _set_instance_attributes(self, params): - try: - self._channel = params['channel'] - self._message_type = params['message_type'] - self._to = params['to'] - self._from = params['from'] - - # Message specific checks - # e.g. for sms this will be an object called 'text': 'hello' - # and for mms will be something like 'image': {'url': 'myurl.com', 'caption' my photo'} - self._message = params[self._message_type] - - # Channel specific checks - if self._channel == 'whatsapp' and self._message_type == 'template': - self._whatsapp = params['whatsapp'] - if self._channel == 'messenger' and 'messenger' in params: - self._messenger = params['messenger'] - if self._channel == 'viber_service': - self._viber_service = params['viber_service'] - except (KeyError, TypeError): - raise MessagesError('You must specify all required properties for this channel and message type.') - - if 'client_ref' in params: - if len(params['client_ref']) <= 40: - self._client_ref = params['client_ref'] - else: - raise MessagesError('client_ref can be a maximum of 40 characters.') - - def _validate_send_message_input(self): - self._check_valid_message_channel() - self._check_valid_message_type() - self._check_valid_recipient() - self._check_valid_sender() + def validate_send_message_input(self, params): + self._check_input_is_dict(params) + self._check_valid_message_channel(params) + self._check_valid_message_type(params) + self._check_valid_recipient(params) + self._check_valid_sender(params) + self._channel_specific_checks(params) + self._check_valid_client_ref(params) - def _check_valid_message_channel(self): - if self._channel not in Messages.valid_message_channels: + def _check_input_is_dict(self, params): + if type(params) is not dict: + raise MessagesError(f'Parameters to the send_message method must be specified as a dictionary.') + + def _check_valid_message_channel(self, params): + if params['channel'] not in Messages.valid_message_channels: raise MessagesError(f""" - '{self._channel}' is an invalid message channel. + '{params['channel']}' is an invalid message channel. Must be one of the following types: {self.valid_message_channels}' """) - def _check_valid_message_type(self): - if self._message_type not in self.valid_message_types[self._channel]: + def _check_valid_message_type(self, params): + if params['message_type'] not in self.valid_message_types[params['channel']]: raise MessagesError(f""" - "{self._message_type}" is not a valid message type for channel "{self._channel}". - Must be one of the following types: {self.valid_message_types[self._channel]} + "{params['message_type']}" is not a valid message type for channel "{params["channel"]}". + Must be one of the following types: {self.valid_message_types[params["channel"]]} """) - def _check_valid_recipient(self): - if not isinstance(self._to, str): - raise MessagesError(f'Message recipient ("to={self._to}") not in a valid format.') - elif self._channel != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', self._to): - raise MessagesError(f'Message recipient number ("to={self._to}") not in a valid format.') - elif self._channel == 'messenger' and not 0 < len(self._to) < 50: - raise MessagesError(f'Message recipient ID ("to={self._to}") not in a valid format.') + def _check_valid_recipient(self, params): + if not isinstance(params['to'], str): + raise MessagesError(f'Message recipient ("to={params["to"]}") not in a valid format.') + elif params['channel'] != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', params['to']): + raise MessagesError(f'Message recipient number ("to={params["to"]}") not in a valid format.') + elif params['channel'] == 'messenger' and not 0 < len(params['to']) < 50: + raise MessagesError(f'Message recipient ID ("to={params["to"]}") not in a valid format.') - def _check_valid_sender(self): - if not isinstance(self._from, str) or self._from == "": - raise MessagesError(f'Message sender ("frm={self._from}") set incorrectly. Set a valid name or number for the sender.') + def _check_valid_sender(self, params): + if not isinstance(params['from'], str) or params['from'] == "": + raise MessagesError(f'Message sender ("frm={params["from"]}") set incorrectly. Set a valid name or number for the sender.') + def _channel_specific_checks(self, params): + try: + if params['channel'] == 'whatsapp' and params['message_type'] == 'template': + params['whatsapp'] + if params['channel'] == 'viber_service': + params['viber_service'] + except (KeyError, TypeError): + raise MessagesError(f'''You must specify all required properties for message channel "{params["channel"]}".''') - def _build_request_string(self): - pass + def _check_valid_client_ref(self, params): + if 'client_ref' in params: + if len(params['client_ref']) <= 40: + self._client_ref = params['client_ref'] + else: + raise MessagesError('client_ref can be a maximum of 40 characters.') diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py new file mode 100644 index 00000000..8f493235 --- /dev/null +++ b/tests/test_messages_send_message.py @@ -0,0 +1,38 @@ +from util import * + +@responses.activate +def test_send_sms_with_messages_api(messages, dummy_data): + stub(responses.POST, 'https://api.nexmo.com/v1/messages') + + params = { + 'channel': 'sms', + 'message_type': 'text', + 'to': '447123456789', + 'from': 'Vonage', + 'text': 'Hello from Vonage' + } + + assert isinstance(messages.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert b'"from": "Vonage"' in request_body() + assert b'"to": "447123456789"' in request_body() + assert b'"text": "Hello from Vonage"' in request_body() + +@responses.activate +def test_send_whatsapp_image_with_messages_api(messages, dummy_data): + stub(responses.POST, 'https://api.nexmo.com/v1/messages') + + params = { + 'channel': 'whatsapp', + 'message_type': 'image', + 'to': '447123456789', + 'from': '440123456789', + 'image': {'url': 'https://example.com/image', 'caption': 'fake test image'} + } + + assert isinstance(messages.send_message(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert b'"from": "440123456789"' in request_body() + assert b'"to": "447123456789"' in request_body() + assert b'"image": {"url": "https://example.com/image", "caption": "fake test image"}' in request_body() + diff --git a/tests/test_messages_validate_input.py b/tests/test_messages_validate_input.py index 43c39a64..bcb25258 100644 --- a/tests/test_messages_validate_input.py +++ b/tests/test_messages_validate_input.py @@ -28,7 +28,7 @@ def test_invalid_message_type(messages): def test_invalid_recipient_not_string(messages): with pytest.raises(MessagesError): messages.send_message({ - 'channel': 'sms', + 'channel': 'sms', 'message_type': 'text', 'to': 12345678, 'from': 'vonage', @@ -66,7 +66,7 @@ def test_invalid_sender(messages): }) def test_set_client_ref(messages): - messages._set_instance_attributes({ + messages._check_valid_client_ref({ 'channel': 'sms', 'message_type': 'text', 'to': '441234567890', @@ -78,7 +78,7 @@ def test_set_client_ref(messages): def test_invalid_client_ref(messages): with pytest.raises(MessagesError): - messages._set_instance_attributes({ + messages._check_valid_client_ref({ 'channel': 'sms', 'message_type': 'text', 'to': '441234567890', @@ -87,21 +87,18 @@ def test_invalid_client_ref(messages): 'client_ref': 'my client reference that is far longer than the 40 character limit' }) -def test_set_whatsapp_template(messages): - messages._set_instance_attributes({ +def test_whatsapp_template(messages): + messages.validate_send_message_input({ 'channel': 'whatsapp', 'message_type': 'template', - 'to': '', + 'to': '4412345678912', 'from': 'vonage', 'template': {'name': 'namespace:mytemplate'}, 'whatsapp': {'policy': 'deterministic', 'locale': 'en-GB'} }) - assert messages._channel == 'whatsapp' - assert messages._whatsapp == {'policy': 'deterministic', 'locale': 'en-GB'} - def test_set_messenger_optional_attribute(messages): - messages._set_instance_attributes({ + messages.validate_send_message_input({ 'channel': 'messenger', 'message_type': 'text', 'to': 'user_messenger_id', @@ -109,10 +106,9 @@ def test_set_messenger_optional_attribute(messages): 'text': 'my important message', 'messenger': {'category': 'response', 'tag': 'ACCOUNT_UPDATE'} }) - assert messages._messenger == {'category': 'response', 'tag': 'ACCOUNT_UPDATE'} def test_set_viber_service_optional_attribute(messages): - messages._set_instance_attributes({ + messages.validate_send_message_input({ 'channel': 'viber_service', 'message_type': 'text', 'to': '44123456789', @@ -120,4 +116,13 @@ def test_set_viber_service_optional_attribute(messages): 'text': 'my important message', 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'text'} }) - assert messages._viber_service == {'category': 'transaction', 'ttl': 30, 'type': 'text'} + +def test_incomplete_input(messages): + with pytest.raises(MessagesError): + messages.validate_send_message_input({ + 'channel': 'viber_service', + 'message_type': 'text', + 'to': '44123456789', + 'from': 'vonage', + 'text': 'my important message' + }) From 65394521c3d3f2bd2f0fd4a82301fcb8f869a471 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 14:23:33 +0100 Subject: [PATCH 169/401] added messages api samples to readme, updated changelog --- CHANGES.md | 3 ++ README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3d66a75d..2667657f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 2.8.0 +- Added Messages API v1.0 support. Messages API can now be used by calling the client.messages.send_message() method. + # 2.7.0 - Moved some client methods into their own classes: `account.py, application.py, message_search.py, number_insight.py, numbers.py, short_codes.py, ussd.py` diff --git a/README.md b/README.md index 5b787ffa..daf11e65 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [Installation](#installation) - [Usage](#usage) - [SMS API](#sms-api) +- [Messages API](#messages-api) - [Voice API](#voice-api) - [Verify API](#verify-api) - [Number Insight API](#number-insight-api) @@ -95,6 +96,7 @@ client.CLASS_NAME.CLASS_METHOD ## SMS API +Although the Messages API adds more messaging channels, the SMS API is still supported. ### Send an SMS ```python @@ -140,6 +142,82 @@ response = client.sms.send_message({ client.sms.submit_sms_conversion(response['message-id']) ``` +## Messages API + +The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. Call the API from your Python code by +passing a dict of parameters into the `client.messages.send_message()` method. + +It accepts JWT or API key/secret authentication. + +Some basic samples are below. For more detailed information and code snippets, please visit the [Vonage Developer Documentation](https://developer.vonage.com). + +### Send an SMS +```python +responseData = client.messages.send_message({ + 'channel': 'sms', + 'message_type': 'text', + 'to': '447123456789', + 'from': 'Vonage', + 'text': 'Hello from Vonage' + }) +``` + +### Send an MMS +Note: only available in the US. You will need a 10DLC number to send an MMS message. + +```python +client.messages.send_message({ + 'channel': 'mms', + 'message_type': 'image', + 'to': '11112223333', + 'from': '1223345567', + 'image': {'url': 'https://example.com/image.jpg', 'caption': 'Test Image'} + }) +``` + +### Send an audio file via WhatsApp + +You will need a WhatsApp Business Account to use WhatsApp messaging. WhatsApp restrictions mean that you +must send a template message to a user if they have not previously messaged you, but you can send any message +type to a user if they have messaged your business number in the last 24 hours. + +```python +client.messages.send_message({ + 'channel': 'whatsapp', + 'message_type': 'audio', + 'to': '447123456789', + 'from': '440123456789', + 'audio': {'url': 'https://example.com/audio.mp3'} + }) +``` + +### Send a video file via Facebook Messenger + +You will need to link your Facebook business page to your Vonage account in the Vonage developer dashboard. (Click on the sidebar +"External Accounts" option to do this.) + +```python +client.messages.send_message({ + 'channel': 'messenger', + 'message_type': 'video', + 'to': '594123123123123', + 'from': '1012312312312', + 'video': {'url': 'https://example.com/video.mp4'} + }) +``` + +### Send a text message with Viber + +```python +client.messages.send_message({ + 'channel': 'viber_service', + 'message_type': 'text', + 'to': '447123456789', + 'from': '440123456789', + 'text': 'Hello from Vonage!' +}) +``` + ## Voice API ### Make a call @@ -249,12 +327,6 @@ client.voice.send_dtmf(response['uuid'], digits='1234') response = client.get_recording(RECORDING_URL) ``` -## Messages API - -The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. - - - ## Verify API @@ -518,12 +590,6 @@ client.api_host('myapi.vonage.com') # rewrite the value of api_host ## Frequently Asked Questions -### Dropping support for Python 2.7 - -Back in 2014 when Guido van Rossum, Python's creator and principal author, made the announcement, January 1, 2020 seemed pretty far away. Python 2.7’s sunset has happened, after which there’ll be absolutely no more support from the core Python team. Many utilized projects pledge to drop Python 2 support in or before 2020. [(Official statement here)](https://www.python.org/doc/sunset-python-2/). - -Just because 2.7 isn’t going to be maintained past 2020 doesn’t mean your applications or libraries suddenly stop working but as of this moment we won't give official support for upcoming releases. Please read the official ["Porting Python 2 Code to Python 3" guide](https://docs.python.org/3/howto/pyporting.html). Please also read the [Python 3 Statement Practicalities](https://python3statement.org/practicalities/) for advice on sunsetting your Python 2 code. - ### Supported APIs The following is a list of Vonage APIs and whether the Python SDK provides support for them: @@ -538,7 +604,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Dispatch API | Beta | ❌ | | External Accounts API | Beta | ❌ | | Media API | Beta | ❌ | -| Messages API | Beta | ❌ | +| Messages API | General Availability | ✅ | | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | From 646bf797290bdde725b577348e4e6fe71f248d39 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 14:29:07 +0100 Subject: [PATCH 170/401] wording --- src/vonage/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 3218329f..7d3ba1e9 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -21,5 +21,5 @@ class CallbackRequiredError(Error): class MessagesError(Error): """ - Indicates an error related to the Messages class, that calls the Vonage Messages API. + Indicates an error related to the Messages class which calls the Vonage Messages API. """ From 70ae5c35355080431fc6f24f2f06f744fa71eb20 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 15:31:07 +0100 Subject: [PATCH 171/401] Update tests/test_messages_send_message.py Co-authored-by: Karl Lingiah --- tests/test_messages_send_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py index 8f493235..9138f61f 100644 --- a/tests/test_messages_send_message.py +++ b/tests/test_messages_send_message.py @@ -27,7 +27,7 @@ def test_send_whatsapp_image_with_messages_api(messages, dummy_data): 'message_type': 'image', 'to': '447123456789', 'from': '440123456789', - 'image': {'url': 'https://example.com/image', 'caption': 'fake test image'} + 'image': {'url': 'https://example.com/image.jpg', 'caption': 'fake test image'} } assert isinstance(messages.send_message(params), dict) From d695c4ca798a6988780f905226446aa27804eaee Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 15:31:17 +0100 Subject: [PATCH 172/401] Update tests/test_messages_send_message.py Co-authored-by: Karl Lingiah --- tests/test_messages_send_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py index 9138f61f..268fd827 100644 --- a/tests/test_messages_send_message.py +++ b/tests/test_messages_send_message.py @@ -34,5 +34,5 @@ def test_send_whatsapp_image_with_messages_api(messages, dummy_data): assert request_user_agent() == dummy_data.user_agent assert b'"from": "440123456789"' in request_body() assert b'"to": "447123456789"' in request_body() - assert b'"image": {"url": "https://example.com/image", "caption": "fake test image"}' in request_body() + assert b'"image": {"url": "https://example.com/image.jpg", "caption": "fake test image"}' in request_body() From ad8d26f8c03fbe97bab17cbeeb528a9e942214e0 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 15:44:24 +0100 Subject: [PATCH 173/401] =?UTF-8?q?Bump=20version:=202.7.0=20=E2=86=92=202?= =?UTF-8?q?.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 24cd4f14..30122bcf 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.7.0 +current_version = 2.8.0 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index cb4addc4..1b8f03e1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.7.0" +version = "2.8.0" # The full version, including alpha/beta/rc tags. -release = "2.7.0" +release = "2.8.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.7.0' +# html_title = u'Vonage v2.8.0' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index b80ef54b..b029f964 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.7.0", + version="2.8.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 2b32e0fa..f34842ea 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "2.7.0" +__version__ = "2.8.0" From 33ed69913e6e1458d5e956103b59759851ef5a63 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 18:51:39 +0100 Subject: [PATCH 174/401] added dev requirements file and extra make target --- .gitignore | 1 + CHANGES.md | 2 +- Makefile | 17 ++++++++++------- requirements-dev.txt | 4 ++++ 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 requirements-dev.txt diff --git a/.gitignore b/.gitignore index d96fba92..6e57e0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,7 @@ ENV* /site .requirements.txt +.requirements-dev.txt *_quickstart* .DS_Store diff --git a/CHANGES.md b/CHANGES.md index 2667657f..49bf9dbd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,5 @@ # 2.8.0 -- Added Messages API v1.0 support. Messages API can now be used by calling the client.messages.send_message() method. +- Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. # 2.7.0 - Moved some client methods into their own classes: `account.py, application.py, diff --git a/Makefile b/Makefile index 5bad49a6..0919a1a3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,4 @@ -.PHONY: clean test build coverage install requirements release - -clean: - rm -rf dist build +.PHONY: clean test build coverage install release coverage: pytest -v --cov @@ -10,6 +7,9 @@ coverage: test: pytest -v +clean: + rm -rf dist build + build: python -m build @@ -18,9 +18,12 @@ release: install: requirements -requirements: .requirements.txt - -.requirements.txt: requirements.txt +requirements: requirements.txt python -m pip install --upgrade pip setuptools python -m pip install -r requirements.txt python -m pip freeze > .requirements.txt + +dev-requirements: requirements-dev.txt + python -m pip install --upgrade pip setuptools + python -m pip install -r requirements-dev.txt + python -m pip freeze > .requirements-dev.txt \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..c594d62a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +bump2version +build +twine \ No newline at end of file From 74659939ef49d4ba5c24d990c63e5b26d02ea2bb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jun 2022 19:52:59 +0100 Subject: [PATCH 175/401] removing requirements-dev --- .gitignore | 1 - .requirements-dev.txt | 47 +++++++++++++++++++++++++++++++++++++++++++ Makefile | 11 ++++------ requirements-dev.txt | 4 ---- requirements.txt | 6 +++++- 5 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 .requirements-dev.txt delete mode 100644 requirements-dev.txt diff --git a/.gitignore b/.gitignore index 6e57e0b7..d96fba92 100644 --- a/.gitignore +++ b/.gitignore @@ -102,7 +102,6 @@ ENV* /site .requirements.txt -.requirements-dev.txt *_quickstart* .DS_Store diff --git a/.requirements-dev.txt b/.requirements-dev.txt new file mode 100644 index 00000000..7ca6fcec --- /dev/null +++ b/.requirements-dev.txt @@ -0,0 +1,47 @@ +attrs==21.4.0 +bleach==5.0.1 +boltons==21.0.0 +build==0.8.0 +bump2version==1.0.1 +certifi==2022.6.15 +cffi==1.15.0 +charset-normalizer==2.1.0 +commonmark==0.9.1 +coverage==6.4.1 +coveralls==3.3.1 +cryptography==37.0.2 +Deprecated==1.2.13 +docopt==0.6.2 +docutils==0.18.1 +face==20.1.1 +glom==22.1.0 +idna==3.3 +importlib-metadata==4.12.0 +iniconfig==1.1.1 +keyring==23.6.0 +packaging==21.3 +pep517==0.12.0 +pkginfo==1.8.3 +pluggy==1.0.0 +py==1.11.0 +pycparser==2.21 +Pygments==2.12.0 +PyJWT==2.4.0 +pyparsing==3.0.9 +pytest==7.1.1 +pytest-cov==3.0.0 +pytz==2022.1 +readme-renderer==35.0 +requests==2.28.1 +requests-toolbelt==0.9.1 +responses==0.20.0 +rfc3986==2.0.0 +rich==12.4.4 +six==1.16.0 +tomli==2.0.1 +twine==4.0.1 +urllib3==1.26.9 +-e git+ssh://git@github.com/Vonage/vonage-python-sdk.git@5f98d04a08d9760554065401b0493d45de8df09f#egg=vonage +webencodings==0.5.1 +wrapt==1.14.1 +zipp==3.8.0 diff --git a/Makefile b/Makefile index 0919a1a3..341b0818 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean test build coverage install release +.PHONY: clean test build coverage install requirements release coverage: pytest -v --cov @@ -18,12 +18,9 @@ release: install: requirements -requirements: requirements.txt +requirements: .requirements.txt + +.requirements.txt: requirements.txt python -m pip install --upgrade pip setuptools python -m pip install -r requirements.txt python -m pip freeze > .requirements.txt - -dev-requirements: requirements-dev.txt - python -m pip install --upgrade pip setuptools - python -m pip install -r requirements-dev.txt - python -m pip freeze > .requirements-dev.txt \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index c594d62a..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ --r requirements.txt -bump2version -build -twine \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 543a3a3c..87122017 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,8 @@ pytest==7.1.1 pytest-cov==3.0.0 responses==0.20.0 coveralls -glom==22.1.0 \ No newline at end of file +glom==22.1.0 + +bump2version +build +twine \ No newline at end of file From 8965ed068c76d3d31e665107a19bbad7c53a1e2f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 3 Jul 2022 16:30:28 +0100 Subject: [PATCH 176/401] added test for async_advanced_number_insight() --- .pre-commit-config.yaml | 4 ++-- src/vonage/application.py | 2 -- src/vonage/messages.py | 2 +- tests/test_number_insight.py | 11 +++++++++++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f3393de..6fdd7365 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,9 @@ repos: rev: v2.5.4 hooks: - id: trailing-whitespace - language_version: python3.6 + language_version: python3.7 - repo: https://github.com/ambv/black rev: 18.6b4 hooks: - id: black - language_version: python3.6 + language_version: python3.7 diff --git a/src/vonage/application.py b/src/vonage/application.py index 4344be11..4c691627 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -5,8 +5,6 @@ from .errors import AuthenticationError, ClientError, ServerError -from deprecated import deprecated - try: from json import JSONDecodeError except ImportError: diff --git a/src/vonage/messages.py b/src/vonage/messages.py index e1c2e30e..8f962873 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -43,7 +43,7 @@ def validate_send_message_input(self, params): def _check_input_is_dict(self, params): if type(params) is not dict: - raise MessagesError(f'Parameters to the send_message method must be specified as a dictionary.') + raise MessagesError('Parameters to the send_message method must be specified as a dictionary.') def _check_valid_message_channel(self, params): if params['channel'] not in Messages.valid_message_channels: diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py index 5eb0946d..35e17730 100644 --- a/tests/test_number_insight.py +++ b/tests/test_number_insight.py @@ -81,3 +81,14 @@ def test_request_number_insight(number_insight, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "callback=https%3A%2F%2Fexample.com" in request_body() + +@responses.activate +def test_get_async_advanced_number_insight(number_insight, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") + + params = {"number": "447525856424", "callback": "https://example.com"} + + assert isinstance(number_insight.get_async_advanced_number_insight(params), dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_query() + assert "callback=https%3A%2F%2Fexample.com" in request_query() From 8cd4543928d18af074aec0b332edaf4b003c065f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 4 Jul 2022 12:58:24 +0100 Subject: [PATCH 177/401] removed unneeded requirements output file --- .requirements-dev.txt | 47 ------------------------------------------- src/vonage/numbers.py | 2 -- 2 files changed, 49 deletions(-) delete mode 100644 .requirements-dev.txt diff --git a/.requirements-dev.txt b/.requirements-dev.txt deleted file mode 100644 index 7ca6fcec..00000000 --- a/.requirements-dev.txt +++ /dev/null @@ -1,47 +0,0 @@ -attrs==21.4.0 -bleach==5.0.1 -boltons==21.0.0 -build==0.8.0 -bump2version==1.0.1 -certifi==2022.6.15 -cffi==1.15.0 -charset-normalizer==2.1.0 -commonmark==0.9.1 -coverage==6.4.1 -coveralls==3.3.1 -cryptography==37.0.2 -Deprecated==1.2.13 -docopt==0.6.2 -docutils==0.18.1 -face==20.1.1 -glom==22.1.0 -idna==3.3 -importlib-metadata==4.12.0 -iniconfig==1.1.1 -keyring==23.6.0 -packaging==21.3 -pep517==0.12.0 -pkginfo==1.8.3 -pluggy==1.0.0 -py==1.11.0 -pycparser==2.21 -Pygments==2.12.0 -PyJWT==2.4.0 -pyparsing==3.0.9 -pytest==7.1.1 -pytest-cov==3.0.0 -pytz==2022.1 -readme-renderer==35.0 -requests==2.28.1 -requests-toolbelt==0.9.1 -responses==0.20.0 -rfc3986==2.0.0 -rich==12.4.4 -six==1.16.0 -tomli==2.0.1 -twine==4.0.1 -urllib3==1.26.9 --e git+ssh://git@github.com/Vonage/vonage-python-sdk.git@5f98d04a08d9760554065401b0493d45de8df09f#egg=vonage -webencodings==0.5.1 -wrapt==1.14.1 -zipp==3.8.0 diff --git a/src/vonage/numbers.py b/src/vonage/numbers.py index 14b736f5..8221180e 100644 --- a/src/vonage/numbers.py +++ b/src/vonage/numbers.py @@ -1,5 +1,3 @@ -import vonage - class Numbers: def __init__(self, client): self._client = client From 80aa6dd04bb0e8cdb6d0da52be8c8f11659a619d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 4 Jul 2022 14:20:20 +0100 Subject: [PATCH 178/401] removed automatic client creation when creating an Sms, Voice or Verify object --- src/vonage/sms.py | 22 ++-------------------- src/vonage/verify.py | 11 ++--------- src/vonage/voice.py | 17 ++--------------- 3 files changed, 6 insertions(+), 44 deletions(-) diff --git a/src/vonage/sms.py b/src/vonage/sms.py index 5882bc14..6efee0ea 100644 --- a/src/vonage/sms.py +++ b/src/vonage/sms.py @@ -3,26 +3,8 @@ from ._internal import _format_date_param class Sms: - #To init Sms class pass a client reference or a key and secret - def __init__( - self, - client=None, - key=None, - secret=None, - signature_secret=None, - signature_method=None - ): - try: - self._client = client - if self._client is None: - self._client = vonage.Client( - key=key, - secret=secret, - signature_secret=signature_secret, - signature_method=signature_method - ) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client def send_message(self, params): """ diff --git a/src/vonage/verify.py b/src/vonage/verify.py index ded81482..b1863b93 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -1,13 +1,6 @@ -import vonage - class Verify: - def __init__(self, client=None, key=None, secret=None): - try: - self._client = client - if self._client is None: - self._client = vonage.Client(key=key, secret=secret) - except Exception as e: - print(f"Error: {str(e)}") + def __init__(self, client): + self._client = client def start_verification(self, params=None, **kwargs): return self._client.post( diff --git a/src/vonage/voice.py b/src/vonage/voice.py index 4533defb..4c3a1177 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -1,21 +1,8 @@ -import vonage - class Voice: #application_id and private_key are needed for the calling methods #Passing a Vonage Client is also possible - def __init__( - self, - client=None, - application_id=None, - private_key=None, - ): - try: - # Client is protected - self._client = client - if self._client is None: - self._client = vonage.Client(application_id=application_id, private_key=private_key) - except Exception as e: - print(f'Error: {str(e)}') + def __init__(self, client): + self._client = client # Creates a new call session def create_call(self, params, **kwargs): From defaaf20734d820db66ee73f56c48cfe14d57241 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 4 Jul 2022 14:22:53 +0100 Subject: [PATCH 179/401] removed the retired Message Search API calls, started WIP changelog --- CHANGES.md | 4 ++ src/vonage/client.py | 2 - src/vonage/message_search.py | 12 ------ tests/conftest.py | 6 --- tests/test_message_search.py | 76 ------------------------------------ 5 files changed, 4 insertions(+), 96 deletions(-) delete mode 100644 src/vonage/message_search.py delete mode 100644 tests/test_message_search.py diff --git a/CHANGES.md b/CHANGES.md index 49bf9dbd..f9cc71a3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# 3.0.0 (Unreleased, WIP) +- Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object +- Removed methods to call the Message Search API, which has been retired by Vonage + # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. diff --git a/src/vonage/client.py b/src/vonage/client.py index 01371d85..effb81bf 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -4,7 +4,6 @@ from .account import Account from .application import ApplicationV2, BasicAuthenticatedServer from .errors import * -from .message_search import MessageSearch from .messages import Messages from .number_insight import NumberInsight from .numbers import Numbers @@ -134,7 +133,6 @@ def __init__( self.application_v2 = ApplicationV2(api_server) self.account = Account(self) - self.message_search = MessageSearch(self) self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) diff --git a/src/vonage/message_search.py b/src/vonage/message_search.py deleted file mode 100644 index 13fe0fe9..00000000 --- a/src/vonage/message_search.py +++ /dev/null @@ -1,12 +0,0 @@ -class MessageSearch: - def __init__(self, client): - self._client = client - - def get_message(self, message_id): - return self._client.get(self._client.host(), "/search/message", {"id": message_id}) - - def search_messages(self, params=None, **kwargs): - return self._client.get(self._client.host(), "/search/messages", params or kwargs) - - def get_message_rejections(self, params=None, **kwargs): - return self._client.get(self._client.host(), "/search/rejections", params or kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 348dd4f1..eb75d62d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,12 +87,6 @@ def numbers(client): return vonage.Numbers(client) -@pytest.fixture -def message_search(client): - import vonage - - return vonage.MessageSearch(client) - @pytest.fixture def ussd(client): import vonage diff --git a/tests/test_message_search.py b/tests/test_message_search.py deleted file mode 100644 index 11463422..00000000 --- a/tests/test_message_search.py +++ /dev/null @@ -1,76 +0,0 @@ -from util import * - - -@responses.activate -def test_deprecated_get_message(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/message") - - assert isinstance(client.get_message("00A0B0C0"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "id=00A0B0C0" in request_query() - -@responses.activate -def test_get_message(message_search, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/message") - - assert isinstance(message_search.get_message("00A0B0C0"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "id=00A0B0C0" in request_query() - -@responses.activate -def test_deprecated_search_messages(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance(client.search_messages(to="1234567890", date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - assert "to=1234567890" in request_query() - -@responses.activate -def test_search_messages(message_search, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance(message_search.search_messages(to="1234567890", date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - assert "to=1234567890" in request_query() - -@responses.activate -def test_deprecated_search_messages_by_ids(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance( - client.search_messages(ids=["00A0B0C0", "00A0B0C1", "00A0B0C2"]), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "ids=00A0B0C0" in request_query() - assert "ids=00A0B0C1" in request_query() - assert "ids=00A0B0C2" in request_query() - -@responses.activate -def test_search_messages_by_ids(message_search, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance( - message_search.search_messages(ids=["00A0B0C0", "00A0B0C1", "00A0B0C2"]), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "ids=00A0B0C0" in request_query() - assert "ids=00A0B0C1" in request_query() - assert "ids=00A0B0C2" in request_query() - -@responses.activate -def test_deprecated_get_message_rejections(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/rejections") - - assert isinstance(client.get_message_rejections(date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - -@responses.activate -def test_get_message_rejections(message_search, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/rejections") - - assert isinstance(message_search.get_message_rejections(date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() From 96c1375252912e749bf74a344fc902e8c83e980d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 16:03:19 +0100 Subject: [PATCH 180/401] added check for allowed pricing type, added get_all_countries_pricing, tests --- src/vonage/account.py | 30 +++++++++++++++++++++++------- src/vonage/errors.py | 5 +++++ tests/test_account.py | 28 +++++++++++++++++++++------- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/vonage/account.py b/src/vonage/account.py index 64ffdcb2..94b15304 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -1,4 +1,8 @@ +from .errors import PricingTypeError + class Account: + allowed_pricing_types = {'sms', 'sms-transit', 'voice'} + def __init__(self, client): self._client = client @@ -8,22 +12,30 @@ def get_balance(self): def topup(self, params=None, **kwargs): return self._client.post(self._client.host(), "/account/top-up", params or kwargs) - def get_country_pricing(self, country_code): + def get_country_pricing(self, country_code: str, type: str = 'sms'): + self.check_allowed_pricing_type(type) + return self._client.get( + self._client.host(), f"/account/get-pricing/outbound/{type}", {"country": country_code} + ) + + def get_all_countries_pricing(self, type: str = 'sms'): + self.check_allowed_pricing_type(type) return self._client.get( - self._client.host(), "/account/get-pricing/outbound", {"country": country_code} + self._client.host(), f"/account/get-full-pricing/outbound/{type}" ) - def get_prefix_pricing(self, prefix): + def get_prefix_pricing(self, prefix: str, type: str = 'sms'): + self.check_allowed_pricing_type(type) return self._client.get( - self._client.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} + self._client.host(), f"/account/get-prefix-pricing/outbound/{type}", {"prefix": prefix} ) - def get_sms_pricing(self, number): + def get_sms_pricing(self, number: str): return self._client.get( self._client.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} ) - def get_voice_pricing(self, number): + def get_voice_pricing(self, number: str): return self._client.get( self._client.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} ) @@ -31,7 +43,7 @@ def get_voice_pricing(self, number): def update_default_sms_webhook(self, params=None, **kwargs): return self._client.post(self._client.host(), "/account/settings", params or kwargs) - def list_secrets(self, api_key): + def get_all_secrets(self, api_key): return self._client.get( self._client.api_host(), f"/accounts/{api_key}/secrets", @@ -57,3 +69,7 @@ def revoke_secret(self, api_key, secret_id): f"/accounts/{api_key}/secrets/{secret_id}", header_auth=True, ) + + def check_allowed_pricing_type(self, type): + if type not in self.allowed_pricing_types: + raise PricingTypeError('Invalid pricing type specified.') diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 7d3ba1e9..b1a6de6d 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -23,3 +23,8 @@ class MessagesError(Error): """ Indicates an error related to the Messages class which calls the Vonage Messages API. """ + +class PricingTypeError(Error): + """ + A pricing type was specified that is not allowed. + """ \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index c97ed46e..c0ed56d6 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -5,6 +5,7 @@ from util import * import vonage +from vonage.errors import PricingTypeError @responses.activate @@ -49,12 +50,20 @@ def test_deprecated_get_country_pricing(client, dummy_data): @responses.activate def test_get_country_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") + stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound/sms") assert isinstance(account.get_country_pricing("GB"), dict) assert request_user_agent() == dummy_data.user_agent assert "country=GB" in request_query() +@responses.activate +def test_get_all_countries_pricing(account, dummy_data): + stub(responses.GET, "https://rest.nexmo.com/account/get-full-pricing/outbound/sms") + + assert isinstance(account.get_all_countries_pricing(), dict) + assert request_user_agent() == dummy_data.user_agent + + @responses.activate def test_deprecated_get_prefix_pricing(client, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") @@ -65,7 +74,7 @@ def test_deprecated_get_prefix_pricing(client, dummy_data): @responses.activate def test_get_prefix_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") + stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound/sms") assert isinstance(account.get_prefix_pricing(44), dict) assert request_user_agent() == dummy_data.user_agent @@ -107,6 +116,11 @@ def test_get_voice_pricing(account, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "phone=447525856424" in request_query() +@responses.activate +def test_invalid_pricing_type_throws_error(account, dummy_data): + with pytest.raises(PricingTypeError): + account.get_country_pricing('GB', 'not_a_valid_pricing_type') + @responses.activate def test_deprecated_update_settings(client, dummy_data): stub(responses.POST, "https://rest.nexmo.com/account/settings") @@ -164,14 +178,14 @@ def test_deprecated_list_secrets(client): ) @responses.activate -def test_list_secrets(account): +def test_get_all_secrets(account): stub( responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", + "https://api.nexmo.com/accounts/myaccountid/secrets", fixture_path="account/secret_management/list.json", ) - secrets = account.list_secrets("meaccountid") + secrets = account.get_all_secrets("myaccountid") assert_basic_auth() assert ( glom(secrets, "_embedded.secrets.0.id") @@ -195,7 +209,7 @@ def test_deprecated_list_secrets_missing(client): ) @responses.activate -def test_list_secrets_missing(account): +def test_get_all_secrets_missing(account): stub( responses.GET, "https://api.nexmo.com/accounts/meaccountid/secrets", @@ -204,7 +218,7 @@ def test_list_secrets_missing(account): ) with pytest.raises(vonage.ClientError) as ce: - account.list_secrets("meaccountid") + account.get_all_secrets("meaccountid") assert_basic_auth() assert ( str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" From 46dd964b05ba83fa96fffee6ed31ea7216cb5962 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 16:11:05 +0100 Subject: [PATCH 181/401] update changes.md --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index f9cc71a3..ebe41489 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,8 @@ # 3.0.0 (Unreleased, WIP) - Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object - Removed methods to call the Message Search API, which has been retired by Vonage +- Added `get_all_countries_pricing` method to `Account` object +- Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. From 50851ec2f19a134addbe8acee8e6cb13b43836b1 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 17:29:25 +0100 Subject: [PATCH 182/401] removing deprecated methods that call the voice, application, number insight, sms and verify apis --- src/vonage/client.py | 265 +-------------------------------- src/vonage/voice.py | 20 +-- tests/test_applications.py | 58 -------- tests/test_nexmo.py | 17 --- tests/test_number_insight.py | 43 ------ tests/test_sms.py | 39 ----- tests/test_verify.py | 113 -------------- tests/test_voice.py | 146 ++---------------- tests/test_voice_deprecated.py | 44 ------ 9 files changed, 17 insertions(+), 728 deletions(-) delete mode 100644 tests/test_applications.py delete mode 100644 tests/test_voice_deprecated.py diff --git a/src/vonage/client.py b/src/vonage/client.py index effb81bf..b514e46d 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -32,7 +32,6 @@ string_types = (str, bytes) -from urllib.parse import urlparse try: from json import JSONDecodeError @@ -165,19 +164,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - def initiate_call(self, params=None, **kwargs): - return self.post(self.host(), "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host(), "/tts-prompt/json", params or kwargs) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - def redact_transaction(self, id, product, type=None): params = {"id": id, "product": product} if type is not None: @@ -418,250 +404,7 @@ def generate_application_jwt(self, when=None): ######################################################### ######################################################### - # SMS API - @deprecated( - reason="vonage.Client#send_message is deprecated. Use Sms#send_message instead" - ) - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :: - client.send_message({ - "to": MY_CELLPHONE, - "from": MY_VONAGE_NUMBER, - "text": "Hello From Vonage!", - }) - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self.post(self.host(), "/sms/json", params, supports_signature_auth=True) - - - # Verfiy API - @deprecated( - reason="vonage.Client#start_verification is deprecated. Use Verify#start_verification instead" - ) - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#send_verification_request is deprecated (use Verify#start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/json", params or kwargs) - - @deprecated( - reason="vonage.Client#check_verification is deprecated. Use Verify#check instead" - ) - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#check_verification_request is deprecated (use Verify#check instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/check/json", params or kwargs) - - @deprecated( - reason="vonage.Client#start_psd2_verification_request is deprecated. Use Verify#psd2 instead" - ) - def start_psd2_verification_request(self, params=None, **kwargs): - return self.post(self.api_host(), "/verify/psd2/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_verification is deprecated. Use Verify#search instead" - ) - def get_verification(self, request_id): - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "vonage.Client#get_verification_request is deprecated (use Verify#search instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host(), "/verify/search/json", {"request_id": request_id} - ) - - @deprecated( - reason="vonage.Client#cancel_verification is deprecated. Use Verify#cancel instead" - ) - def cancel_verification(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - @deprecated( - reason="vonage.Client#trigger_next_verification_event is deprecated. Use Verify#trigger_next_event instead" - ) - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host(), "/verify/control/json", params or kwargs) - - # Application API - def get_applications(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_applications is deprecated (use v2 methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host(), "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "vonage.Client#get_application is deprecated (use v2 methods from #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host(), - f"/v1/applications/{application_id}", - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#create_application is deprecated (use methods from v2 #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host(), "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "vonage.Client#update_application is deprecated (use methods from v2 #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host(), - f"/v1/applications/{application_id}", - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "vonage.Client#delete_application is deprecated (use methods from v2 #application instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host(), - f"/v1/applications/{application_id}" - ) - - # Voice API - @deprecated( - reason="vonage.Client#create_call is deprecated. Use Voice#create_call instead" - ) - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_calls is deprecated. Use Voice#get_calls instead" - ) - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - @deprecated( - reason="vonage.Client#get_call is deprecated. Use Voice#get_call instead" - ) - def get_call(self, uuid): - return self._jwt_signed_get(f"/v1/calls/{uuid}") - - @deprecated( - reason="vonage.Client#update_call is deprecated. Use Voice#update_call instead" - ) - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}", params or kwargs - ) - - @deprecated( - reason="vonage.Client#send_audio is deprecated. Use Voice#send_audio instead" - ) - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/stream", params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_audio is deprecated. Use Voice#stop_audio instead" - ) - def stop_audio(self, uuid): - return self._jwt_signed_delete(f"/v1/calls/{uuid}/stream") - - @deprecated( - reason="vonage.Client#send_speech is deprecated. Use Voice#send_speech instead" - ) - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/talk", params or kwargs - ) - - @deprecated( - reason="vonage.Client#stop_speech is deprecated. Use Voice#stop_speech instead" - ) - def stop_speech(self, uuid): - return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") - - @deprecated( - reason="vonage.Client#send_dtmf is deprecated. Use Voice#send_dtmf instead" - ) - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/dtmf", params or kwargs - ) - - # Number Insight API - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "vonage.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host(), "/number/lookup/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_basic_number_insight is deprecated. Use NumberInsight#get_basic_number_insight instead" - ) - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/basic/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_standard_number_insight is deprecated. Use NumberInsight#get_standard_number_insight instead" - ) - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/standard/json", params or kwargs) - + @deprecated( reason="vonage.Client#get_async_advanced_number_insight is deprecated. Use NumberInsight#get_async_advanced_number_insight instead" ) @@ -676,12 +419,6 @@ def get_async_advanced_number_insight(self, params=None, **kwargs): "Error: Callback needed for async advanced number insight" ) - @deprecated( - reason="vonage.Client#get_advanced_number_insight is deprecated. Use NumberInsight#get_advanced_number_insight instead" - ) - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host(), "/ni/advanced/json", params or kwargs) - @deprecated( reason="vonage.Client#request_number_insight is deprecated. Use NumberInsight#request_number_insight instead" ) diff --git a/src/vonage/voice.py b/src/vonage/voice.py index 4c3a1177..8a742f00 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -1,6 +1,6 @@ +from urllib.parse import urlparse + class Voice: - #application_id and private_key are needed for the calling methods - #Passing a Vonage Client is also possible def __init__(self, client): self._client = client @@ -12,7 +12,7 @@ def create_call(self, params, **kwargs): from the pool of numbers available to the application making the call. - :param params is a dictionry that holds the 'from' and 'random_from_number' + :param params is a dictionary that holds the 'from' and 'random_from_number' """ if not params: @@ -64,18 +64,12 @@ def stop_audio(self, uuid): # Stop a speech recently played into specified call def stop_speech(self, uuid): return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") - - # Deprecated section - # This methods are deprecated, to use them a definition of client with key and secret parameters is mandatory - def initiate_call(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/call/json", params or kwargs) - def initiate_tts_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts/json", params or kwargs) + def get_recording(self, url): + hostname = urlparse(url).hostname + return self._client.parse(hostname, self._client.session.get(url, headers=self._client._headers())) + - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self._client.post(self._client.api_host(), "/tts-prompt/json", params or kwargs) - # End deprecated section # Utils methods # _jwt_signed_post private method that Allows developer perform signed post request diff --git a/tests/test_applications.py b/tests/test_applications.py deleted file mode 100644 index 9e760b3a..00000000 --- a/tests/test_applications.py +++ /dev/null @@ -1,58 +0,0 @@ -from util import * - - -@responses.activate -def test_get_applications(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications") - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.get_applications(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_get_application(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications/xx-xx-xx-xx") - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.get_application("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_create_application(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - - params = {"name": "Example App", "type": "voice"} - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.create_application(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "name=Example+App" in request_body() - assert "type=voice" in request_body() - - -@responses.activate -def test_update_application(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications/xx-xx-xx-xx") - - params = {"answer_url": "https://example.com/ncco"} - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.update_application("xx-xx-xx-xx", params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b'"answer_url": "https://example.com/ncco"' in request_body() - - -@responses.activate -def test_delete_application(client, dummy_data): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v1/applications/xx-xx-xx-xx", - status=204, - ) - - with pytest.warns(DeprecationWarning) as warning_info: - assert client.delete_application("xx-xx-xx-xx") is None - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index 0d1d3b88..d57e9c8a 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -1,7 +1,6 @@ import vonage from util import * -bytes_type = bytes @responses.activate def test_deprecated_send_ussd_push_message(client, dummy_data): @@ -189,19 +188,3 @@ def test_client_can_make_application_requests_without_api_key(dummy_data): client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) voice = vonage.Voice(client) voice.create_call("123455") - - -@responses.activate -def test_get_recording(client, dummy_data): - stub_bytes( - responses.GET, - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", - ) - - assert isinstance( - client.get_recording( - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957" - ), - bytes_type, - ) - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py index 35e17730..38df208a 100644 --- a/tests/test_number_insight.py +++ b/tests/test_number_insight.py @@ -1,14 +1,6 @@ from util import * -@responses.activate -def test_deprecated_get_basic_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/basic/json") - - assert isinstance(client.get_basic_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - @responses.activate def test_get_basic_number_insight(number_insight, dummy_data): stub(responses.GET, "https://api.nexmo.com/ni/basic/json") @@ -18,14 +10,6 @@ def test_get_basic_number_insight(number_insight, dummy_data): assert "number=447525856424" in request_query() -@responses.activate -def test_deprecated_get_standard_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/standard/json") - - assert isinstance(client.get_standard_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - @responses.activate def test_get_standard_number_insight(number_insight, dummy_data): stub(responses.GET, "https://api.nexmo.com/ni/standard/json") @@ -34,22 +18,6 @@ def test_get_standard_number_insight(number_insight, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_query() -@responses.activate -def test_deprecated_get_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/number/lookup/json") - - assert isinstance(client.get_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_deprecated_get_advanced_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") - - assert isinstance(client.get_advanced_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() @responses.activate def test_get_advanced_number_insight(number_insight, dummy_data): @@ -60,17 +28,6 @@ def test_get_advanced_number_insight(number_insight, dummy_data): assert "number=447525856424" in request_query() -@responses.activate -def test_deprecated_request_number_insight(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ni/json") - - params = {"number": "447525856424", "callback": "https://example.com"} - - assert isinstance(client.request_number_insight(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "callback=https%3A%2F%2Fexample.com" in request_body() - @responses.activate def test_request_number_insight(number_insight, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ni/json") diff --git a/tests/test_sms.py b/tests/test_sms.py index 9584c068..e6819eac 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -52,45 +52,6 @@ def test_submit_sms_conversion(sms): assert "timestamp" in request_body() -@responses.activate -def test_deprecated_send_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(client.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_deprecated_authentication_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(vonage.AuthenticationError): - client.send_message({}) - - -@responses.activate -def test_deprecated_client_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(vonage.ClientError) as excinfo: - client.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_deprecated_server_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(vonage.ServerError) as excinfo: - client.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - @responses.activate def test_deprecated_submit_sms_conversion(client): responses.add( diff --git a/tests/test_verify.py b/tests/test_verify.py index cdf70298..e0e54100 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,6 +1,5 @@ from util import * - @responses.activate def test_start_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/json") @@ -62,115 +61,3 @@ def test_start_psd2_verification(verify, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_start_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_deprecated_check_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_get_verification(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_deprecated_cancel_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_trigger_next_verification_event(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_deprecated_start_psd2_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_psd2_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() diff --git a/tests/test_voice.py b/tests/test_voice.py index 4b23882a..fdaf3064 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -167,146 +167,18 @@ def test_authorization_with_private_key_object(voice, dummy_data): ) assert token["application_id"] == dummy_data.application_id - @responses.activate -def test_deprecated_create_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(client.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_deprecated_get_calls(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(client.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_get_call(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_deprecated_update_call(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_deprecated_send_audio(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") +def test_get_recording(voice, dummy_data): + stub_bytes( + responses.GET, + "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", + ) assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, + voice.get_recording( + "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957" + ), + bytes, ) assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_deprecated_stop_audio(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_speech(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - -@responses.activate -def test_deprecated_stop_speech(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_send_dtmf(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_deprecated_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_deprecated_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_deprecated_authorization_with_private_key_object(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) - assert token["application_id"] == dummy_data.application_id diff --git a/tests/test_voice_deprecated.py b/tests/test_voice_deprecated.py deleted file mode 100644 index 83709f6a..00000000 --- a/tests/test_voice_deprecated.py +++ /dev/null @@ -1,44 +0,0 @@ -from util import * - - -@responses.activate -def test_initiate_call(voice, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/call/json") - - params = {"to": "16365553226", "answer_url": "http://example.com/answer"} - - assert isinstance(voice.initiate_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "answer_url=http%3A%2F%2Fexample.com%2Fanswer" in request_body() - - -@responses.activate -def test_initiate_tts_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/tts/json") - - params = {"to": "16365553226", "text": "Hello"} - - assert isinstance(voice.initiate_tts_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_initiate_tts_prompt_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/tts-prompt/json") - - params = { - "to": "16365553226", - "text": "Hello", - "max_digits": 4, - "bye_text": "Goodbye", - } - - assert isinstance(voice.initiate_tts_prompt_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "text=Hello" in request_body() - assert "max_digits=4" in request_body() - assert "bye_text=Goodbye" in request_body() From 1087b24d7b7604f731290bbc1c71e804ef1ad578 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 18:00:49 +0100 Subject: [PATCH 183/401] removing deprecated account and number insight methods --- src/vonage/client.py | 110 --------------------- src/vonage/number_insight.py | 4 +- tests/test_account.py | 179 ++--------------------------------- tests/test_number_insight.py | 11 --- 4 files changed, 9 insertions(+), 295 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index b514e46d..cb3e3b46 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -404,117 +404,7 @@ def generate_application_jwt(self, when=None): ######################################################### ######################################################### - - @deprecated( - reason="vonage.Client#get_async_advanced_number_insight is deprecated. Use NumberInsight#get_async_advanced_number_insight instead" - ) - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if "callback" in argoparams: - return self.get( - self.api_host(), "/ni/advanced/async/json", params or kwargs - ) - else: - raise ClientError( - "Error: Callback needed for async advanced number insight" - ) - - @deprecated( - reason="vonage.Client#request_number_insight is deprecated. Use NumberInsight#request_number_insight instead" - ) - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host(), "/ni/json", params or kwargs) - # Account API - @deprecated( - reason="vonage.Client#get_balance is deprecated. Use Account#get_balance instead" - ) - def get_balance(self): - return self.get(self.host(), "/account/get-balance") - - @deprecated( - reason="vonage.Client#get_country_pricing is deprecated. Use Account#get_country_pricing instead" - ) - def get_country_pricing(self, country_code): - return self.get( - self.host(), "/account/get-pricing/outbound", {"country": country_code} - ) - - @deprecated( - reason="vonage.Client#get_prefix_pricing is deprecated. Use Account#get_prefix_pricing instead" - ) - def get_prefix_pricing(self, prefix): - return self.get( - self.host(), "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - @deprecated( - reason="vonage.Client#get_sms_pricing is deprecated. Use Account#get_sms_pricing instead" - ) - def get_sms_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - @deprecated( - reason="vonage.Client#get_voice_pricing is deprecated. Use Account#get_voice_pricing instead" - ) - def get_voice_pricing(self, number): - return self.get( - self.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - @deprecated( - reason="vonage.Client#update_settings is deprecated. Use Account#update_default_sms_webhook instead" - ) - def update_settings(self, params=None, **kwargs): - return self.post(self.host(), "/account/settings", params or kwargs) - - @deprecated( - reason="vonage.Client#topup is deprecated. Use Account#topup instead" - ) - def topup(self, params=None, **kwargs): - return self.post(self.host(), "/account/top-up", params or kwargs) - - @deprecated( - reason="vonage.Client#list_secrets is deprecated. Use Account#list_secrets instead" - ) - def list_secrets(self, api_key): - return self.get( - self.api_host(), - f"/accounts/{api_key}/secrets", - header_auth=True, - ) - - @deprecated( - reason="vonage.Client#get_secret is deprecated. Use Account#get_secret instead" - ) - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True, - ) - - @deprecated( - reason="vonage.Client#create_secret is deprecated. Use Account#create_secret instead" - ) - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host(), f"/accounts/{api_key}/secrets", body - ) - - @deprecated( - reason="vonage.Client#delete_secret is deprecated. Use Account#revoke_secret instead" - ) - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True, - ) - # Numbers API @deprecated( reason="vonage.Client#get_account_numbers is deprecated. Use Numbers#get_account_numbers instead" diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py index 8245b223..d5a6811a 100644 --- a/src/vonage/number_insight.py +++ b/src/vonage/number_insight.py @@ -23,6 +23,4 @@ def get_async_advanced_number_insight(self, params=None, **kwargs): raise CallbackRequiredError( "A callback is needed for async advanced number insight" ) - - def request_number_insight(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/ni/json", params or kwargs) \ No newline at end of file + \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py index c0ed56d6..5d951711 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -8,13 +8,6 @@ from vonage.errors import PricingTypeError -@responses.activate -def test_deprecated_get_balance(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == dummy_data.user_agent - @responses.activate def test_get_balance(account, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-balance") @@ -22,6 +15,7 @@ def test_get_balance(account, dummy_data): assert isinstance(account.get_balance(), dict) assert request_user_agent() == dummy_data.user_agent + @responses.activate def test_application_info_options(dummy_data): app_name, app_version = "ExampleApp", "X.Y.Z" @@ -36,18 +30,11 @@ def test_application_info_options(dummy_data): ) user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()} {app_name}/{app_version}" - assert isinstance(client.get_balance(), dict) + account = client.account + assert isinstance(account.get_balance(), dict) assert request_user_agent() == user_agent -@responses.activate -def test_deprecated_get_country_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") - - assert isinstance(client.get_country_pricing("GB"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=GB" in request_query() - @responses.activate def test_get_country_pricing(account, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound/sms") @@ -56,6 +43,7 @@ def test_get_country_pricing(account, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "country=GB" in request_query() + @responses.activate def test_get_all_countries_pricing(account, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-full-pricing/outbound/sms") @@ -64,14 +52,6 @@ def test_get_all_countries_pricing(account, dummy_data): assert request_user_agent() == dummy_data.user_agent -@responses.activate -def test_deprecated_get_prefix_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") - - assert isinstance(client.get_prefix_pricing(44), dict) - assert request_user_agent() == dummy_data.user_agent - assert "prefix=44" in request_query() - @responses.activate def test_get_prefix_pricing(account, dummy_data): stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound/sms") @@ -80,13 +60,6 @@ def test_get_prefix_pricing(account, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "prefix=44" in request_query() -@responses.activate -def test_deprecated_get_sms_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") - - assert isinstance(client.get_sms_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() @responses.activate def test_get_sms_pricing(account, dummy_data): @@ -96,15 +69,6 @@ def test_get_sms_pricing(account, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "phone=447525856424" in request_query() -@responses.activate -def test_deprecated_get_voice_pricing(client, dummy_data): - stub( - responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" - ) - - assert isinstance(client.get_voice_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() @responses.activate def test_get_voice_pricing(account, dummy_data): @@ -116,20 +80,12 @@ def test_get_voice_pricing(account, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "phone=447525856424" in request_query() + @responses.activate def test_invalid_pricing_type_throws_error(account, dummy_data): with pytest.raises(PricingTypeError): account.get_country_pricing('GB', 'not_a_valid_pricing_type') -@responses.activate -def test_deprecated_update_settings(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/settings") - - params = {"moCallBackUrl": "http://example.com/callback"} - - assert isinstance(client.update_settings(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() @responses.activate def test_update_default_sms_webhook(account, dummy_data): @@ -141,15 +97,6 @@ def test_update_default_sms_webhook(account, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() -@responses.activate -def test_deprecated_topup(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/top-up") - - params = {"trx": "00X123456Y7890123Z"} - - assert isinstance(client.topup(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "trx=00X123456Y7890123Z" in request_body() @responses.activate def test_topup(account, dummy_data): @@ -162,21 +109,6 @@ def test_topup(account, dummy_data): assert "trx=00X123456Y7890123Z" in request_body() -@responses.activate -def test_deprecated_list_secrets(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/list.json", - ) - - secrets = client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - glom(secrets, "_embedded.secrets.0.id") - == "ad6dc56f-07b5-46e1-a527-85530e625800" - ) - @responses.activate def test_get_all_secrets(account): stub( @@ -192,49 +124,23 @@ def test_get_all_secrets(account): == "ad6dc56f-07b5-46e1-a527-85530e625800" ) -@responses.activate -def test_deprecated_list_secrets_missing(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=404, - fixture_path="account/secret_management/missing.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - ) @responses.activate def test_get_all_secrets_missing(account): stub( responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", + "https://api.nexmo.com/accounts/myaccountid/secrets", status_code=404, fixture_path="account/secret_management/missing.json", ) with pytest.raises(vonage.ClientError) as ce: - account.get_all_secrets("meaccountid") + account.get_all_secrets("myaccountid") assert_basic_auth() assert ( str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" ) -@responses.activate -def test_deprecated_get_secret(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - fixture_path="account/secret_management/get.json", - ) - - secret = client.get_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" @responses.activate def test_get_secret(account): @@ -249,51 +155,6 @@ def test_get_secret(account): assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" -@responses.activate -def test_deprecated_create_secret(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/create.json", - ) - - secret = client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_deprecated_create_secret_max_secrets(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=403, - fixture_path="account/secret_management/max-secrets.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - ) - -@responses.activate -def test_deprecated_create_secret_validation(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=400, - fixture_path="account/secret_management/create-validation.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - ) - @responses.activate def test_create_secret(account): stub( @@ -323,6 +184,7 @@ def test_create_secret_max_secrets(account): str(ce.value) == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" ) + @responses.activate def test_create_secret_validation(account): stub( @@ -339,30 +201,6 @@ def test_create_secret_validation(account): str(ce.value) == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" ) -@responses.activate -def test_deprecated_delete_secret(client): - stub( - responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" - ) - - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - - -@responses.activate -def test_deprecated_delete_secret_last_secret(client): - stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - status_code=403, - fixture_path="account/secret_management/last-secret.json", - ) - with pytest.raises(vonage.ClientError) as ce: - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - ) @responses.activate def test_delete_secret(account): @@ -388,4 +226,3 @@ def test_delete_secret_last_secret(account): assert ( str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" ) - diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py index 38df208a..d13834f1 100644 --- a/tests/test_number_insight.py +++ b/tests/test_number_insight.py @@ -28,17 +28,6 @@ def test_get_advanced_number_insight(number_insight, dummy_data): assert "number=447525856424" in request_query() -@responses.activate -def test_request_number_insight(number_insight, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ni/json") - - params = {"number": "447525856424", "callback": "https://example.com"} - - assert isinstance(number_insight.request_number_insight(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "callback=https%3A%2F%2Fexample.com" in request_body() - @responses.activate def test_get_async_advanced_number_insight(number_insight, dummy_data): stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") From 1be23b8c5047b071399b9825ed5f1e29bb4436ce Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 18:16:25 +0100 Subject: [PATCH 184/401] removed deprecated methods in client.py relating to sms_conversion, numbers api, short codes api and ussd api --- CHANGES.md | 2 + src/vonage/client.py | 131 -------------------------------------- tests/test_nexmo.py | 88 ------------------------- tests/test_numbers.py | 46 ------------- tests/test_short_codes.py | 1 + tests/test_sms.py | 11 ---- 6 files changed, 3 insertions(+), 276 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ebe41489..103ccd8e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,8 @@ - Removed methods to call the Message Search API, which has been retired by Vonage - Added `get_all_countries_pricing` method to `Account` object - Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen +- Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py` +- Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. diff --git a/src/vonage/client.py b/src/vonage/client.py index cb3e3b46..1c81d536 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -396,134 +396,3 @@ def generate_application_jwt(self, when=None): token = bytes(token, 'utf-8') return token - - - - # Deprecated methods that will be removed soon - ######################################################### - ######################################################### - ######################################################### - - - # Numbers API - @deprecated( - reason="vonage.Client#get_account_numbers is deprecated. Use Numbers#get_account_numbers instead" - ) - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host(), "/account/numbers", params or kwargs) - - @deprecated( - reason="vonage.Client#get_available_numbers is deprecated. Use Numbers#get_available_numbers instead" - ) - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host(), "/number/search", dict(params or kwargs, country=country_code) - ) - - @deprecated( - reason="vonage.Client#buy_number is deprecated. Use Numbers#buy_number instead" - ) - def buy_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/buy", params or kwargs) - - @deprecated( - reason="vonage.Client#cancel_number is deprecated. Use Numbers#cancel_number instead" - ) - def cancel_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/cancel", params or kwargs) - - @deprecated( - reason="vonage.Client#update_number is deprecated. Use Numbers#update_number instead" - ) - def update_number(self, params=None, **kwargs): - return self.post(self.host(), "/number/update", params or kwargs) - - # Message Search API - @deprecated( - reason="vonage.Client#get_message is deprecated. Use MessageSearch#get_message instead" - ) - def get_message(self, message_id): - return self.get(self.host(), "/search/message", {"id": message_id}) - - @deprecated( - reason="vonage.Client#search_messages is deprecated. Use MessageSearch#search_messages instead" - ) - def search_messages(self, params=None, **kwargs): - return self.get(self.host(), "/search/messages", params or kwargs) - - @deprecated( - reason="vonage.Client#get_message_rejections is deprecated. Use MessageSearch#get_message_rejections instead" - ) - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host(), "/search/rejections", params or kwargs) - - # SMS Conversion API - @deprecated( - reason="vonage.Client#submit_sms_conversion is deprecated. Use Sms#submit_sms_conversion instead" - ) - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Vonage that an SMS was successfully received. - - If you are using the Verify API for 2FA, this information is sent to Vonage automatically - so you do not need to use this method to submit conversion data about 2FA messages. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host(), "/conversions/sms", params) - - # Ussd API - @deprecated( - reason="vonage.Client#send_ussd_push_message is deprecated. Use Ussd#send_ussd_push_message instead" - ) - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd/json", params or kwargs) - - @deprecated( - reason="vonage.Client#send_ussd_prompt_message is deprecated. Use Ussd#send_ussd_prompt_message instead" - ) - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host(), "/ussd-prompt/json", params or kwargs) - - # Short Codes API - @deprecated( - reason="vonage.Client#send_2fa_message is deprecated. Use ShortCodes#send_2fa_message instead" - ) - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/2fa/json", params or kwargs) - - @deprecated( - reason="vonage.Client#send_event_alert_message is deprecated. Use ShortCodes#send_event_alert_message instead" - ) - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/alert/json", params or kwargs) - - @deprecated( - reason="vonage.Client#send_marketing_message is deprecated. Use ShortCodes#send_marketing_message instead" - ) - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host(), "/sc/us/marketing/json", params or kwargs) - - @deprecated( - reason="vonage.Client#get_event_alert_numbers is deprecated. Use ShortCodes#get_event_alert_numbers instead" - ) - def get_event_alert_numbers(self): - return self.get(self.host(), "/sc/us/alert/opt-in/query/json") - - @deprecated( - reason="vonage.Client#resubscribe_event_alert_number is deprecated. Use ShortCodes#resubscribe_event_alert_number instead" - ) - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post( - self.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs - ) diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py index d57e9c8a..b7885a52 100644 --- a/tests/test_nexmo.py +++ b/tests/test_nexmo.py @@ -2,94 +2,6 @@ from util import * -@responses.activate -def test_deprecated_send_ussd_push_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd/json") - - params = {"from": "MyCompany20", "to": "447525856424", "text": "Hello"} - - assert isinstance(client.send_ussd_push_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=MyCompany20" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() - -@responses.activate -def test_deprecated_send_ussd_prompt_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") - - params = {"from": "long-virtual-number", "to": "447525856424", "text": "Hello"} - - assert isinstance(client.send_ussd_prompt_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=long-virtual-number" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_deprecated_send_2fa_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") - - params = {"to": "16365553226", "pin": "1234"} - - assert isinstance(client.send_2fa_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "pin=1234" in request_body() - - -@responses.activate -def test_deprecated_send_event_alert_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/json") - - params = {"to": "16365553226", "server": "host", "link": "http://example.com/"} - - assert isinstance(client.send_event_alert_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "server=host" in request_body() - assert "link=http%3A%2F%2Fexample.com%2F" in request_body() - - -@responses.activate -def test_deprecated_send_marketing_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/marketing/json") - - params = { - "from": "short-code", - "to": "16365553226", - "keyword": "NEXMO", - "text": "Hello", - } - - assert isinstance(client.send_marketing_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=short-code" in request_body() - assert "to=16365553226" in request_body() - assert "keyword=NEXMO" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_deprecated_get_event_alert_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/sc/us/alert/opt-in/query/json") - - assert isinstance(client.get_event_alert_numbers(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_resubscribe_event_alert_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/opt-in/manage/json") - - params = {"msisdn": "441632960960"} - - assert isinstance(client.resubscribe_event_alert_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "msisdn=441632960960" in request_body() - - def test_check_signature(dummy_data): params = { "a": "1", diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 02e9fc67..f774be3c 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -1,12 +1,5 @@ from util import * -@responses.activate -def test_deprecated_get_account_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") - - assert isinstance(client.get_account_numbers(size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] @responses.activate def test_get_account_numbers(numbers, dummy_data): @@ -16,14 +9,6 @@ def test_get_account_numbers(numbers, dummy_data): assert request_user_agent() == dummy_data.user_agent assert request_params()["size"] == ["25"] -@responses.activate -def test_deprecated_get_available_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/number/search") - - assert isinstance(client.get_available_numbers("CA", size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=CA" in request_query() - assert "size=25" in request_query() @responses.activate def test_get_available_numbers(numbers, dummy_data): @@ -34,16 +19,6 @@ def test_get_available_numbers(numbers, dummy_data): assert "country=CA" in request_query() assert "size=25" in request_query() -@responses.activate -def test_deprecated_buy_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/buy") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(client.buy_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() @responses.activate def test_buy_number(numbers, dummy_data): @@ -56,16 +31,6 @@ def test_buy_number(numbers, dummy_data): assert "country=US" in request_body() assert "msisdn=number" in request_body() -@responses.activate -def test_deprecated_cancel_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/cancel") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(client.cancel_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() @responses.activate def test_cancel_number(numbers, dummy_data): @@ -78,17 +43,6 @@ def test_cancel_number(numbers, dummy_data): assert "country=US" in request_body() assert "msisdn=number" in request_body() -@responses.activate -def test_deprecated_update_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/update") - - params = {"country": "US", "msisdn": "number", "moHttpUrl": "callback"} - - assert isinstance(client.update_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - assert "moHttpUrl=callback" in request_body() @responses.activate def test_update_number(numbers, dummy_data): diff --git a/tests/test_short_codes.py b/tests/test_short_codes.py index 6abf751b..1a6b04ea 100644 --- a/tests/test_short_codes.py +++ b/tests/test_short_codes.py @@ -1,5 +1,6 @@ from util import * + @responses.activate def test_send_2fa_message(short_codes, dummy_data): stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") diff --git a/tests/test_sms.py b/tests/test_sms.py index e6819eac..b02a651a 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -50,14 +50,3 @@ def test_submit_sms_conversion(sms): sms.submit_sms_conversion("a-message-id") assert "message-id=a-message-id" in request_body() assert "timestamp" in request_body() - - -@responses.activate -def test_deprecated_submit_sms_conversion(client): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - client.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() From 5e1c2135acc2725166ef1693c467da7997a82b63 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 18:57:56 +0100 Subject: [PATCH 185/401] added new redact.py module and Redact class, new check of input to redact_transaction, added test --- CHANGES.md | 1 + src/vonage/account.py | 8 ++++---- src/vonage/client.py | 14 ++------------ src/vonage/errors.py | 7 +++++++ src/vonage/redact.py | 20 ++++++++++++++++++++ tests/conftest.py | 6 ++++++ tests/test_account.py | 3 +-- tests/test_redact.py | 13 +++++++++---- 8 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 src/vonage/redact.py diff --git a/CHANGES.md b/CHANGES.md index 103ccd8e..0398c262 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ - Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen - Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py` - Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs +- Added new `redact.py` module and `Redact` class, moved `redact_transaction()` method from client into new redact class # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. diff --git a/src/vonage/account.py b/src/vonage/account.py index 94b15304..ea497f4b 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -13,19 +13,19 @@ def topup(self, params=None, **kwargs): return self._client.post(self._client.host(), "/account/top-up", params or kwargs) def get_country_pricing(self, country_code: str, type: str = 'sms'): - self.check_allowed_pricing_type(type) + self._check_allowed_pricing_type(type) return self._client.get( self._client.host(), f"/account/get-pricing/outbound/{type}", {"country": country_code} ) def get_all_countries_pricing(self, type: str = 'sms'): - self.check_allowed_pricing_type(type) + self._check_allowed_pricing_type(type) return self._client.get( self._client.host(), f"/account/get-full-pricing/outbound/{type}" ) def get_prefix_pricing(self, prefix: str, type: str = 'sms'): - self.check_allowed_pricing_type(type) + self._check_allowed_pricing_type(type) return self._client.get( self._client.host(), f"/account/get-prefix-pricing/outbound/{type}", {"prefix": prefix} ) @@ -70,6 +70,6 @@ def revoke_secret(self, api_key, secret_id): header_auth=True, ) - def check_allowed_pricing_type(self, type): + def _check_allowed_pricing_type(self, type): if type not in self.allowed_pricing_types: raise PricingTypeError('Invalid pricing type specified.') diff --git a/src/vonage/client.py b/src/vonage/client.py index 1c81d536..d9b355a1 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,12 +1,12 @@ import vonage -from ._internal import _format_date_param from .account import Account from .application import ApplicationV2, BasicAuthenticatedServer from .errors import * from .messages import Messages from .number_insight import NumberInsight from .numbers import Numbers +from .redact import Redact from .short_codes import ShortCodes from .sms import Sms from .ussd import Ussd @@ -14,7 +14,6 @@ from .verify import Verify import logging -from datetime import datetime from platform import python_version import base64 @@ -22,14 +21,10 @@ import hmac import jwt import os -import pytz import requests import time from uuid import uuid4 -import warnings import re -from deprecated import deprecated - string_types = (str, bytes) @@ -135,6 +130,7 @@ def __init__( self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) + self.redact = Redact(self) self.short_codes = ShortCodes(self) self.sms = Sms(self) self.ussd = Ussd(self) @@ -164,12 +160,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self.auth_params = params or kwargs - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host(), "/v1/redact/transaction", params) - def check_signature(self, params): params = dict(params) signature = params.pop("sig", "").lower() diff --git a/src/vonage/errors.py b/src/vonage/errors.py index b1a6de6d..1c9a942a 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -19,12 +19,19 @@ class CallbackRequiredError(Error): Indicates a callback is required but was not present. """ + class MessagesError(Error): """ Indicates an error related to the Messages class which calls the Vonage Messages API. """ + class PricingTypeError(Error): """ A pricing type was specified that is not allowed. + """ + +class RedactError(Error): + """ + Error related to the Redact class or Redact API. """ \ No newline at end of file diff --git a/src/vonage/redact.py b/src/vonage/redact.py new file mode 100644 index 00000000..209dd524 --- /dev/null +++ b/src/vonage/redact.py @@ -0,0 +1,20 @@ +from .errors import RedactError + +class Redact: + allowed_product_names = {'sms', 'voice', 'number-insight', 'verify', 'verify-sdk', 'messages'} + + def __init__(self, client): + self._client = client + + def redact_transaction(self, id: str, product: str, type=None): + self._check_allowed_product_name(product) + params = {"id": id, "product": product} + if type is not None: + params["type"] = type + return self._client._post_json(self._client.api_host(), "/v1/redact/transaction", params) + + def _check_allowed_product_name(self, product): + if product not in self.allowed_product_names: + raise RedactError( + f'Invalid product name in redact request. Must be one of {self.allowed_product_names}.' + ) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index eb75d62d..fa2ad329 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -104,3 +104,9 @@ def messages(client): import vonage return vonage.Messages(client) + +@pytest.fixture +def redact(client): + import vonage + + return vonage.Redact(client) diff --git a/tests/test_account.py b/tests/test_account.py index 5d951711..49fa5f8f 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -81,8 +81,7 @@ def test_get_voice_pricing(account, dummy_data): assert "phone=447525856424" in request_query() -@responses.activate -def test_invalid_pricing_type_throws_error(account, dummy_data): +def test_invalid_pricing_type_throws_error(account): with pytest.raises(PricingTypeError): account.get_country_pricing('GB', 'not_a_valid_pricing_type') diff --git a/tests/test_redact.py b/tests/test_redact.py index 06e6c5ca..834f89f6 100644 --- a/tests/test_redact.py +++ b/tests/test_redact.py @@ -1,8 +1,13 @@ from util import * +from vonage.errors import RedactError +def test_redact_invalid_product_name(redact): + with pytest.raises(RedactError): + redact.redact_transaction(id='not-a-real-id', product='fake-product') + @responses.activate -def test_redact_transaction(client, dummy_data): +def test_redact_transaction(redact, dummy_data): responses.add( responses.POST, "https://api.nexmo.com/v1/redact/transaction", @@ -10,13 +15,13 @@ def test_redact_transaction(client, dummy_data): status=204, ) - assert client.redact_transaction(id="not-a-real-id", product="sms") is None + assert redact.redact_transaction(id="not-a-real-id", product="sms") is None assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" @responses.activate -def test_redact_transaction_with_type(client, dummy_data): +def test_redact_transaction_with_type(redact, dummy_data): responses.add( responses.POST, "https://api.nexmo.com/v1/redact/transaction", @@ -24,7 +29,7 @@ def test_redact_transaction_with_type(client, dummy_data): status=204, ) - assert client.redact_transaction(id="some-id", product="sms", type="xyz") is None + assert redact.redact_transaction(id="some-id", product="sms", type="xyz") is None assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" assert b"xyz" in request_body() From 4453e1b51273f98cf33262e1b44ffb5a663697d0 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Jul 2022 22:06:45 +0100 Subject: [PATCH 186/401] removed unused and duplicated rest call methods --- src/vonage/client.py | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index d9b355a1..82e8c9a4 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -261,6 +261,14 @@ def _post_json(self, host, request_uri, json): ) return self.parse(host, self.session.post(uri, headers=headers, json=json)) + def _jwt_signed_post(self, request_uri, params): + uri = f"https://{self.api_host()}{request_uri}" + + return self.parse( + self.api_host(), + self.session.post(uri, json=params, headers=self._headers()), + ) + def put(self, host, request_uri, params, header_auth=False): uri = f"https://{host}{request_uri}" @@ -336,36 +344,6 @@ def parse(self, host, response): message = f"{response.status_code} response from {host}" raise ServerError(message) - def _jwt_signed_get(self, request_uri, params=None): - uri = f"https://{self.api_host()}{request_uri}" - - return self.parse( - self.api_host(), - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = f"https://{self.api_host()}{request_uri}" - - return self.parse( - self.api_host(), - self.session.post(uri, json=params, headers=self._headers()), - ) - - def _jwt_signed_put(self, request_uri, params): - uri = f"https://{self.api_host()}{request_uri}" - - return self.parse( - self.api_host(), self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = f"https://{self.api_host()}{request_uri}" - - return self.parse( - self.api_host(), self.session.delete(uri, headers=self._headers()) - ) - def _headers(self): token = self.generate_application_jwt() return dict(self.headers, Authorization=b"Bearer " + token) From 50f9728b1c5efb2a1083c47d23bfdb1fa4ced918 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Jul 2022 19:58:00 +0100 Subject: [PATCH 187/401] renamed get_all_secrets to list_secrets in line with server sdk spec --- CHANGES.md | 1 - src/vonage/account.py | 2 +- tests/test_account.py | 8 ++++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0398c262..103ccd8e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,6 @@ - Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen - Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py` - Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs -- Added new `redact.py` module and `Redact` class, moved `redact_transaction()` method from client into new redact class # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. diff --git a/src/vonage/account.py b/src/vonage/account.py index ea497f4b..3856149d 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -43,7 +43,7 @@ def get_voice_pricing(self, number: str): def update_default_sms_webhook(self, params=None, **kwargs): return self._client.post(self._client.host(), "/account/settings", params or kwargs) - def get_all_secrets(self, api_key): + def list_secrets(self, api_key): return self._client.get( self._client.api_host(), f"/accounts/{api_key}/secrets", diff --git a/tests/test_account.py b/tests/test_account.py index 49fa5f8f..c87a5dea 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -109,14 +109,14 @@ def test_topup(account, dummy_data): @responses.activate -def test_get_all_secrets(account): +def test_list_secrets(account): stub( responses.GET, "https://api.nexmo.com/accounts/myaccountid/secrets", fixture_path="account/secret_management/list.json", ) - secrets = account.get_all_secrets("myaccountid") + secrets = account.list_secrets("myaccountid") assert_basic_auth() assert ( glom(secrets, "_embedded.secrets.0.id") @@ -125,7 +125,7 @@ def test_get_all_secrets(account): @responses.activate -def test_get_all_secrets_missing(account): +def test_list_secrets_missing(account): stub( responses.GET, "https://api.nexmo.com/accounts/myaccountid/secrets", @@ -134,7 +134,7 @@ def test_get_all_secrets_missing(account): ) with pytest.raises(vonage.ClientError) as ce: - account.get_all_secrets("myaccountid") + account.list_secrets("myaccountid") assert_basic_auth() assert ( str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" From 7503583e693b38b0e52cde0a55d02af44d382d09 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Jul 2022 20:04:44 +0100 Subject: [PATCH 188/401] removing BasicAuthenticatedServer class, remapping Account class calls --- src/vonage/account.py | 2 +- src/vonage/application.py | 125 ++++++----------------------------- src/vonage/client.py | 98 +++++++++++++-------------- src/vonage/errors.py | 12 +--- src/vonage/number_insight.py | 2 +- tests/test_account.py | 8 +-- tests/test_application.py | 20 +++--- tests/test_number_insight.py | 9 +++ 8 files changed, 97 insertions(+), 179 deletions(-) diff --git a/src/vonage/account.py b/src/vonage/account.py index ea497f4b..3856149d 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -43,7 +43,7 @@ def get_voice_pricing(self, number: str): def update_default_sms_webhook(self, params=None, **kwargs): return self._client.post(self._client.host(), "/account/settings", params or kwargs) - def get_all_secrets(self, api_key): + def list_secrets(self, api_key): return self._client.get( self._client.api_host(), f"/accounts/{api_key}/secrets", diff --git a/src/vonage/application.py b/src/vonage/application.py index 4c691627..42ebc541 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -1,97 +1,6 @@ -import logging - -from requests.adapters import HTTPAdapter -from requests.sessions import Session - -from .errors import AuthenticationError, ClientError, ServerError - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("vonage") - - -class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret, timeout=None, pool_connections=10, pool_maxsize=10, max_retries=3): - self._host = host - self._session = session = Session() - self.timeout = timeout - adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries) - self._session.mount("https://", adapter) - self._session.mount("http://", adapter) - session.auth = (api_key, api_secret) # Basic authentication. - session.headers.update({"User-Agent": user_agent}) - - def _uri(self, path): - return f"{self._host}{path}" - - def get(self, path, params=None, headers=None): - return self._parse( - self._session.get(self._uri(path), params=params, headers=headers, timeout=self.timeout) - ) - - def post(self, path, body=None, headers=None): - return self._parse( - self._session.post(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def put(self, path, body=None, headers=None): - return self._parse( - self._session.put(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def delete(self, path, body=None, headers=None): - return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers, timeout=self.timeout) - ) - - def _parse(self, response): - logger.debug(f"Response headers {repr(response.headers)}") - if response.status_code == 401: - raise AuthenticationError() - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - return response.json() - elif 400 <= response.status_code < 500: - logger.warning( - f"Client error: {response.status_code} {repr(response.content)}" - ) - message = f"{response.status_code} response" - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - title=error_data["title"] - detail=error_data["detail"] - type=error_data["type"] - message = f"{title}: {detail} ({type})" - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - f"Server error: {response.status_code} {repr(response.content)}" - ) - message = f"{response.status_code} response" - raise ServerError(message) - - -class ApplicationV2(object): - """ - Provides Application API v2 functionality. - - Don't instantiate this class yourself, access it via :py:attr:`vonage.Client.application` - """ - - def __init__(self, api_server): - self._api_server = api_server +class Application: + def __init__(self, client): + self._client = client def create_application(self, application_data): """ @@ -103,7 +12,11 @@ def create_application(self, application_data): Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication """ - return self._api_server.post("/v2/applications", application_data) + return self._client._post_json( + self._client.api_host(), + "/v2/applications", + application_data + ) def get_application(self, application_id): """ @@ -115,9 +28,10 @@ def get_application(self, application_id): :rtype: dict """ - return self._api_server.get( + return self._client.get( + self._client.api_host(), f"/v2/applications/{application_id}", - headers={"Content-Type": "application/json"}, + header_auth=True ) def update_application(self, application_id, params): @@ -126,9 +40,11 @@ def update_application(self, application_id, params): """ - return self._api_server.put( + return self._client.put( + self._client.api_host(), f"/v2/applications/{application_id}", params, + header_auth=True ) def delete_application(self, application_id): @@ -136,9 +52,11 @@ def delete_application(self, application_id): Delete the application with `application_id`. """ - self._api_server.delete( + self._client.delete( + self._client.api_host(), f"/v2/applications/{application_id}", - headers={"Content-Type": "application/json"}, + additional_headers={"Content-Type": "application/json"}, + header_auth=True ) def list_applications(self, page_size=None, page=None): @@ -152,13 +70,14 @@ def list_applications(self, page_size=None, page=None): """ params = _filter_none_values({"page_size": page_size, "page": page}) - return self._api_server.get( + return self._client.get( + self._client.api_host(), "/v2/applications", params=params, - headers={"Content-Type": "application/json"}, + additional_headers={"Content-Type": "application/json"}, + header_auth=True ) def _filter_none_values(d): return {k: v for k, v in d.items() if v is not None} - diff --git a/src/vonage/client.py b/src/vonage/client.py index 82e8c9a4..ae806ecd 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,7 +1,7 @@ import vonage from .account import Account -from .application import ApplicationV2, BasicAuthenticatedServer +from .application import Application from .errors import * from .messages import Messages from .number_insight import NumberInsight @@ -21,11 +21,13 @@ import hmac import jwt import os -import requests import time from uuid import uuid4 import re +from requests.adapters import HTTPAdapter +from requests.sessions import Session + string_types = (str, bytes) try: @@ -39,17 +41,10 @@ class Client: """ Create a Client object to start making calls to Vonage/Nexmo APIs. - Note on deprecations: most public-facing APIs that are called directly from this class (e.g. voice, - sms, number insight) have been deprecated and will instead be called from modules that house - the relevant classes (e.g. `voice.py`, `sms.py`). Change your code to call these classes directly - as they will be removed in a later release! - - Newer APIs are under namespaces like :attr:`Client.application_v2`. - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Vonage API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. + methods can be called. Consult the `Vonage API docs ` + for details of the authentication used by the APIs you wish to use, and instantiate your + client with the appropriate credentials. :param str key: Your Vonage API key :param str secret: Your Vonage API secret. @@ -61,12 +56,12 @@ class Client: This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. If you want to use a simple MD5 hash, leave this as `None`. :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. + :param str private_key: Your private key, for calling methods which use JWT authentication. This should either be a str containing the key in its PEM form, or a path to a private key file. :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Vonage to track your app statistics. + provided by this library and can be used to track your app statistics. :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Vonage to track your app statistics. + provided by this library and can be used to track your app statistics. """ def __init__( @@ -79,35 +74,29 @@ def __init__( private_key=None, app_name=None, app_version=None, + timeout=None, + pool_connections=10, + pool_maxsize=10, + max_retries=3 ): self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) - self.signature_secret = signature_secret or os.environ.get( - "VONAGE_SIGNATURE_SECRET", None - ) - - self.signature_method = signature_method or os.environ.get( - "VONAGE_SIGNATURE_METHOD", None - ) + self.signature_secret = signature_secret or os.environ.get("VONAGE_SIGNATURE_SECRET", None) + self.signature_method = signature_method or os.environ.get("VONAGE_SIGNATURE_METHOD", None) if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: self.signature_method = getattr(hashlib, signature_method) self.application_id = application_id - self.private_key = private_key if isinstance(self.private_key, string_types) and "\n" not in self.private_key: with open(self.private_key, "rb") as key_file: self.private_key = key_file.read() - self.__host_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" - - self.__host = "rest.nexmo.com" - - self.__api_host = "api.nexmo.com" + self._host = "rest.nexmo.com" + self._api_host = "api.nexmo.com" user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" @@ -118,15 +107,8 @@ def __init__( self.auth_params = {} - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - self.account = Account(self) + self.application = Application(self) self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) @@ -137,25 +119,28 @@ def __init__( self.verify = Verify(self) self.voice = Voice(self) - self.session = requests.Session() + self.timeout = timeout + self.session = Session() + self.adapter = HTTPAdapter( + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + max_retries=max_retries + ) + self.session.mount("https://", self.adapter) - # Get and Set __host attribute + # Get and Set _host attribute def host(self, value=None): if value is None: - return self.__host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for host") + return self._host else: - self.__host = value + self._host = value - # Gets And sets __api_host attribute + # Gets And Set _api_host attribute def api_host(self, value=None): if value is None: - return self.__api_host - elif not re.match(self.__host_pattern, value): - raise Exception("Error: Invalid format for api_host") + return self._api_host else: - self.__api_host = value + self._api_host = value def auth(self, params=None, **kwargs): self.auth_params = params or kwargs @@ -190,8 +175,14 @@ def signature(self, params): return hasher.hexdigest() - def get(self, host, request_uri, params=None, header_auth=False): + def get(self, host, request_uri, params=None, header_auth=False, additional_headers=None): uri = f"https://{host}{request_uri}" + + if not additional_headers: + headers = {**self.headers} + else: + headers = {**self.headers, **additional_headers} + headers = self.headers if header_auth: hash = base64.b64encode( @@ -284,11 +275,16 @@ def put(self, host, request_uri, params, header_auth=False): logger.debug(f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") return self.parse(host, self.session.put(uri, json=params, headers=headers)) - def delete(self, host, request_uri, header_auth=False): + def delete(self, host, request_uri, header_auth=False, additional_headers=None): uri = f"https://{host}{request_uri}" params = None - headers = self.headers + + if not additional_headers: + headers = {**self.headers} + else: + headers = {**self.headers, **additional_headers} + if header_auth: hash = base64.b64encode( f"{self.api_key}:{self.api_secret}".encode("utf-8") diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 1c9a942a..00227445 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -15,9 +15,7 @@ class AuthenticationError(ClientError): class CallbackRequiredError(Error): - """ - Indicates a callback is required but was not present. - """ + """Indicates a callback is required but was not present.""" class MessagesError(Error): @@ -27,11 +25,7 @@ class MessagesError(Error): class PricingTypeError(Error): - """ - A pricing type was specified that is not allowed. - """ + """A pricing type was specified that is not allowed.""" class RedactError(Error): - """ - Error related to the Redact class or Redact API. - """ \ No newline at end of file + """Error related to the Redact class or Redact API.""" \ No newline at end of file diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py index d5a6811a..dfcc9eaf 100644 --- a/src/vonage/number_insight.py +++ b/src/vonage/number_insight.py @@ -15,7 +15,7 @@ def get_advanced_number_insight(self, params=None, **kwargs): def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs - if "callback" in argoparams: + if "callback" in argoparams and type(argoparams["callback"]) == str and argoparams["callback"] != "": return self._client.get( self._client.api_host(), "/ni/advanced/async/json", params or kwargs ) diff --git a/tests/test_account.py b/tests/test_account.py index 49fa5f8f..c87a5dea 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -109,14 +109,14 @@ def test_topup(account, dummy_data): @responses.activate -def test_get_all_secrets(account): +def test_list_secrets(account): stub( responses.GET, "https://api.nexmo.com/accounts/myaccountid/secrets", fixture_path="account/secret_management/list.json", ) - secrets = account.get_all_secrets("myaccountid") + secrets = account.list_secrets("myaccountid") assert_basic_auth() assert ( glom(secrets, "_embedded.secrets.0.id") @@ -125,7 +125,7 @@ def test_get_all_secrets(account): @responses.activate -def test_get_all_secrets_missing(account): +def test_list_secrets_missing(account): stub( responses.GET, "https://api.nexmo.com/accounts/myaccountid/secrets", @@ -134,7 +134,7 @@ def test_get_all_secrets_missing(account): ) with pytest.raises(vonage.ClientError) as ce: - account.get_all_secrets("myaccountid") + account.list_secrets("myaccountid") assert_basic_auth() assert ( str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" diff --git a/tests/test_application.py b/tests/test_application.py index 635f11f9..7d4694fd 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -12,7 +12,7 @@ def test_list_applications(client, dummy_data): fixture_path="applications/list_applications.json", ) - apps = client.application_v2.list_applications() + apps = client.application.list_applications() assert_basic_auth() assert isinstance(apps, dict) assert apps["total_items"] == 30 @@ -27,7 +27,7 @@ def test_get_application(client, dummy_data): fixture_path="applications/get_application.json", ) - app = client.application_v2.get_application("xx-xx-xx-xx") + app = client.application.get_application("xx-xx-xx-xx") assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -44,7 +44,7 @@ def test_create_application(client, dummy_data): params = {"name": "Example App", "type": "voice"} - app = client.application_v2.create_application(params) + app = client.application.create_application(params) assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -63,7 +63,7 @@ def test_update_application(client, dummy_data): params = {"answer_url": "https://example.com/ncco"} - app = client.application_v2.update_application("xx-xx-xx-xx", params) + app = client.application.update_application("xx-xx-xx-xx", params) assert_basic_auth() assert isinstance(app, dict) assert request_user_agent() == dummy_data.user_agent @@ -81,7 +81,7 @@ def test_delete_application(client, dummy_data): status=204, ) - assert client.application_v2.delete_application("xx-xx-xx-xx") is None + assert client.application.delete_application("xx-xx-xx-xx") is None assert_basic_auth() assert request_user_agent() == dummy_data.user_agent @@ -94,7 +94,7 @@ def test_authentication_error(client): status=401, ) with pytest.raises(vonage.AuthenticationError): - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") @responses.activate @@ -112,7 +112,7 @@ def test_client_error(client): ), ) with pytest.raises(vonage.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") assert ( str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" ) @@ -127,8 +127,8 @@ def test_client_error_no_decode(client): body="{this: isnot_json", ) with pytest.raises(vonage.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "430 response" + client.application.delete_application("xx-xx-xx-xx") + assert str(exc_info.value) == "430 response from api.nexmo.com" @responses.activate @@ -139,4 +139,4 @@ def test_server_error(client): status=500, ) with pytest.raises(vonage.ServerError): - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py index d13834f1..75cedc88 100644 --- a/tests/test_number_insight.py +++ b/tests/test_number_insight.py @@ -1,4 +1,5 @@ from util import * +from vonage.errors import CallbackRequiredError @responses.activate @@ -38,3 +39,11 @@ def test_get_async_advanced_number_insight(number_insight, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_query() assert "callback=https%3A%2F%2Fexample.com" in request_query() + +def test_callback_required_error_async_advanced_number_insight(number_insight, dummy_data): + stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") + + params = {"number": "447525856424", "callback": ""} + + with pytest.raises(CallbackRequiredError): + number_insight.get_async_advanced_number_insight(params) From 62bbb90617f884a49901adc60664e63edc568c50 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Jul 2022 20:06:31 +0100 Subject: [PATCH 189/401] edited CHANGES.md --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 0398c262..103ccd8e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,6 @@ - Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen - Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py` - Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs -- Added new `redact.py` module and `Redact` class, moved `redact_transaction()` method from client into new redact class # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. From b8b7e372857b5e92177e641984b1269bcfd5a715 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 16 Jul 2022 04:10:44 +0100 Subject: [PATCH 190/401] refactored client.get() method and updated calls to it, added auth_type parameter --- src/vonage/account.py | 43 +++++++++---- src/vonage/application.py | 11 ++-- src/vonage/client.py | 81 ++++++++++++++++++------- src/vonage/errors.py | 5 +- src/vonage/number_insight.py | 10 +-- src/vonage/numbers.py | 6 +- src/vonage/redact.py | 4 +- src/vonage/short_codes.py | 4 +- src/vonage/verify.py | 4 +- src/vonage/voice.py | 49 +++++++++------ tests/test_application.py | 18 +++--- tests/{test_nexmo.py => test_client.py} | 10 ++- tests/test_rest_calls.py | 8 +-- 13 files changed, 169 insertions(+), 84 deletions(-) rename tests/{test_nexmo.py => test_client.py} (89%) diff --git a/src/vonage/account.py b/src/vonage/account.py index 3856149d..5c889334 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -1,13 +1,17 @@ from .errors import PricingTypeError class Account: + account_auth_type = 'params' + pricing_auth_type = 'params' + secrets_auth_type = 'header' + allowed_pricing_types = {'sms', 'sms-transit', 'voice'} def __init__(self, client): self._client = client def get_balance(self): - return self._client.get(self._client.host(), "/account/get-balance") + return self._client.get(self._client.host(), "/account/get-balance", auth_type=Account.account_auth_type) def topup(self, params=None, **kwargs): return self._client.post(self._client.host(), "/account/top-up", params or kwargs) @@ -15,29 +19,42 @@ def topup(self, params=None, **kwargs): def get_country_pricing(self, country_code: str, type: str = 'sms'): self._check_allowed_pricing_type(type) return self._client.get( - self._client.host(), f"/account/get-pricing/outbound/{type}", {"country": country_code} + self._client.host(), + f"/account/get-pricing/outbound/{type}", + {"country": country_code}, + auth_type=Account.pricing_auth_type ) def get_all_countries_pricing(self, type: str = 'sms'): self._check_allowed_pricing_type(type) return self._client.get( - self._client.host(), f"/account/get-full-pricing/outbound/{type}" + self._client.host(), f"/account/get-full-pricing/outbound/{type}", auth_type=Account.pricing_auth_type ) def get_prefix_pricing(self, prefix: str, type: str = 'sms'): self._check_allowed_pricing_type(type) return self._client.get( - self._client.host(), f"/account/get-prefix-pricing/outbound/{type}", {"prefix": prefix} + self._client.host(), + f"/account/get-prefix-pricing/outbound/{type}", + {"prefix": prefix}, + auth_type=Account.pricing_auth_type, ) def get_sms_pricing(self, number: str): return self._client.get( - self._client.host(), "/account/get-phone-pricing/outbound/sms", {"phone": number} + self._client.host(), + "/account/get-phone-pricing/outbound/sms", + {"phone": number}, + auth_type=Account.pricing_auth_type, ) def get_voice_pricing(self, number: str): return self._client.get( - self._client.host(), "/account/get-phone-pricing/outbound/voice", {"phone": number} + self._client.host(), + "/account/get-phone-pricing/outbound/voice", + {"phone": number}, + auth_type=Account.pricing_auth_type, + ) def update_default_sms_webhook(self, params=None, **kwargs): @@ -47,29 +64,31 @@ def list_secrets(self, api_key): return self._client.get( self._client.api_host(), f"/accounts/{api_key}/secrets", - header_auth=True, + auth_type=Account.secrets_auth_type, ) def get_secret(self, api_key, secret_id): return self._client.get( self._client.api_host(), f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True, + auth_type=Account.secrets_auth_type, ) def create_secret(self, api_key, secret): body = {"secret": secret} - return self._client._post_json( - self._client.api_host(), f"/accounts/{api_key}/secrets", body + return self._client.post_json( + self._client.api_host(), + f"/accounts/{api_key}/secrets", + body, ) def revoke_secret(self, api_key, secret_id): return self._client.delete( self._client.api_host(), f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True, + header_auth=True ) def _check_allowed_pricing_type(self, type): - if type not in self.allowed_pricing_types: + if type not in Account.allowed_pricing_types: raise PricingTypeError('Invalid pricing type specified.') diff --git a/src/vonage/application.py b/src/vonage/application.py index 42ebc541..76357b91 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -1,4 +1,6 @@ -class Application: +class ApplicationV2: + auth_type = 'header' + def __init__(self, client): self._client = client @@ -12,7 +14,7 @@ def create_application(self, application_data): Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication """ - return self._client._post_json( + return self._client.post_json( self._client.api_host(), "/v2/applications", application_data @@ -31,7 +33,7 @@ def get_application(self, application_id): return self._client.get( self._client.api_host(), f"/v2/applications/{application_id}", - header_auth=True + auth_type=ApplicationV2.auth_type, ) def update_application(self, application_id, params): @@ -74,8 +76,7 @@ def list_applications(self, page_size=None, page=None): self._client.api_host(), "/v2/applications", params=params, - additional_headers={"Content-Type": "application/json"}, - header_auth=True + auth_type=ApplicationV2.auth_type, ) diff --git a/src/vonage/client.py b/src/vonage/client.py index ae806ecd..5480be83 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,7 +1,7 @@ import vonage from .account import Account -from .application import Application +from .application import ApplicationV2 from .errors import * from .messages import Messages from .number_insight import NumberInsight @@ -22,8 +22,8 @@ import jwt import os import time -from uuid import uuid4 import re +from uuid import uuid4 from requests.adapters import HTTPAdapter from requests.sessions import Session @@ -88,12 +88,17 @@ def __init__( if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: self.signature_method = getattr(hashlib, signature_method) - self.application_id = application_id - self.private_key = private_key + self._jwt_auth_params = {} + + if private_key is not None and application_id is not None: + self._application_id = application_id + self._private_key = private_key - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() + if isinstance(self._private_key, string_types) and re.search("[.][a-zA-Z0-9_]+$", self._private_key): + with open(self._private_key, "rb") as key_file: + self._private_key = key_file.read() + + self._jwt = self._generate_application_jwt() self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" @@ -105,10 +110,9 @@ def __init__( self.headers = {"User-Agent": user_agent, "Accept": "application/json"} - self.auth_params = {} self.account = Account(self) - self.application = Application(self) + self.application_v2 = ApplicationV2(self) self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) @@ -143,7 +147,8 @@ def api_host(self, value=None): self._api_host = value def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs + self._jwt_auth_params = params or kwargs + self._jwt = self._generate_application_jwt() def check_signature(self, params): params = dict(params) @@ -175,7 +180,36 @@ def signature(self, params): return hasher.hexdigest() - def get(self, host, request_uri, params=None, header_auth=False, additional_headers=None): + def get(self, host, request_uri, params=None, auth_type=None): + uri = f"https://{host}{request_uri}" + + if hasattr(self, '_jwt') and auth_type == 'jwt': + headers_with_jwt = self._add_jwt_to_request_headers() + return self.parse( + host, + self.session.get( + uri, + params=params or {}, + headers=headers_with_jwt)) + elif auth_type == 'params': + params = dict( + params or {}, api_key=self.api_key, api_secret=self.api_secret + ) + return self.parse(host, self.session.get(uri, params=params, headers=self.headers)) + elif auth_type == 'header': + hash = base64.b64encode( + f"{self.api_key}:{self.api_secret}".encode("utf-8") + ).decode("ascii") + headers = dict(self.headers or {}, Authorization=f"Basic {hash}") + return self.parse(host, self.session.get(uri, params=params, headers=headers)) + else: + raise InvalidAuthenticationTypeError( + f'Invalid authentication type. Must be one of "jwt", "header" or "params".' + ) + + + + def _get(self, host, request_uri, params=None, header_auth=False, additional_headers=None): uri = f"https://{host}{request_uri}" if not additional_headers: @@ -208,10 +242,12 @@ def post( """ Low-level method to make a post request to a Vonage API server, which may have a Nexmo url. This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, + then signature authentication will be used. - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - Otherwise the client's key and secret are appended to the post request's params. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided + when initializing this client. :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = f"https://{host}{request_uri}" @@ -236,7 +272,7 @@ def post( ) return self.parse(host, self.session.post(uri, data=params, headers=headers)) - def _post_json(self, host, request_uri, json): + def post_json(self, host, request_uri, json): """ Post json to `request_uri`, using basic auth. """ @@ -257,7 +293,7 @@ def _jwt_signed_post(self, request_uri, params): return self.parse( self.api_host(), - self.session.post(uri, json=params, headers=self._headers()), + self.session.post(uri, json=params, headers=self._add_jwt_to_request_headers()), ) def put(self, host, request_uri, params, header_auth=False): @@ -340,20 +376,19 @@ def parse(self, host, response): message = f"{response.status_code} response from {host}" raise ServerError(message) - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) + def _add_jwt_to_request_headers(self): + return dict(self.headers, Authorization=b"Bearer " + self._jwt) - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) + def _generate_application_jwt(self): + iat = int(time.time()) - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) + payload = dict(self._jwt_auth_params) + payload.setdefault("application_id", self._application_id) payload.setdefault("iat", iat) payload.setdefault("exp", iat + 60) payload.setdefault("jti", str(uuid4())) - token = jwt.encode(payload, self.private_key, algorithm="RS256") + token = jwt.encode(payload, self._private_key, algorithm="RS256") # If token is string transform it to byte type if(type(token) is str): diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 00227445..79624bd6 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -28,4 +28,7 @@ class PricingTypeError(Error): """A pricing type was specified that is not allowed.""" class RedactError(Error): - """Error related to the Redact class or Redact API.""" \ No newline at end of file + """Error related to the Redact class or Redact API.""" + +class InvalidAuthenticationTypeError(Error): + """An authentication method was specified that is not allowed""" \ No newline at end of file diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py index dfcc9eaf..b63dde13 100644 --- a/src/vonage/number_insight.py +++ b/src/vonage/number_insight.py @@ -1,23 +1,25 @@ from .errors import CallbackRequiredError class NumberInsight: + auth_type = 'params' + def __init__(self, client): self._client = client def get_basic_number_insight(self, params=None, **kwargs): - return self._client.get(self._client.api_host(), "/ni/basic/json", params or kwargs) + return self._client.get(self._client.api_host(), "/ni/basic/json", params or kwargs, auth_type=NumberInsight.auth_type) def get_standard_number_insight(self, params=None, **kwargs): - return self._client.get(self._client.api_host(), "/ni/standard/json", params or kwargs) + return self._client.get(self._client.api_host(), "/ni/standard/json", params or kwargs, auth_type=NumberInsight.auth_type) def get_advanced_number_insight(self, params=None, **kwargs): - return self._client.get(self._client.api_host(), "/ni/advanced/json", params or kwargs) + return self._client.get(self._client.api_host(), "/ni/advanced/json", params or kwargs, auth_type=NumberInsight.auth_type) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs if "callback" in argoparams and type(argoparams["callback"]) == str and argoparams["callback"] != "": return self._client.get( - self._client.api_host(), "/ni/advanced/async/json", params or kwargs + self._client.api_host(), "/ni/advanced/async/json", params or kwargs, auth_type=NumberInsight.auth_type ) else: raise CallbackRequiredError( diff --git a/src/vonage/numbers.py b/src/vonage/numbers.py index 8221180e..a3c1a2b1 100644 --- a/src/vonage/numbers.py +++ b/src/vonage/numbers.py @@ -1,13 +1,15 @@ class Numbers: + auth_type = 'params' + def __init__(self, client): self._client = client def get_account_numbers(self, params=None, **kwargs): - return self._client.get(self._client.host(), "/account/numbers", params or kwargs) + return self._client.get(self._client.host(), "/account/numbers", params or kwargs, auth_type=Numbers.auth_type) def get_available_numbers(self, country_code, params=None, **kwargs): return self._client.get( - self._client.host(), "/number/search", dict(params or kwargs, country=country_code) + self._client.host(), "/number/search", dict(params or kwargs, country=country_code), auth_type=Numbers.auth_type ) def buy_number(self, params=None, **kwargs): diff --git a/src/vonage/redact.py b/src/vonage/redact.py index 209dd524..7c8887cc 100644 --- a/src/vonage/redact.py +++ b/src/vonage/redact.py @@ -1,6 +1,8 @@ from .errors import RedactError class Redact: + auth_type = 'header' + allowed_product_names = {'sms', 'voice', 'number-insight', 'verify', 'verify-sdk', 'messages'} def __init__(self, client): @@ -11,7 +13,7 @@ def redact_transaction(self, id: str, product: str, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._client._post_json(self._client.api_host(), "/v1/redact/transaction", params) + return self._client.post_json(self._client.api_host(), "/v1/redact/transaction", params) def _check_allowed_product_name(self, product): if product not in self.allowed_product_names: diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py index a641b700..a257df08 100644 --- a/src/vonage/short_codes.py +++ b/src/vonage/short_codes.py @@ -1,4 +1,6 @@ class ShortCodes: + auth_type = 'params' + def __init__(self, client): self._client = client @@ -12,7 +14,7 @@ def send_marketing_message(self, params=None, **kwargs): return self._client.post(self._client.host(), "/sc/us/marketing/json", params or kwargs) def get_event_alert_numbers(self): - return self._client.get(self._client.host(), "/sc/us/alert/opt-in/query/json") + return self._client.get(self._client.host(), "/sc/us/alert/opt-in/query/json", auth_type=ShortCodes.auth_type) def resubscribe_event_alert_number(self, params=None, **kwargs): return self._client.post( diff --git a/src/vonage/verify.py b/src/vonage/verify.py index b1863b93..722a3d93 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -1,4 +1,6 @@ class Verify: + auth_type = 'params' + def __init__(self, client): self._client = client @@ -16,7 +18,7 @@ def check(self, request_id, params=None, **kwargs): def search(self, request_id): return self._client.get( - self._client.api_host(), "/verify/search/json", {"request_id": request_id} + self._client.api_host(), "/verify/search/json", {"request_id": request_id}, auth_type=Verify.auth_type ) def cancel(self, request_id): diff --git a/src/vonage/voice.py b/src/vonage/voice.py index 8a742f00..abc5fb52 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -1,6 +1,8 @@ from urllib.parse import urlparse class Voice: + auth_type = 'jwt' + def __init__(self, client): self._client = client @@ -23,15 +25,24 @@ def create_call(self, params, **kwargs): params['random_from_number'] = True - return self._jwt_signed_post("/v1/calls", params or kwargs) + return self._client.post_json(self._client.api_host(), "/v1/calls", params or kwargs) # Get call history paginated. Pass start and end dates to filter the retrieved information def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) + return self._client.get( + self._client.api_host(), + "/v1/calls", + params or kwargs, + auth_type=Voice.auth_type + ) # Get a single call record by identifier def get_call(self, uuid): - return self._jwt_signed_get(f"/v1/calls/{uuid}") + return self._client.get( + self._client.api_host(), + f"/v1/calls/{uuid}", + auth_type=Voice.auth_type + ) # Update call data using custom ncco def update_call(self, uuid, params=None, **kwargs): @@ -67,35 +78,35 @@ def stop_speech(self, uuid): def get_recording(self, url): hostname = urlparse(url).hostname - return self._client.parse(hostname, self._client.session.get(url, headers=self._client._headers())) + return self._client.parse(hostname, self._client.session.get(url, headers=self._client._add_jwt_to_request_headers())) # Utils methods # _jwt_signed_post private method that Allows developer perform signed post request - def _jwt_signed_post(self, request_uri, params): - uri = f"https://{self._client.api_host()}{request_uri}" + # def _jwt_signed_post(self, request_uri, params): + # uri = f"https://{self._client.api_host()}{request_uri}" - # Uses the client session to perform the call action with api - return self._client.parse( - self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) - ) + # # Uses the client session to perform the call action with api + # return self._client.parse( + # self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) + # ) - # _jwt_signed_post private method that Allows developer perform signed get request - def _jwt_signed_get(self, request_uri, params=None): - uri = f"https://{self._client.api_host()}{request_uri}" + # _jwt_signed_get private method that Allows developer perform signed get request + # def _jwt_signed_get(self, request_uri, params=None): + # uri = f"https://{self._client.api_host()}{request_uri}" - return self._client.parse( - self._client.api_host(), - self._client.session.get(uri, params=params or {}, headers=self._client._headers()), - ) + # return self._client.parse( + # self._client.api_host(), + # self._client.session.get(uri, params=params or {}, headers=self._client._headers()), + # ) # _jwt_signed_put private method that Allows developer perform signed put request def _jwt_signed_put(self, request_uri, params): uri = f"https://{self._client.api_host()}{request_uri}" return self._client.parse( - self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._headers()) + self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._add_jwt_to_request_headers()) ) # _jwt_signed_put private method that Allows developer perform signed put request @@ -103,5 +114,5 @@ def _jwt_signed_delete(self, request_uri): uri = f"https://{self._client.api_host()}{request_uri}" return self._client.parse( - self._client.api_host(), self._client.session.delete(uri, headers=self._client._headers()) + self._client.api_host(), self._client.session.delete(uri, headers=self._client._add_jwt_to_request_headers()) ) diff --git a/tests/test_application.py b/tests/test_application.py index 7d4694fd..47d97dc4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -12,7 +12,7 @@ def test_list_applications(client, dummy_data): fixture_path="applications/list_applications.json", ) - apps = client.application.list_applications() + apps = client.application_v2.list_applications() assert_basic_auth() assert isinstance(apps, dict) assert apps["total_items"] == 30 @@ -27,7 +27,7 @@ def test_get_application(client, dummy_data): fixture_path="applications/get_application.json", ) - app = client.application.get_application("xx-xx-xx-xx") + app = client.application_v2.get_application("xx-xx-xx-xx") assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -44,7 +44,7 @@ def test_create_application(client, dummy_data): params = {"name": "Example App", "type": "voice"} - app = client.application.create_application(params) + app = client.application_v2.create_application(params) assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -63,7 +63,7 @@ def test_update_application(client, dummy_data): params = {"answer_url": "https://example.com/ncco"} - app = client.application.update_application("xx-xx-xx-xx", params) + app = client.application_v2.update_application("xx-xx-xx-xx", params) assert_basic_auth() assert isinstance(app, dict) assert request_user_agent() == dummy_data.user_agent @@ -81,7 +81,7 @@ def test_delete_application(client, dummy_data): status=204, ) - assert client.application.delete_application("xx-xx-xx-xx") is None + assert client.application_v2.delete_application("xx-xx-xx-xx") is None assert_basic_auth() assert request_user_agent() == dummy_data.user_agent @@ -94,7 +94,7 @@ def test_authentication_error(client): status=401, ) with pytest.raises(vonage.AuthenticationError): - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") @responses.activate @@ -112,7 +112,7 @@ def test_client_error(client): ), ) with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") assert ( str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" ) @@ -127,7 +127,7 @@ def test_client_error_no_decode(client): body="{this: isnot_json", ) with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") assert str(exc_info.value) == "430 response from api.nexmo.com" @@ -139,4 +139,4 @@ def test_server_error(client): status=500, ) with pytest.raises(vonage.ServerError): - client.application.delete_application("xx-xx-xx-xx") + client.application_v2.delete_application("xx-xx-xx-xx") diff --git a/tests/test_nexmo.py b/tests/test_client.py similarity index 89% rename from tests/test_nexmo.py rename to tests/test_client.py index b7885a52..6cdba2e9 100644 --- a/tests/test_nexmo.py +++ b/tests/test_client.py @@ -1,5 +1,6 @@ import vonage from util import * +from vonage.errors import InvalidAuthenticationTypeError def test_check_signature(dummy_data): @@ -86,8 +87,8 @@ def test_signature_sha512(dummy_data): ) -def test_client_doesnt_require_api_key(): - client = vonage.Client(application_id="myid", private_key="abc\nde") +def test_client_doesnt_require_api_key(dummy_data): + client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) assert client is not None assert client.api_key is None assert client.api_secret is None @@ -100,3 +101,8 @@ def test_client_can_make_application_requests_without_api_key(dummy_data): client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) voice = vonage.Voice(client) voice.create_call("123455") + + +def test_invalid_auth_type_raises_error(client): + with pytest.raises(InvalidAuthenticationTypeError): + client.get(client.host(), 'my/request/uri', auth_type='magic') \ No newline at end of file diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index 414b0a3e..bfc85a59 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -2,12 +2,12 @@ @responses.activate -def test_get(client, dummy_data): +def test_get_with_query_params_authentication(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" params = {"aaa": "xxx", "bbb": "yyy"} - response = client.get(host, request_uri, params=params) + response = client.get(host, request_uri, params=params, auth_type='params') assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert "aaa=xxx" in request_query() @@ -15,12 +15,12 @@ def test_get(client, dummy_data): @responses.activate -def test_get_with_auth(client, dummy_data): +def test_get_with_header_authentication(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" params = {"aaa": "xxx", "bbb": "yyy"} - response = client.get(host, request_uri, params=params, header_auth=True) + response = client.get(host, request_uri, params=params, auth_type='header') assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert "aaa=xxx" in request_query() From 2cd7ec70feed8b2e88012858d32fe2ec2e54757f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 16 Jul 2022 04:17:21 +0100 Subject: [PATCH 191/401] adding hint to authentication error message --- src/vonage/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index 5480be83..2eae46c9 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -337,7 +337,9 @@ def delete(self, host, request_uri, header_auth=False, additional_headers=None): def parse(self, host, response): logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: - raise AuthenticationError + raise AuthenticationError( + "Check you're using a valid authentication method for the API you want to use" + ) elif response.status_code == 204: return None elif 200 <= response.status_code < 300: From 4767df8dc872bb758ca1e77226cd035a30ec1bc6 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Jul 2022 20:10:40 +0100 Subject: [PATCH 192/401] combined all post methods into one client.post() and refactored --- src/vonage/account.py | 21 +++++-- src/vonage/application.py | 5 +- src/vonage/client.py | 117 ++++++++++---------------------------- src/vonage/messages.py | 26 ++++----- src/vonage/numbers.py | 18 ++++-- src/vonage/redact.py | 2 +- src/vonage/short_codes.py | 10 ++-- src/vonage/sms.py | 14 ++++- src/vonage/ussd.py | 6 +- src/vonage/verify.py | 11 +++- src/vonage/voice.py | 2 +- tests/test_rest_calls.py | 8 +-- 12 files changed, 111 insertions(+), 129 deletions(-) diff --git a/src/vonage/account.py b/src/vonage/account.py index 5c889334..6157976e 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -14,7 +14,13 @@ def get_balance(self): return self._client.get(self._client.host(), "/account/get-balance", auth_type=Account.account_auth_type) def topup(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/account/top-up", params or kwargs) + return self._client.post( + self._client.host(), + "/account/top-up", + params or kwargs, + auth_type=Account.account_auth_type, + body_is_json=False, + ) def get_country_pricing(self, country_code: str, type: str = 'sms'): self._check_allowed_pricing_type(type) @@ -54,11 +60,16 @@ def get_voice_pricing(self, number: str): "/account/get-phone-pricing/outbound/voice", {"phone": number}, auth_type=Account.pricing_auth_type, - ) def update_default_sms_webhook(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/account/settings", params or kwargs) + return self._client.post( + self._client.host(), + "/account/settings", + params or kwargs, + auth_type=Account.account_auth_type, + body_is_json=False, + ) def list_secrets(self, api_key): return self._client.get( @@ -76,10 +87,12 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} - return self._client.post_json( + return self._client.post( self._client.api_host(), f"/accounts/{api_key}/secrets", body, + auth_type=Account.secrets_auth_type, + body_is_json=False, ) def revoke_secret(self, api_key, secret_id): diff --git a/src/vonage/application.py b/src/vonage/application.py index 76357b91..bf0c7862 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -14,10 +14,11 @@ def create_application(self, application_data): Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication """ - return self._client.post_json( + return self._client.post( self._client.api_host(), "/v2/applications", - application_data + application_data, + auth_type=ApplicationV2.auth_type, ) def get_application(self, application_id): diff --git a/src/vonage/client.py b/src/vonage/client.py index 2eae46c9..fc39e5be 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -89,6 +89,7 @@ def __init__( self.signature_method = getattr(hashlib, signature_method) self._jwt_auth_params = {} + self.jwt = None if private_key is not None and application_id is not None: self._application_id = application_id @@ -98,7 +99,7 @@ def __init__( with open(self._private_key, "rb") as key_file: self._private_key = key_file.read() - self._jwt = self._generate_application_jwt() + self.jwt = self._generate_application_jwt() self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" @@ -148,7 +149,7 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self._jwt_auth_params = params or kwargs - self._jwt = self._generate_application_jwt() + self.jwt = self._generate_application_jwt() def check_signature(self, params): params = dict(params) @@ -182,119 +183,63 @@ def signature(self, params): def get(self, host, request_uri, params=None, auth_type=None): uri = f"https://{host}{request_uri}" + self._request_headers = self.headers - if hasattr(self, '_jwt') and auth_type == 'jwt': - headers_with_jwt = self._add_jwt_to_request_headers() - return self.parse( - host, - self.session.get( - uri, - params=params or {}, - headers=headers_with_jwt)) + if auth_type == 'jwt': + self._request_headers = self._add_jwt_to_request_headers() elif auth_type == 'params': params = dict( params or {}, api_key=self.api_key, api_secret=self.api_secret ) - return self.parse(host, self.session.get(uri, params=params, headers=self.headers)) elif auth_type == 'header': hash = base64.b64encode( f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") - headers = dict(self.headers or {}, Authorization=f"Basic {hash}") - return self.parse(host, self.session.get(uri, params=params, headers=headers)) + self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}") else: raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) + return self.parse( + host, + self.session.get(uri, params=params, headers=self._request_headers)) - - - def _get(self, host, request_uri, params=None, header_auth=False, additional_headers=None): - uri = f"https://{host}{request_uri}" - - if not additional_headers: - headers = {**self.headers} - else: - headers = {**self.headers, **additional_headers} - - headers = self.headers - if header_auth: - hash = base64.b64encode( - f"{self.api_key}:{self.api_secret}".encode("utf-8") - ).decode("ascii") - headers = dict(headers or {}, Authorization=f"Basic {hash}") - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post( - self, - host, - request_uri, - params, - supports_signature_auth=False, - header_auth=False, - additional_headers=None - ): + def post(self, host, request_uri, params, auth_type=None, body_is_json=True, supports_signature_auth=False): """ - Low-level method to make a post request to a Vonage API server, which may have a Nexmo url. + Low-level method to make a post request to an API server. This method automatically adds authentication, picking the first applicable authentication method from the following: - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - - If the header_auth param is True, then basic authentication will be used, with the client's key and secret. - - Otherwise the client's key and secret are appended to the post request's params. :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. - :param bool header_auth: Use basic authentication instead of adding api_key and api_secret to the request params. """ uri = f"https://{host}{request_uri}" + self._request_headers = self.headers - if not additional_headers: - headers = {**self.headers} - else: - headers = {**self.headers, **additional_headers} - if supports_signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) - elif header_auth: + elif auth_type == 'jwt': + self._request_headers = self._add_jwt_to_request_headers() + elif auth_type == 'params': + params = dict( + params, api_key=self.api_key, api_secret=self.api_secret + ) + elif auth_type == 'header': hash = base64.b64encode( f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") - headers = dict(headers or {}, Authorization=f"Basic {hash}") + self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}") else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug( - f"POST to {repr(uri)} with params {repr(params)}, headers {repr(headers)}" - ) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = f"https://{host}{request_uri}" - auth = base64.b64encode( - f"{self.api_key}:{self.api_secret}".encode("utf-8") - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization=f"Basic {auth}" - ) - logger.debug( - f"POST to %{repr(request_uri)} with body: {repr(json)}, headers: {repr(headers)}" - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def _jwt_signed_post(self, request_uri, params): - uri = f"https://{self.api_host()}{request_uri}" - - return self.parse( - self.api_host(), - self.session.post(uri, json=params, headers=self._add_jwt_to_request_headers()), - ) + raise InvalidAuthenticationTypeError( + f'Invalid authentication type. Must be one of "jwt", "header" or "params".' + ) + if body_is_json: + return self.parse( + host, self.session.post(uri, json=params, headers=self._request_headers)) + else: + return self.parse( + host, self.session.post(uri, data=params, headers=self._request_headers)) def put(self, host, request_uri, params, header_auth=False): uri = f"https://{host}{request_uri}" @@ -379,7 +324,7 @@ def parse(self, host, response): raise ServerError(message) def _add_jwt_to_request_headers(self): - return dict(self.headers, Authorization=b"Bearer " + self._jwt) + return dict(self.headers, Authorization=b"Bearer " + self.jwt) def _generate_application_jwt(self): iat = int(time.time()) diff --git a/src/vonage/messages.py b/src/vonage/messages.py index 8f962873..746f8c66 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -1,7 +1,6 @@ from .errors import MessagesError import re -import json class Messages: valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber_service'} @@ -15,22 +14,19 @@ class Messages: def __init__(self, client): self._client = client + self._auth_type = 'jwt' - def send_message(self, params: dict, header_auth=False): + def send_message(self, params: dict): self.validate_send_message_input(params) - - json_formatted_params = json.dumps(params) - if header_auth: # Using base64 encoded API key/secret pair - return self._client.post( - self._client.api_host(), - "/v1/messages", - json_formatted_params, - header_auth=header_auth, - additional_headers={'Content-Type': 'application/json'}) - else: # If using jwt auth - return self._client._jwt_signed_post( - "/v1/messages", - params) + + if self._client.jwt is None: + self._auth_type='header' + return self._client.post( + self._client.api_host(), + "/v1/messages", + params, + auth_type=self._auth_type, + ) def validate_send_message_input(self, params): self._check_input_is_dict(params) diff --git a/src/vonage/numbers.py b/src/vonage/numbers.py index a3c1a2b1..177c461b 100644 --- a/src/vonage/numbers.py +++ b/src/vonage/numbers.py @@ -1,5 +1,6 @@ class Numbers: auth_type = 'params' + defaults = {'auth_type': auth_type, 'body_is_json': False} def __init__(self, client): self._client = client @@ -9,14 +10,23 @@ def get_account_numbers(self, params=None, **kwargs): def get_available_numbers(self, country_code, params=None, **kwargs): return self._client.get( - self._client.host(), "/number/search", dict(params or kwargs, country=country_code), auth_type=Numbers.auth_type + self._client.host(), + "/number/search", + dict(params or kwargs, country=country_code), + auth_type=Numbers.auth_type ) def buy_number(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/number/buy", params or kwargs) + return self._client.post( + self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults + ) def cancel_number(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/number/cancel", params or kwargs) + return self._client.post( + self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults + ) def update_number(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/number/update", params or kwargs) + return self._client.post( + self._client.host(), "/number/update", params or kwargs, **Numbers.defaults + ) diff --git a/src/vonage/redact.py b/src/vonage/redact.py index 7c8887cc..2b26ce80 100644 --- a/src/vonage/redact.py +++ b/src/vonage/redact.py @@ -13,7 +13,7 @@ def redact_transaction(self, id: str, product: str, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._client.post_json(self._client.api_host(), "/v1/redact/transaction", params) + return self._client.post(self._client.api_host(), "/v1/redact/transaction", params, auth_type=Redact.auth_type) def _check_allowed_product_name(self, product): if product not in self.allowed_product_names: diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py index a257df08..43464b6b 100644 --- a/src/vonage/short_codes.py +++ b/src/vonage/short_codes.py @@ -1,23 +1,23 @@ class ShortCodes: auth_type = 'params' + defaults = {'auth_type': auth_type, 'body_is_json': False} def __init__(self, client): self._client = client def send_2fa_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/sc/us/2fa/json", params or kwargs) + return self._client.post(self._client.host(), "/sc/us/2fa/json", params or kwargs, **ShortCodes.defaults) def send_event_alert_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/sc/us/alert/json", params or kwargs) + return self._client.post(self._client.host(), "/sc/us/alert/json", params or kwargs, **ShortCodes.defaults) def send_marketing_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/sc/us/marketing/json", params or kwargs) + return self._client.post(self._client.host(), "/sc/us/marketing/json", params or kwargs, **ShortCodes.defaults) def get_event_alert_numbers(self): return self._client.get(self._client.host(), "/sc/us/alert/opt-in/query/json", auth_type=ShortCodes.auth_type) def resubscribe_event_alert_number(self, params=None, **kwargs): return self._client.post( - self._client.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs - ) + self._client.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs, **ShortCodes.defaults) \ No newline at end of file diff --git a/src/vonage/sms.py b/src/vonage/sms.py index 6efee0ea..ab676fc2 100644 --- a/src/vonage/sms.py +++ b/src/vonage/sms.py @@ -1,8 +1,10 @@ -import vonage, pytz +import pytz from datetime import datetime from ._internal import _format_date_param class Sms: + defaults = {'auth_type': 'params', 'body_is_json': False} + def __init__(self, client): self._client = client @@ -12,7 +14,13 @@ def send_message(self, params): Requires a client initialized with `key` and either `secret` or `signature_secret`. :param dict params: A dict of values described at `Send an SMS `_ """ - return self._client.post(self._client.host(), "/sms/json", params, supports_signature_auth=True) + return self._client.post( + self._client.host(), + "/sms/json", + params, + supports_signature_auth=True, + **Sms.defaults, + ) def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ @@ -33,4 +41,4 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): } # Ensure timestamp is a string: _format_date_param(params, "timestamp") - return self._client.post(self._client.api_host(), "/conversions/sms", params) + return self._client.post(self._client.api_host(), "/conversions/sms", params, **Sms.defaults) diff --git a/src/vonage/ussd.py b/src/vonage/ussd.py index 893fb242..addde45e 100644 --- a/src/vonage/ussd.py +++ b/src/vonage/ussd.py @@ -1,9 +1,11 @@ class Ussd: + defaults = {'auth_type': 'params', 'body_is_json': False} + def __init__(self, client): self._client = client def send_ussd_push_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/ussd/json", params or kwargs) + return self._client.post(self._client.host(), "/ussd/json", params or kwargs, **Ussd.defaults) def send_ussd_prompt_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/ussd-prompt/json", params or kwargs) + return self._client.post(self._client.host(), "/ussd-prompt/json", params or kwargs, **Ussd.defaults) diff --git a/src/vonage/verify.py b/src/vonage/verify.py index 722a3d93..f84c9313 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -1,12 +1,16 @@ class Verify: auth_type = 'params' + defaults = {'auth_type': auth_type, 'body_is_json': False} def __init__(self, client): self._client = client def start_verification(self, params=None, **kwargs): return self._client.post( - self._client.api_host(), "/verify/json", params or kwargs + self._client.api_host(), + "/verify/json", + params or kwargs, + **Verify.defaults, ) def check(self, request_id, params=None, **kwargs): @@ -14,6 +18,7 @@ def check(self, request_id, params=None, **kwargs): self._client.api_host(), "/verify/check/json", dict(params or kwargs, request_id=request_id), + **Verify.defaults, ) def search(self, request_id): @@ -26,6 +31,7 @@ def cancel(self, request_id): self._client.api_host(), "/verify/control/json", {"request_id": request_id, "cmd": "cancel"}, + **Verify.defaults, ) def trigger_next_event(self, request_id): @@ -33,10 +39,11 @@ def trigger_next_event(self, request_id): self._client.api_host(), "/verify/control/json", {"request_id": request_id, "cmd": "trigger_next_event"}, + **Verify.defaults, ) def psd2(self, params=None, **kwargs): return self._client.post( - self._client.api_host(), "/verify/psd2/json", params or kwargs + self._client.api_host(), "/verify/psd2/json", params or kwargs, **Verify.defaults, ) diff --git a/src/vonage/voice.py b/src/vonage/voice.py index abc5fb52..cf92514a 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -25,7 +25,7 @@ def create_call(self, params, **kwargs): params['random_from_number'] = True - return self._client.post_json(self._client.api_host(), "/v1/calls", params or kwargs) + return self._client.post(self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type) # Get call history paginated. Pass start and end dates to filter the retrieved information def get_calls(self, params=None, **kwargs): diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index bfc85a59..d099f7f6 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -29,12 +29,12 @@ def test_get_with_header_authentication(client, dummy_data): @responses.activate -def test_post(client, dummy_data): +def test_post_with_params_auth(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params) + response = client.post(host, request_uri, params, auth_type='params', body_is_json=False) assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert "aaa=xxx" in request_body() @@ -42,12 +42,12 @@ def test_post(client, dummy_data): @responses.activate -def test_post_with_auth(client, dummy_data): +def test_post_with_header_auth(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params, header_auth=True) + response = client.post(host, request_uri, params, auth_type='header', body_is_json=False) assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert "aaa=xxx" in request_body() From f326259b25e23bc3acd85d63672ba201418d08ea Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 20 Jul 2022 17:26:43 +0100 Subject: [PATCH 193/401] refactored all PUT and DELETE requests into methods in client.py --- src/vonage/account.py | 2 +- src/vonage/application.py | 5 ++- src/vonage/client.py | 45 +++++++++++------------ src/vonage/voice.py | 75 +++++++++++++++------------------------ tests/test_rest_calls.py | 39 ++++---------------- tests/test_voice.py | 1 - 6 files changed, 61 insertions(+), 106 deletions(-) diff --git a/src/vonage/account.py b/src/vonage/account.py index 6157976e..09784208 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -99,7 +99,7 @@ def revoke_secret(self, api_key, secret_id): return self._client.delete( self._client.api_host(), f"/accounts/{api_key}/secrets/{secret_id}", - header_auth=True + auth_type=Account.secrets_auth_type, ) def _check_allowed_pricing_type(self, type): diff --git a/src/vonage/application.py b/src/vonage/application.py index bf0c7862..dd515399 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -47,7 +47,7 @@ def update_application(self, application_id, params): self._client.api_host(), f"/v2/applications/{application_id}", params, - header_auth=True + auth_type=ApplicationV2.auth_type, ) def delete_application(self, application_id): @@ -58,8 +58,7 @@ def delete_application(self, application_id): self._client.delete( self._client.api_host(), f"/v2/applications/{application_id}", - additional_headers={"Content-Type": "application/json"}, - header_auth=True + auth_type=ApplicationV2.auth_type, ) def list_applications(self, page_size=None, page=None): diff --git a/src/vonage/client.py b/src/vonage/client.py index fc39e5be..1af4d80a 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -241,42 +241,43 @@ def post(self, host, request_uri, params, auth_type=None, body_is_json=True, sup return self.parse( host, self.session.post(uri, data=params, headers=self._request_headers)) - def put(self, host, request_uri, params, header_auth=False): + def put(self, host, request_uri, params, auth_type=None): uri = f"https://{host}{request_uri}" + self._request_headers = self.headers - headers = self.headers - if header_auth: + if auth_type == 'jwt': + self._request_headers = self._add_jwt_to_request_headers() + elif auth_type == 'header': hash = base64.b64encode( f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization=f"Basic {hash}") + self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}") else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug(f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False, additional_headers=None): - uri = f"https://{host}{request_uri}" + raise InvalidAuthenticationTypeError( + f'Invalid authentication type. Must be one of "jwt", "header" or "params".' + ) - params = None + # All APIs that currently use put methods require a json-formatted body + return self.parse(host, self.session.put(uri, json=params, headers=self._request_headers)) - if not additional_headers: - headers = {**self.headers} - else: - headers = {**self.headers, **additional_headers} + def delete(self, host, request_uri, auth_type=None): + uri = f"https://{host}{request_uri}" + self._request_headers = self.headers - if header_auth: + if auth_type == 'jwt': + self._request_headers = self._add_jwt_to_request_headers() + elif auth_type =='header': hash = base64.b64encode( f"{self.api_key}:{self.api_secret}".encode("utf-8") ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization=f"Basic {hash}") + self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}") else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug(f"DELETE to {repr(uri)} with params {repr(params)}, headers {repr(headers)}") + raise InvalidAuthenticationTypeError( + f'Invalid authentication type. Must be one of "jwt", "header" or "params".' + ) + return self.parse( - host, self.session.delete(uri, params=params, headers=headers) + host, self.session.delete(uri, headers=self._request_headers) ) def parse(self, host, response): diff --git a/src/vonage/voice.py b/src/vonage/voice.py index cf92514a..2a8aa419 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -46,73 +46,54 @@ def get_call(self, uuid): # Update call data using custom ncco def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}", params or kwargs + return self._client.put( + self._client.api_host(), + f"/v1/calls/{uuid}", + params or kwargs, + auth_type=Voice.auth_type ) # Plays audio streaming into call in progress - stream_url parameter is required def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/stream", params or kwargs + return self._client.put( + self._client.api_host(), + f"/v1/calls/{uuid}/stream", + params or kwargs, + auth_type=Voice.auth_type ) # Play an speech into specified call - text parameter (text to speech) is required def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/talk", params or kwargs + return self._client.put( + self._client.api_host(), + f"/v1/calls/{uuid}/talk", + params or kwargs, + auth_type=Voice.auth_type ) # plays DTMF tones into the specified call def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - f"/v1/calls/{uuid}/dtmf", params or kwargs + return self._client.put( + self._client.api_host(), + f"/v1/calls/{uuid}/dtmf", + params or kwargs, + auth_type=Voice.auth_type ) # Stops audio recently played into specified call def stop_audio(self, uuid): - return self._jwt_signed_delete(f"/v1/calls/{uuid}/stream") + return self._client.delete(self._client.api_host(), + f"/v1/calls/{uuid}/stream", + auth_type=Voice.auth_type + ) # Stop a speech recently played into specified call def stop_speech(self, uuid): - return self._jwt_signed_delete(f"/v1/calls/{uuid}/talk") + return self._client.delete(self._client.api_host(), + f"/v1/calls/{uuid}/talk", + auth_type=Voice.auth_type + ) def get_recording(self, url): hostname = urlparse(url).hostname return self._client.parse(hostname, self._client.session.get(url, headers=self._client._add_jwt_to_request_headers())) - - - - # Utils methods - # _jwt_signed_post private method that Allows developer perform signed post request - # def _jwt_signed_post(self, request_uri, params): - # uri = f"https://{self._client.api_host()}{request_uri}" - - # # Uses the client session to perform the call action with api - # return self._client.parse( - # self._client.api_host(), self._client.session.post(uri, json=params, headers=self._client._headers()) - # ) - - # _jwt_signed_get private method that Allows developer perform signed get request - # def _jwt_signed_get(self, request_uri, params=None): - # uri = f"https://{self._client.api_host()}{request_uri}" - - # return self._client.parse( - # self._client.api_host(), - # self._client.session.get(uri, params=params or {}, headers=self._client._headers()), - # ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_put(self, request_uri, params): - uri = f"https://{self._client.api_host()}{request_uri}" - - return self._client.parse( - self._client.api_host(), self._client.session.put(uri, json=params, headers=self._client._add_jwt_to_request_headers()) - ) - - # _jwt_signed_put private method that Allows developer perform signed put request - def _jwt_signed_delete(self, request_uri): - uri = f"https://{self._client.api_host()}{request_uri}" - - return self._client.parse( - self._client.api_host(), self._client.session.delete(uri, headers=self._client._add_jwt_to_request_headers()) - ) diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index d099f7f6..1b8f41eb 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -2,7 +2,7 @@ @responses.activate -def test_get_with_query_params_authentication(client, dummy_data): +def test_get_with_query_params_auth(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" @@ -15,7 +15,7 @@ def test_get_with_query_params_authentication(client, dummy_data): @responses.activate -def test_get_with_header_authentication(client, dummy_data): +def test_get_with_header_auth(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" @@ -29,7 +29,7 @@ def test_get_with_header_authentication(client, dummy_data): @responses.activate -def test_post_with_params_auth(client, dummy_data): +def test_post_with_query_params_auth(client, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" @@ -56,27 +56,12 @@ def test_post_with_header_auth(client, dummy_data): @responses.activate -def test_put(client, dummy_data): +def test_put_with_header_auth(client, dummy_data): stub(responses.PUT, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" params = {"aaa": "xxx", "bbb": "yyy"} - response = client.put(host, request_uri, params=params) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() - - -@responses.activate -def test_put_with_auth(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.put(host, request_uri, params=params, header_auth=True) + response = client.put(host, request_uri, params=params, auth_type='header') assert_basic_auth() assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent @@ -87,21 +72,11 @@ def test_put_with_auth(client, dummy_data): @responses.activate -def test_delete(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - response = client.delete(host, request_uri) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_delete_with_auth(client, dummy_data): +def test_delete_with_header_auth(client, dummy_data): stub(responses.DELETE, "https://api.nexmo.com/v1/applications") host = "api.nexmo.com" request_uri = "/v1/applications" - response = client.delete(host, request_uri, header_auth=True) + response = client.delete(host, request_uri, auth_type='header') assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert_basic_auth() diff --git a/tests/test_voice.py b/tests/test_voice.py index fdaf3064..c7f87faa 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -2,7 +2,6 @@ import time import jwt -import requests import vonage from util import * From b6099488c51a93f9dc601867a0959e16d510eec0 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 20 Jul 2022 17:49:30 +0100 Subject: [PATCH 194/401] deprecated ApplicationV2 class and added new Application class --- src/vonage/application.py | 84 ++++++++++++++++++++ src/vonage/client.py | 4 +- tests/conftest.py | 6 ++ tests/test_application.py | 156 +++++++++++++++++++++++++++++++++++--- 4 files changed, 239 insertions(+), 11 deletions(-) diff --git a/src/vonage/application.py b/src/vonage/application.py index dd515399..f95b1702 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -1,3 +1,6 @@ +from deprecated import deprecated + +@deprecated(version='3.0.0', reason='renaming to Application in a later release as v1 is out of support') class ApplicationV2: auth_type = 'header' @@ -42,6 +45,87 @@ def update_application(self, application_id, params): Update the application with `application_id` using the values provided in `params`. + """ + return self._client.put( + self._client.api_host(), + f"/v2/applications/{application_id}", + params, + auth_type=ApplicationV2.auth_type, + ) + + def delete_application(self, application_id): + """ + Delete the application with `application_id`. + """ + + self._client.delete( + self._client.api_host(), + f"/v2/applications/{application_id}", + auth_type=ApplicationV2.auth_type, + ) + + def list_applications(self, page_size=None, page=None): + """ + List all applications for your account. + + Results are paged, so each page will need to be requested to see all applications. + + :param int page_size: The number of items in the page to be returned + :param int page: The page number of the page to be returned. + """ + params = _filter_none_values({"page_size": page_size, "page": page}) + + return self._client.get( + self._client.api_host(), + "/v2/applications", + params=params, + auth_type=ApplicationV2.auth_type, + ) + +class Application: + auth_type = 'header' + + def __init__(self, client): + self._client = client + + def create_application(self, application_data): + """ + Create an application using the provided `application_data`. + + :param dict application_data: A JSON-style dict describing the application to be created. + + >>> client.application.create_application({ 'name': 'My Cool App!' }) + + Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication + """ + return self._client.post( + self._client.api_host(), + "/v2/applications", + application_data, + auth_type=ApplicationV2.auth_type, + ) + + def get_application(self, application_id): + """ + Get application details for the application with `application_id`. + + The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication + + :param str application_id: The application ID. + :rtype: dict + """ + + return self._client.get( + self._client.api_host(), + f"/v2/applications/{application_id}", + auth_type=ApplicationV2.auth_type, + ) + + def update_application(self, application_id, params): + """ + Update the application with `application_id` using the values provided in `params`. + + """ return self._client.put( self._client.api_host(), diff --git a/src/vonage/client.py b/src/vonage/client.py index 1af4d80a..43ae9891 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,7 +1,7 @@ import vonage from .account import Account -from .application import ApplicationV2 +from .application import ApplicationV2, Application from .errors import * from .messages import Messages from .number_insight import NumberInsight @@ -113,7 +113,7 @@ def __init__( self.account = Account(self) - self.application_v2 = ApplicationV2(self) + self.application = Application(self) self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) diff --git a/tests/conftest.py b/tests/conftest.py index fa2ad329..eaa16d50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,3 +110,9 @@ def redact(client): import vonage return vonage.Redact(client) + +@pytest.fixture +def application_v2(client): + import vonage + + return vonage.ApplicationV2(client) \ No newline at end of file diff --git a/tests/test_application.py b/tests/test_application.py index 47d97dc4..7fe168be 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -3,6 +3,144 @@ import vonage +@responses.activate +def test_deprecated_list_applications(application_v2, dummy_data): + stub( + responses.GET, + "https://api.nexmo.com/v2/applications", + fixture_path="applications/list_applications.json", + ) + + apps = application_v2.list_applications() + assert_basic_auth() + assert isinstance(apps, dict) + assert apps["total_items"] == 30 + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_get_application(application_v2, dummy_data): + stub( + responses.GET, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + fixture_path="applications/get_application.json", + ) + + app = application_v2.get_application("xx-xx-xx-xx") + assert_basic_auth() + assert isinstance(app, dict) + assert app["name"] == "My Test Application" + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_create_application(application_v2, dummy_data): + stub( + responses.POST, + "https://api.nexmo.com/v2/applications", + fixture_path="applications/create_application.json", + ) + + params = {"name": "Example App", "type": "voice"} + + app = application_v2.create_application(params) + assert_basic_auth() + assert isinstance(app, dict) + assert app["name"] == "My Test Application" + assert request_user_agent() == dummy_data.user_agent + body_data = json.loads(request_body().decode("utf-8")) + assert body_data["type"] == "voice" + + +@responses.activate +def test_deprecated_update_application(application_v2, dummy_data): + stub( + responses.PUT, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + fixture_path="applications/update_application.json", + ) + + params = {"answer_url": "https://example.com/ncco"} + + app = application_v2.update_application("xx-xx-xx-xx", params) + assert_basic_auth() + assert isinstance(app, dict) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + assert b'"answer_url": "https://example.com/ncco"' in request_body() + + assert app["name"] == "A Better Name" + + +@responses.activate +def test_deprecated_delete_application(application_v2, dummy_data): + responses.add( + responses.DELETE, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + status=204, + ) + + assert application_v2.delete_application("xx-xx-xx-xx") is None + assert_basic_auth() + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_deprecated_authentication_error(application_v2): + responses.add( + responses.DELETE, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + status=401, + ) + with pytest.raises(vonage.AuthenticationError): + application_v2.delete_application("xx-xx-xx-xx") + + +@responses.activate +def test_deprecated_client_error(application_v2): + responses.add( + responses.DELETE, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + status=430, + body=json.dumps( + { + "type": "nope_error", + "title": "Nope", + "detail": "You really shouldn't have done that", + } + ), + ) + with pytest.raises(vonage.ClientError) as exc_info: + application_v2.delete_application("xx-xx-xx-xx") + assert ( + str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" + ) + + +@responses.activate +def test_deprecated_client_error_no_decode(application_v2): + responses.add( + responses.DELETE, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + status=430, + body="{this: isnot_json", + ) + with pytest.raises(vonage.ClientError) as exc_info: + application_v2.delete_application("xx-xx-xx-xx") + assert str(exc_info.value) == "430 response from api.nexmo.com" + + +@responses.activate +def test_deprecated_server_error(application_v2): + responses.add( + responses.DELETE, + "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", + status=500, + ) + with pytest.raises(vonage.ServerError): + application_v2.delete_application("xx-xx-xx-xx") + + @responses.activate def test_list_applications(client, dummy_data): @@ -12,7 +150,7 @@ def test_list_applications(client, dummy_data): fixture_path="applications/list_applications.json", ) - apps = client.application_v2.list_applications() + apps = client.application.list_applications() assert_basic_auth() assert isinstance(apps, dict) assert apps["total_items"] == 30 @@ -27,7 +165,7 @@ def test_get_application(client, dummy_data): fixture_path="applications/get_application.json", ) - app = client.application_v2.get_application("xx-xx-xx-xx") + app = client.application.get_application("xx-xx-xx-xx") assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -44,7 +182,7 @@ def test_create_application(client, dummy_data): params = {"name": "Example App", "type": "voice"} - app = client.application_v2.create_application(params) + app = client.application.create_application(params) assert_basic_auth() assert isinstance(app, dict) assert app["name"] == "My Test Application" @@ -63,7 +201,7 @@ def test_update_application(client, dummy_data): params = {"answer_url": "https://example.com/ncco"} - app = client.application_v2.update_application("xx-xx-xx-xx", params) + app = client.application.update_application("xx-xx-xx-xx", params) assert_basic_auth() assert isinstance(app, dict) assert request_user_agent() == dummy_data.user_agent @@ -81,7 +219,7 @@ def test_delete_application(client, dummy_data): status=204, ) - assert client.application_v2.delete_application("xx-xx-xx-xx") is None + assert client.application.delete_application("xx-xx-xx-xx") is None assert_basic_auth() assert request_user_agent() == dummy_data.user_agent @@ -94,7 +232,7 @@ def test_authentication_error(client): status=401, ) with pytest.raises(vonage.AuthenticationError): - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") @responses.activate @@ -112,7 +250,7 @@ def test_client_error(client): ), ) with pytest.raises(vonage.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") assert ( str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" ) @@ -127,7 +265,7 @@ def test_client_error_no_decode(client): body="{this: isnot_json", ) with pytest.raises(vonage.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") assert str(exc_info.value) == "430 response from api.nexmo.com" @@ -139,4 +277,4 @@ def test_server_error(client): status=500, ) with pytest.raises(vonage.ServerError): - client.application_v2.delete_application("xx-xx-xx-xx") + client.application.delete_application("xx-xx-xx-xx") From f43cec20aeb2c83019013ef476154f9ef9103b59 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 20 Jul 2022 18:17:22 +0100 Subject: [PATCH 195/401] Added new Application class, deprecated ApplicationV2, Redact and old Pricing methods --- CHANGES.md | 20 +++++++++++++------- src/vonage/account.py | 3 +++ src/vonage/application.py | 13 +++++++------ src/vonage/client.py | 1 - src/vonage/redact.py | 3 +++ 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 103ccd8e..ecf28f29 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,16 @@ -# 3.0.0 (Unreleased, WIP) -- Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object -- Removed methods to call the Message Search API, which has been retired by Vonage -- Added `get_all_countries_pricing` method to `Account` object -- Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen -- Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py` -- Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs +# 3.0.0 +- Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object. +- Removed methods to call the Message Search API, which has been retired by Vonage. +- Added `get_all_countries_pricing` method to `Account` object. +- Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen. +- Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py`. +- Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs. +- Deprecated the ApplicationV2 class and created an Application class with the same methods to bring the naming in line with other classes. +- Deprecated old Pricing API endpoints. +- Deprecated Redact class as it's a dev preview product that's unsupported in the SDK. +- Removed automatic client object creation when calling an API class. Objects are accessed from a client-instantiated method as introduced in v2.7.0 of the SDK. +- Added `max_retries`, `timeout`, `pool_connections` and `pool_maxsize` optional keyword arguments to the `Client` class, which can now be specified and used in the API calls made with the client. + # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. diff --git a/src/vonage/account.py b/src/vonage/account.py index 09784208..4185decd 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -1,5 +1,6 @@ from .errors import PricingTypeError +from deprecated import deprecated class Account: account_auth_type = 'params' pricing_auth_type = 'params' @@ -46,6 +47,7 @@ def get_prefix_pricing(self, prefix: str, type: str = 'sms'): auth_type=Account.pricing_auth_type, ) + @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') def get_sms_pricing(self, number: str): return self._client.get( self._client.host(), @@ -54,6 +56,7 @@ def get_sms_pricing(self, number: str): auth_type=Account.pricing_auth_type, ) + @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') def get_voice_pricing(self, number: str): return self._client.get( self._client.host(), diff --git a/src/vonage/application.py b/src/vonage/application.py index f95b1702..68997e25 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -1,6 +1,7 @@ from deprecated import deprecated -@deprecated(version='3.0.0', reason='renaming to Application in a later release as v1 is out of support') +@deprecated(version='3.0.0', reason='Renamed to Application as V1 is out of support and this new \ + naming is in line with other APIs. Please use Application instead.') class ApplicationV2: auth_type = 'header' @@ -102,7 +103,7 @@ def create_application(self, application_data): self._client.api_host(), "/v2/applications", application_data, - auth_type=ApplicationV2.auth_type, + auth_type=Application.auth_type, ) def get_application(self, application_id): @@ -118,7 +119,7 @@ def get_application(self, application_id): return self._client.get( self._client.api_host(), f"/v2/applications/{application_id}", - auth_type=ApplicationV2.auth_type, + auth_type=Application.auth_type, ) def update_application(self, application_id, params): @@ -131,7 +132,7 @@ def update_application(self, application_id, params): self._client.api_host(), f"/v2/applications/{application_id}", params, - auth_type=ApplicationV2.auth_type, + auth_type=Application.auth_type, ) def delete_application(self, application_id): @@ -142,7 +143,7 @@ def delete_application(self, application_id): self._client.delete( self._client.api_host(), f"/v2/applications/{application_id}", - auth_type=ApplicationV2.auth_type, + auth_type=Application.auth_type, ) def list_applications(self, page_size=None, page=None): @@ -160,7 +161,7 @@ def list_applications(self, page_size=None, page=None): self._client.api_host(), "/v2/applications", params=params, - auth_type=ApplicationV2.auth_type, + auth_type=Application.auth_type, ) diff --git a/src/vonage/client.py b/src/vonage/client.py index 43ae9891..0294c64a 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -117,7 +117,6 @@ def __init__( self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) - self.redact = Redact(self) self.short_codes = ShortCodes(self) self.sms = Sms(self) self.ussd = Ussd(self) diff --git a/src/vonage/redact.py b/src/vonage/redact.py index 2b26ce80..1e1fb5f4 100644 --- a/src/vonage/redact.py +++ b/src/vonage/redact.py @@ -1,5 +1,8 @@ from .errors import RedactError +from deprecated import deprecated + +@deprecated(version='3.0.0', reason='This is a dev preview product and as such is not supported in this SDK.') class Redact: auth_type = 'header' From de8c99a4dc470195759edc072ebb31fcacce55eb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 20 Jul 2022 18:18:14 +0100 Subject: [PATCH 196/401] =?UTF-8?q?Bump=20version:=202.8.0=20=E2=86=92=203?= =?UTF-8?q?.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 30122bcf..f316e0e8 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.8.0 +current_version = 3.0.0 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index 1b8f03e1..a638ccb1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "2.8.0" +version = "3.0.0" # The full version, including alpha/beta/rc tags. -release = "2.8.0" +release = "3.0.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v2.8.0' +# html_title = u'Vonage v3.0.0' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index b029f964..f68cfa91 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="2.8.0", + version="3.0.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index f34842ea..8cd789c9 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "2.8.0" +__version__ = "3.0.0" From b25acec60c1b1de82d05db956d119ad47c0965e1 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 25 Jul 2022 17:08:48 +0100 Subject: [PATCH 197/401] added undocumented methods to README --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index daf11e65..be9ad01d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,16 @@ response = client.sms.send_message({ client.sms.submit_sms_conversion(response['message-id']) ``` +### Update the default SMS webhook URLs for callbacks/delivery reciepts +```python +client.sms.update_default_sms_webhook({ + 'moCallBackUrl': 'new.url.vonage.com', # Default inbound sms webhook url + 'drCallBackUrl': 'different.url.vonage.com' # Delivery receipt url + }}) +``` + +The delivery receipt URL can be unset by sending an empty string. + ## Messages API The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. Call the API from your Python code by @@ -458,6 +468,37 @@ client.number_insight.get_advanced_number_insight(number='447700900000') Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +## Account API + +### Get your account balance +```python +client.account.get_balance() +``` + +### Top up your account +This feature is only enabled when you enable auto-reload for your account in the dashboard. +```python +# trx is the reference from when auto-reload was enabled and money was added +client.account.topup(trx=transaction_reference) +``` + +## Pricing API + +### Get pricing for a single country +```python +client.get_country_pricing(country_code='GB', type='sms') # Default type is sms +``` + +### Get pricing for all countries +```python +client.get_all_countries_pricing(type='sms') # Default type is sms, can be voice +``` + +### Get pricing for a specific dialling prefix +```python +client.get_country_pricing(prefix='44', type='sms') +``` + ## Managing Secrets An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. @@ -468,6 +509,12 @@ An API is provided to allow you to rotate your API secrets. You can create a new secrets = client.account.list_secrets(API_KEY) ``` +### Get information about a specific secret + +```python +secrets = client.account.get_secret(API_KEY, secret_id) +``` + ### Create A New Secret Create a new secret (the created dates will help you know which is which): @@ -481,7 +528,7 @@ client.account.create_secret(API_KEY, 'awes0meNewSekret!!;'); Delete the old secret (any application still using these credentials will stop working): ```python -client.account.delete_secret(API_KEY, 'my-secret-id') +client.account.revoke_secret(API_KEY, 'my-secret-id') ``` ## Application API @@ -489,7 +536,7 @@ client.account.delete_secret(API_KEY, 'my-secret-id') ### Create an application ```python -response = client.application_v2.create_application({name='Example App', type='voice'}) +response = client.application.create_application({name='Example App', type='voice'}) ``` Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) @@ -497,7 +544,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https:/ ### Retrieve a list of applications ```python -response = client.application_v2.list_applications() +response = client.application.list_applications() ``` Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) @@ -505,7 +552,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://d ### Retrieve a single application ```python -response = client.application_v2.get_application(uuid) +response = client.application.get_application(uuid) ``` Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) @@ -513,7 +560,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://de ### Update an application ```python -response = client.application_v2.update_application(uuid, answer_method='POST') +response = client.application.update_application(uuid, answer_method='POST') ``` Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) @@ -521,7 +568,7 @@ Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https:/ ### Delete an application ```python -response = client.application_v2.delete_application(uuid) +response = client.application.delete_application(uuid) ``` Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) @@ -608,7 +655,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | -| Redact API | General Availability | ✅ | +| Redact API | Developer Preview | ❌ | | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | | Verify API | General Availability | ✅ | From 166a0afa60167769e73ee048a6bc071d59ea0687 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 27 Jul 2022 14:21:42 +0100 Subject: [PATCH 198/401] added logging to new rest calls, updated changelog --- CHANGES.md | 21 ++++++++++++--------- src/vonage/client.py | 8 +++++++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ecf28f29..f31f6291 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,16 +1,19 @@ # 3.0.0 -- Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object. + +Breaking changes: +- Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs. E.g. to call the number insight API, the methods are now called in this way: `client.number_insight.get_basic_number_insight(...)`, or by instantiating the `NumberInsight` class directly: `ni = vonage.NumberInsight(client)`, `ni.get_basic_number_insight(...)` etc. +- Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object. You can now use these APIs from a client instance you create (e.g. `client.sms.send_message()`) or pass in a client to the API class to create it (e.g. `sms = vonage.Sms(client)`), as has been the case since v2.7.0 of the SDK. - Removed methods to call the Message Search API, which has been retired by Vonage. +- Removed deprecated voice and number insight methods from `voice.py` (`initiate_call, initiate_tts_call and initiate_tts_prompt_call`) and `number_insight.py` (`request_number_insight`). +- Deprecated the ApplicationV2 class and created an Application class with the same methods to bring the naming in line with other classes. This can be called from the client object with `client.application.create_application(...)` etc. or directly with `application = vonage.Application(client)`, `application.create_application(...)` etc. +- Deprecated old Pricing API methods `get_sms_pricing` and `get_voice_pricing`. +- Deprecated Redact class as it's a dev preview product that's unsupported in the SDK and will be removed in a later release. +- Renamed the `Account.delete_secret()` method to `revoke_secret()` to bring it in line with what is described in our documentation. + +Enhancements: - Added `get_all_countries_pricing` method to `Account` object. - Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen. -- Removed deprecated voice and number insight methods from `voice.py` and `number_insight.py`. -- Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs. -- Deprecated the ApplicationV2 class and created an Application class with the same methods to bring the naming in line with other classes. -- Deprecated old Pricing API endpoints. -- Deprecated Redact class as it's a dev preview product that's unsupported in the SDK. -- Removed automatic client object creation when calling an API class. Objects are accessed from a client-instantiated method as introduced in v2.7.0 of the SDK. -- Added `max_retries`, `timeout`, `pool_connections` and `pool_maxsize` optional keyword arguments to the `Client` class, which can now be specified and used in the API calls made with the client. - +- Added `max_retries`, `timeout`, `pool_connections` and `pool_maxsize` optional keyword arguments to the `Client` class, which can now be specified on instantiation and used in the API calls made with the client. # 2.8.0 - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. diff --git a/src/vonage/client.py b/src/vonage/client.py index 0294c64a..bd0c3599 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -199,6 +199,8 @@ def get(self, host, request_uri, params=None, auth_type=None): raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) + + logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") return self.parse( host, self.session.get(uri, params=params, headers=self._request_headers)) @@ -233,6 +235,8 @@ def post(self, host, request_uri, params, auth_type=None, body_is_json=True, sup raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) + + logger.debug(f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") if body_is_json: return self.parse( host, self.session.post(uri, json=params, headers=self._request_headers)) @@ -256,7 +260,8 @@ def put(self, host, request_uri, params, auth_type=None): f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) - # All APIs that currently use put methods require a json-formatted body + logger.debug(f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") + # All APIs that currently use put methods require a json-formatted body so don't need to check this return self.parse(host, self.session.put(uri, json=params, headers=self._request_headers)) def delete(self, host, request_uri, auth_type=None): @@ -275,6 +280,7 @@ def delete(self, host, request_uri, auth_type=None): f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) + logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") return self.parse( host, self.session.delete(uri, headers=self._request_headers) ) From 03d4077cbd45a44e76b2873df39ed381530ab280 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 2 Aug 2022 16:44:55 +0100 Subject: [PATCH 199/401] fixing README pricing API typos and adding deprecations section to changelog --- CHANGES.md | 4 +++- README.md | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f31f6291..0ac25750 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,10 +5,12 @@ Breaking changes: - Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object. You can now use these APIs from a client instance you create (e.g. `client.sms.send_message()`) or pass in a client to the API class to create it (e.g. `sms = vonage.Sms(client)`), as has been the case since v2.7.0 of the SDK. - Removed methods to call the Message Search API, which has been retired by Vonage. - Removed deprecated voice and number insight methods from `voice.py` (`initiate_call, initiate_tts_call and initiate_tts_prompt_call`) and `number_insight.py` (`request_number_insight`). +- Renamed the `Account.delete_secret()` method to `revoke_secret()` to bring it in line with what is described in our documentation. + +Deprecations: - Deprecated the ApplicationV2 class and created an Application class with the same methods to bring the naming in line with other classes. This can be called from the client object with `client.application.create_application(...)` etc. or directly with `application = vonage.Application(client)`, `application.create_application(...)` etc. - Deprecated old Pricing API methods `get_sms_pricing` and `get_voice_pricing`. - Deprecated Redact class as it's a dev preview product that's unsupported in the SDK and will be removed in a later release. -- Renamed the `Account.delete_secret()` method to `revoke_secret()` to bring it in line with what is described in our documentation. Enhancements: - Added `get_all_countries_pricing` method to `Account` object. diff --git a/README.md b/README.md index be9ad01d..05308a6d 100644 --- a/README.md +++ b/README.md @@ -486,17 +486,17 @@ client.account.topup(trx=transaction_reference) ### Get pricing for a single country ```python -client.get_country_pricing(country_code='GB', type='sms') # Default type is sms +client.account.get_country_pricing(country_code='GB', type='sms') # Default type is sms ``` ### Get pricing for all countries ```python -client.get_all_countries_pricing(type='sms') # Default type is sms, can be voice +client.account.get_all_countries_pricing(type='sms') # Default type is sms, can be voice ``` ### Get pricing for a specific dialling prefix ```python -client.get_country_pricing(prefix='44', type='sms') +client.account.get_prefix_pricing(prefix='44', type='sms') ``` ## Managing Secrets From cf8a0b7d4776f1daa5777abcf072fe645427aa97 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 24 Aug 2022 16:45:53 +0100 Subject: [PATCH 200/401] added new tests for verification requests to blocklisted numbers --- tests/data/verify/blocked_with_network.json | 1 + .../blocked_with_network_and_request_id.json | 1 + .../data/verify/blocked_with_request_id.json | 1 + tests/test_verify.py | 55 +++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 tests/data/verify/blocked_with_network.json create mode 100644 tests/data/verify/blocked_with_network_and_request_id.json create mode 100644 tests/data/verify/blocked_with_request_id.json diff --git a/tests/data/verify/blocked_with_network.json b/tests/data/verify/blocked_with_network.json new file mode 100644 index 00000000..06c71609 --- /dev/null +++ b/tests/data/verify/blocked_with_network.json @@ -0,0 +1 @@ +{"status":"7","error_text":"The number you are trying to verify is blacklisted for verification","network":"25503"} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_network_and_request_id.json b/tests/data/verify/blocked_with_network_and_request_id.json new file mode 100644 index 00000000..971f276b --- /dev/null +++ b/tests/data/verify/blocked_with_network_and_request_id.json @@ -0,0 +1 @@ +{"request_id":"12345678","status":"7","error_text":"The number you are trying to verify is blacklisted for verification","network":"25503"} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_request_id.json b/tests/data/verify/blocked_with_request_id.json new file mode 100644 index 00000000..61c8446f --- /dev/null +++ b/tests/data/verify/blocked_with_request_id.json @@ -0,0 +1 @@ +{"request_id":"12345678","status":"7","error_text":"The number you are trying to verify is blacklisted for verification"} \ No newline at end of file diff --git a/tests/test_verify.py b/tests/test_verify.py index e0e54100..67b26ef6 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -61,3 +61,58 @@ def test_start_psd2_verification(verify, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "number=447525856424" in request_body() assert "brand=MyApp" in request_body() + + +@responses.activate +def test_start_verification_blacklisted_error_with_network(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json", + fixture_path="verify/blocked_with_network.json" + ) + + params = {"number": "447525856424", "brand": "MyApp"} + response = client.verify.start_verification(params) + + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + assert response["status"] == "7" + assert response["network"] == "25503" + assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + + +@responses.activate +def test_start_verification_blacklisted_error_with_request_id(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json", + fixture_path="verify/blocked_with_request_id.json" + ) + + params = {"number": "447525856424", "brand": "MyApp"} + response = client.verify.start_verification(params) + + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + assert response["status"] == "7" + assert response["request_id"] == "12345678" + assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + + +@responses.activate +def test_start_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/json", + fixture_path="verify/blocked_with_network_and_request_id.json" + ) + + params = {"number": "447525856424", "brand": "MyApp"} + response = client.verify.start_verification(params) + + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + assert response["status"] == "7" + assert response["network"] == "25503" + assert response["request_id"] == "12345678" + assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" From a9d4730a132b07d726a3f3737198efdcf3adc5d8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 25 Aug 2022 14:33:04 +0100 Subject: [PATCH 201/401] added psd2 tests for blocklist case --- tests/test_verify.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_verify.py b/tests/test_verify.py index 67b26ef6..adb7ec27 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -116,3 +116,57 @@ def test_start_verification_blacklisted_error_with_network_and_request_id(client assert response["network"] == "25503" assert response["request_id"] == "12345678" assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + +@responses.activate +def test_start_psd2_verification_blacklisted_error_with_network(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json", + fixture_path="verify/blocked_with_network.json" + ) + + params = {"number": "447525856424", "brand": "MyApp"} + response = client.verify.psd2(params) + + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + assert response["status"] == "7" + assert response["network"] == "25503" + assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + + +@responses.activate +def test_start_psd2_verification_blacklisted_error_with_request_id(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json", + fixture_path="verify/blocked_with_request_id.json" + ) + + params = {"number": "447525856424", "brand": "MyApp"} + response = client.verify.psd2(params) + + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + assert response["status"] == "7" + assert response["request_id"] == "12345678" + assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + + +@responses.activate +def test_start_psd2_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): + stub(responses.POST, "https://api.nexmo.com/verify/psd2/json", + fixture_path="verify/blocked_with_network_and_request_id.json" + ) + + params = {"number": "447525856424", "brand": "MyApp"} + response = client.verify.psd2(params) + + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert "number=447525856424" in request_body() + assert "brand=MyApp" in request_body() + assert response["status"] == "7" + assert response["network"] == "25503" + assert response["request_id"] == "12345678" + assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" From 3de626e251c8e98f917b49f7630a69f25257951f Mon Sep 17 00:00:00 2001 From: Yohann Gabory Date: Thu, 22 Sep 2022 17:06:27 +0200 Subject: [PATCH 202/401] Add self.timeout on get/post/put and delete method of Client.session On Client a timeout parameter can be define in the `__init__` method This parameter is used to set `self.timeout` but is not used afterward. This PR adds `self.timeout` each time `self.session` make a request --- src/vonage/client.py | 14 +++++++++----- tests/test_client.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index bd0c3599..ac01c5f7 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -62,6 +62,10 @@ class Client: provided by this library and can be used to track your app statistics. :param str app_version: This optional value is added to the user-agent header provided by this library and can be used to track your app statistics. + :param timeout: (optional) How many seconds to wait for the server to send data + before giving up, as a float, or a (connect timeout, read + timeout) tuple. If set this timeout is used for every call to the Vonage enpoints + :type timeout: float or tuple """ def __init__( @@ -203,7 +207,7 @@ def get(self, host, request_uri, params=None, auth_type=None): logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") return self.parse( host, - self.session.get(uri, params=params, headers=self._request_headers)) + self.session.get(uri, params=params, headers=self._request_headers, timeout=self.timeout)) def post(self, host, request_uri, params, auth_type=None, body_is_json=True, supports_signature_auth=False): """ @@ -239,10 +243,10 @@ def post(self, host, request_uri, params, auth_type=None, body_is_json=True, sup logger.debug(f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") if body_is_json: return self.parse( - host, self.session.post(uri, json=params, headers=self._request_headers)) + host, self.session.post(uri, json=params, headers=self._request_headers, timeout=self.timeout)) else: return self.parse( - host, self.session.post(uri, data=params, headers=self._request_headers)) + host, self.session.post(uri, data=params, headers=self._request_headers, timeout=self.timeout)) def put(self, host, request_uri, params, auth_type=None): uri = f"https://{host}{request_uri}" @@ -262,7 +266,7 @@ def put(self, host, request_uri, params, auth_type=None): logger.debug(f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") # All APIs that currently use put methods require a json-formatted body so don't need to check this - return self.parse(host, self.session.put(uri, json=params, headers=self._request_headers)) + return self.parse(host, self.session.put(uri, json=params, headers=self._request_headers, timeout=self.timeout)) def delete(self, host, request_uri, auth_type=None): uri = f"https://{host}{request_uri}" @@ -282,7 +286,7 @@ def delete(self, host, request_uri, auth_type=None): logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") return self.parse( - host, self.session.delete(uri, headers=self._request_headers) + host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout) ) def parse(self, host, response): diff --git a/tests/test_client.py b/tests/test_client.py index 6cdba2e9..9044e4d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -105,4 +105,16 @@ def test_client_can_make_application_requests_without_api_key(dummy_data): def test_invalid_auth_type_raises_error(client): with pytest.raises(InvalidAuthenticationTypeError): - client.get(client.host(), 'my/request/uri', auth_type='magic') \ No newline at end of file + client.get(client.host(), 'my/request/uri', auth_type='magic') + +@responses.activate +def test_timeout_is_set_on_client_calls(dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + client = vonage.Client(application_id="myid", private_key=dummy_data.private_key, timeout=1) + voice = vonage.Voice(client) + voice.create_call("123455") + + assert len(responses.calls) == 1 + assert responses.calls[0].request.req_kwargs["timeout"] == 1 + From 5b39f3c90765eb7f33c6dc51d491af4be222a120 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 3 Oct 2022 18:47:09 +0100 Subject: [PATCH 203/401] generating jwts when requests are made --- src/vonage/client.py | 11 +++-------- src/vonage/messages.py | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index ac01c5f7..c9fd32b8 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -93,7 +93,6 @@ def __init__( self.signature_method = getattr(hashlib, signature_method) self._jwt_auth_params = {} - self.jwt = None if private_key is not None and application_id is not None: self._application_id = application_id @@ -103,8 +102,6 @@ def __init__( with open(self._private_key, "rb") as key_file: self._private_key = key_file.read() - self.jwt = self._generate_application_jwt() - self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" @@ -115,7 +112,6 @@ def __init__( self.headers = {"User-Agent": user_agent, "Accept": "application/json"} - self.account = Account(self) self.application = Application(self) self.messages = Messages(self) @@ -152,7 +148,6 @@ def api_host(self, value=None): def auth(self, params=None, **kwargs): self._jwt_auth_params = params or kwargs - self.jwt = self._generate_application_jwt() def check_signature(self, params): params = dict(params) @@ -293,7 +288,7 @@ def parse(self, host, response): logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: raise AuthenticationError( - "Check you're using a valid authentication method for the API you want to use" + "Authentication failed. Check you're using a valid authentication method." ) elif response.status_code == 204: return None @@ -313,7 +308,6 @@ def parse(self, host, response): # Test for standard error format: try: - error_data = response.json() if ( "type" in error_data @@ -334,7 +328,8 @@ def parse(self, host, response): raise ServerError(message) def _add_jwt_to_request_headers(self): - return dict(self.headers, Authorization=b"Bearer " + self.jwt) + jwt = self._generate_application_jwt() + return dict(self.headers, Authorization=b"Bearer " + jwt) def _generate_application_jwt(self): iat = int(time.time()) diff --git a/src/vonage/messages.py b/src/vonage/messages.py index 746f8c66..6cb521f7 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -19,7 +19,7 @@ def __init__(self, client): def send_message(self, params: dict): self.validate_send_message_input(params) - if self._client.jwt is None: + if self._client._application_id is None: self._auth_type='header' return self._client.post( self._client.api_host(), From 67db34645e6834b6293c5c4d6b8dd0bfcc25fc83 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 3 Oct 2022 18:49:31 +0100 Subject: [PATCH 204/401] small refactor --- src/vonage/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index c9fd32b8..7397591d 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -328,8 +328,7 @@ def parse(self, host, response): raise ServerError(message) def _add_jwt_to_request_headers(self): - jwt = self._generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + jwt) + return dict(self.headers, Authorization=b"Bearer " + self._generate_application_jwt()) def _generate_application_jwt(self): iat = int(time.time()) From 546c28a3712e9c70805225cafa2e77e6e523c001 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 4 Oct 2022 16:34:42 +0100 Subject: [PATCH 205/401] =?UTF-8?q?Bump=20version:=203.0.0=20=E2=86=92=203?= =?UTF-8?q?.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- docs/conf.py | 6 +++--- setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f316e0e8..03e53f47 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.0 +current_version = 3.0.1 commit = True tag = False diff --git a/docs/conf.py b/docs/conf.py index a638ccb1..1ae4eea0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = "3.0.0" +version = "3.0.1" # The full version, including alpha/beta/rc tags. -release = "3.0.0" +release = "3.0.1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -143,7 +143,7 @@ # The name for this set of Sphinx documents. # " v documentation" by default. # -# html_title = u'Vonage v3.0.0' +# html_title = u'Vonage v3.0.1' # A shorter title for the navigation bar. Default is the same as html_title. # diff --git a/setup.py b/setup.py index f68cfa91..14d884d1 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.0.0", + version="3.0.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 8cd789c9..3304bea9 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "3.0.0" +__version__ = "3.0.1" From 5ee9b6b8f16f518d89dc8f22396602e70162b73f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 4 Oct 2022 16:39:54 +0100 Subject: [PATCH 206/401] updated CHANGES.md --- CHANGES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 0ac25750..5bc71cd6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,8 @@ -# 3.0.0 +# 3.0.1 +- Fixed bug where a JWT was created globally and could expire. Now a new JWT is generated when a request is made. +- Fixed bug where timeout was not passed to session object. +# 3.0.0 Breaking changes: - Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs. E.g. to call the number insight API, the methods are now called in this way: `client.number_insight.get_basic_number_insight(...)`, or by instantiating the `NumberInsight` class directly: `ni = vonage.NumberInsight(client)`, `ni.get_basic_number_insight(...)` etc. - Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object. You can now use these APIs from a client instance you create (e.g. `client.sms.send_message()`) or pass in a client to the API class to create it (e.g. `sms = vonage.Sms(client)`), as has been the case since v2.7.0 of the SDK. From e809748859da17fb310fe152b795dd663ad7ce45 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 4 Oct 2022 17:04:12 +0100 Subject: [PATCH 207/401] removing old update changelog github action --- .github/workflows/release.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d10c2521..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Release published -on: - release: - types: [published] -jobs: - add-changelog: - name: Add Changelog - runs-on: ubuntu-latest - steps: - - name: Add Changelog - uses: nexmo/github-actions/nexmo-changelog@master - env: - CHANGELOG_AUTH_TOKEN: ${{ secrets.CHANGELOG_AUTH_TOKEN }} - CHANGELOG_CATEGORY: Server SDK - CHANGELOG_RELEASE_TITLE: vonage-python-sdk - CHANGELOG_SUBCATEGORY: python From c577aa7f110f8297fd2a6d66f75179fcdd2f9de8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 4 Oct 2022 17:25:48 +0100 Subject: [PATCH 208/401] removed old docs --- docs/.gitignore | 1 - docs/Makefile | 228 ------------------------- docs/_static/.gitignore | 33 ---- docs/conf.py | 347 -------------------------------------- docs/index.rst | 18 -- docs/make.bat | 281 ------------------------------- docs/quickstart.rst | 358 ---------------------------------------- docs/reference.rst | 14 -- requirements/docs.txt | 2 - 9 files changed, 1282 deletions(-) delete mode 100644 docs/.gitignore delete mode 100644 docs/Makefile delete mode 100644 docs/_static/.gitignore delete mode 100644 docs/conf.py delete mode 100644 docs/index.rst delete mode 100644 docs/make.bat delete mode 100644 docs/quickstart.rst delete mode 100644 docs/reference.rst delete mode 100644 requirements/docs.txt diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index e35d8850..00000000 --- a/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_build diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 01db0284..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,228 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " epub3 to make an epub3" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - @echo " dummy to check syntax errors of document sources" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -quickstart.rst: ../README.md - pandoc -f markdown -t rst ../README.md -o quickstart.rst - -.PHONY: html -html: quickstart.rst - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Vonage.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Vonage.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Vonage" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Vonage" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: epub3 -epub3: - $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 - @echo - @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." - -.PHONY: dummy -dummy: - $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy - @echo - @echo "Build finished. Dummy builder generates no files." diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore deleted file mode 100644 index dfdb05af..00000000 --- a/docs/_static/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ - -# Created by https://www.gitignore.io/api/osx -# Edit at https://www.gitignore.io/?templates=osx - -### OSX ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# End of https://www.gitignore.io/api/osx diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 1ae4eea0..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,347 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Vonage documentation build configuration file, created by -# sphinx-quickstart on Sun Sep 18 14:36:55 2016. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import datetime -import os -import sys - -sys.path.insert(0, os.path.abspath("..")) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.doctest", - "sphinx.ext.todo", - "sphinx.ext.coverage", - "sphinx.ext.viewcode", - "sphinx.ext.githubpages", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The encoding of source files. -# -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = "Vonage" -copyright = f"{datetime.datetime.now().year}, Vonage" -author = "Vonage" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = "3.0.1" -# The full version, including alpha/beta/rc tags. -release = "3.0.1" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# -# today = '' -# -# Else, today_fmt is used as the format for a strftime call. -# -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - -# -- Options for HTML output ---------------------------------------------- - - -import sphinx_rtd_theme - -html_theme = "sphinx_rtd_theme" - -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -# -# html_title = u'Vonage v3.0.1' - -# A shorter title for the navigation bar. Default is the same as html_title. -# -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# -# html_logo = None - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# -# html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -# -# html_last_updated_fmt = None - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# -# html_additional_pages = {} - -# If false, no module index is generated. -# -# html_domain_indices = True - -# If false, no index is generated. -# -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -# -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -# -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = "Vonagedoc" - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, "Vonage.tex", "Vonage Documentation", "developer@vonage.com", "manual") -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "vonage", "Vonage Documentation", [author], 1)] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "Vonage", - "Vonage Documentation", - author, - "Vonage", - "One line description of project.", - "Miscellaneous", - ) -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index b7d930fd..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,18 +0,0 @@ - -Welcome to Vonage's documentation! -================================= - -.. toctree:: - :maxdepth: 2 - - quickstart - reference - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 62035422..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,281 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Vonage.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Vonage.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end diff --git a/docs/quickstart.rst b/docs/quickstart.rst deleted file mode 100644 index 39f8ccd6..00000000 --- a/docs/quickstart.rst +++ /dev/null @@ -1,358 +0,0 @@ -Vonage Client Library for Python -=============================== - -|PyPI version| |Build Status| - -This is the Python client library for Vonage's API. To use it you'll need -a Vonage account. Sign up `for free at -vonage.com `__. - -- `Installation <#installation>`__ -- `Usage <#usage>`__ -- `SMS API <#sms-api>`__ -- `Voice API <#voice-api>`__ -- `Verify API <#verify-api>`__ -- `Application API <#application-api>`__ -- `Coverage <#api-coverage>`__ -- `License <#license>`__ - -Installation ------------- - -To install the Python SDK using pip: - -:: - - pip install vonage - -Alternatively, you can clone the repository: - -:: - - git clone git@github.com:Vonage/vonage-python-sdk.git - -Usage ------ - -Begin by importing the vonage module: - -.. code:: python - - import vonage - -Then construct a client object with your key and secret: - -.. code:: python - - client = vonage.Client(key=api_key, secret=api_secret) - -For production, you can specify the ``VONAGE_API_KEY`` and -``VONAGE_API_SECRET`` environment variables instead of specifying the key -and secret explicitly. - -For newer endpoints that support JWT authentication such as the Voice -API, you can also specify the ``application_id`` and ``private_key`` -arguments: - -.. code:: python - - client = vonage.Client(application_id=application_id, private_key=private_key) - -In order to check signatures for incoming webhook requests, you'll also -need to specify the ``signature_secret`` argument (or the -``VONAGE_SIGNATURE_SECRET`` environment variable). - -If the argument ``signature_method`` is omitted, it will default to the md5 hash -algorithm. Otherwise, it will use the selected method as in md5, sha1, sha256 or -sha512 with hmac. - -SMS API -------- - -Send a text message -~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.send_message({'from': 'Python', 'to': 'YOUR-NUMBER', 'text': 'Hello world'}) - - response = response['messages'][0] - - if response['status'] == '0': - print('Sent message', response['message-id']) - - print('Remaining balance is', response['remaining-balance']) - else: - print('Error:', response['error-text']) - -Docs: -`https://docs.nexmo.com/messaging/sms-api/api-reference#request `__ - -Voice API ---------- - -Make a call -~~~~~~~~~~~ - -.. code:: python - - response = client.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] - }) - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_create `__ - -Retrieve a list of calls -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_calls() - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve `__ - -Retrieve a single call -~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_call(uuid) - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve\_single `__ - -Update a call -~~~~~~~~~~~~~ - -.. code:: python - - response = client.update_call(uuid, action='hangup') - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_modify\_single `__ - -Verify API ----------- - -Start a verification -~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.start_verification(number='441632960960', brand='MyApp') - - if response['status'] == '0': - print f'Started verification request_id={response['request_id']}' - else: - print('Error:', response['error_text']) - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#vrequest `__ - -The response contains a verification request id which you will need to -store temporarily (in the session, database, url etc). - -Check a verification -~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') - - if response['status'] == '0': - print 'Verification complete, event_id={response['event_id']}' - else: - print('Error:', response['error_text']) - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#check `__ - -The verification request id comes from the call to the -start\_verification method. The PIN code is entered into your -application by the user. - -Cancel a verification -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ - -Trigger next verification step -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ - -Application API ---------------- - -Create an application -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.create_application(name='Example App', type='voice', answer_url=answer_url) - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#create `__ - -Retrieve a list of applications -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_applications() - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#list `__ - -Retrieve a single application -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_application(uuid) - -Docs: -`https://developer.nexmo.com/api/application#retrieve-an-application `__ - -Update an application -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.update_application(uuid, answer_method='POST') - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#update `__ - -Delete an application -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.delete_application(uuid) - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#delete `__ - -Validate webhook signatures ---------------------------- - -.. code:: python - - client = vonage.Client(signature_secret='secret') - - if client.check_signature(request.query): - # valid signature - else: - # invalid signature - - - or by using signature method via POST: - - client = vonage.Client(signature_secret='secret', signature_method='sha256') - - if client.check_signature(request.body.decode()): - # valid signature - else: - # invalid signature - -Docs: -`https://docs.nexmo.com/messaging/signing-messages `__ - -Note: you'll need to contact support@nexmo.com to enable message signing -on your account before you can validate webhook signatures. - -JWT parameters --------------- - -By default, the library generates short-lived tokens for JWT -authentication. - -Use the auth method to specify parameters for a longer life token or to -specify a different token identifier: - -.. code:: python - - client.auth(nbf=nbf, exp=exp, jti=jti) - -API Coverage ------------- - -- Account - - - [X] Balance - - [X] Pricing - - [X] Settings - - [X] Top Up - - [X] Numbers - - - [X] Search - - [X] Buy - - [X] Cancel - - [X] Update - -- Number Insight - - - [X] Basic - - [X] Standard - - [X] Advanced - - [ ] Webhook Notification - -- Verify - - - [X] Verify - - [X] Check - - [X] Search - - [X] Control - -- Messaging - - - [X] Send - - [ ] Delivery Receipt - - [ ] Inbound Messages - - [X] Search - - - [X] Message - - [X] Messages - - [X] Rejections - - - [X] US Short Codes - - - [X] Two-Factor Authentication - - [X] Event Based Alerts - - - [X] Sending Alerts - - [X] Campaign Subscription Management - -- Voice - - - [X] Outbound Calls - - [ ] Inbound Call - - [X] Text-To-Speech Call - - [X] Text-To-Speech Prompt - -License -------- - -This library is released under the `MIT License `__ - -.. |PyPI version| image:: https://badge.fury.io/py/vonage.svg - :target: https://badge.fury.io/py/vonage -.. |Build Status| image:: (https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg) - :target: https://github.com/Vonage/vonage-python-sdk/actions - - diff --git a/docs/reference.rst b/docs/reference.rst deleted file mode 100644 index 33e14019..00000000 --- a/docs/reference.rst +++ /dev/null @@ -1,14 +0,0 @@ -API Reference -============= - -.. autoclass:: vonage.Client - :members: - :undoc-members: - - .. attribute:: application_v2 - - An instance of :class:`vonage.ApplicationV2` for accessing the Application API. - -.. autoclass:: vonage.ApplicationV2 - :members: - :undoc-members: diff --git a/requirements/docs.txt b/requirements/docs.txt deleted file mode 100644 index 4994a700..00000000 --- a/requirements/docs.txt +++ /dev/null @@ -1,2 +0,0 @@ -sphinx==1.4.6 -sphinx_rtd_theme==0.1.9 From 1b81b377b3a24929be73b8dbabeeaa2eb56369e3 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 25 Oct 2022 18:11:07 +0100 Subject: [PATCH 209/401] bugfix in messages api auth check --- .gitignore | 1 + src/vonage/messages.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d96fba92..dad3aaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,4 @@ ENV* .idea .pypirc .pytest_cache +html/ diff --git a/src/vonage/messages.py b/src/vonage/messages.py index 6cb521f7..d74efca8 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -16,10 +16,10 @@ def __init__(self, client): self._client = client self._auth_type = 'jwt' - def send_message(self, params: dict): + def send_message(self, params: dict): self.validate_send_message_input(params) - if self._client._application_id is None: + if not hasattr(self._client, '_application_id'): self._auth_type='header' return self._client.post( self._client.api_host(), From 84958d2687af11b051ead6fc7f3d75b4cc0f6738 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 25 Oct 2022 18:12:05 +0100 Subject: [PATCH 210/401] removed old directory from .bumpversion.cfg --- .bumpversion.cfg | 2 -- 1 file changed, 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 03e53f47..b6681d94 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -6,5 +6,3 @@ tag = False [bumpversion:file:src/vonage/__init__.py] [bumpversion:file:setup.py] - -[bumpversion:file:docs/conf.py] From 2e44fd2ed4ae16a5e6efea77aa81a783bb310de4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 25 Oct 2022 18:12:08 +0100 Subject: [PATCH 211/401] =?UTF-8?q?Bump=20version:=203.0.1=20=E2=86=92=203?= =?UTF-8?q?.0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b6681d94..456fedae 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.1 +current_version = 3.0.2 commit = True tag = False diff --git a/setup.py b/setup.py index 14d884d1..08cabd66 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.0.1", + version="3.0.2", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 3304bea9..1cb78f2e 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "3.0.1" +__version__ = "3.0.2" From c75fe04d10d0e6ed4b5f79573aaffa61c8b8afbb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 25 Oct 2022 18:13:46 +0100 Subject: [PATCH 212/401] update CHANGES.md --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 5bc71cd6..d5cfd08e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.0.2 +- Bugfix in `messages.py` where authentication method was not being checked for correctly, throwing an error when using header auth. + # 3.0.1 - Fixed bug where a JWT was created globally and could expire. Now a new JWT is generated when a request is made. - Fixed bug where timeout was not passed to session object. From 0f11399f10ebf43a0b40d7014548ce14cf7dc3a2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 26 Oct 2022 13:34:59 +0100 Subject: [PATCH 213/401] adding python 3.11 to github action, updating pytest dependency --- .github/workflows/build.yml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21842f86..242426b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.7", "3.8", "3.9", "3.10"] + python: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: ["ubuntu-latest", "macos-latest"] steps: diff --git a/requirements.txt b/requirements.txt index 87122017..58db0a8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -e . -pytest==7.1.1 +pytest==7.2.0 pytest-cov==3.0.0 responses==0.20.0 coveralls From 02810b3cc6ba56826e188fa82349a226919e86e8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 26 Oct 2022 14:19:54 +0100 Subject: [PATCH 214/401] removing old coverage dependencies --- Makefile | 2 +- requirements.txt | 6 ++---- setup.py | 1 + tests/test_account.py | 7 +------ 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 341b0818..31ad6327 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: clean test build coverage install requirements release coverage: - pytest -v --cov + coverage run -m pytest -v coverage html test: diff --git a/requirements.txt b/requirements.txt index 58db0a8c..e3c2db7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,7 @@ -e . pytest==7.2.0 -pytest-cov==3.0.0 -responses==0.20.0 -coveralls -glom==22.1.0 +responses==0.22.0 +coverage bump2version build diff --git a/setup.py b/setup.py index 08cabd66..6f56aed9 100644 --- a/setup.py +++ b/setup.py @@ -37,5 +37,6 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], ) diff --git a/tests/test_account.py b/tests/test_account.py index c87a5dea..191d9357 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,7 +1,5 @@ import platform -from glom import glom - from util import * import vonage @@ -118,10 +116,7 @@ def test_list_secrets(account): secrets = account.list_secrets("myaccountid") assert_basic_auth() - assert ( - glom(secrets, "_embedded.secrets.0.id") - == "ad6dc56f-07b5-46e1-a527-85530e625800" - ) + assert secrets["_embedded"]["secrets"][0]["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" @responses.activate From 449b2d529f9fad32aa2b45c3f3b8d041042ba28d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 26 Oct 2022 14:27:52 +0100 Subject: [PATCH 215/401] update CHANGES.md --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d5cfd08e..08560a6f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# 3.1.0 +- Supporting Python 3.11 +- Upgrading some old dependencies + # 3.0.2 - Bugfix in `messages.py` where authentication method was not being checked for correctly, throwing an error when using header auth. From ddc2e6b031256f87a245e8dd36dfce6a86f50e1a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 26 Oct 2022 14:27:58 +0100 Subject: [PATCH 216/401] =?UTF-8?q?Bump=20version:=203.0.2=20=E2=86=92=203?= =?UTF-8?q?.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 456fedae..5506f07f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.2 +current_version = 3.1.0 commit = True tag = False diff --git a/setup.py b/setup.py index 6f56aed9..c4d47257 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.0.2", + version="3.1.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 1cb78f2e..4f47f9d3 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "3.0.2" +__version__ = "3.1.0" From 7d5d53e1a90b876731b74271e7631269aa7b0c05 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 4 Nov 2022 16:34:16 +0000 Subject: [PATCH 217/401] new upload process in Makefile --- .gitignore | 1 + Makefile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dad3aaf6..dd549bbf 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,4 @@ ENV* .pypirc .pytest_cache html/ +.mutmut-cache diff --git a/Makefile b/Makefile index 31ad6327..77af5097 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ build: python -m build release: - python -m twine upload dist/* + twine upload --repository pypi dist/* install: requirements From 0185e5180f7372334610343b1aa9f2fb693cc6a5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 9 Nov 2022 19:00:23 +0000 Subject: [PATCH 218/401] refactoring signature-related tests into their own file, adding new test for patch method --- tests/test_client.py | 85 ---------------------------------------- tests/test_rest_calls.py | 17 ++++++++ 2 files changed, 17 insertions(+), 85 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 9044e4d3..bdedbf62 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,90 +3,6 @@ from vonage.errors import InvalidAuthenticationTypeError -def test_check_signature(dummy_data): - params = { - "a": "1", - "b": "2", - "timestamp": "1461605396", - "sig": "6af838ef94998832dbfc29020b564830", - } - - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - - assert client.check_signature(params) - - -def test_signature(client, dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - assert client.signature(params) == "6af838ef94998832dbfc29020b564830" - - -def test_signature_adds_timestamp(dummy_data): - params = {"a=7": "1", "b": "2 & 5"} - - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - - client.signature(params) - assert params["timestamp"] is not None - - -def test_signature_md5(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="md5", - ) - assert client.signature(params) == "c15c21ced558c93a226c305f58f902f2" - - -def test_signature_sha1(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha1", - ) - assert client.signature(params) == "3e19a4e6880fdc2c1426bfd0587c98b9532f0210" - - -def test_signature_sha256(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha256", - ) - assert ( - client.signature(params) - == "a321e824b9b816be7c3f28859a31749a098713d39f613c80d455bbaffae1cd24" - ) - - -def test_signature_sha512(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha512", - ) - assert ( - client.signature(params) - == "812a18f76680fa0fe1b8bd9ee1625466ceb1bd96242e4d050d2cfd9a7b40166c63ed26ec9702168781b6edcf1633db8ff95af9341701004eec3fcf9550572ee8" - ) - - def test_client_doesnt_require_api_key(dummy_data): client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) assert client is not None @@ -117,4 +33,3 @@ def test_timeout_is_set_on_client_calls(dummy_data): assert len(responses.calls) == 1 assert responses.calls[0].request.req_kwargs["timeout"] == 1 - diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index 1b8f41eb..ad3eac59 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -80,3 +80,20 @@ def test_delete_with_header_auth(client, dummy_data): assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert_basic_auth() + + +@responses.activate +def test_patch(client, dummy_data): + stub(responses.PATCH, "https://api.nexmo.com/v1/applications") + host = "api.nexmo.com" + request_uri = "/v1/applications" + params = {"aaa": "xxx", "bbb": "yyy"} + response = client.patch(host, request_uri, params=params, auth_type='jwt') + assert request_headers()['Content-Type'] == 'application/json' + assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert b"aaa" in request_body() + assert b"xxx" in request_body() + assert b"bbb" in request_body() + assert b"yyy" in request_body() From c27e2f56b64a15977df3ae9d2a2fae9aeb72c9b1 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 9 Nov 2022 19:08:29 +0000 Subject: [PATCH 219/401] refactoring tests, changing release command --- Makefile | 2 +- tests/test_rest_calls.py | 17 -------- tests/test_signature.py | 86 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 tests/test_signature.py diff --git a/Makefile b/Makefile index 77af5097..31ad6327 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ build: python -m build release: - twine upload --repository pypi dist/* + python -m twine upload dist/* install: requirements diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index ad3eac59..1b8f41eb 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -80,20 +80,3 @@ def test_delete_with_header_auth(client, dummy_data): assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert_basic_auth() - - -@responses.activate -def test_patch(client, dummy_data): - stub(responses.PATCH, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.patch(host, request_uri, params=params, auth_type='jwt') - assert request_headers()['Content-Type'] == 'application/json' - assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() diff --git a/tests/test_signature.py b/tests/test_signature.py new file mode 100644 index 00000000..8ae86468 --- /dev/null +++ b/tests/test_signature.py @@ -0,0 +1,86 @@ +import vonage +from util import * + + +def test_check_signature(dummy_data): + params = { + "a": "1", + "b": "2", + "timestamp": "1461605396", + "sig": "6af838ef94998832dbfc29020b564830", + } + + client = vonage.Client( + key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" + ) + + assert client.check_signature(params) + + +def test_signature(client, dummy_data): + params = {"a": "1", "b": "2", "timestamp": "1461605396"} + client = vonage.Client( + key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" + ) + assert client.signature(params) == "6af838ef94998832dbfc29020b564830" + + +def test_signature_adds_timestamp(dummy_data): + params = {"a=7": "1", "b": "2 & 5"} + + client = vonage.Client( + key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" + ) + + client.signature(params) + assert params["timestamp"] is not None + + +def test_signature_md5(dummy_data): + params = {"a": "1", "b": "2", "timestamp": "1461605396"} + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + signature_secret=dummy_data.signature_secret, + signature_method="md5", + ) + assert client.signature(params) == "c15c21ced558c93a226c305f58f902f2" + + +def test_signature_sha1(dummy_data): + params = {"a": "1", "b": "2", "timestamp": "1461605396"} + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + signature_secret=dummy_data.signature_secret, + signature_method="sha1", + ) + assert client.signature(params) == "3e19a4e6880fdc2c1426bfd0587c98b9532f0210" + + +def test_signature_sha256(dummy_data): + params = {"a": "1", "b": "2", "timestamp": "1461605396"} + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + signature_secret=dummy_data.signature_secret, + signature_method="sha256", + ) + assert ( + client.signature(params) + == "a321e824b9b816be7c3f28859a31749a098713d39f613c80d455bbaffae1cd24" + ) + + +def test_signature_sha512(dummy_data): + params = {"a": "1", "b": "2", "timestamp": "1461605396"} + client = vonage.Client( + key=dummy_data.api_key, + secret=dummy_data.api_secret, + signature_secret=dummy_data.signature_secret, + signature_method="sha512", + ) + assert ( + client.signature(params) + == "812a18f76680fa0fe1b8bd9ee1625466ceb1bd96242e4d050d2cfd9a7b40166c63ed26ec9702168781b6edcf1633db8ff95af9341701004eec3fcf9550572ee8" + ) From 6775ec3dfaa3a2b03cba00910b6c9f676d8d45da Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 2 Dec 2022 18:40:00 +0000 Subject: [PATCH 220/401] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..0d1d3611 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + + + +## Expected Behavior + + + +## Current Behavior + + + +## Possible Solution + + + +## Steps to Reproduce (for bugs) + + +1. +2. +3. +4. + +## Context + + + +## Your Environment + +* Version used: +* Environment name and version (e.g. language and server version): +* Operating System and version: From 99dca4b4bef18cf11009e2375db0c39e82f2cdf5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 2 Dec 2022 18:41:53 +0000 Subject: [PATCH 221/401] Update issue templates --- .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/something-else.md | 10 ++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/something-else.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/something-else.md b/.github/ISSUE_TEMPLATE/something-else.md new file mode 100644 index 00000000..fc32985f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/something-else.md @@ -0,0 +1,10 @@ +--- +name: Something else +about: Custom template +title: '' +labels: '' +assignees: '' + +--- + +Tell us what's up! From 2c97ff85a364ce813d4a3b70a5d86966da33d403 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 2 Dec 2022 20:59:42 +0000 Subject: [PATCH 222/401] Create mutation test github action (#232) * initial mutation-test action upload * running only on one python version * not showing test progress * only testing one module * removing regular tests * uploading html output as an artifact * adding PR commenter * fix typo * new path * add workflow dispatch trigger, don't run on push or PR * running full mutant test suite * fixing typo * showing output to find bug * specifying files to mutate * specifying different files to mutate * specifying different files to mutate * rolling back mutmut version * fixing typo * running only on sms module * rolling back python version * adding continue statements * fix typo * continue in next step * manually returning zero * manually returning zero * running full test suite * testing _internal module * testing only modules where all mutants are caught * adding new bash to check for error code * using bash command to allow error codes != 1 * enabling on whole test suite * adding build action back on push, setting mutation test workflow to run on manual dispatch * updating setup-python github action version --- .github/workflows/build.yml | 2 +- .github/workflows/mutation-test.yml | 36 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/mutation-test.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 242426b9..ba07353d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ jobs: os: ["ubuntu-latest", "macos-latest"] steps: - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: Clone repo diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml new file mode 100644 index 00000000..1acdf8ed --- /dev/null +++ b/.github/workflows/mutation-test.yml @@ -0,0 +1,36 @@ +name: Mutation Test +on: workflow_dispatch + +jobs: + mutation: + name: run mutation test + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.10"] + + continue-on-error: true + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python -m pip install mutmut + - name: Run mutation test + run: | + mutmut run --no-progress || \ + if [ $? -eq 1 ]; then exit 1; fi + - name: Save HTML output + run: | + mutmut html + - uses: actions/upload-artifact@v3 + with: + name: mutation-test-report + path: html/ From 6056fc7f46b0fb0c9563a06559fed27a9bba67ae Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 13 Jan 2023 17:12:23 +0000 Subject: [PATCH 223/401] Add ncco builder (#236) * initial Ncco class creation * adding optional fields, changing structure for namespacing reasons * added pydantic as dependency * using List type from typing module * adding and testing Notify and Talk actions, refactoring build_ncco method * more endpoints and tests * added connect_endpoints.py, Endpoints types, refactored into separate modules, testing connect action and endpoint models * placeholder validator test * changing NCCO builder test and module structure, add Connect and Stream endpoints, starting Input endpoint * added Input action and submodels, testing Input action, adding pay and submodels * renaming to fix validator conflict * adding pay prompt actions and errors, pay action testing * adding PayPrompts tests, adding type hints * finished Pay action, testing Ncco.build_ncco method * removing custom URL types as they cause problems, testing NCCO builder * adding full ncco builder test * added voice test using ncco builder --- README.md | 60 ++++ requirements.txt | 1 + setup.py | 1 + src/vonage/client.py | 88 ++---- src/vonage/ncco_builder/connect_endpoints.py | 50 ++++ src/vonage/ncco_builder/input_types.py | 26 ++ src/vonage/ncco_builder/ncco.py | 221 ++++++++++++++ src/vonage/ncco_builder/pay_prompts.py | 43 +++ .../ncco_samples/ncco_action_samples.py | 53 ++++ .../ncco_samples/ncco_builder_samples.py | 142 +++++++++ .../test_connect_endpoints.py | 58 ++++ tests/test_ncco_builder/test_input_types.py | 43 +++ tests/test_ncco_builder/test_ncco_actions.py | 283 ++++++++++++++++++ tests/test_ncco_builder/test_ncco_builder.py | 45 +++ tests/test_ncco_builder/test_pay_prompts.py | 54 ++++ tests/test_voice.py | 45 ++- 16 files changed, 1145 insertions(+), 68 deletions(-) create mode 100644 src/vonage/ncco_builder/connect_endpoints.py create mode 100644 src/vonage/ncco_builder/input_types.py create mode 100644 src/vonage/ncco_builder/ncco.py create mode 100644 src/vonage/ncco_builder/pay_prompts.py create mode 100644 tests/test_ncco_builder/ncco_samples/ncco_action_samples.py create mode 100644 tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py create mode 100644 tests/test_ncco_builder/test_connect_endpoints.py create mode 100644 tests/test_ncco_builder/test_input_types.py create mode 100644 tests/test_ncco_builder/test_ncco_actions.py create mode 100644 tests/test_ncco_builder/test_ncco_builder.py create mode 100644 tests/test_ncco_builder/test_pay_prompts.py diff --git a/README.md b/README.md index 05308a6d..e91400ee 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [SMS API](#sms-api) - [Messages API](#messages-api) - [Voice API](#voice-api) +- [NCCO Builder](#ncco-builder) - [Verify API](#verify-api) - [Number Insight API](#number-insight-api) - [Number Management API](#number-management-api) @@ -337,6 +338,65 @@ client.voice.send_dtmf(response['uuid'], digits='1234') response = client.get_recording(RECORDING_URL) ``` +## NCCO Builder + +The SDK contains a builder to help you create Call Control Objects (NCCOs) for use with the Vonage Voice API. + +For more information, [check the full NCCO reference documentation on the Vonage website](https://developer.vonage.com/voice/voice-api/ncco-reference). + +An NCCO is a list of "Actions": steps to be followed when a call is initiated or received. + +Use the builder to construct valid NCCO actions, which are modelled in the SDK as [Pydantic](https://docs.pydantic.dev) models, and build them into an NCCO. The NCCO actions supported by the builder are: + +* Record +* Conversation +* Connect +* Talk +* Stream +* Input +* Notify +* Pay + +### Construct actions + +```python +record = Ncco.Record(eventUrl=['https://example.com']) +talk = Ncco.Talk(text='Hello from Vonage!', bargeIn=True, loop=5, premium=True) +``` + +The Connect action has each valid endpoint type (phone, application, WebSocket, SIP and VBC) specified as a Pydantic model so these can be validated, though it is also possible to pass in a dict with the endpoint properties directly into the `Ncco.Connect` object. + +This example shows a Connect action created with an endpoint object. + +```python +phone = ConnectEndpoints.PhoneEndpoint( + number='447000000000', + dtmfAnswer='1p2p3p#**903#', + ) +connect = Ncco.Connect(endpoint=phone, eventUrl=['https://example.com/events'], from_='447000000000') +``` + +This example shows a different Connect action, created with a dictionary. + +```python +connect = Ncco.Connect(endpoint={'type': 'phone', 'number': '447000000000', 'dtmfAnswer': '2p02p'}, randomFromNumber=True) +``` + +### Build into an NCCO + +Create an NCCO from the actions with the `Ncco.build_ncco` method. This will be returned as a list of dicts representing each action and can be used in calls to the Voice API. + +```python +ncco = Ncco.build_ncco(record, connect, talk) + +response = client.voice.create_call({ + 'to': [{'type': 'phone', 'number': TO_NUMBER}], + 'from': {'type': 'phone', 'number': VONAGE_NUMBER}, + 'ncco': ncco +}) + +pprint(response) +``` ## Verify API diff --git a/requirements.txt b/requirements.txt index e3c2db7d..4fbefed0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ pytest==7.2.0 responses==0.22.0 coverage +pydantic bump2version build diff --git a/setup.py b/setup.py index c4d47257..8208fbd6 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ "PyJWT[crypto]>=1.6.4", "pytz>=2018.5", "Deprecated", + "pydantic>=1.10.2", ], python_requires=">=3.7", tests_require=["cryptography>=2.3.1"], diff --git a/src/vonage/client.py b/src/vonage/client.py index 7397591d..b55716b9 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -4,6 +4,7 @@ from .application import ApplicationV2, Application from .errors import * from .messages import Messages +from .ncco_builder.ncco import Ncco, ConnectEndpoints, InputTypes, PayPrompts from .number_insight import NumberInsight from .numbers import Numbers from .redact import Redact @@ -37,6 +38,7 @@ logger = logging.getLogger("vonage") + class Client: """ Create a Client object to start making calls to Vonage/Nexmo APIs. @@ -78,10 +80,10 @@ def __init__( private_key=None, app_name=None, app_version=None, - timeout=None, - pool_connections=10, - pool_maxsize=10, - max_retries=3 + timeout=None, + pool_connections=10, + pool_maxsize=10, + max_retries=3, ): self.api_key = key or os.environ.get("VONAGE_API_KEY", None) self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) @@ -126,9 +128,7 @@ def __init__( self.timeout = timeout self.session = Session() self.adapter = HTTPAdapter( - pool_connections=pool_connections, - pool_maxsize=pool_maxsize, - max_retries=max_retries + pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries ) self.session.mount("https://", self.adapter) @@ -156,9 +156,7 @@ def check_signature(self, params): def signature(self, params): if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) + hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method) else: hasher = hashlib.md5() @@ -186,13 +184,9 @@ def get(self, host, request_uri, params=None, auth_type=None): if auth_type == 'jwt': self._request_headers = self._add_jwt_to_request_headers() elif auth_type == 'params': - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) + params = dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) elif auth_type == 'header': - hash = base64.b64encode( - f"{self.api_key}:{self.api_secret}".encode("utf-8") - ).decode("ascii") + hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}") else: raise InvalidAuthenticationTypeError( @@ -201,47 +195,45 @@ def get(self, host, request_uri, params=None, auth_type=None): logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") return self.parse( - host, - self.session.get(uri, params=params, headers=self._request_headers, timeout=self.timeout)) + host, self.session.get(uri, params=params, headers=self._request_headers, timeout=self.timeout) + ) def post(self, host, request_uri, params, auth_type=None, body_is_json=True, supports_signature_auth=False): """ Low-level method to make a post request to an API server. This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, + - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, then signature authentication will be used. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided + :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided when initializing this client. """ uri = f"https://{host}{request_uri}" self._request_headers = self.headers - + if supports_signature_auth and self.signature_secret: params["api_key"] = self.api_key params["sig"] = self.signature(params) elif auth_type == 'jwt': self._request_headers = self._add_jwt_to_request_headers() elif auth_type == 'params': - params = dict( - params, api_key=self.api_key, api_secret=self.api_secret - ) + params = dict(params, api_key=self.api_key, api_secret=self.api_secret) elif auth_type == 'header': - hash = base64.b64encode( - f"{self.api_key}:{self.api_secret}".encode("utf-8") - ).decode("ascii") + hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}") else: raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) - + logger.debug(f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") if body_is_json: return self.parse( - host, self.session.post(uri, json=params, headers=self._request_headers, timeout=self.timeout)) + host, self.session.post(uri, json=params, headers=self._request_headers, timeout=self.timeout) + ) else: return self.parse( - host, self.session.post(uri, data=params, headers=self._request_headers, timeout=self.timeout)) + host, self.session.post(uri, data=params, headers=self._request_headers, timeout=self.timeout) + ) def put(self, host, request_uri, params, auth_type=None): uri = f"https://{host}{request_uri}" @@ -250,9 +242,7 @@ def put(self, host, request_uri, params, auth_type=None): if auth_type == 'jwt': self._request_headers = self._add_jwt_to_request_headers() elif auth_type == 'header': - hash = base64.b64encode( - f"{self.api_key}:{self.api_secret}".encode("utf-8") - ).decode("ascii") + hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}") else: raise InvalidAuthenticationTypeError( @@ -269,10 +259,8 @@ def delete(self, host, request_uri, auth_type=None): if auth_type == 'jwt': self._request_headers = self._add_jwt_to_request_headers() - elif auth_type =='header': - hash = base64.b64encode( - f"{self.api_key}:{self.api_secret}".encode("utf-8") - ).decode("ascii") + elif auth_type == 'header': + hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}") else: raise InvalidAuthenticationTypeError( @@ -280,16 +268,12 @@ def delete(self, host, request_uri, auth_type=None): ) logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") - return self.parse( - host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout) - ) + return self.parse(host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout)) def parse(self, host, response): logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: - raise AuthenticationError( - "Authentication failed. Check you're using a valid authentication method." - ) + raise AuthenticationError("Authentication failed. Check you're using a valid authentication method.") elif response.status_code == 204: return None elif 200 <= response.status_code < 300: @@ -301,22 +285,16 @@ def parse(self, host, response): else: return response.content elif 400 <= response.status_code < 500: - logger.warning( - f"Client error: {response.status_code} {repr(response.content)}" - ) + logger.warning(f"Client error: {response.status_code} {repr(response.content)}") message = f"{response.status_code} response from {host}" # Test for standard error format: try: error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - title=error_data["title"] - detail=error_data["detail"] - type=error_data["type"] + if "type" in error_data and "title" in error_data and "detail" in error_data: + title = error_data["title"] + detail = error_data["detail"] + type = error_data["type"] message = f"{title}: {detail} ({type})" except JSONDecodeError: @@ -342,7 +320,7 @@ def _generate_application_jwt(self): token = jwt.encode(payload, self._private_key, algorithm="RS256") # If token is string transform it to byte type - if(type(token) is str): + if type(token) is str: token = bytes(token, 'utf-8') return token diff --git a/src/vonage/ncco_builder/connect_endpoints.py b/src/vonage/ncco_builder/connect_endpoints.py new file mode 100644 index 00000000..f9b84ff6 --- /dev/null +++ b/src/vonage/ncco_builder/connect_endpoints.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, HttpUrl, AnyUrl, Field, constr +from typing import Optional, Dict +from typing_extensions import Literal + + +class ConnectEndpoints: + class Endpoint(BaseModel): + type: str = None + + class PhoneEndpoint(Endpoint): + type = Field('phone', const=True) + number: constr(regex=r'^[1-9]\d{6,14}$') + dtmfAnswer: Optional[constr(regex='^[0-9*#p]+$')] + onAnswer: Optional[Dict[str, HttpUrl]] + + class AppEndpoint(Endpoint): + type = Field('app', const=True) + user: str + + class WebsocketEndpoint(Endpoint): + type = Field('websocket', const=True) + uri: AnyUrl + contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] + headers: Optional[dict] + + class SipEndpoint(Endpoint): + type = Field('sip', const=True) + uri: str + headers: Optional[dict] + + class VbcEndpoint(Endpoint): + type = Field('vbc', const=True) + extension: str + + @classmethod + def create_endpoint_model_from_dict(cls, d) -> Endpoint: + if d['type'] == 'phone': + return cls.PhoneEndpoint.parse_obj(d) + elif d['type'] == 'app': + return cls.AppEndpoint.parse_obj(d) + elif d['type'] == 'websocket': + return cls.WebsocketEndpoint.parse_obj(d) + elif d['type'] == 'sip': + return cls.WebsocketEndpoint.parse_obj(d) + elif d['type'] == 'vbc': + return cls.WebsocketEndpoint.parse_obj(d) + else: + raise ValueError( + 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' + ) diff --git a/src/vonage/ncco_builder/input_types.py b/src/vonage/ncco_builder/input_types.py new file mode 100644 index 00000000..761ba31d --- /dev/null +++ b/src/vonage/ncco_builder/input_types.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, confloat, conint +from typing import Optional, List + + +class InputTypes: + class Dtmf(BaseModel): + timeOut: Optional[conint(ge=0, le=10)] + maxDigits: Optional[conint(ge=1, le=20)] + submitOnHash: Optional[bool] + + class Speech(BaseModel): + uuid: Optional[str] + endOnSilence: Optional[confloat(ge=0.4, le=10.0)] + language: Optional[str] + context: Optional[List[str]] + startTimeout: Optional[conint(ge=1, le=60)] + maxDuration: Optional[conint(ge=1, le=60)] + saveAudio: Optional[bool] + + @classmethod + def create_dtmf_model(cls, dict) -> Dtmf: + return cls.Dtmf.parse_obj(dict) + + @classmethod + def create_speech_model(cls, dict) -> Speech: + return cls.Speech.parse_obj(dict) diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py new file mode 100644 index 00000000..296fdca1 --- /dev/null +++ b/src/vonage/ncco_builder/ncco.py @@ -0,0 +1,221 @@ +from pydantic import BaseModel, Field, validator, constr, confloat, conint +from typing import Optional, Union, List +from typing_extensions import Literal + +from .connect_endpoints import ConnectEndpoints +from .input_types import InputTypes +from .pay_prompts import PayPrompts + + +class Ncco: + class Action(BaseModel): + action: str = None + + class Record(Action): + """Use the record action to record a call or part of a call.""" + + action = Field('record', const=True) + format: Optional[Literal['mp3', 'wav', 'ogg']] + split: Optional[Literal['conversation']] + channels: Optional[conint(ge=1, le=32)] + endOnSilence: Optional[conint(ge=3, le=10)] + endOnKey: Optional[constr(regex='^[0-9*#]$')] + timeOut: Optional[conint(ge=3, le=7200)] + beepStart: Optional[bool] + eventUrl: Optional[Union[List[str], str]] + eventMethod: Optional[constr(to_upper=True)] + + @validator('channels') + def enable_split(cls, v, values): + if values['split'] is None: + values['split'] = 'conversation' + return v + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + class Conversation(Action): + """You can use the conversation action to create standard or moderated conferences, + while preserving the communication context. + Using conversation with the same name reuses the same persisted conversation.""" + + action = Field('notify', const=True) + name: str + musicOnHoldUrl: Optional[Union[List[str], str]] + startOnEnter: Optional[bool] + endOnExit: Optional[bool] + record: Optional[bool] + canSpeak: Optional[List[str]] + canHear: Optional[List[str]] + mute: Optional[bool] + + @validator('musicOnHoldUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('mute') + def can_mute(cls, v, values): + if 'canSpeak' in values and values['canSpeak'] is not None: + raise ValueError('Cannot use mute option if canSpeak option is specified.') + return v + + class Connect(Action): + """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" + + action = Field('connect', const=True) + endpoint: Union[dict, ConnectEndpoints.Endpoint, List[dict]] + from_: Optional[constr(regex=r'^[1-9]\d{6,14}$')] + randomFromNumber: Optional[bool] + eventType: Optional[Literal['synchronous']] + timeout: Optional[int] + limit: Optional[conint(le=7200)] + machineDetection: Optional[Literal['continue', 'hangup']] + eventUrl: Optional[Union[List[str], str]] + eventMethod: Optional[constr(to_upper=True)] + ringbackTone: Optional[str] + + @validator('endpoint') + def validate_endpoint(cls, v): + if type(v) is dict: + return [ConnectEndpoints.create_endpoint_model_from_dict(v)] + elif type(v) is list: + return [ConnectEndpoints.create_endpoint_model_from_dict(v[0])] + else: + return [v] + + @validator('from_') + def set_from_field(cls, v, values): + values['from'] = v + + @validator('randomFromNumber') + def check_from_not_set(cls, v, values): + if v is True and 'from' in values: + if values['from'] is not None: + raise ValueError( + 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' + ) + return v + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + class Config: + smart_union = True + + class Talk(Action): + """The talk action sends synthesized speech to a Conversation.""" + + action = Field('talk', const=True) + text: constr(max_length=1500) + bargeIn: Optional[bool] + loop: Optional[conint(ge=0)] + level: Optional[confloat(ge=-1, le=1)] + language: Optional[str] + style: Optional[int] + premium: Optional[bool] + + class Stream(Action): + """The stream action allows you to send an audio stream to a Conversation.""" + + action = Field('stream', const=True) + streamUrl: Union[List[str], str] + level: Optional[confloat(ge=-1, le=1)] + bargeIn: Optional[bool] + loop: Optional[conint(ge=0)] + + @validator('streamUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + class Input(Action): + """Collect digits or speech input by the person you are are calling.""" + + action = Field('input', const=True) + type: Union[ + Literal['dtmf', 'speech'], List[Literal['dtmf']], List[Literal['speech']], List[Literal['dtmf', 'speech']] + ] + dtmf: Optional[Union[InputTypes.Dtmf, dict]] + speech: Optional[Union[InputTypes.Speech, dict]] + eventUrl: Optional[Union[List[str], str]] + eventMethod: Optional[constr(to_upper=True)] + + @validator('type', 'eventUrl') + def ensure_value_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('dtmf') + def ensure_input_object_is_dtmf_model(cls, v): + if type(v) is dict: + return InputTypes.create_dtmf_model(v) + else: + return v + + @validator('speech') + def ensure_input_object_is_speech_model(cls, v): + if type(v) is dict: + return InputTypes.create_speech_model(v) + else: + return v + + class Notify(Action): + """Use the notify action to send a custom payload to your event URL.""" + + action = Field('notify', const=True) + payload: dict + eventUrl: Union[List[str], str] + eventMethod: Optional[constr(to_upper=True)] + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + class Pay(Action): + """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" + + action = Field('pay', const=True) + amount: confloat(ge=0) + currency: Optional[constr(to_lower=True)] + eventUrl: Optional[Union[List[str], str]] + prompts: Optional[Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict]] + voice: Optional[Union[PayPrompts.VoicePrompt, dict]] + + @validator('amount') + def round_amount(cls, v): + return round(v, 2) + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('prompts') + def ensure_text_model(cls, v): + if type(v) is dict: + return PayPrompts.create_text_model(v) + else: + return v + + @validator('voice') + def ensure_voice_model(cls, v): + if type(v) is dict: + return PayPrompts.create_voice_model(v) + else: + return v + + @staticmethod + def build_ncco(*args: Action, actions: List[Action] = None) -> str: + ncco = [] + if actions is not None: + for action in actions: + ncco.append(action.dict(exclude_none=True)) + for action in args: + ncco.append(action.dict(exclude_none=True)) + return ncco + + @staticmethod + def _ensure_object_in_list(obj): + if type(obj) != list: + return [obj] + else: + return obj diff --git a/src/vonage/ncco_builder/pay_prompts.py b/src/vonage/ncco_builder/pay_prompts.py new file mode 100644 index 00000000..4463a7f8 --- /dev/null +++ b/src/vonage/ncco_builder/pay_prompts.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel, validator +from typing import Optional, Dict +from typing_extensions import Literal + + +class PayPrompts: + class VoicePrompt(BaseModel): + language: Optional[str] + style: Optional[int] + + class TextPrompt(BaseModel): + type: Literal['CardNumber', 'ExpirationDate', 'SecurityCode'] + text: str + errors: Dict[ + Literal['InvalidCardType', 'InvalidCardNumber', 'InvalidExpirationDate', 'InvalidSecurityCode', 'Timeout'], + Dict[Literal['text'], str], + ] + + @validator('errors') + def check_valid_error_format(cls, v, values): + if values['type'] == 'CardNumber': + allowed_values = {'InvalidCardType', 'InvalidCardNumber', 'Timeout'} + cls.check_allowed_values(v, allowed_values, values['type']) + elif values['type'] == 'ExpirationDate': + allowed_values = {'InvalidExpirationDate', 'Timeout'} + cls.check_allowed_values(v, allowed_values, values['type']) + elif values['type'] == 'SecurityCode': + allowed_values = {'InvalidSecurityCode', 'Timeout'} + cls.check_allowed_values(v, allowed_values, values['type']) + return v + + def check_allowed_values(errors, allowed_values, prompt_type): + for key in errors: + if key not in allowed_values: + raise ValueError(f'Value "{key}" is not a valid error for the "{prompt_type}" prompt type.') + + @classmethod + def create_voice_model(cls, dict) -> VoicePrompt: + return cls.VoicePrompt.parse_obj(dict) + + @classmethod + def create_text_model(cls, dict) -> TextPrompt: + return cls.TextPrompt.parse_obj(dict) diff --git a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py new file mode 100644 index 00000000..838fe9d6 --- /dev/null +++ b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py @@ -0,0 +1,53 @@ +record_full = '{"action": "record", "format": "wav", "split": "conversation", "channels": 4, "endOnSilence": 5, "endOnKey": "*", "timeOut": 100, "beepStart": true, "eventUrl": ["http://example.com"], "eventMethod": "PUT"}' + +record_url_as_str = '{"action": "record", "eventUrl": ["http://example.com/events"]}' + +record_add_split = '{"action": "record", "split": "conversation", "channels": 4}' + +conversation_basic = '{"action": "notify", "name": "my_conversation"}' + +conversation_full = '{"action": "notify", "name": "my_conversation", "musicOnHoldUrl": ["http://example.com/music.mp3"], "startOnEnter": true, "endOnExit": true, "record": true, "canSpeak": ["asdf", "qwer"], "canHear": ["asdf"]}' + +conversation_mute_option = '{"action": "notify", "name": "my_conversation", "mute": true}' + +connect_phone = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000", "dtmfAnswer": "1p2p3p#**903#", "onAnswer": {"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}}]}' + +connect_app = '{"action": "connect", "endpoint": [{"type": "app", "user": "test_user"}]}' + +connect_websocket = '{"action": "connect", "endpoint": [{"type": "websocket", "uri": "ws://example.com/socket", "contentType": "audio/l16;rate=8000", "headers": {"language": "en-GB"}}]}' + +connect_sip = '{"action": "connect", "endpoint": [{"type": "sip", "uri": "sip:rebekka@sip.mcrussell.com", "headers": {"location": "New York City", "occupation": "developer"}}]}' + +connect_vbc = '{"action": "connect", "endpoint": [{"type": "vbc", "extension": "111"}]}' + +connect_full = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "randomFromNumber": false, "eventType": "synchronous", "timeout": 15, "limit": 1000, "machineDetection": "hangup", "eventUrl": ["http://example.com"], "eventMethod": "PUT", "ringbackTone": "http://example.com"}' + +talk_basic = '{"action": "talk", "text": "hello"}' + +talk_full = '{"action": "talk", "text": "hello", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}' + +stream_basic = '{"action": "stream", "streamUrl": ["https://example.com/stream/music.mp3"]}' + +stream_full = '{"action": "stream", "streamUrl": ["https://example.com/stream/music.mp3"], "level": 0.1, "bargeIn": true, "loop": 10}' + +input_basic_dtmf = '{"action": "input", "type": ["dtmf"]}' + +input_basic_dtmf_speech = '{"action": "input", "type": ["dtmf", "speech"]}' + +input_dtmf_and_speech_full = '{"action": "input", "type": ["dtmf", "speech"], "dtmf": {"timeOut": 5, "maxDigits": 12, "submitOnHash": true}, "speech": {"uuid": "my-uuid", "endOnSilence": 2.5, "language": "en-GB", "context": ["sales", "billing"], "startTimeout": 20, "maxDuration": 30, "saveAudio": true}, "eventUrl": ["http://example.com/speech"], "eventMethod": "PUT"}' + +notify_basic = '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}' + +notify_full = ( + '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"], "eventMethod": "POST"}' +) + +pay_basic = '{"action": "pay", "amount": 10.0}' + +pay_voice_full = '{"action": "pay", "amount": 99.99, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "voice": {"language": "en-GB", "style": 1}}' + +pay_text = '{"action": "pay", "amount": 12.35, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "prompts": {"type": "CardNumber", "text": "Enter your card number.", "errors": {"InvalidCardType": {"text": "The card you are trying to use is not valid for this purchase."}}}}' + +pay_text_multiple_prompts = '{"action": "pay", "amount": 12.0, "prompts": [{"type": "CardNumber", "text": "Enter your card number.", "errors": {"InvalidCardType": {"text": "The card you are trying to use is not valid for this purchase."}}}, {"type": "ExpirationDate", "text": "Enter your card expiration date.", "errors": {"InvalidExpirationDate": {"text": "You have entered an invalid expiration date."}, "Timeout": {"text": "Please enter your card\'s expiration date."}}}, {"type": "SecurityCode", "text": "Enter your 3-digit security code.", "errors": {"InvalidSecurityCode": {"text": "You have entered an invalid security code."}, "Timeout": {"text": "Please enter your card\'s security code."}}}]}' + +two_notify_ncco = '[{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}, {"action": "notify", "payload": {"message": "world"}, "eventUrl": ["http://example.com"], "eventMethod": "PUT"}]' diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py new file mode 100644 index 00000000..c5eb958e --- /dev/null +++ b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py @@ -0,0 +1,142 @@ +from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts + +record = Ncco.Record(eventUrl='http://example.com/events') + +conversation = Ncco.Conversation(name='my_conversation') + +connect = Ncco.Connect( + endpoint=ConnectEndpoints.PhoneEndpoint(number='447000000000'), + from_='447400000000', + randomFromNumber=False, + eventType='synchronous', + timeout=15, + limit=1000, + machineDetection='hangup', + eventUrl='http://example.com', + eventMethod='PUT', + ringbackTone='http://example.com', +) + +talk_minimal = Ncco.Talk(text='hello') + +talk = Ncco.Talk(text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True) + +stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10) + +input = Ncco.Input( + type=['dtmf', 'speech'], + dtmf=InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True), + speech=InputTypes.Speech( + uuid='my-uuid', + endOnSilence=2.5, + language='en-GB', + context=['sales', 'billing'], + startTimeout=20, + maxDuration=30, + saveAudio=True, + ), + eventUrl='http://example.com/speech', + eventMethod='put', +) + +notify = Ncco.Notify(payload={"message": "world"}, eventUrl=["http://example.com"], eventMethod='PUT') + +pay_voice_prompt = Ncco.Pay( + amount=99.99, + currency='gbp', + eventUrl='https://example.com/payment', + voice=PayPrompts.VoicePrompt(language='en-GB', style=1), +) + +pay_text_prompt = Ncco.Pay( + amount=12.345, + currency='gbp', + eventUrl='https://example.com/payment', + prompts=PayPrompts.TextPrompt( + type='CardNumber', + text='Enter your card number.', + errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + ), +) + +basic_ncco = [{"action": "talk", "text": "hello"}] + +two_part_ncco = [ + { + 'action': 'record', + 'eventUrl': ['http://example.com/events'], + }, + {'action': 'talk', 'text': 'hello'}, +] + +insane_ncco = [ + {'action': 'record', 'eventUrl': ['http://example.com/events']}, + {'action': 'notify', 'name': 'my_conversation'}, + { + 'action': 'connect', + 'endpoint': [{'number': '447000000000', 'type': 'phone'}], + 'eventMethod': 'PUT', + 'eventType': 'synchronous', + 'eventUrl': ['http://example.com'], + 'from': '447400000000', + 'limit': 1000, + 'machineDetection': 'hangup', + 'randomFromNumber': False, + 'ringbackTone': 'http://example.com', + 'timeout': 15, + }, + { + 'action': 'talk', + 'bargeIn': True, + 'language': 'en-GB', + 'level': 0.5, + 'loop': 3, + 'premium': True, + 'style': 1, + 'text': 'hello', + }, + { + 'action': 'stream', + 'bargeIn': True, + 'level': 0.1, + 'loop': 10, + 'streamUrl': ['https://example.com/stream/music.mp3'], + }, + { + 'action': 'input', + 'dtmf': {'maxDigits': 12, 'submitOnHash': True, 'timeOut': 5}, + 'eventMethod': 'PUT', + 'eventUrl': ['http://example.com/speech'], + 'speech': { + 'context': ['sales', 'billing'], + 'endOnSilence': 2.5, + 'language': 'en-GB', + 'maxDuration': 30, + 'saveAudio': True, + 'startTimeout': 20, + 'uuid': 'my-uuid', + }, + 'type': ['dtmf', 'speech'], + }, + {'action': 'notify', 'eventMethod': 'PUT', 'eventUrl': ['http://example.com'], 'payload': {'message': 'world'}}, + { + 'action': 'pay', + 'amount': 99.99, + 'currency': 'gbp', + 'eventUrl': ['https://example.com/payment'], + 'voice': {'language': 'en-GB', 'style': 1}, + }, + { + 'action': 'pay', + 'amount': 12.35, + 'currency': 'gbp', + 'eventUrl': ['https://example.com/payment'], + 'prompts': { + 'errors': { + 'InvalidCardType': {'text': 'The card you are trying ' 'to use is not valid for ' 'this purchase.'} + }, + 'text': 'Enter your card number.', + 'type': 'CardNumber', + }, + }, +] diff --git a/tests/test_ncco_builder/test_connect_endpoints.py b/tests/test_ncco_builder/test_connect_endpoints.py new file mode 100644 index 00000000..c263d92c --- /dev/null +++ b/tests/test_ncco_builder/test_connect_endpoints.py @@ -0,0 +1,58 @@ +from vonage import ConnectEndpoints, Ncco +import ncco_samples.ncco_action_samples as nas + +import json +import pytest +from pydantic import ValidationError + + +def _action_as_dict(action: Ncco.Action): + return action.dict(exclude_none=True) + + +def test_connect_all_endpoints_from_model(): + phone = ConnectEndpoints.PhoneEndpoint( + number='447000000000', + dtmfAnswer='1p2p3p#**903#', + onAnswer={"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}, + ) + connect_phone = Ncco.Connect(endpoint=phone) + assert json.dumps(_action_as_dict(connect_phone)) == nas.connect_phone + + app = ConnectEndpoints.AppEndpoint(user='test_user') + connect_app = Ncco.Connect(endpoint=app) + assert json.dumps(_action_as_dict(connect_app)) == nas.connect_app + + websocket = ConnectEndpoints.WebsocketEndpoint( + uri='ws://example.com/socket', contentType='audio/l16;rate=8000', headers={"language": "en-GB"} + ) + connect_websocket = Ncco.Connect(endpoint=websocket) + assert json.dumps(_action_as_dict(connect_websocket)) == nas.connect_websocket + + sip = ConnectEndpoints.SipEndpoint( + uri='sip:rebekka@sip.mcrussell.com', headers={"location": "New York City", "occupation": "developer"} + ) + connect_sip = Ncco.Connect(endpoint=sip) + assert json.dumps(_action_as_dict(connect_sip)) == nas.connect_sip + + vbc = ConnectEndpoints.VbcEndpoint(extension='111') + connect_vbc = Ncco.Connect(endpoint=vbc) + assert json.dumps(_action_as_dict(connect_vbc)) == nas.connect_vbc + + +def test_connect_endpoints_errors(): + with pytest.raises(ValidationError) as err: + ConnectEndpoints.PhoneEndpoint(number='447000000000', onAnswer={'url': 'not-a-valid-url'}) + + with pytest.raises(ValidationError) as err: + ConnectEndpoints.PhoneEndpoint( + number='447000000000', + onAnswer={'url': 'http://example.com/answer', 'ringbackTone': 'not-a-valid-url'}, + ) + + with pytest.raises(ValueError) as err: + ConnectEndpoints.create_endpoint_model_from_dict({'type': 'carrier_pigeon'}) + assert ( + str(err.value) + == 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' + ) diff --git a/tests/test_ncco_builder/test_input_types.py b/tests/test_ncco_builder/test_input_types.py new file mode 100644 index 00000000..f32d4016 --- /dev/null +++ b/tests/test_ncco_builder/test_input_types.py @@ -0,0 +1,43 @@ +from vonage import InputTypes + + +def test_create_dtmf_model(): + dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=2, submitOnHash=True) + assert type(dtmf) == InputTypes.Dtmf + assert dtmf.dict() == {'maxDigits': 2, 'submitOnHash': True, 'timeOut': 5} + + +def test_create_dtmf_model_from_dict(): + dtmf_dict = {'timeOut': 3, 'maxDigits': 4, 'submitOnHash': True} + dtmf_model = InputTypes.create_dtmf_model(dtmf_dict) + assert type(dtmf_model) == InputTypes.Dtmf + assert dtmf_model.dict() == {'maxDigits': 4, 'submitOnHash': True, 'timeOut': 3} + + +def test_create_speech_model(): + speech = InputTypes.Speech( + uuid='my-uuid', + endOnSilence=2.5, + language='en-GB', + context=['sales', 'billing'], + startTimeout=20, + maxDuration=30, + saveAudio=True, + ) + assert type(speech) == InputTypes.Speech + assert speech.dict() == { + 'uuid': 'my-uuid', + 'endOnSilence': 2.5, + 'language': 'en-GB', + 'context': ['sales', 'billing'], + 'startTimeout': 20, + 'maxDuration': 30, + 'saveAudio': True, + } + + +def test_create_speech_model_from_dict(): + speech_dict = {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} + speech_model = InputTypes.create_speech_model(speech_dict) + assert type(speech_model) == InputTypes.Speech + assert speech_model.dict(exclude_none=True) == {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} diff --git a/tests/test_ncco_builder/test_ncco_actions.py b/tests/test_ncco_builder/test_ncco_actions.py new file mode 100644 index 00000000..2c6ecd9b --- /dev/null +++ b/tests/test_ncco_builder/test_ncco_actions.py @@ -0,0 +1,283 @@ +from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts +import ncco_samples.ncco_action_samples as nas + +import json +import pytest +from pydantic import ValidationError + + +def _action_as_dict(action: Ncco.Action): + return action.dict(exclude_none=True) + + +def test_record_full(): + record = Ncco.Record( + format='wav', + split='conversation', + channels=4, + endOnSilence=5, + endOnKey='*', + timeOut=100, + beepStart=True, + eventUrl=['http://example.com'], + eventMethod='PUT', + ) + assert type(record) == Ncco.Record + assert json.dumps(_action_as_dict(record)) == nas.record_full + + +def test_record_url_passed_as_str(): + record = Ncco.Record(eventUrl='http://example.com/events') + assert json.dumps(_action_as_dict(record)) == nas.record_url_as_str + + +def test_record_channels_adds_split_parameter(): + record = Ncco.Record(channels=4) + assert json.dumps(_action_as_dict(record)) == nas.record_add_split + + +def test_record_model_errors(): + with pytest.raises(ValidationError): + Ncco.Record(format='mp4') + with pytest.raises(ValidationError): + Ncco.Record(endOnKey='asdf') + + +def test_conversation_basic(): + conversation = Ncco.Conversation(name='my_conversation') + assert type(conversation) == Ncco.Conversation + assert json.dumps(_action_as_dict(conversation)) == nas.conversation_basic + + +def test_conversation_full(): + conversation = Ncco.Conversation( + name='my_conversation', + musicOnHoldUrl='http://example.com/music.mp3', + startOnEnter=True, + endOnExit=True, + record=True, + canSpeak=['asdf', 'qwer'], + canHear=['asdf'], + ) + assert json.dumps(_action_as_dict(conversation)) == nas.conversation_full + + +def test_conversation_field_type_error(): + with pytest.raises(ValidationError): + Ncco.Conversation(name='my_conversation', startOnEnter='asdf') + + +def test_conversation_mute(): + conversation = Ncco.Conversation(name='my_conversation', mute=True) + assert json.dumps(_action_as_dict(conversation)) == nas.conversation_mute_option + + +def test_conversation_incompatible_options_error(): + with pytest.raises(ValidationError) as err: + Ncco.Conversation(name='my_conversation', canSpeak=['asdf', 'qwer'], mute=True) + str(err.value) == 'Cannot use mute option if canSpeak option is specified.+' + + +def test_connect_phone_endpoint_from_dict(): + connect = Ncco.Connect( + endpoint={ + "type": "phone", + "number": "447000000000", + "dtmfAnswer": "1p2p3p#**903#", + "onAnswer": {"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}, + } + ) + assert type(connect) is Ncco.Connect + assert json.dumps(_action_as_dict(connect)) == nas.connect_phone + + +def test_connect_phone_endpoint_from_list(): + connect = Ncco.Connect( + endpoint=[ + { + "type": "phone", + "number": "447000000000", + "dtmfAnswer": "1p2p3p#**903#", + "onAnswer": { + "url": "https://example.com/answer", + "ringbackTone": "http://example.com/ringbackTone.wav", + }, + } + ] + ) + assert json.dumps(_action_as_dict(connect)) == nas.connect_phone + + +def test_connect_options(): + endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') + connect = Ncco.Connect( + endpoint=endpoint, + from_='447400000000', + randomFromNumber=False, + eventType='synchronous', + timeout=15, + limit=1000, + machineDetection='hangup', + eventUrl='http://example.com', + eventMethod='PUT', + ringbackTone='http://example.com', + ) + assert json.dumps(_action_as_dict(connect)) == nas.connect_full + + +def test_connect_random_from_number_error(): + endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') + with pytest.raises(ValueError) as err: + Ncco.Connect(endpoint=endpoint, from_='447400000000', randomFromNumber=True) + + assert 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' in str(err.value) + + +def test_connect_validation_errors(): + endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') + with pytest.raises(ValidationError): + Ncco.Connect(endpoint=endpoint, from_=1234) + with pytest.raises(ValidationError): + Ncco.Connect(endpoint=endpoint, eventType='asynchronous') + with pytest.raises(ValidationError): + Ncco.Connect(endpoint=endpoint, limit=7201) + with pytest.raises(ValidationError): + Ncco.Connect(endpoint=endpoint, machineDetection='do_nothing') + + +def test_talk_basic(): + talk = Ncco.Talk(text='hello') + assert type(talk) == Ncco.Talk + assert json.dumps(_action_as_dict(talk)) == nas.talk_basic + + +def test_talk_optional_params(): + talk = Ncco.Talk(text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True) + assert json.dumps(_action_as_dict(talk)) == nas.talk_full + + +def test_talk_validation_error(): + with pytest.raises(ValidationError): + Ncco.Talk(text='hello', bargeIn='go ahead') + + +def test_stream_basic(): + stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3') + assert type(stream) == Ncco.Stream + assert json.dumps(_action_as_dict(stream)) == nas.stream_basic + + +def test_stream_full(): + stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10) + assert json.dumps(_action_as_dict(stream)) == nas.stream_full + + +def test_input_basic(): + input = Ncco.Input(type='dtmf') + assert type(input) == Ncco.Input + assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf + + +def test_input_basic_list(): + input = Ncco.Input(type=['dtmf', 'speech']) + assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf_speech + + +def test_input_dtmf_and_speech_options(): + dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True) + speech = InputTypes.Speech( + uuid='my-uuid', + endOnSilence=2.5, + language='en-GB', + context=['sales', 'billing'], + startTimeout=20, + maxDuration=30, + saveAudio=True, + ) + input = Ncco.Input( + type=['dtmf', 'speech'], dtmf=dtmf, speech=speech, eventUrl='http://example.com/speech', eventMethod='put' + ) + assert json.dumps(_action_as_dict(input)) == nas.input_dtmf_and_speech_full + + +def test_input_validation_error(): + with pytest.raises(ValidationError): + Ncco.Input(type='invalid_type') + + +def test_notify_basic(): + notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com']) + assert type(notify) == Ncco.Notify + assert json.dumps(_action_as_dict(notify)) == nas.notify_basic + + +def test_notify_basic_str_in_event_url(): + notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl='http://example.com') + assert type(notify) == Ncco.Notify + assert json.dumps(_action_as_dict(notify)) == nas.notify_basic + + +def test_notify_full(): + notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST') + assert type(notify) == Ncco.Notify + assert json.dumps(_action_as_dict(notify)) == nas.notify_full + + +def test_notify_validation_error(): + with pytest.raises(ValidationError): + Ncco.Notify(payload={'message', 'hello'}, eventUrl=['http://example.com']) + + +def test_pay_voice_basic(): + pay = Ncco.Pay(amount='10.00') + assert type(pay) == Ncco.Pay + assert json.dumps(_action_as_dict(pay)) == nas.pay_basic + + +def test_pay_voice_full(): + voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) + pay = Ncco.Pay(amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings) + assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full + + +def test_pay_text(): + text_prompts = PayPrompts.TextPrompt( + type='CardNumber', + text='Enter your card number.', + errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + ) + pay = Ncco.Pay(amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts) + assert json.dumps(_action_as_dict(pay)) == nas.pay_text + + +def test_pay_text_multiple_prompts(): + card_prompt = PayPrompts.TextPrompt( + type='CardNumber', + text='Enter your card number.', + errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + ) + expiration_date_prompt = PayPrompts.TextPrompt( + type='ExpirationDate', + text='Enter your card expiration date.', + errors={ + 'InvalidExpirationDate': {'text': 'You have entered an invalid expiration date.'}, + 'Timeout': {'text': 'Please enter your card\'s expiration date.'}, + }, + ) + security_code_prompt = PayPrompts.TextPrompt( + type='SecurityCode', + text='Enter your 3-digit security code.', + errors={ + 'InvalidSecurityCode': {'text': 'You have entered an invalid security code.'}, + 'Timeout': {'text': 'Please enter your card\'s security code.'}, + }, + ) + + text_prompts = [card_prompt, expiration_date_prompt, security_code_prompt] + pay = Ncco.Pay(amount=12, prompts=text_prompts) + assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts + + +def test_pay_validation_error(): + with pytest.raises(ValidationError): + Ncco.Pay(amount='not-valid') diff --git a/tests/test_ncco_builder/test_ncco_builder.py b/tests/test_ncco_builder/test_ncco_builder.py new file mode 100644 index 00000000..14333467 --- /dev/null +++ b/tests/test_ncco_builder/test_ncco_builder.py @@ -0,0 +1,45 @@ +import pytest +import json + +from vonage import Ncco +import ncco_samples.ncco_builder_samples as nbs + + +def test_build_basic_ncco(): + ncco = Ncco.build_ncco(nbs.talk_minimal) + assert ncco == nbs.basic_ncco + + +def test_build_ncco_from_args(): + ncco = Ncco.build_ncco(nbs.record, nbs.talk_minimal) + assert ncco == nbs.two_part_ncco + assert ( + json.dumps(ncco) + == '[{"action": "record", "eventUrl": ["http://example.com/events"]}, {"action": "talk", "text": "hello"}]' + ) + + +def test_build_ncco_from_list(): + action_list = [nbs.record, nbs.talk_minimal] + ncco = Ncco.build_ncco(actions=action_list) + assert ncco == nbs.two_part_ncco + assert ( + json.dumps(ncco) + == '[{"action": "record", "eventUrl": ["http://example.com/events"]}, {"action": "talk", "text": "hello"}]' + ) + + +def test_build_insane_ncco(): + action_list = [ + nbs.record, + nbs.conversation, + nbs.connect, + nbs.talk, + nbs.stream, + nbs.input, + nbs.notify, + nbs.pay_voice_prompt, + nbs.pay_text_prompt, + ] + ncco = Ncco.build_ncco(actions=action_list) + assert ncco == nbs.insane_ncco diff --git a/tests/test_ncco_builder/test_pay_prompts.py b/tests/test_ncco_builder/test_pay_prompts.py new file mode 100644 index 00000000..c5628bd7 --- /dev/null +++ b/tests/test_ncco_builder/test_pay_prompts.py @@ -0,0 +1,54 @@ +from vonage import PayPrompts + +import pytest +from pydantic import ValidationError + + +def test_create_voice_model(): + voice_prompt = PayPrompts.VoicePrompt(language='en-GB', style=1) + assert (type(voice_prompt)) == PayPrompts.VoicePrompt + + +def test_create_voice_model_from_dict(): + voice_dict = {'language': 'en-GB', 'style': 1} + voice_prompt = PayPrompts.create_voice_model(voice_dict) + assert (type(voice_prompt)) == PayPrompts.VoicePrompt + + +def test_create_text_model(): + text_prompt = PayPrompts.TextPrompt( + type='CardNumber', + text='Enter your card number.', + errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + ) + assert type(text_prompt) == PayPrompts.TextPrompt + + +def test_create_text_model_from_dict(): + text_dict = { + 'type': 'CardNumber', + 'text': 'Enter your card number.', + 'errors': {'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + } + text_prompt = PayPrompts.create_text_model(text_dict) + assert type(text_prompt) == PayPrompts.TextPrompt + + +def test_error_message_not_in_subdictionary(): + with pytest.raises(ValidationError): + PayPrompts.TextPrompt( + type='CardNumber', + text='Enter your card number.', + errors={'InvalidCardType': 'The card you are trying to use is not valid for this purchase.'}, + ) + + +def test_invalid_error_type_for_prompt(): + with pytest.raises(ValueError) as err: + PayPrompts.TextPrompt( + type='SecurityCode', + text='Enter your card number.', + errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + ) + + assert 'Value "InvalidCardType" is not a valid error for the "SecurityCode" prompt type.' in str(err.value) diff --git a/tests/test_voice.py b/tests/test_voice.py index c7f87faa..9a8fd332 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -4,6 +4,7 @@ import jwt import vonage +from vonage import Ncco from util import * @@ -14,21 +15,22 @@ def test_create_call(voice, dummy_data): params = { "to": [{"type": "phone", "number": "14843331234"}], "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"] + "answer_url": ["https://example.com/answer"], } assert isinstance(voice.create_call(params), dict) assert request_user_agent() == dummy_data.user_agent assert request_content_type() == "application/json" + @responses.activate def test_params_with_random_number(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") params = { "to": [{"type": "phone", "number": "14843331234"}], - "random_from_number":True, - "answer_url": ["https://example.com/answer"] + "random_from_number": True, + "answer_url": ["https://example.com/answer"], } assert isinstance(voice.create_call(params), dict) @@ -36,6 +38,29 @@ def test_params_with_random_number(voice, dummy_data): assert request_content_type() == "application/json" +@responses.activate +def test_create_call_with_ncco_builder(voice, dummy_data): + stub(responses.POST, "https://api.nexmo.com/v1/calls") + + talk = Ncco.Talk( + text='Hello from Vonage!', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True + ) + ncco = Ncco.build_ncco(talk) + voice.create_call( + { + 'to': [{'type': 'phone', 'number': '447449815316'}], + 'from': {'type': 'phone', 'number': '447418370240'}, + 'ncco': ncco, + } + ) + assert ( + request_body() + == b'{"to": [{"type": "phone", "number": "447449815316"}], "from": {"type": "phone", "number": "447418370240"}, "ncco": [{"action": "talk", "text": "Hello from Vonage!", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}]}' + ) + assert request_user_agent() == dummy_data.user_agent + assert request_content_type() == "application/json" + + @responses.activate def test_get_calls(voice, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls") @@ -149,9 +174,7 @@ def test_authorization_with_private_key_path(dummy_data): voice = vonage.Voice(client) voice.get_call("xx-xx-xx-xx") - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) + token = jwt.decode(request_authorization().split()[1], dummy_data.public_key, algorithms="RS256") assert token["application_id"] == dummy_data.application_id @@ -161,11 +184,10 @@ def test_authorization_with_private_key_object(voice, dummy_data): voice.get_call("xx-xx-xx-xx") - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) + token = jwt.decode(request_authorization().split()[1], dummy_data.public_key, algorithms="RS256") assert token["application_id"] == dummy_data.application_id + @responses.activate def test_get_recording(voice, dummy_data): stub_bytes( @@ -174,10 +196,7 @@ def test_get_recording(voice, dummy_data): ) assert isinstance( - voice.get_recording( - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957" - ), + voice.get_recording("https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957"), bytes, ) assert request_user_agent() == dummy_data.user_agent - From 394f464dfeb80f0faa3b96112e4ed5d13cef159b Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 14 Jan 2023 02:37:58 +0000 Subject: [PATCH 224/401] updating changelog --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 08560a6f..39003c60 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# 3.2.0 +- Adding an NCCO Builder to make it easier to work with NCCOs when using the Voice API +- Individual NCCO Actions can be created as Pydantic models, which can be built into an NCCO via the `Ncco.build_ncco` method + # 3.1.0 - Supporting Python 3.11 - Upgrading some old dependencies From bfa6cb4cb39313166bcf9a4d25ecfadaf1567541 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 14 Jan 2023 02:41:40 +0000 Subject: [PATCH 225/401] =?UTF-8?q?Bump=20version:=203.1.0=20=E2=86=92=203?= =?UTF-8?q?.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5506f07f..f324cee3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.0 +current_version = 3.2.0 commit = True tag = False diff --git a/setup.py b/setup.py index 8208fbd6..74c14960 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.1.0", + version="3.2.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 4f47f9d3..d0d68206 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "3.1.0" +__version__ = "3.2.0" From 1aefe00e27d5b81e7b3843ba5bcfe7080288b69a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 17 Jan 2023 15:21:58 +0000 Subject: [PATCH 226/401] Fix ncco error (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump version: 3.2.0 → 3.2.1 * fixing import issue with uncommitted init file * adding check that subfolders contain an __init__.py file * refactoring, raising exception --- .bumpversion.cfg | 2 +- CHANGES.md | 3 +++ setup.py | 2 +- src/vonage/__init__.py | 2 +- src/vonage/ncco_builder/__init__.py | 0 tests/test_packages.py | 12 ++++++++++++ 6 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 src/vonage/ncco_builder/__init__.py create mode 100644 tests/test_packages.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f324cee3..4d3ee899 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.2.0 +current_version = 3.2.1 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index 39003c60..706bc390 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.2.1 +- Fixing an import bug + # 3.2.0 - Adding an NCCO Builder to make it easier to work with NCCOs when using the Voice API - Individual NCCO Actions can be created as Pydantic models, which can be built into an NCCO via the `Ncco.build_ncco` method diff --git a/setup.py b/setup.py index 74c14960..4b3bbd9f 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.2.0", + version="3.2.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index d0d68206..73fdb78c 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,3 @@ from .client import * -__version__ = "3.2.0" +__version__ = "3.2.1" diff --git a/src/vonage/ncco_builder/__init__.py b/src/vonage/ncco_builder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_packages.py b/tests/test_packages.py new file mode 100644 index 00000000..d9cbceb6 --- /dev/null +++ b/tests/test_packages.py @@ -0,0 +1,12 @@ +import os + + +def test_subdirectories_are_python_packages(): + subdirs = [ + os.path.join('src/vonage', o) for o in os.listdir('src/vonage') if os.path.isdir(os.path.join('src/vonage', o)) + ] + for subdir in subdirs: + if '__pycache__' in subdir or os.path.isfile(f'{subdir}/__init__.py'): + continue + else: + raise Exception(f'Subfolder {subdir} doesn\'t have an __init__.py file') From 008267d448b84ff0f2af3f49509f66eeaf33ef2c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 19 Jan 2023 03:13:37 +0000 Subject: [PATCH 227/401] adding ncco builder import to __init__ files --- src/vonage/__init__.py | 1 + src/vonage/client.py | 1 - src/vonage/ncco_builder/__init__.py | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 73fdb78c..98304579 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,3 +1,4 @@ from .client import * +from .ncco_builder.ncco import * __version__ = "3.2.1" diff --git a/src/vonage/client.py b/src/vonage/client.py index b55716b9..0950f0ed 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -4,7 +4,6 @@ from .application import ApplicationV2, Application from .errors import * from .messages import Messages -from .ncco_builder.ncco import Ncco, ConnectEndpoints, InputTypes, PayPrompts from .number_insight import NumberInsight from .numbers import Numbers from .redact import Redact diff --git a/src/vonage/ncco_builder/__init__.py b/src/vonage/ncco_builder/__init__.py index e69de29b..f1908afe 100644 --- a/src/vonage/ncco_builder/__init__.py +++ b/src/vonage/ncco_builder/__init__.py @@ -0,0 +1 @@ +from .ncco import * From 0d8f8aefbf47aea044703ee0949fe4ef1e2b70ad Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 19 Jan 2023 03:14:33 +0000 Subject: [PATCH 228/401] updating changelog --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 706bc390..1d7e0296 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.2.2 +- Fixing a bug on Windows + # 3.2.1 - Fixing an import bug From de120782bff3cfc8a484f52d5bd47170323635e5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 19 Jan 2023 03:14:46 +0000 Subject: [PATCH 229/401] =?UTF-8?q?Bump=20version:=203.2.1=20=E2=86=92=203?= =?UTF-8?q?.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4d3ee899..8020c925 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.2.1 +current_version = 3.2.2 commit = True tag = False diff --git a/setup.py b/setup.py index 4b3bbd9f..ad0d0f8c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.2.1", + version="3.2.2", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 98304579..bf37a98c 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.2.1" +__version__ = "3.2.2" From 025636a95d55ca5e053354c220633d163e2b3317 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 30 Jan 2023 23:11:17 +0000 Subject: [PATCH 230/401] using CI mode rather than bash to get appropriate exit codes for GitHub Actions --- .github/workflows/mutation-test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml index 1acdf8ed..af0f3bc7 100644 --- a/.github/workflows/mutation-test.yml +++ b/.github/workflows/mutation-test.yml @@ -25,8 +25,7 @@ jobs: python -m pip install mutmut - name: Run mutation test run: | - mutmut run --no-progress || \ - if [ $? -eq 1 ]; then exit 1; fi + mutmut run --no-progress --CI - name: Save HTML output run: | mutmut html From 5458a68765584e54fbdfd26efa99d306c4682290 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 28 Feb 2023 12:05:17 +0000 Subject: [PATCH 231/401] deprecating pay ncco action (#245) * deprecating pay ncco action * removing reference to Pay action from README --- README.md | 1 - src/vonage/ncco_builder/ncco.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e91400ee..d80d3883 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,6 @@ Use the builder to construct valid NCCO actions, which are modelled in the SDK a * Stream * Input * Notify -* Pay ### Construct actions diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py index 296fdca1..6cf9c03f 100644 --- a/src/vonage/ncco_builder/ncco.py +++ b/src/vonage/ncco_builder/ncco.py @@ -6,6 +6,8 @@ from .input_types import InputTypes from .pay_prompts import PayPrompts +from deprecated import deprecated + class Ncco: class Action(BaseModel): @@ -171,6 +173,7 @@ class Notify(Action): def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) + @deprecated(version='3.2.3', reason='The Pay NCCO action has been deprecated.') class Pay(Action): """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" From ac870cc14aae8da9574ad42839a03001be1b554a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 28 Feb 2023 14:53:15 +0000 Subject: [PATCH 232/401] renamed numbers.py -> number_management.py to avoid namespace clash (#247) --- src/vonage/client.py | 3 +-- src/vonage/{numbers.py => number_management.py} | 0 tests/{test_numbers.py => test_number_management.py} | 0 3 files changed, 1 insertion(+), 2 deletions(-) rename src/vonage/{numbers.py => number_management.py} (100%) rename tests/{test_numbers.py => test_number_management.py} (100%) diff --git a/src/vonage/client.py b/src/vonage/client.py index 0950f0ed..b8566848 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -5,7 +5,7 @@ from .errors import * from .messages import Messages from .number_insight import NumberInsight -from .numbers import Numbers +from .number_management import Numbers from .redact import Redact from .short_codes import ShortCodes from .sms import Sms @@ -276,7 +276,6 @@ def parse(self, host, response): elif response.status_code == 204: return None elif 200 <= response.status_code < 300: - # Strip off any encoding from the content-type header: content_mime = response.headers.get("content-type").split(";", 1)[0] if content_mime == "application/json": diff --git a/src/vonage/numbers.py b/src/vonage/number_management.py similarity index 100% rename from src/vonage/numbers.py rename to src/vonage/number_management.py diff --git a/tests/test_numbers.py b/tests/test_number_management.py similarity index 100% rename from tests/test_numbers.py rename to tests/test_number_management.py From 4971eda09bd92f79e822a1288d871a5fb5a7d0fd Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 2 Mar 2023 17:45:20 +0000 Subject: [PATCH 233/401] adding links to all sections (#250) --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d80d3883..e8f8a397 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,16 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [NCCO Builder](#ncco-builder) - [Verify API](#verify-api) - [Number Insight API](#number-insight-api) +- [Account API](#account-api) - [Number Management API](#number-management-api) +- [Pricing API](#pricing-api) - [Managing Secrets](#managing-secrets) - [Application API](#application-api) +- [Validating Webhook Signatures](#validate-webhook-signatures) +- [JWT Parameters](#jwt-parameters) - [Overriding API Attributes](#overriding-api-attributes) - [Frequently Asked Questions](#frequently-asked-questions) +- [Contributing](#contributing) - [License](#license) ## Installation @@ -720,7 +725,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Verify API | General Availability | ✅ | | Voice API | General Availability | ✅ | -## asyncio Support +### asyncio Support [asyncio](https://docs.python.org/3/library/asyncio.html) is a library to write **concurrent** code using the **async/await** syntax. From 1b0d779d82455e80bd9cd4b78cd1a0b45e5e1e45 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 3 Mar 2023 15:48:10 +0000 Subject: [PATCH 234/401] Update messages api (#251) * adding new viber channels * adding new viber message types and tests * adding tests for new viber action response * add sticker as a whatsapp channel, add specific errors to validation tests, increase max client_ref value * add new check for exclusive sticker dictionary keys --- src/vonage/messages.py | 64 +++-- tests/test_messages_send_message.py | 19 +- tests/test_messages_validate_input.py | 336 ++++++++++++++++++-------- 3 files changed, 290 insertions(+), 129 deletions(-) diff --git a/src/vonage/messages.py b/src/vonage/messages.py index d74efca8..42cc4cba 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -2,31 +2,32 @@ import re + class Messages: valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber_service'} valid_message_types = { 'sms': {'text'}, 'mms': {'image', 'vcard', 'audio', 'video'}, - 'whatsapp': {'text', 'image', 'audio', 'video', 'file', 'template', 'custom'}, + 'whatsapp': {'text', 'image', 'audio', 'video', 'file', 'template', 'sticker', 'custom'}, 'messenger': {'text', 'image', 'audio', 'video', 'file'}, - 'viber_service': {'text', 'image'} + 'viber_service': {'text', 'image', 'video', 'file'}, } - + def __init__(self, client): self._client = client self._auth_type = 'jwt' - def send_message(self, params: dict): + def send_message(self, params: dict): self.validate_send_message_input(params) - + if not hasattr(self._client, '_application_id'): - self._auth_type='header' + self._auth_type = 'header' return self._client.post( - self._client.api_host(), + self._client.api_host(), "/v1/messages", - params, + params, auth_type=self._auth_type, - ) + ) def validate_send_message_input(self, params): self._check_input_is_dict(params) @@ -36,24 +37,28 @@ def validate_send_message_input(self, params): self._check_valid_sender(params) self._channel_specific_checks(params) self._check_valid_client_ref(params) - + def _check_input_is_dict(self, params): if type(params) is not dict: raise MessagesError('Parameters to the send_message method must be specified as a dictionary.') def _check_valid_message_channel(self, params): if params['channel'] not in Messages.valid_message_channels: - raise MessagesError(f""" - '{params['channel']}' is an invalid message channel. + raise MessagesError( + f""" + "{params['channel']}" is an invalid message channel. Must be one of the following types: {self.valid_message_channels}' - """) + """ + ) def _check_valid_message_type(self, params): if params['message_type'] not in self.valid_message_types[params['channel']]: - raise MessagesError(f""" + raise MessagesError( + f""" "{params['message_type']}" is not a valid message type for channel "{params["channel"]}". Must be one of the following types: {self.valid_message_types[params["channel"]]} - """) + """ + ) def _check_valid_recipient(self, params): if not isinstance(params['to'], str): @@ -65,20 +70,29 @@ def _check_valid_recipient(self, params): def _check_valid_sender(self, params): if not isinstance(params['from'], str) or params['from'] == "": - raise MessagesError(f'Message sender ("frm={params["from"]}") set incorrectly. Set a valid name or number for the sender.') + raise MessagesError( + f'Message sender ("frm={params["from"]}") set incorrectly. Set a valid name or number for the sender.' + ) def _channel_specific_checks(self, params): - try: - if params['channel'] == 'whatsapp' and params['message_type'] == 'template': - params['whatsapp'] - if params['channel'] == 'viber_service': - params['viber_service'] - except (KeyError, TypeError): - raise MessagesError(f'''You must specify all required properties for message channel "{params["channel"]}".''') + if ( + (params['channel'] == 'whatsapp' and params['message_type'] == 'template' and 'whatsapp' not in params) + or (params['channel'] == 'whatsapp' and params['message_type'] == 'sticker' and 'sticker' not in params) + or (params['channel'] == 'viber_service' and 'viber_service' not in params) + ): + raise MessagesError( + f'''You must specify all required properties for message channel "{params["channel"]}".''' + ) + elif params['channel'] == 'whatsapp' and params['message_type'] == 'sticker': + self._check_valid_whatsapp_sticker(params['sticker']) def _check_valid_client_ref(self, params): if 'client_ref' in params: - if len(params['client_ref']) <= 40: + if len(params['client_ref']) <= 100: self._client_ref = params['client_ref'] else: - raise MessagesError('client_ref can be a maximum of 40 characters.') + raise MessagesError('client_ref can be a maximum of 100 characters.') + + def _check_valid_whatsapp_sticker(self, sticker): + if ('id' not in sticker and 'url' not in sticker) or ('id' in sticker and 'url' in sticker): + raise MessagesError('Must specify one, and only one, of "id" or "url" in the "sticker" field.') diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py index 268fd827..a993eaa2 100644 --- a/tests/test_messages_send_message.py +++ b/tests/test_messages_send_message.py @@ -1,15 +1,16 @@ from util import * + @responses.activate def test_send_sms_with_messages_api(messages, dummy_data): stub(responses.POST, 'https://api.nexmo.com/v1/messages') params = { - 'channel': 'sms', - 'message_type': 'text', - 'to': '447123456789', + 'channel': 'sms', + 'message_type': 'text', + 'to': '447123456789', 'from': 'Vonage', - 'text': 'Hello from Vonage' + 'text': 'Hello from Vonage', } assert isinstance(messages.send_message(params), dict) @@ -18,16 +19,17 @@ def test_send_sms_with_messages_api(messages, dummy_data): assert b'"to": "447123456789"' in request_body() assert b'"text": "Hello from Vonage"' in request_body() + @responses.activate def test_send_whatsapp_image_with_messages_api(messages, dummy_data): stub(responses.POST, 'https://api.nexmo.com/v1/messages') params = { - 'channel': 'whatsapp', - 'message_type': 'image', - 'to': '447123456789', + 'channel': 'whatsapp', + 'message_type': 'image', + 'to': '447123456789', 'from': '440123456789', - 'image': {'url': 'https://example.com/image.jpg', 'caption': 'fake test image'} + 'image': {'url': 'https://example.com/image.jpg', 'caption': 'fake test image'}, } assert isinstance(messages.send_message(params), dict) @@ -35,4 +37,3 @@ def test_send_whatsapp_image_with_messages_api(messages, dummy_data): assert b'"from": "440123456789"' in request_body() assert b'"to": "447123456789"' in request_body() assert b'"image": {"url": "https://example.com/image.jpg", "caption": "fake test image"}' in request_body() - diff --git a/tests/test_messages_validate_input.py b/tests/test_messages_validate_input.py index bcb25258..9b346fd0 100644 --- a/tests/test_messages_validate_input.py +++ b/tests/test_messages_validate_input.py @@ -1,128 +1,274 @@ from util import * from vonage.errors import MessagesError + def test_invalid_send_message_params_object(messages): - with pytest.raises(MessagesError): + with pytest.raises(MessagesError) as err: messages.send_message('hi') + assert str(err.value) == 'Parameters to the send_message method must be specified as a dictionary.' + def test_invalid_message_channel(messages): - with pytest.raises(MessagesError): - messages.send_message({ - 'channel': 'carrier_pigeon', - 'message_type': 'text', - 'to': '12345678', - 'from': 'vonage', - 'text': 'my important message' - }) + with pytest.raises(MessagesError) as err: + messages.send_message( + { + 'channel': 'carrier_pigeon', + 'message_type': 'text', + 'to': '12345678', + 'from': 'vonage', + 'text': 'my important message', + } + ) + assert '"carrier_pigeon" is an invalid message channel.' in str(err.value) + def test_invalid_message_type(messages): - with pytest.raises(MessagesError): - messages.send_message({ - 'channel': 'sms', - 'message_type': 'video', - 'to': '12345678', - 'from': 'vonage', - 'video': 'my_url.com' - }) + with pytest.raises(MessagesError) as err: + messages.send_message( + {'channel': 'sms', 'message_type': 'video', 'to': '12345678', 'from': 'vonage', 'video': 'my_url.com'} + ) + assert '"video" is not a valid message type for channel "sms".' in str(err.value) + def test_invalid_recipient_not_string(messages): - with pytest.raises(MessagesError): - messages.send_message({ - 'channel': 'sms', - 'message_type': 'text', - 'to': 12345678, - 'from': 'vonage', - 'text': 'my important message' - }) + with pytest.raises(MessagesError) as err: + messages.send_message( + {'channel': 'sms', 'message_type': 'text', 'to': 12345678, 'from': 'vonage', 'text': 'my important message'} + ) + assert str(err.value) == 'Message recipient ("to=12345678") not in a valid format.' + def test_invalid_recipient_number(messages): - with pytest.raises(MessagesError): - messages.send_message({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '+441234567890', - 'from': 'vonage', - 'text': 'my important message' - }) + with pytest.raises(MessagesError) as err: + messages.send_message( + { + 'channel': 'sms', + 'message_type': 'text', + 'to': '+441234567890', + 'from': 'vonage', + 'text': 'my important message', + } + ) + assert str(err.value) == 'Message recipient number ("to=+441234567890") not in a valid format.' + def test_invalid_messenger_recipient(messages): - with pytest.raises(MessagesError): - messages.send_message({ - 'channel': 'messenger', - 'message_type': 'text', - 'to': '', - 'from': 'vonage', - 'text': 'my important message' - }) + with pytest.raises(MessagesError) as err: + messages.send_message( + {'channel': 'messenger', 'message_type': 'text', 'to': '', 'from': 'vonage', 'text': 'my important message'} + ) + assert str(err.value) == 'Message recipient ID ("to=") not in a valid format.' + def test_invalid_sender(messages): - with pytest.raises(MessagesError): - messages.send_message({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 1234, - 'text': 'my important message' - }) + with pytest.raises(MessagesError) as err: + messages.send_message( + { + 'channel': 'sms', + 'message_type': 'text', + 'to': '441234567890', + 'from': 1234, + 'text': 'my important message', + } + ) + assert str(err.value) == 'Message sender ("frm=1234") set incorrectly. Set a valid name or number for the sender.' + def test_set_client_ref(messages): - messages._check_valid_client_ref({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 'vonage', - 'text': 'my important message', - 'client_ref': 'my client reference' - }) + messages._check_valid_client_ref( + { + 'channel': 'sms', + 'message_type': 'text', + 'to': '441234567890', + 'from': 'vonage', + 'text': 'my important message', + 'client_ref': 'my client reference', + } + ) assert messages._client_ref == 'my client reference' + def test_invalid_client_ref(messages): - with pytest.raises(MessagesError): - messages._check_valid_client_ref({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 'vonage', - 'text': 'my important message', - 'client_ref': 'my client reference that is far longer than the 40 character limit' - }) + with pytest.raises(MessagesError) as err: + messages._check_valid_client_ref( + { + 'channel': 'sms', + 'message_type': 'text', + 'to': '441234567890', + 'from': 'vonage', + 'text': 'my important message', + 'client_ref': 'my client reference that is, in fact, a small, but significant amount longer than the 100 character limit imposed at this present juncture.', + } + ) + assert str(err.value) == 'client_ref can be a maximum of 100 characters.' + def test_whatsapp_template(messages): - messages.validate_send_message_input({ - 'channel': 'whatsapp', + messages.validate_send_message_input( + { + 'channel': 'whatsapp', 'message_type': 'template', - 'to': '4412345678912', + 'to': '4412345678912', 'from': 'vonage', 'template': {'name': 'namespace:mytemplate'}, - 'whatsapp': {'policy': 'deterministic', 'locale': 'en-GB'} - }) + 'whatsapp': {'policy': 'deterministic', 'locale': 'en-GB'}, + } + ) + def test_set_messenger_optional_attribute(messages): - messages.validate_send_message_input({ - 'channel': 'messenger', - 'message_type': 'text', - 'to': 'user_messenger_id', - 'from': 'vonage', - 'text': 'my important message', - 'messenger': {'category': 'response', 'tag': 'ACCOUNT_UPDATE'} - }) + messages.validate_send_message_input( + { + 'channel': 'messenger', + 'message_type': 'text', + 'to': 'user_messenger_id', + 'from': 'vonage', + 'text': 'my important message', + 'messenger': {'category': 'response', 'tag': 'ACCOUNT_UPDATE'}, + } + ) + def test_set_viber_service_optional_attribute(messages): - messages.validate_send_message_input({ - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'text'} - }) + messages.validate_send_message_input( + { + 'channel': 'viber_service', + 'message_type': 'text', + 'to': '44123456789', + 'from': 'vonage', + 'text': 'my important message', + 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'text'}, + } + ) + + +def test_viber_service_video(messages): + messages.validate_send_message_input( + { + 'channel': 'viber_service', + 'message_type': 'video', + 'to': '44123456789', + 'from': 'vonage', + 'video': { + 'url': 'https://example.com/video.mp4', + 'caption': 'Look at this video', + 'thumb_url': 'https://example.com/thumbnail.jpg', + }, + 'viber_service': {'category': 'transaction', 'duration': '120', 'ttl': 30, 'type': 'string'}, + } + ) + + +def test_viber_service_file(messages): + messages.validate_send_message_input( + { + 'channel': 'viber_service', + 'message_type': 'file', + 'to': '44123456789', + 'from': 'vonage', + 'video': {'url': 'https://example.com/files', 'name': 'example.pdf'}, + 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'string'}, + } + ) + + +def test_viber_service_text_action_button(messages): + messages.validate_send_message_input( + { + 'channel': 'viber_service', + 'message_type': 'text', + 'to': '44123456789', + 'from': 'vonage', + 'text': 'my important message', + 'viber_service': { + 'category': 'transaction', + 'ttl': 30, + 'type': 'string', + 'action': {'url': 'https://example.com/page1.html', 'text': 'Find out more'}, + }, + } + ) + + +def test_viber_service_image_action_button(messages): + messages.validate_send_message_input( + { + 'channel': 'viber_service', + 'message_type': 'image', + 'to': '44123456789', + 'from': 'vonage', + 'image': {'url': 'https://example.com/image.jpg', 'caption': 'Check out this new promotion'}, + 'viber_service': { + 'category': 'transaction', + 'ttl': 30, + 'type': 'string', + 'action': {'url': 'https://example.com/page1.html', 'text': 'Find out more'}, + }, + } + ) + def test_incomplete_input(messages): - with pytest.raises(MessagesError): - messages.validate_send_message_input({ - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message' - }) + with pytest.raises(MessagesError) as err: + messages.validate_send_message_input( + { + 'channel': 'viber_service', + 'message_type': 'text', + 'to': '44123456789', + 'from': 'vonage', + 'text': 'my important message', + } + ) + assert str(err.value) == 'You must specify all required properties for message channel "viber_service".' + + +def test_whatsapp_sticker_id(messages): + messages.validate_send_message_input( + { + 'channel': 'whatsapp', + 'message_type': 'sticker', + 'sticker': {'id': '13aaecab-2485-4255-a0a7-97a2be6906b9'}, + 'to': '44123456789', + 'from': 'vonage', + } + ) + + +def test_whatsapp_sticker_url(messages): + messages.validate_send_message_input( + { + 'channel': 'whatsapp', + 'message_type': 'sticker', + 'sticker': {'url': 'https://example.com/sticker1.webp'}, + 'to': '44123456789', + 'from': 'vonage', + } + ) + + +def test_whatsapp_sticker_invalid_input_error(messages): + with pytest.raises(MessagesError) as err: + messages.validate_send_message_input( + { + 'channel': 'whatsapp', + 'message_type': 'sticker', + 'sticker': {'my_sticker'}, + 'to': '44123456789', + 'from': 'vonage', + } + ) + assert str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' + + +def test_whatsapp_sticker_exclusive_keys_error(messages): + with pytest.raises(MessagesError) as err: + messages.validate_send_message_input( + { + 'channel': 'whatsapp', + 'message_type': 'sticker', + 'sticker': {'id': '13aaecab-2485-4255-a0a7-97a2be6906b9', 'url': 'https://example.com/sticker1.webp'}, + 'to': '44123456789', + 'from': 'vonage', + } + ) + assert str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' From 70b7209803a456ea3ffb6aa5df86dc94dff4d2c2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 3 Mar 2023 16:31:42 +0000 Subject: [PATCH 235/401] V3.3.0 release (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * updating CHANGES.md for v3.3.0 release * Bump version: 3.2.2 → 3.3.0 --- .bumpversion.cfg | 2 +- CHANGES.md | 7 +++++++ setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8020c925..63e2fd23 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.2.2 +current_version = 3.3.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index 1d7e0296..638170f2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,10 @@ +# 3.3.0 +- Updated Messages API: + - Added new messaging channels for Viber Service Messages (`video`, `file`) + - Added new WhatsApp `sticker` message channel + - Increased `client_ref` max value to 100 characters +- Deprecated `pay` action in the NCCO builder as it is being removed by Vonage + # 3.2.2 - Fixing a bug on Windows diff --git a/setup.py b/setup.py index ad0d0f8c..221f0d06 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.2.2", + version="3.3.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index bf37a98c..ec21872b 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.2.2" +__version__ = "3.3.0" From d338f372427fd5e44a5b668fa3a30d29774305f4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 14 Mar 2023 18:19:10 +0000 Subject: [PATCH 236/401] updating NCCO info (#253) * updating NCCO info * updating ncco builder tests --- README.md | 5 +++++ src/vonage/ncco_builder/ncco.py | 2 +- tests/test_ncco_builder/ncco_samples/ncco_action_samples.py | 6 +++--- .../test_ncco_builder/ncco_samples/ncco_builder_samples.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e8f8a397..bc1955a1 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,11 @@ response = client.voice.create_call({ pprint(response) ``` +### Note on from_ parameter in connect action + +When using the `connect` action, use the parameter `from_` to specify the recipient (as `from` is a reserved keyword in Python!) + + ## Verify API ### Search for a Verification request diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py index 6cf9c03f..7d8a4016 100644 --- a/src/vonage/ncco_builder/ncco.py +++ b/src/vonage/ncco_builder/ncco.py @@ -42,7 +42,7 @@ class Conversation(Action): while preserving the communication context. Using conversation with the same name reuses the same persisted conversation.""" - action = Field('notify', const=True) + action = Field('conversation', const=True) name: str musicOnHoldUrl: Optional[Union[List[str], str]] startOnEnter: Optional[bool] diff --git a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py index 838fe9d6..b6dd8363 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py @@ -4,11 +4,11 @@ record_add_split = '{"action": "record", "split": "conversation", "channels": 4}' -conversation_basic = '{"action": "notify", "name": "my_conversation"}' +conversation_basic = '{"action": "conversation", "name": "my_conversation"}' -conversation_full = '{"action": "notify", "name": "my_conversation", "musicOnHoldUrl": ["http://example.com/music.mp3"], "startOnEnter": true, "endOnExit": true, "record": true, "canSpeak": ["asdf", "qwer"], "canHear": ["asdf"]}' +conversation_full = '{"action": "conversation", "name": "my_conversation", "musicOnHoldUrl": ["http://example.com/music.mp3"], "startOnEnter": true, "endOnExit": true, "record": true, "canSpeak": ["asdf", "qwer"], "canHear": ["asdf"]}' -conversation_mute_option = '{"action": "notify", "name": "my_conversation", "mute": true}' +conversation_mute_option = '{"action": "conversation", "name": "my_conversation", "mute": true}' connect_phone = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000", "dtmfAnswer": "1p2p3p#**903#", "onAnswer": {"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}}]}' diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py index c5eb958e..df1bfbc6 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py @@ -71,7 +71,7 @@ insane_ncco = [ {'action': 'record', 'eventUrl': ['http://example.com/events']}, - {'action': 'notify', 'name': 'my_conversation'}, + {'action': 'conversation', 'name': 'my_conversation'}, { 'action': 'connect', 'endpoint': [{'number': '447000000000', 'type': 'phone'}], From f4ebdfaf0a8874e39a93935254d54e8a2acca4e8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 14 Apr 2023 02:59:27 +0100 Subject: [PATCH 237/401] changing numbers API to use header authentication (#255) --- src/vonage/client.py | 3 ++- src/vonage/number_management.py | 24 +++++++++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index b8566848..ea6c02ca 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -25,6 +25,7 @@ import re from uuid import uuid4 +from requests import Response from requests.adapters import HTTPAdapter from requests.sessions import Session @@ -269,7 +270,7 @@ def delete(self, host, request_uri, auth_type=None): logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") return self.parse(host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout)) - def parse(self, host, response): + def parse(self, host, response: Response): logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: raise AuthenticationError("Authentication failed. Check you're using a valid authentication method.") diff --git a/src/vonage/number_management.py b/src/vonage/number_management.py index 177c461b..65f04ae3 100644 --- a/src/vonage/number_management.py +++ b/src/vonage/number_management.py @@ -1,32 +1,26 @@ class Numbers: - auth_type = 'params' + auth_type = 'header' defaults = {'auth_type': auth_type, 'body_is_json': False} def __init__(self, client): self._client = client - + def get_account_numbers(self, params=None, **kwargs): return self._client.get(self._client.host(), "/account/numbers", params or kwargs, auth_type=Numbers.auth_type) def get_available_numbers(self, country_code, params=None, **kwargs): return self._client.get( - self._client.host(), - "/number/search", - dict(params or kwargs, country=country_code), - auth_type=Numbers.auth_type + self._client.host(), + "/number/search", + dict(params or kwargs, country=country_code), + auth_type=Numbers.auth_type, ) def buy_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults - ) + return self._client.post(self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults) def cancel_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults - ) + return self._client.post(self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults) def update_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/update", params or kwargs, **Numbers.defaults - ) + return self._client.post(self._client.host(), "/number/update", params or kwargs, **Numbers.defaults) From 25f695ea83b623d941d7eeb652f3ac8a3eab2af0 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 14 Apr 2023 15:44:31 +0100 Subject: [PATCH 238/401] V3.4.0 release (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * updating changelog for v3.4.0 release * Bump version: 3.3.0 → 3.4.0 --- .bumpversion.cfg | 2 +- CHANGES.md | 4 ++++ setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 63e2fd23..2cbee5d3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.3.0 +current_version = 3.4.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index 638170f2..f24def8b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# 3.4.0 +- Internal refactoring changes +- Using header authentication for the Numbers API + # 3.3.0 - Updated Messages API: - Added new messaging channels for Viber Service Messages (`video`, `file`) diff --git a/setup.py b/setup.py index 221f0d06..57bd5085 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.3.0", + version="3.4.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index ec21872b..91c26d11 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.3.0" +__version__ = "3.4.0" From 99b9635c4724c6110ad2ff9a261aad8d429b4759 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 16 May 2023 13:03:33 +0100 Subject: [PATCH 239/401] adding advancedMachineDetection to ncco builder and testing (#259) * adding advancedMachineDetection to ncco builder and testing * removing beep_timeout --- src/vonage/ncco_builder/ncco.py | 9 +++++++++ .../ncco_samples/ncco_action_samples.py | 2 ++ .../ncco_samples/ncco_builder_samples.py | 16 ++++++++++++++++ tests/test_ncco_builder/test_ncco_actions.py | 16 ++++++++++++++++ tests/test_ncco_builder/test_ncco_builder.py | 9 ++------- 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py index 7d8a4016..2a5698e9 100644 --- a/src/vonage/ncco_builder/ncco.py +++ b/src/vonage/ncco_builder/ncco.py @@ -73,6 +73,7 @@ class Connect(Action): timeout: Optional[int] limit: Optional[conint(le=7200)] machineDetection: Optional[Literal['continue', 'hangup']] + advancedMachineDetection: Optional[dict] eventUrl: Optional[Union[List[str], str]] eventMethod: Optional[constr(to_upper=True)] ringbackTone: Optional[str] @@ -103,6 +104,14 @@ def check_from_not_set(cls, v, values): def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) + @validator('advancedMachineDetection') + def validate_advancedMachineDetection(cls, v): + if 'behavior' in v and v['behavior'] not in ('continue', 'hangup'): + raise ValueError('advancedMachineDetection["behavior"] must be one of: "continue", "hangup".') + if 'mode' in v and v['mode'] not in ('detect, detect_beep'): + raise ValueError('advancedMachineDetection["mode"] must be one of: "detect", "detect_beep".') + return v + class Config: smart_union = True diff --git a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py index b6dd8363..ddf73411 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py @@ -22,6 +22,8 @@ connect_full = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "randomFromNumber": false, "eventType": "synchronous", "timeout": 15, "limit": 1000, "machineDetection": "hangup", "eventUrl": ["http://example.com"], "eventMethod": "PUT", "ringbackTone": "http://example.com"}' +connect_advancedMachineDetection = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "advancedMachineDetection": {"behavior": "continue", "mode": "detect"}, "eventUrl": ["http://example.com"]}' + talk_basic = '{"action": "talk", "text": "hello"}' talk_full = '{"action": "talk", "text": "hello", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}' diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py index df1bfbc6..07559aef 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py @@ -17,6 +17,12 @@ ringbackTone='http://example.com', ) +connect_advancedMachineDetection = Ncco.Connect( + endpoint=ConnectEndpoints.PhoneEndpoint(number='447000000000'), + advancedMachineDetection={'behavior': 'continue', 'mode': 'detect'}, +) + + talk_minimal = Ncco.Talk(text='hello') talk = Ncco.Talk(text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True) @@ -69,6 +75,16 @@ {'action': 'talk', 'text': 'hello'}, ] +three_part_advancedMachineDetection_ncco = [ + {'action': 'record', 'eventUrl': ['http://example.com/events']}, + { + 'action': 'connect', + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'advancedMachineDetection': {'behavior': 'continue', 'mode': 'detect'}, + }, + {'action': 'talk', 'text': 'hello'}, +] + insane_ncco = [ {'action': 'record', 'eventUrl': ['http://example.com/events']}, {'action': 'conversation', 'name': 'my_conversation'}, diff --git a/tests/test_ncco_builder/test_ncco_actions.py b/tests/test_ncco_builder/test_ncco_actions.py index 2c6ecd9b..c0379e85 100644 --- a/tests/test_ncco_builder/test_ncco_actions.py +++ b/tests/test_ncco_builder/test_ncco_actions.py @@ -125,6 +125,18 @@ def test_connect_options(): assert json.dumps(_action_as_dict(connect)) == nas.connect_full +def test_connect_advanced_machine_detection(): + advancedMachineDetectionParams = {'behavior': 'continue', 'mode': 'detect'} + endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') + connect = Ncco.Connect( + endpoint=endpoint, + from_='447400000000', + advancedMachineDetection=advancedMachineDetectionParams, + eventUrl='http://example.com', + ) + assert json.dumps(_action_as_dict(connect)) == nas.connect_advancedMachineDetection + + def test_connect_random_from_number_error(): endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') with pytest.raises(ValueError) as err: @@ -143,6 +155,10 @@ def test_connect_validation_errors(): Ncco.Connect(endpoint=endpoint, limit=7201) with pytest.raises(ValidationError): Ncco.Connect(endpoint=endpoint, machineDetection='do_nothing') + with pytest.raises(ValidationError): + Ncco.Connect(endpoint=endpoint, advancedMachineDetection={'behavior': 'do_nothing'}) + with pytest.raises(ValidationError): + Ncco.Connect(endpoint=endpoint, advancedMachineDetection={'mode': 'detect_nothing'}) def test_talk_basic(): diff --git a/tests/test_ncco_builder/test_ncco_builder.py b/tests/test_ncco_builder/test_ncco_builder.py index 14333467..fbabd42c 100644 --- a/tests/test_ncco_builder/test_ncco_builder.py +++ b/tests/test_ncco_builder/test_ncco_builder.py @@ -1,4 +1,3 @@ -import pytest import json from vonage import Ncco @@ -20,13 +19,9 @@ def test_build_ncco_from_args(): def test_build_ncco_from_list(): - action_list = [nbs.record, nbs.talk_minimal] + action_list = [nbs.record, nbs.connect_advancedMachineDetection, nbs.talk_minimal] ncco = Ncco.build_ncco(actions=action_list) - assert ncco == nbs.two_part_ncco - assert ( - json.dumps(ncco) - == '[{"action": "record", "eventUrl": ["http://example.com/events"]}, {"action": "talk", "text": "hello"}]' - ) + assert ncco == nbs.three_part_advancedMachineDetection_ncco def test_build_insane_ncco(): From b3b5d34f642a114ba66278ab0fe05631642dd461 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 16 May 2023 16:12:31 +0100 Subject: [PATCH 240/401] Add verify2 (#254) * starting verify implementation * using Literal from typing-extensions for 3.7 compatibility * add new_request and check_code verify2 methods, use new class structure, add tests * adding more test cases * adding custom verification code validation and testing * moving custom code validation from workflow object to the main request body * adding fraud_check parameter to verify v2 request * adding verify v2 to readme, adding verify2.cancel_verification * removing build on PR as we already build on push * fixing typo in link --- .github/workflows/build.yml | 2 +- README.md | 72 ++- src/vonage/client.py | 11 +- src/vonage/errors.py | 8 +- src/vonage/verify.py | 10 +- src/vonage/verify2.py | 107 +++++ tests/conftest.py | 16 +- tests/data/no_content.json | 0 tests/data/verify2/already_verified.json | 6 + tests/data/verify2/check_code.json | 4 + tests/data/verify2/code_not_supported.json | 6 + tests/data/verify2/create_request.json | 3 + tests/data/verify2/error_conflict.json | 7 + .../verify2/fraud_check_invalid_account.json | 6 + tests/data/verify2/invalid_code.json | 6 + tests/data/verify2/invalid_email.json | 12 + tests/data/verify2/invalid_sender.json | 6 + tests/data/verify2/rate_limit.json | 6 + tests/data/verify2/request_not_found.json | 6 + .../data/verify2/too_many_code_attempts.json | 6 + tests/test_verify2.py | 454 ++++++++++++++++++ 21 files changed, 740 insertions(+), 14 deletions(-) create mode 100644 src/vonage/verify2.py create mode 100644 tests/data/no_content.json create mode 100644 tests/data/verify2/already_verified.json create mode 100644 tests/data/verify2/check_code.json create mode 100644 tests/data/verify2/code_not_supported.json create mode 100644 tests/data/verify2/create_request.json create mode 100644 tests/data/verify2/error_conflict.json create mode 100644 tests/data/verify2/fraud_check_invalid_account.json create mode 100644 tests/data/verify2/invalid_code.json create mode 100644 tests/data/verify2/invalid_email.json create mode 100644 tests/data/verify2/invalid_sender.json create mode 100644 tests/data/verify2/rate_limit.json create mode 100644 tests/data/verify2/request_not_found.json create mode 100644 tests/data/verify2/too_many_code_attempts.json create mode 100644 tests/test_verify2.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba07353d..5ad1e94f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ name: Build -on: [push, pull_request] +on: [push] jobs: test: name: Test diff --git a/README.md b/README.md index bc1955a1..c5204821 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [Messages API](#messages-api) - [Voice API](#voice-api) - [NCCO Builder](#ncco-builder) -- [Verify API](#verify-api) +- [Verify V2 API](#verify-v2-api) +- [Verify V1 API](#verify-v1-api) - [Number Insight API](#number-insight-api) - [Account API](#account-api) - [Number Management API](#number-management-api) @@ -406,8 +407,75 @@ pprint(response) When using the `connect` action, use the parameter `from_` to specify the recipient (as `from` is a reserved keyword in Python!) +## Verify V2 API -## Verify API +V2 of the Vonage Verify API lets you send verification codes via SMS, WhatsApp, Voice and Email + +You can also verify a user by WhatsApp Interactive Message or by Silent Authentication on their mobile device. + +### Send a verification code + +```python +params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '447700900000'}] +} +verify_request = verify2.new_request(params) +``` + +### Use silent authentication, with email as a fallback + +```python +params = { + 'brand': 'ACME, Inc', + 'workflow': [ + {'channel': 'silent_auth', 'to': '447700900000'}, + {'channel': 'email', 'to': 'customer@example.com', 'from': 'business@example.com'} + ] +} +verify_request = verify2.new_request(params) +``` + +### Send a verification code with custom options, including a custom code + +```python +params = { + 'locale': 'en-gb', + 'channel_timeout': 120, + 'client_ref': 'my client reference', + 'code': 'asdf1234', + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': 'asdfghjklqw'}], +} +verify_request = verify2.new_request(params) +``` + +### Send a verification request to a blocked network + +This feature is only enabled if you have requested for it to be added to your account. + +```python +params = { + 'brand': 'ACME, Inc', + 'fraud_check': False, + 'workflow': [{'channel': 'sms', 'to': '447700900000'}] +} +verify_request = verify2.new_request(params) +``` + +### Check a verification code + +```python +verify2.check_code(REQUEST_ID, CODE) +``` + +### Cancel an ongoing verification + +```python +verify2.cancel_verification(REQUEST_ID) +``` + +## Verify V1 API ### Search for a Verification request diff --git a/src/vonage/client.py b/src/vonage/client.py index ea6c02ca..ed230ad8 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -12,6 +12,7 @@ from .ussd import Ussd from .voice import Voice from .verify import Verify +from .verify2 import Verify2 import logging from platform import python_version @@ -123,6 +124,7 @@ def __init__( self.sms = Sms(self) self.ussd = Ussd(self) self.verify = Verify(self) + self.verify2 = Verify2(self) self.voice = Voice(self) self.timeout = timeout @@ -278,7 +280,11 @@ def parse(self, host, response: Response): return None elif 200 <= response.status_code < 300: # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] + try: + content_mime = response.headers.get("content-type").split(";", 1)[0] + except AttributeError: + if response.json() is None: + return None if content_mime == "application/json": return response.json() else: @@ -295,7 +301,8 @@ def parse(self, host, response: Response): detail = error_data["detail"] type = error_data["type"] message = f"{title}: {detail} ({type})" - + else: + message = error_data except JSONDecodeError: pass raise ClientError(message) diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 79624bd6..b7b21ebb 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -27,8 +27,14 @@ class MessagesError(Error): class PricingTypeError(Error): """A pricing type was specified that is not allowed.""" + class RedactError(Error): """Error related to the Redact class or Redact API.""" + class InvalidAuthenticationTypeError(Error): - """An authentication method was specified that is not allowed""" \ No newline at end of file + """An authentication method was specified that is not allowed.""" + + +class Verify2Error(ClientError): + """An error relating to the Verify (V2) API.""" diff --git a/src/vonage/verify.py b/src/vonage/verify.py index f84c9313..76d5d05e 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -7,8 +7,8 @@ def __init__(self, client): def start_verification(self, params=None, **kwargs): return self._client.post( - self._client.api_host(), - "/verify/json", + self._client.api_host(), + "/verify/json", params or kwargs, **Verify.defaults, ) @@ -44,6 +44,8 @@ def trigger_next_event(self, request_id): def psd2(self, params=None, **kwargs): return self._client.post( - self._client.api_host(), "/verify/psd2/json", params or kwargs, **Verify.defaults, + self._client.api_host(), + "/verify/psd2/json", + params or kwargs, + **Verify.defaults, ) - diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py new file mode 100644 index 00000000..7912b81b --- /dev/null +++ b/src/vonage/verify2.py @@ -0,0 +1,107 @@ +from pydantic import BaseModel, ValidationError, validator, conint, constr +from typing import Optional, List + +import copy +import re + +from .errors import Verify2Error + + +class Verify2: + valid_channels = [ + 'sms', + 'whatsapp', + 'whatsapp_interactive', + 'voice', + 'email', + 'silent_auth', + ] + + def __init__(self, client): + self._client = client + self._auth_type = 'jwt' + + def new_request(self, params: dict): + try: + params_to_verify = copy.deepcopy(params) + Verify2.VerifyRequest.parse_obj(params_to_verify) + except (ValidationError, Verify2Error) as err: + raise err + + if not hasattr(self._client, '_application_id'): + self._auth_type = 'header' + + return self._client.post( + self._client.api_host(), + '/v2/verify', + params, + auth_type=self._auth_type, + ) + + def check_code(self, request_id: str, code: str): + params = {'code': str(code)} + + if not hasattr(self._client, '_application_id'): + self._auth_type = 'header' + + return self._client.post( + self._client.api_host(), + f'/v2/verify/{request_id}', + params, + auth_type=self._auth_type, + ) + + def cancel_verification(self, request_id: str): + if not hasattr(self._client, '_application_id'): + self._auth_type = 'header' + + return self._client.delete( + self._client.api_host(), + f'/v2/verify/{request_id}', + auth_type=self._auth_type, + ) + + class VerifyRequest(BaseModel): + brand: str + workflow: List[dict] + locale: Optional[str] + channel_timeout: Optional[conint(ge=60, le=900)] + client_ref: Optional[str] + code_length: Optional[conint(ge=4, le=10)] + fraud_check: Optional[bool] + code: Optional[constr(min_length=4, max_length=10, regex='^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$')] + + @validator('workflow') + def check_valid_workflow(cls, v): + for workflow in v: + Verify2._check_valid_channel(workflow) + Verify2._check_valid_recipient(workflow) + Verify2._check_app_hash(workflow) + if workflow['channel'] == 'whatsapp' and 'from' in workflow: + Verify2._check_whatsapp_sender(workflow) + + def _check_valid_channel(workflow): + if 'channel' not in workflow or workflow['channel'] not in Verify2.valid_channels: + raise Verify2Error( + f'You must specify a valid verify channel inside the "workflow" object, one of: "{Verify2.valid_channels}"' + ) + + def _check_valid_recipient(workflow): + if 'to' not in workflow or ( + workflow['channel'] != 'email' and not re.search(r'^[1-9]\d{6,14}$', workflow['to']) + ): + raise Verify2Error(f'You must specify a valid "to" value for channel "{workflow["channel"]}"') + + def _check_app_hash(workflow): + if workflow['channel'] == 'sms' and 'app_hash' in workflow: + if type(workflow['app_hash']) != str or len(workflow['app_hash']) != 11: + raise Verify2Error( + 'Invalid "app_hash" specified. If specifying app_hash, \ + it must be passed as a string and contain exactly 11 characters.' + ) + elif workflow['channel'] != 'sms' and 'app_hash' in workflow: + raise Verify2Error('Cannot specify a value for "app_hash" unless using SMS for authentication.') + + def _check_whatsapp_sender(workflow): + if not re.search(r'^[1-9]\d{6,14}$', workflow['from']): + raise Verify2Error(f'You must specify a valid "from" value if included.') diff --git a/tests/conftest.py b/tests/conftest.py index eaa16d50..720b0326 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,50 +69,58 @@ def verify(client): return vonage.Verify(client) + @pytest.fixture def number_insight(client): import vonage return vonage.NumberInsight(client) + @pytest.fixture def account(client): import vonage return vonage.Account(client) + @pytest.fixture def numbers(client): import vonage - + return vonage.Numbers(client) + @pytest.fixture def ussd(client): import vonage - + return vonage.Ussd(client) + @pytest.fixture def short_codes(client): import vonage - + return vonage.ShortCodes(client) + @pytest.fixture def messages(client): import vonage return vonage.Messages(client) + @pytest.fixture def redact(client): import vonage return vonage.Redact(client) + @pytest.fixture def application_v2(client): import vonage - return vonage.ApplicationV2(client) \ No newline at end of file + return vonage.ApplicationV2(client) diff --git a/tests/data/no_content.json b/tests/data/no_content.json new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/verify2/already_verified.json b/tests/data/verify2/already_verified.json new file mode 100644 index 00000000..c1a557ff --- /dev/null +++ b/tests/data/verify2/already_verified.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#not-found", + "title": "Not Found", + "detail": "Request '5fcc26ef-1e54-48a6-83ab-c47546a19824' was not found or it has been verified already.", + "instance": "02cabfcc-2e09-4b5d-b098-1fa7ccef4607" +} \ No newline at end of file diff --git a/tests/data/verify2/check_code.json b/tests/data/verify2/check_code.json new file mode 100644 index 00000000..2016cb21 --- /dev/null +++ b/tests/data/verify2/check_code.json @@ -0,0 +1,4 @@ +{ + "request_id": "e043d872-459b-4750-a20c-d33f91d6959f", + "status": "completed" +} \ No newline at end of file diff --git a/tests/data/verify2/code_not_supported.json b/tests/data/verify2/code_not_supported.json new file mode 100644 index 00000000..e690eb1e --- /dev/null +++ b/tests/data/verify2/code_not_supported.json @@ -0,0 +1,6 @@ +{ + "title": "Conflict", + "detail": "The current Verify workflow step does not support a code.", + "instance": "690c48de-c5d1-49f2-8712-b3b0a840f911", + "type": "https://developer.nexmo.com/api-errors#conflict" +} \ No newline at end of file diff --git a/tests/data/verify2/create_request.json b/tests/data/verify2/create_request.json new file mode 100644 index 00000000..106dc1cc --- /dev/null +++ b/tests/data/verify2/create_request.json @@ -0,0 +1,3 @@ +{ + "request_id": "c11236f4-00bf-4b89-84ba-88b25df97315" +} \ No newline at end of file diff --git a/tests/data/verify2/error_conflict.json b/tests/data/verify2/error_conflict.json new file mode 100644 index 00000000..69eebdc7 --- /dev/null +++ b/tests/data/verify2/error_conflict.json @@ -0,0 +1,7 @@ +{ + "title": "Conflict", + "type": "https://www.developer.vonage.com/api-errors/verify#conflict", + "detail": "Concurrent verifications to the same number are not allowed.", + "instance": "738f9313-418a-4259-9b0d-6670f06fa82d", + "request_id": "575a2054-aaaf-4405-994e-290be7b9a91f" +} \ No newline at end of file diff --git a/tests/data/verify2/fraud_check_invalid_account.json b/tests/data/verify2/fraud_check_invalid_account.json new file mode 100644 index 00000000..ee5d7053 --- /dev/null +++ b/tests/data/verify2/fraud_check_invalid_account.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#forbidden", + "title": "Forbidden", + "detail": "Your account does not have permission to perform this action.", + "instance": "1995bc0d-c850-4bf0-aa1e-6c40da43d3bf" +} \ No newline at end of file diff --git a/tests/data/verify2/invalid_code.json b/tests/data/verify2/invalid_code.json new file mode 100644 index 00000000..6e6d7b17 --- /dev/null +++ b/tests/data/verify2/invalid_code.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#bad-request", + "title": "Invalid Code", + "detail": "The code you provided does not match the expected value.", + "instance": "16d6bca6-c0dc-4add-94b2-0dbc12cba83b" +} \ No newline at end of file diff --git a/tests/data/verify2/invalid_email.json b/tests/data/verify2/invalid_email.json new file mode 100644 index 00000000..34cb0edc --- /dev/null +++ b/tests/data/verify2/invalid_email.json @@ -0,0 +1,12 @@ +{ + "title": "Invalid params", + "detail": "The value of one or more parameters is invalid", + "instance": "e151c892-76b2-4486-8a37-b88faa70babd", + "type": "https://www.nexmo.com/messages/Errors#InvalidParams", + "invalid_parameters": [ + { + "name": "workflow[0]", + "reason": "`to` Email address is invalid" + } + ] +} \ No newline at end of file diff --git a/tests/data/verify2/invalid_sender.json b/tests/data/verify2/invalid_sender.json new file mode 100644 index 00000000..dc3cda26 --- /dev/null +++ b/tests/data/verify2/invalid_sender.json @@ -0,0 +1,6 @@ +{ + "title": "Invalid sender", + "detail": "The `from` parameter is invalid.", + "instance": "1711258a-12e2-48ad-99a2-43fe3315409c", + "type": "https://developer.nexmo.com/api-errors#invalid-param" +} \ No newline at end of file diff --git a/tests/data/verify2/rate_limit.json b/tests/data/verify2/rate_limit.json new file mode 100644 index 00000000..ddafeb6f --- /dev/null +++ b/tests/data/verify2/rate_limit.json @@ -0,0 +1,6 @@ +{ + "title": "Rate Limit Hit", + "type": "https://www.developer.vonage.com/api-errors#throttled", + "detail": "Please wait, then retry your request", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" +} \ No newline at end of file diff --git a/tests/data/verify2/request_not_found.json b/tests/data/verify2/request_not_found.json new file mode 100644 index 00000000..45abf814 --- /dev/null +++ b/tests/data/verify2/request_not_found.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#not-found", + "title": "Not Found", + "detail": "Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already.", + "instance": "a5f25ba1-c760-4966-81d4-6bdbb19f29d7" +} \ No newline at end of file diff --git a/tests/data/verify2/too_many_code_attempts.json b/tests/data/verify2/too_many_code_attempts.json new file mode 100644 index 00000000..50b05c09 --- /dev/null +++ b/tests/data/verify2/too_many_code_attempts.json @@ -0,0 +1,6 @@ +{ + "title": "Invalid Code", + "detail": "An incorrect code has been provided too many times. Workflow terminated.", + "instance": "060246db-1c9f-4fdf-b9fa-2bd8b772f5d9", + "type": "https://developer.nexmo.com/api-errors#gone" +} \ No newline at end of file diff --git a/tests/test_verify2.py b/tests/test_verify2.py new file mode 100644 index 00000000..613468c0 --- /dev/null +++ b/tests/test_verify2.py @@ -0,0 +1,454 @@ +from vonage import Client, Verify2 +from util import * +from vonage.errors import ClientError, Verify2Error + +from pydantic import ValidationError +from pytest import raises +import responses + +verify2 = Verify2(Client()) + + +@responses.activate +def test_new_request_sms_basic(dummy_data): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert request_user_agent() == dummy_data.user_agent + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_sms_full(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = { + 'locale': 'en-gb', + 'channel_timeout': 120, + 'client_ref': 'my client ref', + 'code_length': 8, + 'fraud_check': False, + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': 'asdfghjklqw'}], + } + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_sms_custom_code(dummy_data): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'code': 'asdfghjk', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert request_user_agent() == dummy_data.user_agent + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_error_fraud_check_invalid_account(dummy_data): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/fraud_check_invalid_account.json', + status_code=403, + ) + + params = {'brand': 'ACME, Inc', 'fraud_check': False, 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + + with raises(ClientError) as err: + verify2.new_request(params) + assert ( + str(err.value) + == 'Forbidden: Your account does not have permission to perform this action. (https://developer.nexmo.com/api-errors#forbidden)' + ) + + +def test_new_request_sms_custom_code_length_error(): + params = { + 'code_length': 4, + 'brand': 'ACME, Inc', + 'code': 'a', + 'workflow': [{'channel': 'sms', 'to': '447700900000'}], + } + + with raises(ValidationError) as err: + verify2.new_request(params) + assert 'ensure this value has at least 4 characters' in str(err.value) + + +def test_new_request_sms_custom_code_character_error(): + params = { + 'code_length': 4, + 'brand': 'ACME, Inc', + 'code': '?!@%', + 'workflow': [{'channel': 'sms', 'to': '447700900000'}], + } + + with raises(ValidationError) as err: + verify2.new_request(params) + assert 'string does not match regex' in str(err.value) + + +def test_new_request_invalid_channel_error(): + params = { + 'code_length': 4, + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'carrier_pigeon', 'to': '447700900000'}], + } + + with raises(Verify2Error) as err: + verify2.new_request(params) + assert ( + str(err.value) + == 'You must specify a valid verify channel inside the "workflow" object, one of: "[\'sms\', \'whatsapp\', \'whatsapp_interactive\', \'voice\', \'email\', \'silent_auth\']"' + ) + + +def test_new_request_code_length_error(): + params = { + 'code_length': 1000, + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '447700900000'}], + } + + with raises(ValidationError) as err: + verify2.new_request(params) + assert 'ensure this value is less than or equal to 10' in str(err.value) + + +def test_new_request_to_error(): + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '123'}], + } + + with raises(Verify2Error) as err: + verify2.new_request(params) + assert 'You must specify a valid "to" value for channel "sms"' in str(err.value) + + +def test_new_request_sms_app_hash_error(): + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': '00'}], + } + + with raises(Verify2Error) as err: + verify2.new_request(params) + assert 'Invalid "app_hash" specified.' in str(err.value) + + +def test_new_request_whatsapp_app_hash_error(): + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'app_hash': 'asdfqwerzxc'}], + } + + with raises(Verify2Error) as err: + verify2.new_request(params) + assert str(err.value) == 'Cannot specify a value for "app_hash" unless using SMS for authentication.' + + +@responses.activate +def test_new_request_whatsapp(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_whatsapp_custom_code(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'code': 'asdfghjk', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_whatsapp_from_field(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447000000000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_whatsapp_invalid_sender_error(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/invalid_sender.json', status_code=422) + + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': 'asdfghjkl'}], + } + with pytest.raises(ClientError) as err: + verify2.new_request(params) + assert str(err.value) == 'You must specify a valid "from" value if included.' + + +@responses.activate +def test_new_request_whatsapp_sender_unregistered_error(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/invalid_sender.json', status_code=422) + + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447999999999'}], + } + with pytest.raises(ClientError) as err: + verify2.new_request(params) + assert ( + str(err.value) + == 'Invalid sender: The `from` parameter is invalid. (https://developer.nexmo.com/api-errors#invalid-param)' + ) + + +@responses.activate +def test_new_request_whatsapp_interactive(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp_interactive', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_voice(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'voice', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_voice_custom_code(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'code': 'asdfhjkl', 'workflow': [{'channel': 'voice', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_email(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'email', 'to': 'recipient@example.com'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_email_additional_fields(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = { + 'locale': 'en-gb', + 'channel_timeout': 120, + 'client_ref': 'my client ref', + 'code_length': 8, + 'brand': 'ACME, Inc', + 'code': 'asdfhjkl', + 'workflow': [{'channel': 'email', 'to': 'recipient@example.com', 'from': 'sender@example.com'}], + } + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_email_error(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/invalid_email.json', status_code=422) + + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'email', 'to': 'not-an-email-address'}], + } + with pytest.raises(ClientError) as err: + verify2.new_request(params) + assert ( + str(err.value) + == 'Invalid params: The value of one or more parameters is invalid (https://www.nexmo.com/messages/Errors#InvalidParams)' + ) + + +@responses.activate +def test_new_request_silent_auth(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'silent_auth', 'to': '447700900000'}]} + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + +@responses.activate +def test_new_request_error_conflict(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/error_conflict.json', status_code=409) + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + + with raises(ClientError) as err: + verify2.new_request(params) + assert ( + str(err.value) + == "Conflict: Concurrent verifications to the same number are not allowed. (https://www.developer.vonage.com/api-errors/verify#conflict)" + ) + + +@responses.activate +def test_new_request_rate_limit(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/rate_limit.json', status_code=429) + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + + with raises(ClientError) as err: + verify2.new_request(params) + assert ( + str(err.value) + == "Rate Limit Hit: Please wait, then retry your request (https://www.developer.vonage.com/api-errors#throttled)" + ) + + +@responses.activate +def test_check_code(): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/check_code.json', + ) + + response = verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '1234') + assert response['request_id'] == 'e043d872-459b-4750-a20c-d33f91d6959f' + assert response['status'] == 'completed' + + +@responses.activate +def test_check_code_invalid_code(): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/invalid_code.json', + status_code=400, + ) + + with pytest.raises(ClientError) as err: + verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') + + assert ( + str(err.value) + == 'Invalid Code: The code you provided does not match the expected value. (https://developer.nexmo.com/api-errors#bad-request)' + ) + + +@responses.activate +def test_check_code_already_verified(): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/already_verified.json', + status_code=404, + ) + + with pytest.raises(ClientError) as err: + verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') + + assert ( + str(err.value) + == "Not Found: Request '5fcc26ef-1e54-48a6-83ab-c47546a19824' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" + ) + + +@responses.activate +def test_check_code_workflow_not_supported(): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/code_not_supported.json', + status_code=409, + ) + + with pytest.raises(ClientError) as err: + verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') + + assert ( + str(err.value) + == 'Conflict: The current Verify workflow step does not support a code. (https://developer.nexmo.com/api-errors#conflict)' + ) + + +@responses.activate +def test_check_code_too_many_invalid_code_attempts(): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/too_many_code_attempts.json', + status_code=410, + ) + + with pytest.raises(ClientError) as err: + verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') + + assert ( + str(err.value) + == 'Invalid Code: An incorrect code has been provided too many times. Workflow terminated. (https://developer.nexmo.com/api-errors#gone)' + ) + + +@responses.activate +def test_check_code_rate_limit(): + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/rate_limit.json', + status_code=429, + ) + + with raises(ClientError) as err: + verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') + assert ( + str(err.value) + == "Rate Limit Hit: Please wait, then retry your request (https://www.developer.vonage.com/api-errors#throttled)" + ) + + +@responses.activate +def test_cancel_verification(): + stub( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='no_content.json', + status_code=204, + ) + + assert verify2.cancel_verification('c11236f4-00bf-4b89-84ba-88b25df97315') == None + + +@responses.activate +def test_cancel_verification_error_not_found(): + stub( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', + fixture_path='verify2/request_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + verify2.cancel_verification('c11236f4-00bf-4b89-84ba-88b25df97315') + assert ( + str(err.value) + == "Not Found: Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" + ) From c0f1a5b88c53dda625c93fa1638b9e1e0388529f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 16 May 2023 16:44:52 +0100 Subject: [PATCH 241/401] V3.5.0 release (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * updating changelog * Bump version: 3.4.0 → 3.5.0 --- .bumpversion.cfg | 2 +- CHANGES.md | 7 +++++++ setup.py | 2 +- src/vonage/__init__.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2cbee5d3..d706edb9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.4.0 +current_version = 3.5.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index f24def8b..9f919d26 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,10 @@ +# 3.5.0 +- Adding support for V2 of the Vonage Verify API + - Multiple authentication channels are supported (sms, voice, email, whatsapp, whatsapp interactive messages and silent authentication) + - Using fallback channels is now possible in case verification methods fail + - You can now customise the verification code that is sent, or even specify your own custom code +- Adding `advancedMachineDetection` functionality to the NCCO builder for the Vonage Voice API + # 3.4.0 - Internal refactoring changes - Using header authentication for the Numbers API diff --git a/setup.py b/setup.py index 57bd5085..6440cb44 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.4.0", + version="3.5.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 91c26d11..832993c0 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.4.0" +__version__ = "3.5.0" From 896f990be6472f556840388051e70afb48145a84 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 23 May 2023 16:38:02 +0100 Subject: [PATCH 242/401] Update fraud check (#261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * only sending verify v2 fraud_check when set to false * Bump version: 3.5.0 → 3.5.1 --- .bumpversion.cfg | 2 +- CHANGES.md | 3 +++ setup.py | 2 +- src/vonage/__init__.py | 2 +- src/vonage/verify2.py | 5 +++++ tests/test_verify2.py | 7 +++++++ 6 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d706edb9..4c598a4c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.5.0 +current_version = 3.5.1 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index 9f919d26..a0dcac7a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.5.1 +- Updating the internal use of the `fraud_check` parameter in the Vonage Verify V2 API + # 3.5.0 - Adding support for V2 of the Vonage Verify API - Multiple authentication channels are supported (sms, voice, email, whatsapp, whatsapp interactive messages and silent authentication) diff --git a/setup.py b/setup.py index 6440cb44..510c173b 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.5.0", + version="3.5.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 832993c0..da5b866e 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.5.0" +__version__ = "3.5.1" diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py index 7912b81b..d1f997ed 100644 --- a/src/vonage/verify2.py +++ b/src/vonage/verify2.py @@ -22,6 +22,7 @@ def __init__(self, client): self._auth_type = 'jwt' def new_request(self, params: dict): + self._remove_unnecessary_fraud_check(params) try: params_to_verify = copy.deepcopy(params) Verify2.VerifyRequest.parse_obj(params_to_verify) @@ -61,6 +62,10 @@ def cancel_verification(self, request_id: str): auth_type=self._auth_type, ) + def _remove_unnecessary_fraud_check(self, params): + if 'fraud_check' in params and params['fraud_check'] != False: + del params['fraud_check'] + class VerifyRequest(BaseModel): brand: str workflow: List[dict] diff --git a/tests/test_verify2.py b/tests/test_verify2.py index 613468c0..a5b79209 100644 --- a/tests/test_verify2.py +++ b/tests/test_verify2.py @@ -452,3 +452,10 @@ def test_cancel_verification_error_not_found(): str(err.value) == "Not Found: Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" ) + + +def test_remove_unnecessary_fraud_check(): + params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}], 'fraud_check': True} + verify2._remove_unnecessary_fraud_check(params) + + assert 'fraud_check' not in params From 867d386d220f96d43a979e584f0f89db6935e7fe Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 6 Jun 2023 18:23:58 +0100 Subject: [PATCH 243/401] use Vonage JWT generator instead of PyJWT for requests (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use Vonage JWT generator instead of PyJWT for requests * adding new test for multiple workflows in verify v2 (#263) * updating the changelog for new release * Bump version: 3.5.1 → 3.5.2 * updating the changelog for new release * internal refactoring to check for new Client._jwt_client object * removing superseded check * removing potentially misleading message --- .bumpversion.cfg | 2 +- CHANGES.md | 4 ++++ Makefile | 2 +- README.md | 23 +++++++++----------- setup.py | 4 ++-- src/vonage/__init__.py | 2 +- src/vonage/_internal.py | 14 ++++++++++++ src/vonage/client.py | 41 ++++++++++------------------------- src/vonage/messages.py | 33 ++++++++++++++++++++-------- src/vonage/verify2.py | 32 +++++++++++++++------------ tests/test_getters_setters.py | 16 ++------------ tests/test_jwt.py | 39 +++++++++++++++++++++++++++++++++ tests/test_rest_calls.py | 9 ++++++++ tests/test_verify2.py | 16 ++++++++++++++ tests/test_voice.py | 15 +++++++------ tests/util.py | 11 +++------- 16 files changed, 163 insertions(+), 100 deletions(-) create mode 100644 tests/test_jwt.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4c598a4c..f5abe342 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.5.1 +current_version = 3.5.2 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index a0dcac7a..cbd4a0b2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# 3.5.2 +- Using the [Vonage JWT Generator](https://github.com/Vonage/vonage-python-jwt) instead of `PyJWT` for generating JWTs. +- Other internal refactoring and enhancements + # 3.5.1 - Updating the internal use of the `fraud_check` parameter in the Vonage Verify V2 API diff --git a/Makefile b/Makefile index 31ad6327..863d937e 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ coverage: coverage html test: - pytest -v + pytest -vv --disable-warnings clean: rm -rf dist build diff --git a/README.md b/README.md index c5204821..f2066ba1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) [![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) -[![codecov](https://codecov.io/gh/Vonage/vonage-python-sdk/branch/master/graph/badge.svg)](https://codecov.io/gh/Vonage/vonage-python-sdk) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) @@ -728,34 +727,32 @@ your account before you can validate webhook signatures. ## JWT parameters -By default, the library generates short-lived tokens for JWT authentication. +By default, the library generates 15-minute tokens for JWT authentication. -Use the auth method to specify parameters for a longer life token or to -specify a different token identifier: +Use the `auth` method of the client class to specify custom parameters: ```python client.auth(nbf=nbf, exp=exp, jti=jti) +# OR +client.auth({'nbf': nbf, 'exp': exp, 'jti': jti}) ``` ## Overriding API Attributes -In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class. +In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client to use with all API classes. An example using setters/getters with `Object references`: ```python -from vonage import Client, Sms +from vonage import Client -#Defines the client +# Define the client client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') print(client.host()) # using getter for host -- value returned: rest.nexmo.com -#Define the sms instance -sms = Sms(client) - -#Change the value in client -client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms - +# Change the value in client +client.host('mio.nexmo.com') # Change host to mio.nexmo.com - this change will be available for sms +client.sms.send_message(params) # Sends an SMS to the host above ``` ### Overriding API Host / Host Attributes diff --git a/setup.py b/setup.py index 510c173b..16d7c71e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.5.1", + version="3.5.2", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", @@ -23,8 +23,8 @@ package_dir={"": "src"}, platforms=["any"], install_requires=[ + "vonage-jwt>=1.0.0", "requests>=2.4.2", - "PyJWT[crypto]>=1.6.4", "pytz>=2018.5", "Deprecated", "pydantic>=1.10.2", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index da5b866e..af7253ac 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.5.1" +__version__ = "3.5.2" diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py index d701224a..4c2d8177 100644 --- a/src/vonage/_internal.py +++ b/src/vonage/_internal.py @@ -1,3 +1,10 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vonage import Client + + def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): """ Utility function to convert datetime values to strings. @@ -12,3 +19,10 @@ def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): param = params[key] if hasattr(param, "strftime"): params[key] = param.strftime(format) + + +def set_auth_type(client: Client) -> str: + if hasattr(client, '_jwt_client'): + return 'jwt' + else: + return 'header' diff --git a/src/vonage/client.py b/src/vonage/client.py index ed230ad8..492d5286 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -1,4 +1,5 @@ import vonage +from vonage_jwt.jwt import JwtClient from .account import Account from .application import ApplicationV2, Application @@ -20,11 +21,8 @@ import base64 import hashlib import hmac -import jwt import os import time -import re -from uuid import uuid4 from requests import Response from requests.adapters import HTTPAdapter @@ -95,16 +93,10 @@ def __init__( if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: self.signature_method = getattr(hashlib, signature_method) - self._jwt_auth_params = {} - if private_key is not None and application_id is not None: - self._application_id = application_id - self._private_key = private_key - - if isinstance(self._private_key, string_types) and re.search("[.][a-zA-Z0-9_]+$", self._private_key): - with open(self._private_key, "rb") as key_file: - self._private_key = key_file.read() + self._jwt_client = JwtClient(application_id, private_key) + self._jwt_claims = {} self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" @@ -149,7 +141,7 @@ def api_host(self, value=None): self._api_host = value def auth(self, params=None, **kwargs): - self._jwt_auth_params = params or kwargs + self._jwt_claims = params or kwargs def check_signature(self, params): params = dict(params) @@ -275,7 +267,7 @@ def delete(self, host, request_uri, auth_type=None): def parse(self, host, response: Response): logger.debug(f"Response headers {repr(response.headers)}") if response.status_code == 401: - raise AuthenticationError("Authentication failed. Check you're using a valid authentication method.") + raise AuthenticationError("Authentication failed.") elif response.status_code == 204: return None elif 200 <= response.status_code < 300: @@ -312,21 +304,10 @@ def parse(self, host, response: Response): raise ServerError(message) def _add_jwt_to_request_headers(self): - return dict(self.headers, Authorization=b"Bearer " + self._generate_application_jwt()) - + return dict( + self.headers, + Authorization=b"Bearer " + self._generate_application_jwt() + ) + def _generate_application_jwt(self): - iat = int(time.time()) - - payload = dict(self._jwt_auth_params) - payload.setdefault("application_id", self._application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - token = jwt.encode(payload, self._private_key, algorithm="RS256") - - # If token is string transform it to byte type - if type(token) is str: - token = bytes(token, 'utf-8') - - return token + return self._jwt_client.generate_application_jwt(self._jwt_claims) diff --git a/src/vonage/messages.py b/src/vonage/messages.py index 42cc4cba..23e7df20 100644 --- a/src/vonage/messages.py +++ b/src/vonage/messages.py @@ -1,3 +1,4 @@ +from ._internal import set_auth_type from .errors import MessagesError import re @@ -15,13 +16,11 @@ class Messages: def __init__(self, client): self._client = client - self._auth_type = 'jwt' + self._auth_type = set_auth_type(self._client) def send_message(self, params: dict): self.validate_send_message_input(params) - if not hasattr(self._client, '_application_id'): - self._auth_type = 'header' return self._client.post( self._client.api_host(), "/v1/messages", @@ -40,7 +39,9 @@ def validate_send_message_input(self, params): def _check_input_is_dict(self, params): if type(params) is not dict: - raise MessagesError('Parameters to the send_message method must be specified as a dictionary.') + raise MessagesError( + 'Parameters to the send_message method must be specified as a dictionary.' + ) def _check_valid_message_channel(self, params): if params['channel'] not in Messages.valid_message_channels: @@ -64,9 +65,13 @@ def _check_valid_recipient(self, params): if not isinstance(params['to'], str): raise MessagesError(f'Message recipient ("to={params["to"]}") not in a valid format.') elif params['channel'] != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', params['to']): - raise MessagesError(f'Message recipient number ("to={params["to"]}") not in a valid format.') + raise MessagesError( + f'Message recipient number ("to={params["to"]}") not in a valid format.' + ) elif params['channel'] == 'messenger' and not 0 < len(params['to']) < 50: - raise MessagesError(f'Message recipient ID ("to={params["to"]}") not in a valid format.') + raise MessagesError( + f'Message recipient ID ("to={params["to"]}") not in a valid format.' + ) def _check_valid_sender(self, params): if not isinstance(params['from'], str) or params['from'] == "": @@ -76,8 +81,16 @@ def _check_valid_sender(self, params): def _channel_specific_checks(self, params): if ( - (params['channel'] == 'whatsapp' and params['message_type'] == 'template' and 'whatsapp' not in params) - or (params['channel'] == 'whatsapp' and params['message_type'] == 'sticker' and 'sticker' not in params) + ( + params['channel'] == 'whatsapp' + and params['message_type'] == 'template' + and 'whatsapp' not in params + ) + or ( + params['channel'] == 'whatsapp' + and params['message_type'] == 'sticker' + and 'sticker' not in params + ) or (params['channel'] == 'viber_service' and 'viber_service' not in params) ): raise MessagesError( @@ -95,4 +108,6 @@ def _check_valid_client_ref(self, params): def _check_valid_whatsapp_sticker(self, sticker): if ('id' not in sticker and 'url' not in sticker) or ('id' in sticker and 'url' in sticker): - raise MessagesError('Must specify one, and only one, of "id" or "url" in the "sticker" field.') + raise MessagesError( + 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' + ) diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py index d1f997ed..33e0822f 100644 --- a/src/vonage/verify2.py +++ b/src/vonage/verify2.py @@ -1,9 +1,16 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vonage import Client + from pydantic import BaseModel, ValidationError, validator, conint, constr from typing import Optional, List import copy import re +from ._internal import set_auth_type from .errors import Verify2Error @@ -17,9 +24,9 @@ class Verify2: 'silent_auth', ] - def __init__(self, client): + def __init__(self, client: Client): self._client = client - self._auth_type = 'jwt' + self._auth_type = set_auth_type(self._client) def new_request(self, params: dict): self._remove_unnecessary_fraud_check(params) @@ -29,9 +36,6 @@ def new_request(self, params: dict): except (ValidationError, Verify2Error) as err: raise err - if not hasattr(self._client, '_application_id'): - self._auth_type = 'header' - return self._client.post( self._client.api_host(), '/v2/verify', @@ -42,9 +46,6 @@ def new_request(self, params: dict): def check_code(self, request_id: str, code: str): params = {'code': str(code)} - if not hasattr(self._client, '_application_id'): - self._auth_type = 'header' - return self._client.post( self._client.api_host(), f'/v2/verify/{request_id}', @@ -53,9 +54,6 @@ def check_code(self, request_id: str, code: str): ) def cancel_verification(self, request_id: str): - if not hasattr(self._client, '_application_id'): - self._auth_type = 'header' - return self._client.delete( self._client.api_host(), f'/v2/verify/{request_id}', @@ -74,7 +72,9 @@ class VerifyRequest(BaseModel): client_ref: Optional[str] code_length: Optional[conint(ge=4, le=10)] fraud_check: Optional[bool] - code: Optional[constr(min_length=4, max_length=10, regex='^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$')] + code: Optional[ + constr(min_length=4, max_length=10, regex='^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$') + ] @validator('workflow') def check_valid_workflow(cls, v): @@ -95,7 +95,9 @@ def _check_valid_recipient(workflow): if 'to' not in workflow or ( workflow['channel'] != 'email' and not re.search(r'^[1-9]\d{6,14}$', workflow['to']) ): - raise Verify2Error(f'You must specify a valid "to" value for channel "{workflow["channel"]}"') + raise Verify2Error( + f'You must specify a valid "to" value for channel "{workflow["channel"]}"' + ) def _check_app_hash(workflow): if workflow['channel'] == 'sms' and 'app_hash' in workflow: @@ -105,7 +107,9 @@ def _check_app_hash(workflow): it must be passed as a string and contain exactly 11 characters.' ) elif workflow['channel'] != 'sms' and 'app_hash' in workflow: - raise Verify2Error('Cannot specify a value for "app_hash" unless using SMS for authentication.') + raise Verify2Error( + 'Cannot specify a value for "app_hash" unless using SMS for authentication.' + ) def _check_whatsapp_sender(workflow): if not re.search(r'^[1-9]\d{6,14}$', workflow['from']): diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py index 30abda7c..f1edfe3e 100644 --- a/tests/test_getters_setters.py +++ b/tests/test_getters_setters.py @@ -1,24 +1,12 @@ -from util import * - -@responses.activate def test_getters(client, dummy_data): assert client.host() == dummy_data.host assert client.api_host() == dummy_data.api_host -@responses.activate def test_setters(client, dummy_data): try: - client.host('host.nexmo.com') - client.api_host('host.nexmo.com') + client.host('host.vonage.com') + client.api_host('host.vonage.com') assert client.host() != dummy_data.host assert client.api_host() != dummy_data.api_host except: assert False - -@responses.activate -def test_fail_setter_url_format(client, dummy_data): - try: - client.host('1000.1000') - assert False - except: - assert True \ No newline at end of file diff --git a/tests/test_jwt.py b/tests/test_jwt.py new file mode 100644 index 00000000..f70ed042 --- /dev/null +++ b/tests/test_jwt.py @@ -0,0 +1,39 @@ +from time import time +from unittest.mock import patch + +now = int(time()) + + +def test_auth_sets_claims_from_kwargs(client): + client.auth(jti='asdfzxcv1234', nbf=now + 100, exp=now + 1000) + assert client._jwt_claims['jti'] == 'asdfzxcv1234' + assert client._jwt_claims['nbf'] == now + 100 + assert client._jwt_claims['exp'] == now + 1000 + + +def test_auth_sets_claims_from_dict(client): + custom_jwt_claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100, 'exp': now + 1000} + client.auth(custom_jwt_claims) + assert client._jwt_claims['jti'] == 'asdfzxcv1234' + assert client._jwt_claims['nbf'] == now + 100 + assert client._jwt_claims['exp'] == now + 1000 + + +test_jwt = b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBsaWNhdGlvbl9pZCI6ImFzZGYxMjM0IiwiaWF0IjoxNjg1NzMxMzkxLCJqdGkiOiIwYzE1MDJhZS05YmI5LTQ4YzQtYmQyZC0yOGFhNWUxYjZkMTkiLCJleHAiOjE2ODU3MzIyOTF9.mAkGeVgWOb7Mrzka7DSj32vSM8RaFpYse_2E7jCQ4DuH8i32wq9FxXGgfwdBQDHzgku3RYIjLM1xlVrGjNM3MsnZgR7ymQ6S4bdTTOmSK0dKbk91SrN7ZAC9k2a6JpCC2ZYgXpZ5BzpDTdy9BYu6msHKmkL79_aabFAhrH36Nk26pLvoI0-KiGImEex-aRR4iiaXhOebXBeqiQTRPKoKizREq4-8zBQv_j6yy4AiEYvBatQ8L_sjHsLj9jjITreX8WRvEW-G4TPpPLMaHACHTDMpJSOZAnegAkzTV2frVRmk6DyVXnemm4L0RQD1XZDaH7JPsKk24Hd2WZQyIgHOqQ' + + +def vonage_jwt_mock(self, claims): + return test_jwt + + +def test_generate_application_jwt(client): + with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): + jwt = client._generate_application_jwt() + assert jwt == test_jwt + + +def test_add_jwt_to_request_headers(client): + with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): + headers = client._add_jwt_to_request_headers() + assert headers['Accept'] == 'application/json' + assert headers['Authorization'] == b'Bearer ' + test_jwt diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index 1b8f41eb..614880b2 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -80,3 +80,12 @@ def test_delete_with_header_auth(client, dummy_data): assert isinstance(response, dict) assert request_user_agent() == dummy_data.user_agent assert_basic_auth() + +@responses.activate +def test_get_with_jwt_auth(client, dummy_data): + stub(responses.GET, "https://api.nexmo.com/v1/calls") + host = "api.nexmo.com" + request_uri = "/v1/calls" + response = client.get(host, request_uri, auth_type='jwt') + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_verify2.py b/tests/test_verify2.py index a5b79209..86b8cb12 100644 --- a/tests/test_verify2.py +++ b/tests/test_verify2.py @@ -454,6 +454,22 @@ def test_cancel_verification_error_not_found(): ) +@responses.activate +def test_new_request_multiple_workflows(): + stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + + params = { + 'brand': 'ACME, Inc', + 'workflow': [ + {'channel': 'whatsapp_interactive', 'to': '447700900000'}, + {'channel': 'sms', 'to': '4477009999999'}, + ], + } + verify_request = verify2.new_request(params) + + assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + + def test_remove_unnecessary_fraud_check(): params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}], 'fraud_check': True} verify2._remove_unnecessary_fraud_check(params) diff --git a/tests/test_voice.py b/tests/test_voice.py index 9a8fd332..9433d4be 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -139,21 +139,22 @@ def test_send_dtmf(voice, dummy_data): @responses.activate -def test_user_provided_authorization(client, dummy_data): +def test_user_provided_authorization(dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - application_id = "different-nexmo-application-id" + application_id = "different-application-id" + client = vonage.Client(application_id=application_id, private_key=dummy_data.private_key) + nbf = int(time.time()) exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - voice = vonage.Voice(client) - voice.get_call("xx-xx-xx-xx") + + client.auth(nbf=nbf, exp=exp) + client.voice.get_call("xx-xx-xx-xx") token = request_authorization().split()[1] token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") - + print(token) assert token["application_id"] == application_id assert token["nbf"] == nbf assert token["exp"] == exp diff --git a/tests/util.py b/tests/util.py index 55f917e5..8756d761 100644 --- a/tests/util.py +++ b/tests/util.py @@ -17,7 +17,7 @@ def request_query(): def request_params(): - """ Obtain the query params, as a dict. """ + """Obtain the query params, as a dict.""" return parse_qs(request_query()) @@ -39,9 +39,7 @@ def request_content_type(): def stub(method, url, fixture_path=None, status_code=200): body = load_fixture(fixture_path) if fixture_path else '{"key":"value"}' - responses.add( - method, url, body=body, status=status_code, content_type="application/json" - ) + responses.add(method, url, body=body, status=status_code, content_type="application/json") def stub_bytes(method, url): @@ -58,10 +56,7 @@ def assert_basic_auth(): params = request_params() assert "api_key" not in params assert "api_secret" not in params - assert ( - request_headers()["Authorization"] - == "Basic bmV4bW8tYXBpLWtleTpuZXhtby1hcGktc2VjcmV0" - ) + assert request_headers()["Authorization"] == "Basic bmV4bW8tYXBpLWtleTpuZXhtby1hcGktc2VjcmV0" def load_fixture(fixture_path): From 0a8ebf68e6c198bfdfa3e11adcd984cee4aca343 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 14 Jun 2023 14:06:23 +0100 Subject: [PATCH 244/401] Add Subaccounts API (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create subaccounts class, start adding methods and testing * refactoring client class methods, testing subaccounts methods * adding balance and credit transfers/tests * adding subaccounts.transfer_number and tests * updating readme for subaccounts and numbers api * updating changelog * Bump version: 3.5.2 → 3.6.0 * removed milliseconds and added to the readme --- .bumpversion.cfg | 2 +- CHANGES.md | 3 + README.md | 148 +++- setup.py | 2 +- src/vonage/__init__.py | 2 +- src/vonage/_internal.py | 3 + src/vonage/client.py | 48 +- src/vonage/errors.py | 4 + src/vonage/subaccounts.py | 163 ++++ src/vonage/voice.py | 4 +- tests/data/subaccounts/balance_transfer.json | 14 + tests/data/subaccounts/credit_transfer.json | 14 + tests/data/subaccounts/forbidden.json | 6 + .../data/subaccounts/insufficient_credit.json | 6 + .../data/subaccounts/invalid_credentials.json | 6 + .../subaccounts/invalid_number_transfer.json | 6 + tests/data/subaccounts/invalid_transfer.json | 6 + .../subaccounts/list_balance_transfers.json | 27 + .../subaccounts/list_credit_transfers.json | 27 + tests/data/subaccounts/list_subaccounts.json | 31 + .../data/subaccounts/modified_subaccount.json | 10 + tests/data/subaccounts/must_be_number.json | 12 + tests/data/subaccounts/not_found.json | 6 + tests/data/subaccounts/number_not_found.json | 6 + .../same_from_and_to_accounts.json | 12 + tests/data/subaccounts/subaccount.json | 11 + tests/data/subaccounts/transfer_number.json | 7 + .../transfer_validation_error.json | 12 + tests/data/subaccounts/validation_error.json | 12 + tests/test_jwt.py | 5 +- tests/test_subaccounts.py | 730 ++++++++++++++++++ tests/test_verify2.py | 180 ++++- 32 files changed, 1473 insertions(+), 52 deletions(-) create mode 100644 src/vonage/subaccounts.py create mode 100644 tests/data/subaccounts/balance_transfer.json create mode 100644 tests/data/subaccounts/credit_transfer.json create mode 100644 tests/data/subaccounts/forbidden.json create mode 100644 tests/data/subaccounts/insufficient_credit.json create mode 100644 tests/data/subaccounts/invalid_credentials.json create mode 100644 tests/data/subaccounts/invalid_number_transfer.json create mode 100644 tests/data/subaccounts/invalid_transfer.json create mode 100644 tests/data/subaccounts/list_balance_transfers.json create mode 100644 tests/data/subaccounts/list_credit_transfers.json create mode 100644 tests/data/subaccounts/list_subaccounts.json create mode 100644 tests/data/subaccounts/modified_subaccount.json create mode 100644 tests/data/subaccounts/must_be_number.json create mode 100644 tests/data/subaccounts/not_found.json create mode 100644 tests/data/subaccounts/number_not_found.json create mode 100644 tests/data/subaccounts/same_from_and_to_accounts.json create mode 100644 tests/data/subaccounts/subaccount.json create mode 100644 tests/data/subaccounts/transfer_number.json create mode 100644 tests/data/subaccounts/transfer_validation_error.json create mode 100644 tests/data/subaccounts/validation_error.json create mode 100644 tests/test_subaccounts.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f5abe342..1eb9d0d0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.5.2 +current_version = 3.6.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index cbd4a0b2..a047b2d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.6.0 +- Adding support for the [Vonage Subaccounts API](https://developer.vonage.com/en/account/subaccounts/overview) + # 3.5.2 - Using the [Vonage JWT Generator](https://github.com/Vonage/vonage-python-jwt) instead of `PyJWT` for generating JWTs. - Other internal refactoring and enhancements diff --git a/README.md b/README.md index f2066ba1..cd593d5a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [Verify V1 API](#verify-v1-api) - [Number Insight API](#number-insight-api) - [Account API](#account-api) +- [Subaccounts API](#subaccounts-api) - [Number Management API](#number-management-api) - [Pricing API](#pricing-api) - [Managing Secrets](#managing-secrets) @@ -408,7 +409,7 @@ When using the `connect` action, use the parameter `from_` to specify the recipi ## Verify V2 API -V2 of the Vonage Verify API lets you send verification codes via SMS, WhatsApp, Voice and Email +V2 of the Vonage Verify API lets you send verification codes via SMS, WhatsApp, Voice and Email. You can also verify a user by WhatsApp Interactive Message or by Silent Authentication on their mobile device. @@ -618,6 +619,150 @@ This feature is only enabled when you enable auto-reload for your account in the client.account.topup(trx=transaction_reference) ``` +## Subaccounts API + +This API is used to create and configure subaccounts related to your primary account and transfer credit, balances and bought numbers between accounts. + +The subaccounts API is disabled by default. If you want to use subaccounts, [contact support](https://api.support.vonage.com) to have the API enabled on your account. + +### Get a list of all subaccounts + +```python +client.subaccounts.list_subaccounts() +``` + +### Create a subaccount + +```python +client.subaccounts.create_subaccount(name='my subaccount') + +# With options +client.subaccounts.create_subaccount( + name='my subaccount', + secret='Password123', + use_primary_account_balance=False, +) +``` + +### Get information about a subaccount + +```python +client.subaccounts.get_subaccount(SUBACCOUNT_API_KEY) +``` + +### Modify a subaccount + +```python +client.subaccounts.modify_subaccount( + SUBACCOUNT_KEY, + suspended=True, + use_primary_account_balance=False, + name='my modified subaccount', +) +``` + +### List credit transfers between accounts + +All fields are optional. If `start_date` or `end_date` are used, the dates must be specified in UTC ISO 8601 format, e.g. `1970-01-01T00:00:00Z`. Don't use milliseconds. + +```python +client.subaccounts.list_credit_transfers( + start_date='2022-03-29T14:16:56Z', + end_date='2023-06-12T17:20:01Z', + subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key +) +``` + +### Transfer credit between accounts + +Transferring credit is only possible for postpaid accounts, i.e. accounts that can have a negative balance. For prepaid and self-serve customers, account balances can be transferred between accounts (see below). + +```python +client.subaccounts.transfer_credit( + from_=FROM_ACCOUNT, + to=TO_ACCOUNT, + amount=0.50, + reference='test credit transfer', +) +``` + +### List balance transfers between accounts + +All fields are optional. If `start_date` or `end_date` are used, the dates must be specified in UTC ISO 8601 format, e.g. `1970-01-01T00:00:00Z`. Don't use milliseconds. + +```python +client.subaccounts.list_balance_transfers( + start_date='2022-03-29T14:16:56Z', + end_date='2023-06-12T17:20:01Z', + subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key +) +``` + +### Transfer account balances between accounts + +```python +client.subaccounts.transfer_balance( + from_=FROM_ACCOUNT, + to=TO_ACCOUNT, + amount=0.50, + reference='test balance transfer', +) +``` + +### Transfer bought phone numbers between accounts + +```python +client.subaccounts.transfer_balance( + from_=FROM_ACCOUNT, + to=TO_ACCOUNT, + number=NUMBER_TO_TRANSFER, + country='US', +) +``` + +## Number Management API + +### Get numbers associated with your account + +```python +client.numbers.get_account_numbers(size=25) +``` + +### Get numbers that are available to buy + +```python +client.numbers.get_available_numbers('CA', size=25) +``` + +### Buy an available number + +```python +params = {'country': 'US', 'msisdn': 'number_to_buy'} +client.numbers.buy_number(params) + +# To buy a number for a subaccount +params = {'country': 'US', 'msisdn': 'number_to_buy', 'target_api_key': SUBACCOUNT_API_KEY} +client.numbers.buy_number(params) +``` + +### Cancel your subscription for a specific number + +```python +params = {'country': 'US', 'msisdn': 'number_to_cancel'} +client.numbers.cancel_number(params) + +# To cancel a number assigned to a subaccount +params = {'country': 'US', 'msisdn': 'number_to_buy', 'target_api_key': SUBACCOUNT_API_KEY} +client.numbers.cancel_number(params) +``` + +### Update the behaviour of a number that you own + +```python +params = {"country": "US", "msisdn": "number_to_update", "moHttpUrl": "callback_url"} +client.numbers.update_number(params) +``` + ## Pricing API ### Get pricing for a single country @@ -792,6 +937,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Redact API | Developer Preview | ❌ | | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | +| Subaccounts API | General Availability | ✅ | | Verify API | General Availability | ✅ | | Voice API | General Availability | ✅ | diff --git a/setup.py b/setup.py index 16d7c71e..793166fc 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="vonage", - version="3.5.2", + version="3.6.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index af7253ac..a5a5f623 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.5.2" +__version__ = "3.6.0" diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py index 4c2d8177..5d6d2d74 100644 --- a/src/vonage/_internal.py +++ b/src/vonage/_internal.py @@ -22,6 +22,9 @@ def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): def set_auth_type(client: Client) -> str: + """Sets the authentication type used. If a JWT Client has been created, + it will create a JWT and use JWT authentication.""" + if hasattr(client, '_jwt_client'): return 'jwt' else: diff --git a/src/vonage/client.py b/src/vonage/client.py index 492d5286..257284cd 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -10,6 +10,7 @@ from .redact import Redact from .short_codes import ShortCodes from .sms import Sms +from .subaccounts import Subaccounts from .ussd import Ussd from .voice import Voice from .verify import Verify @@ -114,6 +115,7 @@ def __init__( self.numbers = Numbers(self) self.short_codes = ShortCodes(self) self.sms = Sms(self) + self.subaccounts = Subaccounts(self) self.ussd = Ussd(self) self.verify = Verify(self) self.verify2 = Verify2(self) @@ -176,12 +178,11 @@ def get(self, host, request_uri, params=None, auth_type=None): self._request_headers = self.headers if auth_type == 'jwt': - self._request_headers = self._add_jwt_to_request_headers() + self._request_headers['Authorization'] = self._create_jwt_auth_string() elif auth_type == 'params': params = dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) elif auth_type == 'header': - hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") - self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}") + self._request_headers['Authorization'] = self._create_header_auth_string() else: raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' @@ -208,12 +209,11 @@ def post(self, host, request_uri, params, auth_type=None, body_is_json=True, sup params["api_key"] = self.api_key params["sig"] = self.signature(params) elif auth_type == 'jwt': - self._request_headers = self._add_jwt_to_request_headers() + self._request_headers['Authorization'] = self._create_jwt_auth_string() elif auth_type == 'params': params = dict(params, api_key=self.api_key, api_secret=self.api_secret) elif auth_type == 'header': - hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") - self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}") + self._request_headers['Authorization'] = self._create_header_auth_string() else: raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' @@ -234,10 +234,9 @@ def put(self, host, request_uri, params, auth_type=None): self._request_headers = self.headers if auth_type == 'jwt': - self._request_headers = self._add_jwt_to_request_headers() + self._request_headers['Authorization'] = self._create_jwt_auth_string() elif auth_type == 'header': - hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") - self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}") + self._request_headers['Authorization'] = self._create_header_auth_string() else: raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' @@ -247,15 +246,29 @@ def put(self, host, request_uri, params, auth_type=None): # All APIs that currently use put methods require a json-formatted body so don't need to check this return self.parse(host, self.session.put(uri, json=params, headers=self._request_headers, timeout=self.timeout)) + def patch(self, host, request_uri, params, auth_type=None): + uri = f"https://{host}{request_uri}" + self._request_headers = self.headers + + if auth_type == 'jwt': + self._request_headers['Authorization'] = self._create_jwt_auth_string() + elif auth_type == 'header': + self._request_headers['Authorization'] = self._create_header_auth_string() + else: + raise InvalidAuthenticationTypeError(f"""Invalid authentication type.""") + + logger.debug(f"PATCH to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") + # Only newer APIs (that expect json-bodies) currently use this method, so we will always send a json-formatted body + return self.parse(host, self.session.patch(uri, json=params, headers=self._request_headers)) + def delete(self, host, request_uri, auth_type=None): uri = f"https://{host}{request_uri}" self._request_headers = self.headers if auth_type == 'jwt': - self._request_headers = self._add_jwt_to_request_headers() + self._request_headers['Authorization'] = self._create_jwt_auth_string() elif auth_type == 'header': - hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") - self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}") + self._request_headers['Authorization'] = self._create_header_auth_string() else: raise InvalidAuthenticationTypeError( f'Invalid authentication type. Must be one of "jwt", "header" or "params".' @@ -303,11 +316,12 @@ def parse(self, host, response: Response): message = f"{response.status_code} response from {host}" raise ServerError(message) - def _add_jwt_to_request_headers(self): - return dict( - self.headers, - Authorization=b"Bearer " + self._generate_application_jwt() - ) + def _create_jwt_auth_string(self): + return b"Bearer " + self._generate_application_jwt() def _generate_application_jwt(self): return self._jwt_client.generate_application_jwt(self._jwt_claims) + + def _create_header_auth_string(self): + hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") + return f"Basic {hash}" diff --git a/src/vonage/errors.py b/src/vonage/errors.py index b7b21ebb..afd23eff 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -38,3 +38,7 @@ class InvalidAuthenticationTypeError(Error): class Verify2Error(ClientError): """An error relating to the Verify (V2) API.""" + + +class SubaccountsError(ClientError): + """An error relating to the Subaccounts API.""" diff --git a/src/vonage/subaccounts.py b/src/vonage/subaccounts.py new file mode 100644 index 00000000..bc88fa14 --- /dev/null +++ b/src/vonage/subaccounts.py @@ -0,0 +1,163 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Union + +from .errors import SubaccountsError + +if TYPE_CHECKING: + from vonage import Client + + +class Subaccounts: + """Class containing methods for working with the Vonage Subaccounts API.""" + + default_start_date = '1970-01-01T00:00:00Z' + + def __init__(self, client: Client): + self._client = client + self._api_key = self._client.api_key + self._api_host = self._client.api_host() + self._auth_type = 'header' + + def list_subaccounts(self): + return self._client.get( + self._api_host, + f'/accounts/{self._api_key}/subaccounts', + auth_type=self._auth_type, + ) + + def create_subaccount( + self, + name: str, + secret: Optional[str] = None, + use_primary_account_balance: Optional[bool] = None, + ): + params = {'name': name, 'secret': secret} + if self._is_boolean(use_primary_account_balance): + params['use_primary_account_balance'] = use_primary_account_balance + + return self._client.post( + self._api_host, + f'/accounts/{self._api_key}/subaccounts', + params=params, + auth_type=self._auth_type, + ) + + def get_subaccount(self, subaccount_key: str): + return self._client.get( + self._api_host, + f'/accounts/{self._api_key}/subaccounts/{subaccount_key}', + auth_type=self._auth_type, + ) + + def modify_subaccount( + self, + subaccount_key: str, + suspended: Optional[bool] = None, + use_primary_account_balance: Optional[bool] = None, + name: Optional[str] = None, + ): + params = {'name': name} + if self._is_boolean(suspended): + params['suspended'] = suspended + if self._is_boolean(use_primary_account_balance): + params['use_primary_account_balance'] = use_primary_account_balance + + return self._client.patch( + self._api_host, + f'/accounts/{self._api_key}/subaccounts/{subaccount_key}', + params=params, + auth_type=self._auth_type, + ) + + def list_credit_transfers( + self, + start_date: str = default_start_date, + end_date: Optional[str] = None, + subaccount: Optional[str] = None, + ): + params = { + 'start_date': start_date, + 'end_date': end_date, + 'subaccount': subaccount, + } + + return self._client.get( + self._api_host, + f'/accounts/{self._api_key}/credit-transfers', + params=params, + auth_type=self._auth_type, + ) + + def transfer_credit( + self, + from_: str, + to: str, + amount: Union[float, int], + reference: str = None, + ): + params = { + 'from': from_, + 'to': to, + 'amount': amount, + 'reference': reference, + } + + return self._client.post( + self._api_host, + f'/accounts/{self._api_key}/credit-transfers', + params=params, + auth_type=self._auth_type, + ) + + def list_balance_transfers( + self, + start_date: str = default_start_date, + end_date: Optional[str] = None, + subaccount: Optional[str] = None, + ): + params = { + 'start_date': start_date, + 'end_date': end_date, + 'subaccount': subaccount, + } + + return self._client.get( + self._api_host, + f'/accounts/{self._api_key}/balance-transfers', + params=params, + auth_type=self._auth_type, + ) + + def transfer_balance( + self, + from_: str, + to: str, + amount: Union[float, int], + reference: str = None, + ): + params = {'from': from_, 'to': to, 'amount': amount, 'reference': reference} + + return self._client.post( + self._api_host, + f'/accounts/{self._api_key}/balance-transfers', + params=params, + auth_type=self._auth_type, + ) + + def transfer_number(self, from_: str, to: str, number: int, country: str): + params = {'from': from_, 'to': to, 'number': number, 'country': country} + return self._client.post( + self._api_host, + f'/accounts/{self._api_key}/transfer-number', + params=params, + auth_type=self._auth_type, + ) + + def _is_boolean(self, var): + if var is not None: + if type(var) == bool: + return True + else: + raise SubaccountsError( + f'If providing a value, it needs to be a boolean. You provided: "{var}"' + ) diff --git a/src/vonage/voice.py b/src/vonage/voice.py index 2a8aa419..e138f3b3 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -96,4 +96,6 @@ def stop_speech(self, uuid): def get_recording(self, url): hostname = urlparse(url).hostname - return self._client.parse(hostname, self._client.session.get(url, headers=self._client._add_jwt_to_request_headers())) + headers = self._client.headers + headers['Authorization'] = self._client._create_jwt_auth_string() + return self._client.parse(hostname, self._client.session.get(url, headers=headers)) diff --git a/tests/data/subaccounts/balance_transfer.json b/tests/data/subaccounts/balance_transfer.json new file mode 100644 index 00000000..70fdbd80 --- /dev/null +++ b/tests/data/subaccounts/balance_transfer.json @@ -0,0 +1,14 @@ +{ + "masterAccountId": "1234asdf", + "_links": { + "self": { + "href": "/accounts/1234asdf/balance-transfers/83c4da50-9d42-434d-aaa9-76cf3109e9a5" + } + }, + "from": "1234asdf", + "to": "asdfzxcv", + "amount": 0.5, + "reference": "test balance transfer", + "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", + "created_at": "2023-06-12T17:20:00.000Z" +} \ No newline at end of file diff --git a/tests/data/subaccounts/credit_transfer.json b/tests/data/subaccounts/credit_transfer.json new file mode 100644 index 00000000..5687a271 --- /dev/null +++ b/tests/data/subaccounts/credit_transfer.json @@ -0,0 +1,14 @@ +{ + "masterAccountId": "1234asdf", + "_links": { + "self": { + "href": "/accounts/1234asdf/credit-transfers/83c4da50-9d42-434d-aaa9-76cf3109e9a5" + } + }, + "from": "1234asdf", + "to": "asdfzxcv", + "amount": 0.5, + "reference": "test credit transfer", + "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", + "created_at": "2023-06-12T17:20:00.000Z" +} \ No newline at end of file diff --git a/tests/data/subaccounts/forbidden.json b/tests/data/subaccounts/forbidden.json new file mode 100644 index 00000000..39227a21 --- /dev/null +++ b/tests/data/subaccounts/forbidden.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#unprovisioned", + "title": "Authorisation error", + "detail": "Account 1234adsf is not provisioned to access Subaccount Provisioning API", + "instance": "158b8f199c45014ab7b08bfe9cc1c12c" +} \ No newline at end of file diff --git a/tests/data/subaccounts/insufficient_credit.json b/tests/data/subaccounts/insufficient_credit.json new file mode 100644 index 00000000..dec73938 --- /dev/null +++ b/tests/data/subaccounts/insufficient_credit.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers", + "title": "Transfer amount is invalid", + "detail": "Insufficient Credit", + "instance": "70160200-6424-4fa3-a57d-21ed8be2c0b1" +} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_credentials.json b/tests/data/subaccounts/invalid_credentials.json new file mode 100644 index 00000000..79ff6d15 --- /dev/null +++ b/tests/data/subaccounts/invalid_credentials.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#unauthorized", + "title": "Invalid credentials supplied", + "detail": "You did not provide correct credentials", + "instance": "798b8f199c45014ab7b08bfe9cc1c12c" +} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_number_transfer.json b/tests/data/subaccounts/invalid_number_transfer.json new file mode 100644 index 00000000..f5c29518 --- /dev/null +++ b/tests/data/subaccounts/invalid_number_transfer.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#invalid-number-transfer", + "title": "Invalid Number Transfer", + "detail": "Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode is not owned by from account", + "instance": "632768d8-84ea-47e0-91a4-7bda1409a89f" +} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_transfer.json b/tests/data/subaccounts/invalid_transfer.json new file mode 100644 index 00000000..9726e28e --- /dev/null +++ b/tests/data/subaccounts/invalid_transfer.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers", + "title": "Invalid Transfer", + "detail": "Transfers are only allowed between a primary account and its subaccount", + "instance": "85a351ee-a180-4b17-a594-fe1df12616d0" +} \ No newline at end of file diff --git a/tests/data/subaccounts/list_balance_transfers.json b/tests/data/subaccounts/list_balance_transfers.json new file mode 100644 index 00000000..33cadaad --- /dev/null +++ b/tests/data/subaccounts/list_balance_transfers.json @@ -0,0 +1,27 @@ +{ + "_links": { + "self": { + "href": "/accounts/1234asdf/balance-transfers" + } + }, + "_embedded": { + "balance_transfers": [ + { + "from": "1234asdf", + "to": "asdfzxcv", + "amount": 0.5, + "reference": "test transfer", + "id": "7380eecb-b82c-46e8-9478-af6b5793af1b", + "created_at": "2023-06-12T17:31:48.000Z" + }, + { + "from": "1234asdf", + "to": "asdfzxcv", + "amount": 0.5, + "reference": "", + "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", + "created_at": "2023-06-12T17:20:01.000Z" + } + ] + } +} \ No newline at end of file diff --git a/tests/data/subaccounts/list_credit_transfers.json b/tests/data/subaccounts/list_credit_transfers.json new file mode 100644 index 00000000..8169cfc1 --- /dev/null +++ b/tests/data/subaccounts/list_credit_transfers.json @@ -0,0 +1,27 @@ +{ + "_links": { + "self": { + "href": "/accounts/1234asdf/credit-transfers" + } + }, + "_embedded": { + "credit_transfers": [ + { + "from": "1234asdf", + "to": "asdfzxcv", + "amount": 0.5, + "reference": "test credit transfer", + "id": "7380eecb-b82c-46e8-9478-af6b5793af1b", + "created_at": "2023-06-12T17:31:48.000Z" + }, + { + "from": "1234asdf", + "to": "asdfzxcv", + "amount": 0.5, + "reference": "", + "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", + "created_at": "2023-06-12T17:20:01.000Z" + } + ] + } +} \ No newline at end of file diff --git a/tests/data/subaccounts/list_subaccounts.json b/tests/data/subaccounts/list_subaccounts.json new file mode 100644 index 00000000..97836dc7 --- /dev/null +++ b/tests/data/subaccounts/list_subaccounts.json @@ -0,0 +1,31 @@ +{ + "_links": { + "self": { + "href": "/accounts/1234asdf/subaccounts" + } + }, + "total_balance": 9.9999, + "total_credit_limit": 0.0, + "_embedded": { + "primary_account": { + "api_key": "1234asdf", + "name": null, + "balance": 9.9999, + "credit_limit": 0.0, + "suspended": false, + "created_at": "2022-03-28T14:16:56.000Z" + }, + "subaccounts": [ + { + "api_key": "qwerasdf", + "primary_account_api_key": "1234asdf", + "use_primary_account_balance": true, + "name": "test_subaccount", + "balance": null, + "credit_limit": null, + "suspended": false, + "created_at": "2023-06-07T10:50:44.000Z" + } + ] + } +} \ No newline at end of file diff --git a/tests/data/subaccounts/modified_subaccount.json b/tests/data/subaccounts/modified_subaccount.json new file mode 100644 index 00000000..1fcdac2f --- /dev/null +++ b/tests/data/subaccounts/modified_subaccount.json @@ -0,0 +1,10 @@ +{ + "api_key": "asdfzxcv", + "primary_account_api_key": "1234asdf", + "use_primary_account_balance": false, + "name": "my modified subaccount", + "balance": 0, + "credit_limit": 0, + "suspended": true, + "created_at": "2023-06-09T14:42:55.000Z" +} \ No newline at end of file diff --git a/tests/data/subaccounts/must_be_number.json b/tests/data/subaccounts/must_be_number.json new file mode 100644 index 00000000..ae356b4f --- /dev/null +++ b/tests/data/subaccounts/must_be_number.json @@ -0,0 +1,12 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", + "title": "Bad Request", + "detail": "The request failed due to validation errors", + "instance": "b4fb726d-0a83-4f97-b4b8-30b64d1aeac7", + "invalid_parameters": [ + { + "reason": "Only positive values of data type JSON number are allowed", + "name": "amount" + } + ] +} \ No newline at end of file diff --git a/tests/data/subaccounts/not_found.json b/tests/data/subaccounts/not_found.json new file mode 100644 index 00000000..7eddfd67 --- /dev/null +++ b/tests/data/subaccounts/not_found.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#invalid-api-key", + "title": "Invalid API Key", + "detail": "API key '1234asdf' does not exist, or you do not have access", + "instance": "158b8f199c45014ab7b08bfe9cc1c12c" +} \ No newline at end of file diff --git a/tests/data/subaccounts/number_not_found.json b/tests/data/subaccounts/number_not_found.json new file mode 100644 index 00000000..31d8c771 --- /dev/null +++ b/tests/data/subaccounts/number_not_found.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer", + "title": "Invalid Number Transfer", + "detail": "Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found", + "instance": "37f2f76c-ade3-4f26-a448-a1adee0ff85e" +} \ No newline at end of file diff --git a/tests/data/subaccounts/same_from_and_to_accounts.json b/tests/data/subaccounts/same_from_and_to_accounts.json new file mode 100644 index 00000000..171a2c1c --- /dev/null +++ b/tests/data/subaccounts/same_from_and_to_accounts.json @@ -0,0 +1,12 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", + "title": "Bad Request", + "detail": "The request failed due to validation errors", + "instance": "5499d21c-35e2-42c9-a50e-e96bdccdb34c", + "invalid_parameters": [ + { + "reason": "Invalid accounts. From and To accounts should be different", + "name": "from" + } + ] +} \ No newline at end of file diff --git a/tests/data/subaccounts/subaccount.json b/tests/data/subaccounts/subaccount.json new file mode 100644 index 00000000..2ead67ee --- /dev/null +++ b/tests/data/subaccounts/subaccount.json @@ -0,0 +1,11 @@ +{ + "api_key": "asdfzxcv", + "secret": "Password123", + "primary_account_api_key": "1234asdf", + "use_primary_account_balance": true, + "name": "my subaccount", + "balance": null, + "credit_limit": null, + "suspended": false, + "created_at": "2023-06-09T02:23:21.327Z" +} \ No newline at end of file diff --git a/tests/data/subaccounts/transfer_number.json b/tests/data/subaccounts/transfer_number.json new file mode 100644 index 00000000..de6c818f --- /dev/null +++ b/tests/data/subaccounts/transfer_number.json @@ -0,0 +1,7 @@ +{ + "from": "1234asdf", + "to": "asdfzxcv", + "number": "12345678901", + "country": "US", + "masterAccountId": "1234asdf" +} \ No newline at end of file diff --git a/tests/data/subaccounts/transfer_validation_error.json b/tests/data/subaccounts/transfer_validation_error.json new file mode 100644 index 00000000..229a49df --- /dev/null +++ b/tests/data/subaccounts/transfer_validation_error.json @@ -0,0 +1,12 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", + "title": "Bad Request", + "detail": "The request failed due to validation errors", + "instance": "c0b6fae7-7c83-4cdc-9c0b-00672e284da9", + "invalid_parameters": [ + { + "reason": "Malformed", + "name": "start_date" + } + ] +} \ No newline at end of file diff --git a/tests/data/subaccounts/validation_error.json b/tests/data/subaccounts/validation_error.json new file mode 100644 index 00000000..a4b0aad9 --- /dev/null +++ b/tests/data/subaccounts/validation_error.json @@ -0,0 +1,12 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", + "title": "Bad Request", + "detail": "The request failed due to validation errors", + "instance": "fb97a734-7087-4b3a-8ec3-88d271e27fb2", + "invalid_parameters": [ + { + "reason": "Transitioning from 'use_primary_account_balance = false' to 'use_primary_account_balance = true' is not supported", + "name": "use_primary_account_balance" + } + ] +} \ No newline at end of file diff --git a/tests/test_jwt.py b/tests/test_jwt.py index f70ed042..3880d6c3 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -32,8 +32,9 @@ def test_generate_application_jwt(client): assert jwt == test_jwt -def test_add_jwt_to_request_headers(client): +def test_create_jwt_auth_string(client): + headers = client.headers with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): - headers = client._add_jwt_to_request_headers() + headers['Authorization'] = client._create_jwt_auth_string() assert headers['Accept'] == 'application/json' assert headers['Authorization'] == b'Bearer ' + test_jwt diff --git a/tests/test_subaccounts.py b/tests/test_subaccounts.py new file mode 100644 index 00000000..1278e7ea --- /dev/null +++ b/tests/test_subaccounts.py @@ -0,0 +1,730 @@ +from vonage import Client, ClientError, SubaccountsError +from util import stub + +from pytest import raises +import responses + +api_key = '1234asdf' +api_secret = 'qwerasdfzxcv' +client = Client(key=api_key, secret=api_secret) +subaccount_key = 'asdfzxcv' + + +@responses.activate +def test_list_subaccounts(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/list_subaccounts.json', + ) + subaccounts = client.subaccounts.list_subaccounts() + assert subaccounts['total_balance'] == 9.9999 + assert subaccounts['_embedded']['primary_account']['api_key'] == api_key + assert subaccounts['_embedded']['primary_account']['balance'] == 9.9999 + assert subaccounts['_embedded']['subaccounts'][0]['api_key'] == 'qwerasdf' + assert subaccounts['_embedded']['subaccounts'][0]['name'] == 'test_subaccount' + + +@responses.activate +def test_list_subaccounts_error_authentication(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + with raises(ClientError) as err: + client.subaccounts.list_subaccounts() + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_list_subaccounts_error_forbidden(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/forbidden.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.list_subaccounts() + assert ( + str(err.value) + == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' + ) + + +@responses.activate +def test_list_subaccounts_error_not_found(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + with raises(ClientError) as err: + client.subaccounts.list_subaccounts() + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_create_subaccount(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/subaccount.json', + ) + subaccount = client.subaccounts.create_subaccount( + name='my subaccount', secret='Password123', use_primary_account_balance=True + ) + assert subaccount['api_key'] == 'asdfzxcv' + assert subaccount['secret'] == 'Password123' + assert subaccount['primary_account_api_key'] == api_key + assert subaccount['use_primary_account_balance'] == True + + +@responses.activate +def test_create_subaccount_error_authentication(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + + with raises(ClientError) as err: + client.subaccounts.create_subaccount('failed subaccount') + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_create_subaccount_error_forbidden(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/forbidden.json', + status_code=403, + ) + + with raises(ClientError) as err: + client.subaccounts.create_subaccount('failed subaccount') + assert ( + str(err.value) + == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' + ) + + +@responses.activate +def test_create_subaccount_error_not_found(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.create_subaccount('failed subaccount') + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +def test_create_subaccount_error_non_boolean(): + with raises(SubaccountsError) as err: + client.subaccounts.create_subaccount( + 'failed subaccount', use_primary_account_balance='yes please' + ) + assert ( + str(err.value) + == 'If providing a value, it needs to be a boolean. You provided: "yes please"' + ) + + +@responses.activate +def test_get_subaccount(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/subaccount.json', + ) + subaccount = client.subaccounts.get_subaccount(subaccount_key) + assert subaccount['api_key'] == 'asdfzxcv' + assert subaccount['secret'] == 'Password123' + assert subaccount['primary_account_api_key'] == api_key + assert subaccount['use_primary_account_balance'] == True + + +@responses.activate +def test_get_subaccount_error_authentication(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + with raises(ClientError) as err: + client.subaccounts.get_subaccount(subaccount_key) + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_get_subaccount_error_forbidden(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/forbidden.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.get_subaccount(subaccount_key) + assert ( + str(err.value) + == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' + ) + + +@responses.activate +def test_get_subaccount_error_not_found(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + with raises(ClientError) as err: + client.subaccounts.get_subaccount(subaccount_key) + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_modify_subaccount(): + stub( + responses.PATCH, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/modified_subaccount.json', + ) + subaccount = client.subaccounts.modify_subaccount( + subaccount_key, + suspended=True, + use_primary_account_balance=False, + name='my modified subaccount', + ) + assert subaccount['api_key'] == 'asdfzxcv' + assert subaccount['name'] == 'my modified subaccount' + assert subaccount['suspended'] == True + assert subaccount['primary_account_api_key'] == api_key + assert subaccount['use_primary_account_balance'] == False + + +@responses.activate +def test_modify_subaccount_error_authentication(): + stub( + responses.PATCH, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + with raises(ClientError) as err: + client.subaccounts.modify_subaccount(subaccount_key, suspended=True) + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_modify_subaccount_error_forbidden(): + stub( + responses.PATCH, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/forbidden.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.modify_subaccount(subaccount_key, use_primary_account_balance=False) + assert ( + str(err.value) + == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' + ) + + +@responses.activate +def test_modify_subaccount_error_not_found(): + stub( + responses.PATCH, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + with raises(ClientError) as err: + client.subaccounts.modify_subaccount(subaccount_key, name='my modified subaccount name') + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_modify_subaccount_validation_error(): + stub( + responses.PATCH, + f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', + fixture_path='subaccounts/validation_error.json', + status_code=422, + ) + with raises(ClientError) as err: + client.subaccounts.modify_subaccount(subaccount_key, use_primary_account_balance=True) + assert ( + str(err.value) + == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' + ) + + +@responses.activate +def test_list_credit_transfers(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/list_credit_transfers.json', + ) + transfers = client.subaccounts.list_credit_transfers( + start_date='2022-03-29T14:16:56Z', + end_date='2023-06-12T17:20:01Z', + subaccount='asdfzxcv', + ) + assert transfers['_embedded']['credit_transfers'][0]['from'] == '1234asdf' + assert transfers['_embedded']['credit_transfers'][0]['reference'] == 'test credit transfer' + assert transfers['_embedded']['credit_transfers'][1]['to'] == 'asdfzxcv' + assert transfers['_embedded']['credit_transfers'][1]['created_at'] == '2023-06-12T17:20:01.000Z' + + +@responses.activate +def test_list_credit_transfers_error_authentication(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + + with raises(ClientError) as err: + client.subaccounts.list_credit_transfers() + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_list_credit_transfers_error_forbidden(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/forbidden.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.list_credit_transfers() + assert ( + str(err.value) + == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' + ) + + +@responses.activate +def test_list_credit_transfers_error_not_found(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.list_credit_transfers() + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_list_credit_transfers_validation_error(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/transfer_validation_error.json', + status_code=422, + ) + with raises(ClientError) as err: + client.subaccounts.list_credit_transfers(start_date='invalid-date-format') + assert ( + str(err.value) + == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' + ) + + +@responses.activate +def test_transfer_credit(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/credit_transfer.json', + ) + transfer = client.subaccounts.transfer_credit( + from_='1234asdf', to='asdfzxcv', amount=0.50, reference='test credit transfer' + ) + assert transfer['from'] == '1234asdf' + assert transfer['to'] == 'asdfzxcv' + assert transfer['amount'] == 0.5 + assert transfer['reference'] == 'test credit transfer' + + +@responses.activate +def test_transfer_credit_error_authentication(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzxcv', amount=0.1) + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_transfer_credit_invalid_transfer(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/invalid_transfer.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_credit(from_='asdfzxcv', to='qwerasdf', amount=1) + assert ( + str(err.value) + == 'Invalid Transfer: Transfers are only allowed between a primary account and its subaccount (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' + ) + + +@responses.activate +def test_transfer_credit_insufficient_credit(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/insufficient_credit.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_credit(from_='asdfzxcv', to='qwerasdf', amount=1) + assert ( + str(err.value) + == 'Transfer amount is invalid: Insufficient Credit (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' + ) + + +@responses.activate +def test_transfer_credit_error_not_found(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzcv', amount=0.1) + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_transfer_credit_validation_error(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', + fixture_path='subaccounts/must_be_number.json', + status_code=422, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzxcv', amount='0.50') + assert ( + str(err.value) + == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' + ) + + +@responses.activate +def test_list_balance_transfers(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/list_balance_transfers.json', + ) + transfers = client.subaccounts.list_balance_transfers( + start_date='2022-03-29T14:16:56Z', + end_date='2023-06-12T17:20:01Z', + subaccount='asdfzxcv', + ) + assert transfers['_embedded']['balance_transfers'][0]['from'] == '1234asdf' + assert transfers['_embedded']['balance_transfers'][0]['reference'] == 'test transfer' + assert transfers['_embedded']['balance_transfers'][1]['to'] == 'asdfzxcv' + assert ( + transfers['_embedded']['balance_transfers'][1]['created_at'] == '2023-06-12T17:20:01.000Z' + ) + + +@responses.activate +def test_list_balance_transfers_error_authentication(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + + with raises(ClientError) as err: + client.subaccounts.list_balance_transfers() + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_list_balance_transfers_error_forbidden(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/forbidden.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.list_balance_transfers() + assert ( + str(err.value) + == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' + ) + + +@responses.activate +def test_list_balance_transfers_error_not_found(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.list_balance_transfers() + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_list_balance_transfers_validation_error(): + stub( + responses.GET, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/transfer_validation_error.json', + status_code=422, + ) + with raises(ClientError) as err: + client.subaccounts.list_balance_transfers(start_date='invalid-date-format') + assert ( + str(err.value) + == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' + ) + + +@responses.activate +def test_transfer_balance(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/balance_transfer.json', + ) + transfer = client.subaccounts.transfer_balance( + from_='1234asdf', to='asdfzxcv', amount=0.50, reference='test balance transfer' + ) + assert transfer['from'] == '1234asdf' + assert transfer['to'] == 'asdfzxcv' + assert transfer['amount'] == 0.5 + assert transfer['reference'] == 'test balance transfer' + + +@responses.activate +def test_transfer_balance_error_authentication(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzxcv', amount=0.1) + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_transfer_balance_invalid_transfer(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/invalid_transfer.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_balance(from_='asdfzxcv', to='qwerasdf', amount=1) + assert ( + str(err.value) + == 'Invalid Transfer: Transfers are only allowed between a primary account and its subaccount (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' + ) + + +@responses.activate +def test_transfer_balance_error_not_found(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzcv', amount=0.1) + assert ( + str(err.value) + == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" + ) + + +@responses.activate +def test_transfer_balance_validation_error(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', + fixture_path='subaccounts/must_be_number.json', + status_code=422, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzxcv', amount='0.50') + assert ( + str(err.value) + == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' + ) + + +@responses.activate +def test_transfer_number(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/transfer-number', + fixture_path='subaccounts/transfer_number.json', + ) + transfer = client.subaccounts.transfer_number( + from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' + ) + assert transfer['from'] == '1234asdf' + assert transfer['to'] == 'asdfzxcv' + assert transfer['number'] == '12345678901' + assert transfer['country'] == 'US' + assert transfer['masterAccountId'] == '1234asdf' + + +@responses.activate +def test_transfer_number_error_authentication(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/transfer-number', + fixture_path='subaccounts/invalid_credentials.json', + status_code=401, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_number( + from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' + ) + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_transfer_number_invalid_transfer(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/transfer-number', + fixture_path='subaccounts/invalid_number_transfer.json', + status_code=403, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_number( + from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' + ) + assert ( + str(err.value) + == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode is not owned by from account (https://developer.nexmo.com/api-errors/account/subaccounts#invalid-number-transfer)' + ) + + +@responses.activate +def test_transfer_number_error_number_not_found(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/transfer-number', + fixture_path='subaccounts/number_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_number( + from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' + ) + assert ( + str(err.value) + == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found (https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer)' + ) + + +@responses.activate +def test_transfer_number_error_number_not_found(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/transfer-number', + fixture_path='subaccounts/number_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + client.subaccounts.transfer_number( + from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' + ) + assert ( + str(err.value) + == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found (https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer)' + ) + + +@responses.activate +def test_transfer_number_validation_error(): + stub( + responses.POST, + f'https://api.nexmo.com/accounts/{api_key}/transfer-number', + fixture_path='subaccounts/same_from_and_to_accounts.json', + status_code=422, + ) + with raises(ClientError) as err: + client.subaccounts.transfer_number( + from_='asdfzxcv', to='asdfzxcv', number='12345678901', country='US' + ) + assert ( + str(err.value) + == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' + ) diff --git a/tests/test_verify2.py b/tests/test_verify2.py index 86b8cb12..71714f41 100644 --- a/tests/test_verify2.py +++ b/tests/test_verify2.py @@ -11,7 +11,12 @@ @responses.activate def test_new_request_sms_basic(dummy_data): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} verify_request = verify2.new_request(params) @@ -22,7 +27,12 @@ def test_new_request_sms_basic(dummy_data): @responses.activate def test_new_request_sms_full(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = { 'locale': 'en-gb', @@ -40,9 +50,18 @@ def test_new_request_sms_full(): @responses.activate def test_new_request_sms_custom_code(dummy_data): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) - params = {'brand': 'ACME, Inc', 'code': 'asdfghjk', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + params = { + 'brand': 'ACME, Inc', + 'code': 'asdfghjk', + 'workflow': [{'channel': 'sms', 'to': '447700900000'}], + } verify_request = verify2.new_request(params) assert request_user_agent() == dummy_data.user_agent @@ -58,7 +77,11 @@ def test_new_request_error_fraud_check_invalid_account(dummy_data): status_code=403, ) - params = {'brand': 'ACME, Inc', 'fraud_check': False, 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} + params = { + 'brand': 'ACME, Inc', + 'fraud_check': False, + 'workflow': [{'channel': 'sms', 'to': '447700900000'}], + } with raises(ClientError) as err: verify2.new_request(params) @@ -151,12 +174,20 @@ def test_new_request_whatsapp_app_hash_error(): with raises(Verify2Error) as err: verify2.new_request(params) - assert str(err.value) == 'Cannot specify a value for "app_hash" unless using SMS for authentication.' + assert ( + str(err.value) + == 'Cannot specify a value for "app_hash" unless using SMS for authentication.' + ) @responses.activate def test_new_request_whatsapp(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}]} verify_request = verify2.new_request(params) @@ -166,9 +197,18 @@ def test_new_request_whatsapp(): @responses.activate def test_new_request_whatsapp_custom_code(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) - params = {'brand': 'ACME, Inc', 'code': 'asdfghjk', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}]} + params = { + 'brand': 'ACME, Inc', + 'code': 'asdfghjk', + 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}], + } verify_request = verify2.new_request(params) assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' @@ -176,9 +216,17 @@ def test_new_request_whatsapp_custom_code(): @responses.activate def test_new_request_whatsapp_from_field(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447000000000'}]} + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447000000000'}], + } verify_request = verify2.new_request(params) assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' @@ -186,7 +234,12 @@ def test_new_request_whatsapp_from_field(): @responses.activate def test_new_request_whatsapp_invalid_sender_error(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/invalid_sender.json', status_code=422) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/invalid_sender.json', + status_code=422, + ) params = { 'brand': 'ACME, Inc', @@ -199,7 +252,12 @@ def test_new_request_whatsapp_invalid_sender_error(): @responses.activate def test_new_request_whatsapp_sender_unregistered_error(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/invalid_sender.json', status_code=422) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/invalid_sender.json', + status_code=422, + ) params = { 'brand': 'ACME, Inc', @@ -215,9 +273,17 @@ def test_new_request_whatsapp_sender_unregistered_error(): @responses.activate def test_new_request_whatsapp_interactive(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp_interactive', 'to': '447700900000'}]} + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'whatsapp_interactive', 'to': '447700900000'}], + } verify_request = verify2.new_request(params) assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' @@ -225,7 +291,12 @@ def test_new_request_whatsapp_interactive(): @responses.activate def test_new_request_voice(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'voice', 'to': '447700900000'}]} verify_request = verify2.new_request(params) @@ -235,9 +306,18 @@ def test_new_request_voice(): @responses.activate def test_new_request_voice_custom_code(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) - params = {'brand': 'ACME, Inc', 'code': 'asdfhjkl', 'workflow': [{'channel': 'voice', 'to': '447700900000'}]} + params = { + 'brand': 'ACME, Inc', + 'code': 'asdfhjkl', + 'workflow': [{'channel': 'voice', 'to': '447700900000'}], + } verify_request = verify2.new_request(params) assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' @@ -245,9 +325,17 @@ def test_new_request_voice_custom_code(): @responses.activate def test_new_request_email(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'email', 'to': 'recipient@example.com'}]} + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'email', 'to': 'recipient@example.com'}], + } verify_request = verify2.new_request(params) assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' @@ -255,7 +343,12 @@ def test_new_request_email(): @responses.activate def test_new_request_email_additional_fields(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = { 'locale': 'en-gb', @@ -264,7 +357,9 @@ def test_new_request_email_additional_fields(): 'code_length': 8, 'brand': 'ACME, Inc', 'code': 'asdfhjkl', - 'workflow': [{'channel': 'email', 'to': 'recipient@example.com', 'from': 'sender@example.com'}], + 'workflow': [ + {'channel': 'email', 'to': 'recipient@example.com', 'from': 'sender@example.com'} + ], } verify_request = verify2.new_request(params) @@ -273,7 +368,12 @@ def test_new_request_email_additional_fields(): @responses.activate def test_new_request_email_error(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/invalid_email.json', status_code=422) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/invalid_email.json', + status_code=422, + ) params = { 'brand': 'ACME, Inc', @@ -289,7 +389,12 @@ def test_new_request_email_error(): @responses.activate def test_new_request_silent_auth(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'silent_auth', 'to': '447700900000'}]} verify_request = verify2.new_request(params) @@ -299,7 +404,12 @@ def test_new_request_silent_auth(): @responses.activate def test_new_request_error_conflict(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/error_conflict.json', status_code=409) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/error_conflict.json', + status_code=409, + ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} with raises(ClientError) as err: @@ -312,7 +422,12 @@ def test_new_request_error_conflict(): @responses.activate def test_new_request_rate_limit(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/rate_limit.json', status_code=429) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/rate_limit.json', + status_code=429, + ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} with raises(ClientError) as err: @@ -456,7 +571,12 @@ def test_cancel_verification_error_not_found(): @responses.activate def test_new_request_multiple_workflows(): - stub(responses.POST, 'https://api.nexmo.com/v2/verify', fixture_path='verify2/create_request.json', status_code=202) + stub( + responses.POST, + 'https://api.nexmo.com/v2/verify', + fixture_path='verify2/create_request.json', + status_code=202, + ) params = { 'brand': 'ACME, Inc', @@ -471,7 +591,11 @@ def test_new_request_multiple_workflows(): def test_remove_unnecessary_fraud_check(): - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}], 'fraud_check': True} + params = { + 'brand': 'ACME, Inc', + 'workflow': [{'channel': 'sms', 'to': '447700900000'}], + 'fraud_check': True, + } verify2._remove_unnecessary_fraud_check(params) assert 'fraud_check' not in params From 6ba7c1bc0c04b6e50c13c59cf491b4cbc621e56e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 20 Jun 2023 16:39:02 +0100 Subject: [PATCH 245/401] Add meetings api (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding patch method, creating Meetings class and initial methods * adding new Meetings class methods, optional params field to client.delete, new error type * basic implementation of Meetings API methods * Bump version: 3.2.0 → 3.2.1 * fixing import issue with uncommitted init file * adding check that subfolders contain an __init__.py file * refactoring, raising exception * adding get_meetings_api_host method and _meetings_api_host attribute to Client * adding new test module and adding meetings object instantiation to client * small tweaks to methods * implementing Meetings API endpoints, test room and recording endpoints, add mocks * adding tests for themes methods, adding new mocks, check in client.parse * commenting out unimplemented test * adding Meetings.upload_logo_to_theme method to wrap the logo upload process, added tests and mocks * using Literal type from typing-extensions module for python 3.7 compatibility * adding Meetings API info to README * Bump version: 3.2.2 → 3.3.0 * adding xml response mock from aws * setting "no content" responses * catching exception where no values for application_id and private_key but try to use jwt auth * adding page_size parameter and code formatting * testing for returned empty json body on list_themes with no themes, response formatting * adding tests for previously undocumented properties * catching expires_at errors for creating room, updating error responses --- README.md | 144 +++- setup.py | 4 +- src/vonage/client.py | 42 +- src/vonage/errors.py | 6 +- src/vonage/meetings.py | 173 ++++ tests/conftest.py | 8 + .../meetings/delete_recording_not_found.json | 5 + tests/data/meetings/delete_theme_in_use.json | 8 + tests/data/meetings/empty_themes.json | 1 + tests/data/meetings/get_recording.json | 12 + .../meetings/get_recording_not_found.json | 5 + .../data/meetings/get_session_recordings.json | 18 + .../get_session_recordings_not_found.json | 5 + tests/data/meetings/list_dial_in_numbers.json | 12 + .../data/meetings/list_logo_upload_urls.json | 47 ++ .../list_rooms_theme_id_not_found.json | 5 + .../meetings/list_rooms_with_theme_id.json | 57 ++ tests/data/meetings/list_themes.json | 34 + tests/data/meetings/logo_key_error.json | 11 + tests/data/meetings/long_term_room.json | 37 + .../meetings/long_term_room_with_theme.json | 37 + tests/data/meetings/meeting_room.json | 38 + tests/data/meetings/multiple_fewer_rooms.json | 94 +++ tests/data/meetings/multiple_rooms.json | 205 +++++ tests/data/meetings/theme.json | 16 + tests/data/meetings/theme_name_in_use.json | 5 + tests/data/meetings/theme_not_found.json | 5 + tests/data/meetings/transparent_logo.png | Bin 0 -> 8843 bytes tests/data/meetings/unauthorized.json | 4 + .../meetings/update_application_theme.json | 5 + ...update_application_theme_id_not_found.json | 5 + tests/data/meetings/update_no_keys.json | 5 + tests/data/meetings/update_room.json | 37 + .../data/meetings/update_room_type_error.json | 5 + .../meetings/update_theme_already_exists.json | 5 + tests/data/meetings/updated_theme.json | 16 + tests/data/meetings/upload_to_aws_error.xml | 1 + tests/test_jwt.py | 11 + tests/test_meetings.py | 783 ++++++++++++++++++ tests/test_rest_calls.py | 48 ++ tests/test_voice.py | 3 +- tests/util.py | 4 +- 42 files changed, 1950 insertions(+), 16 deletions(-) create mode 100644 src/vonage/meetings.py create mode 100644 tests/data/meetings/delete_recording_not_found.json create mode 100644 tests/data/meetings/delete_theme_in_use.json create mode 100644 tests/data/meetings/empty_themes.json create mode 100644 tests/data/meetings/get_recording.json create mode 100644 tests/data/meetings/get_recording_not_found.json create mode 100644 tests/data/meetings/get_session_recordings.json create mode 100644 tests/data/meetings/get_session_recordings_not_found.json create mode 100644 tests/data/meetings/list_dial_in_numbers.json create mode 100644 tests/data/meetings/list_logo_upload_urls.json create mode 100644 tests/data/meetings/list_rooms_theme_id_not_found.json create mode 100644 tests/data/meetings/list_rooms_with_theme_id.json create mode 100644 tests/data/meetings/list_themes.json create mode 100644 tests/data/meetings/logo_key_error.json create mode 100644 tests/data/meetings/long_term_room.json create mode 100644 tests/data/meetings/long_term_room_with_theme.json create mode 100644 tests/data/meetings/meeting_room.json create mode 100644 tests/data/meetings/multiple_fewer_rooms.json create mode 100644 tests/data/meetings/multiple_rooms.json create mode 100644 tests/data/meetings/theme.json create mode 100644 tests/data/meetings/theme_name_in_use.json create mode 100644 tests/data/meetings/theme_not_found.json create mode 100644 tests/data/meetings/transparent_logo.png create mode 100644 tests/data/meetings/unauthorized.json create mode 100644 tests/data/meetings/update_application_theme.json create mode 100644 tests/data/meetings/update_application_theme_id_not_found.json create mode 100644 tests/data/meetings/update_no_keys.json create mode 100644 tests/data/meetings/update_room.json create mode 100644 tests/data/meetings/update_room_type_error.json create mode 100644 tests/data/meetings/update_theme_already_exists.json create mode 100644 tests/data/meetings/updated_theme.json create mode 100644 tests/data/meetings/upload_to_aws_error.xml create mode 100644 tests/test_meetings.py diff --git a/README.md b/README.md index cd593d5a..39d4c442 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [NCCO Builder](#ncco-builder) - [Verify V2 API](#verify-v2-api) - [Verify V1 API](#verify-v1-api) +- [Meetings API](#meetings-api) - [Number Insight API](#number-insight-api) - [Account API](#account-api) - [Subaccounts API](#subaccounts-api) @@ -579,6 +580,144 @@ else: print("Error: %s" % response["error_text"]) ``` +## Meetings API + +Full docs for the [Meetings API are available here](https://developer.vonage.com/en/meetings/overview). + +### Create a meeting room + +```python +# Instant room +params = {'display_name': 'my_test_room'} +meeting = client.meetings.create_room(params) + +# Long term room +params = {'display_name': 'test_long_term_room', 'type': 'long_term', 'expires_at': '2023-01-30T00:47:04+0000'} +meeting = client.meetings.create_room(params) +``` + +### Get all meeting rooms + +```python +client.meetings.list_rooms() +``` + +### Get a room by id + +```python +client.meetings.get_room('MY_ROOM_ID') +``` + +### Update a long term room + +```python +params = { + 'update_details': { + "available_features": { + "is_recording_available": False, + "is_chat_available": False, + } + } +} +meeting = client.meetings.update_room('MY_ROOM_ID', params) +``` + +### Get all recordings for a session + +```python +session = client.meetings.get_session_recordings('MY_SESSION_ID') +``` + +### Get a recording by id + +```python +recording = client.meetings.get_recording('MY_RECORDING_ID') +``` + +### Delete a recording + +```python +client.meetings.delete_recording('MY_RECORDING_ID') +``` + +### List dial-in numbers + +```python +numbers = client.meetings.list_dial_in_numbers() +``` + +### Create a theme + +```python +params = { + 'theme_name': 'my_theme', + 'main_color': '#12f64e', + 'brand_text': 'My Company', + 'short_company_url': 'my-company', +} +theme = client.meetings.create_theme(params) +``` + +### Add a theme to a room + +```python +meetings.add_theme_to_room('MY_ROOM_ID', 'MY_THEME_ID') +``` + +### List themes + +```python +themes = client.meetings.list_themes() +``` + +### Get theme information + +```python +theme = client.meetings.get_theme('MY_THEME_ID') +``` + +### Delete a theme + +```python +client.meetings.delete_theme('MY_THEME_ID') +``` + +### Update a theme + +```python +params = { + 'update_details': { + 'theme_name': 'updated_theme', + 'main_color': '#FF0000', + 'brand_text': 'My Updated Company Name', + 'short_company_url': 'updated_company_url', + } +} +theme = client.meetings.update_theme('MY_THEME_ID', params) +``` + +### List all rooms using a specified theme + +```python +rooms = client.meetings.list_rooms_with_theme_id('MY_THEME_ID') +``` + +### Update the default theme for your application + +```python +response = client.meetings.update_application_theme('MY_THEME_ID') +``` + +### Upload a logo to a theme + +```python +response = client.meetings.upload_logo_to_theme( + theme_id='MY_THEME_ID', + path_to_image='path/to/my/image.png', + logo_type='white', # 'white', 'colored' or 'favicon' + ) +``` + ## Number Insight API ### Basic Number Insight @@ -909,9 +1048,9 @@ from vonage import Client client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') print(client.host()) # return rest.nexmo.com -client.host('mio.nexmo.com') # rewrites the host value to mio.nexmo.com +client.host('newhost.vonage.com') # rewrites the host value to newhost.vonage.com print(client.api_host()) # returns api.vonage.com -client.api_host('myapi.vonage.com') # rewrite the value of api_host +client.api_host('myapi.vonage.com') # rewrite the value of api_host to myapi.vonage.com ``` ## Frequently Asked Questions @@ -930,6 +1069,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Dispatch API | Beta | ❌ | | External Accounts API | Beta | ❌ | | Media API | Beta | ❌ | +| Meetings API | General Availability | ✅ | | Messages API | General Availability | ✅ | | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | diff --git a/setup.py b/setup.py index 793166fc..151a3e59 100644 --- a/setup.py +++ b/setup.py @@ -4,9 +4,7 @@ from setuptools import setup, find_packages -with io.open( - os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8" -) as f: +with io.open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8") as f: long_description = f.read() setup( diff --git a/src/vonage/client.py b/src/vonage/client.py index 257284cd..b800c6b6 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -4,6 +4,7 @@ from .account import Account from .application import ApplicationV2, Application from .errors import * +from .meetings import Meetings from .messages import Messages from .number_insight import NumberInsight from .number_management import Numbers @@ -100,6 +101,7 @@ def __init__( self._jwt_claims = {} self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" + self._meetings_api_host = "api-eu.vonage.com/beta/meetings" user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" @@ -110,6 +112,7 @@ def __init__( self.account = Account(self) self.application = Application(self) + self.meetings = Meetings(self) self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) @@ -128,20 +131,27 @@ def __init__( ) self.session.mount("https://", self.adapter) - # Get and Set _host attribute + # Gets and sets _host attribute def host(self, value=None): if value is None: return self._host else: self._host = value - # Gets And Set _api_host attribute + # Gets and sets _api_host attribute def api_host(self, value=None): if value is None: return self._api_host else: self._api_host = value + # Gets and sets _meetings_api_host attribute + def meetings_api_host(self, value=None): + if value is None: + return self._meetings_api_host + else: + self._meetings_api_host = value + def auth(self, params=None, **kwargs): self._jwt_claims = params or kwargs @@ -261,7 +271,7 @@ def patch(self, host, request_uri, params, auth_type=None): # Only newer APIs (that expect json-bodies) currently use this method, so we will always send a json-formatted body return self.parse(host, self.session.patch(uri, json=params, headers=self._request_headers)) - def delete(self, host, request_uri, auth_type=None): + def delete(self, host, request_uri, params=None, auth_type=None): uri = f"https://{host}{request_uri}" self._request_headers = self.headers @@ -275,7 +285,11 @@ def delete(self, host, request_uri, auth_type=None): ) logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") - return self.parse(host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout)) + if params is not None: + logger.debug(f"DELETE call has params {repr(params)}") + return self.parse( + host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout, params=params) + ) def parse(self, host, response: Response): logger.debug(f"Response headers {repr(response.headers)}") @@ -291,7 +305,10 @@ def parse(self, host, response: Response): if response.json() is None: return None if content_mime == "application/json": - return response.json() + try: + return response.json() + except JSONDecodeError: + pass else: return response.content elif 400 <= response.status_code < 500: @@ -306,6 +323,11 @@ def parse(self, host, response: Response): detail = error_data["detail"] type = error_data["type"] message = f"{title}: {detail} ({type})" + elif 'status' in error_data and 'message' in error_data and 'name' in error_data: + message = f'Status Code {error_data["status"]}: {error_data["name"]}: {error_data["message"]}' + if 'errors' in error_data: + for error in error_data['errors']: + message += f', error: {error}' else: message = error_data except JSONDecodeError: @@ -320,7 +342,15 @@ def _create_jwt_auth_string(self): return b"Bearer " + self._generate_application_jwt() def _generate_application_jwt(self): - return self._jwt_client.generate_application_jwt(self._jwt_claims) + try: + return self._jwt_client.generate_application_jwt(self._jwt_claims) + except AttributeError as err: + if '_jwt_client' in str(err): + raise ClientError( + 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' + ) + else: + raise err def _create_header_auth_string(self): hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") diff --git a/src/vonage/errors.py b/src/vonage/errors.py index afd23eff..1c3d11fb 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -33,7 +33,11 @@ class RedactError(Error): class InvalidAuthenticationTypeError(Error): - """An authentication method was specified that is not allowed.""" + """An authentication method was specified that is not allowed""" + + +class MeetingsError(ClientError): + """An error related to the Meetings class which calls the Vonage Meetings API.""" class Verify2Error(ClientError): diff --git a/src/vonage/meetings.py b/src/vonage/meetings.py new file mode 100644 index 00000000..a506bddc --- /dev/null +++ b/src/vonage/meetings.py @@ -0,0 +1,173 @@ +from .errors import MeetingsError + +from typing_extensions import Literal +import logging +import requests + + +logger = logging.getLogger("vonage") + + +class Meetings: + """Class containing methods used to create and manage meetings using the Meetings API.""" + + _auth_type = 'jwt' + + def __init__(self, client): + self._client = client + self._meetings_api_host = client.meetings_api_host() + + def list_rooms(self, page_size: str = 20, start_id: str = None, end_id: str = None): + params = Meetings.set_start_and_end_params(start_id, end_id) + params['page_size'] = page_size + return self._client.get( + self._meetings_api_host, '/rooms', params, auth_type=Meetings._auth_type + ) + + def create_room(self, params: dict = {}): + if 'display_name' not in params: + raise MeetingsError( + 'You must include a value for display_name as a field in the params dict when creating a meeting room.' + ) + if 'type' not in params or 'type' in params and params['type'] != 'long_term': + if 'expires_at' in params: + raise MeetingsError('Cannot set "expires_at" for an instant room.') + elif params['type'] == 'long_term' and 'expires_at' not in params: + raise MeetingsError('You must set a value for "expires_at" for a long-term room.') + + return self._client.post( + self._meetings_api_host, '/rooms', params, auth_type=Meetings._auth_type + ) + + def get_room(self, room_id: str): + return self._client.get( + self._meetings_api_host, f'/rooms/{room_id}', auth_type=Meetings._auth_type + ) + + def update_room(self, room_id: str, params: dict): + return self._client.patch( + self._meetings_api_host, f'/rooms/{room_id}', params, auth_type=Meetings._auth_type + ) + + def add_theme_to_room(self, room_id: str, theme_id: str): + params = {'update_details': {'theme_id': theme_id}} + return self._client.patch( + self._meetings_api_host, f'/rooms/{room_id}', params, auth_type=Meetings._auth_type + ) + + def get_recording(self, recording_id: str): + return self._client.get( + self._meetings_api_host, f'/recordings/{recording_id}', auth_type=Meetings._auth_type + ) + + def delete_recording(self, recording_id: str): + return self._client.delete( + self._meetings_api_host, f'/recordings/{recording_id}', auth_type=Meetings._auth_type + ) + + def get_session_recordings(self, session_id: str): + return self._client.get( + self._meetings_api_host, + f'/sessions/{session_id}/recordings', + auth_type=Meetings._auth_type, + ) + + def list_dial_in_numbers(self): + return self._client.get( + self._meetings_api_host, '/dial-in-numbers', auth_type=Meetings._auth_type + ) + + def list_themes(self): + return self._client.get(self._meetings_api_host, '/themes', auth_type=Meetings._auth_type) + + def create_theme(self, params: dict): + if 'main_color' not in params or 'brand_text' not in params: + raise MeetingsError('Values for "main_color" and "brand_text" must be specified') + + return self._client.post( + self._meetings_api_host, '/themes', params, auth_type=Meetings._auth_type + ) + + def get_theme(self, theme_id: str): + return self._client.get( + self._meetings_api_host, f'/themes/{theme_id}', auth_type=Meetings._auth_type + ) + + def delete_theme(self, theme_id: str, force: bool = False): + params = {'force': force} + return self._client.delete( + self._meetings_api_host, + f'/themes/{theme_id}', + params=params, + auth_type=Meetings._auth_type, + ) + + def update_theme(self, theme_id: str, params: dict): + return self._client.patch( + self._meetings_api_host, f'/themes/{theme_id}', params, auth_type=Meetings._auth_type + ) + + def list_rooms_with_theme_id( + self, theme_id: str, page_size: int = 20, start_id: str = None, end_id: str = None + ): + params = Meetings.set_start_and_end_params(start_id, end_id) + params['page_size'] = page_size + + return self._client.get( + self._meetings_api_host, + f'/themes/{theme_id}/rooms', + params, + auth_type=Meetings._auth_type, + ) + + def update_application_theme(self, theme_id: str): + params = {'update_details': {'default_theme_id': theme_id}} + return self._client.patch( + self._meetings_api_host, '/applications', params, auth_type=Meetings._auth_type + ) + + def upload_logo_to_theme( + self, theme_id: str, path_to_image: str, logo_type: Literal['white', 'colored', 'favicon'] + ): + params = self._get_logo_upload_url(logo_type) + self._upload_to_aws(params, path_to_image) + self._add_logo_to_theme(theme_id, params['fields']['key']) + return f'Logo upload to theme: {theme_id} was successful.' + + def _get_logo_upload_url(self, logo_type): + upload_urls = self._client.get( + self._meetings_api_host, '/themes/logos-upload-urls', auth_type=Meetings._auth_type + ) + for url_object in upload_urls: + if url_object['fields']['logoType'] == logo_type: + return url_object + raise MeetingsError('Cannot find the upload URL for the specified logo type.') + + def _upload_to_aws(self, params, path_to_image): + form = {**params['fields'], 'file': open(path_to_image, 'rb')} + + logger.debug(f"POST to {params['url']} to upload file {path_to_image}") + logo_upload = requests.post( + url=params['url'], + files=form, + ) + if logo_upload.status_code != 204: + raise MeetingsError(f'Logo upload process failed. {logo_upload.content}') + + def _add_logo_to_theme(self, theme_id: str, key: str): + params = {'keys': [key]} + return self._client.put( + self._meetings_api_host, + f'/themes/{theme_id}/finalizeLogos', + params, + auth_type=Meetings._auth_type, + ) + + @staticmethod + def set_start_and_end_params(start_id, end_id): + params = {} + if start_id is not None: + params['start_id'] = start_id + if end_id is not None: + params['end_id'] = end_id + return params diff --git a/tests/conftest.py b/tests/conftest.py index 720b0326..6e3a0790 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ def __init__(self): self.user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()}" self.host = "rest.nexmo.com" self.api_host = "api.nexmo.com" + self.meetings_api_host = "api-eu.vonage.com/beta/meetings" @pytest.fixture(scope="session") @@ -124,3 +125,10 @@ def application_v2(client): import vonage return vonage.ApplicationV2(client) + + +@pytest.fixture +def meetings(client): + import vonage + + return vonage.Meetings(client) diff --git a/tests/data/meetings/delete_recording_not_found.json b/tests/data/meetings/delete_recording_not_found.json new file mode 100644 index 00000000..0e38eb8f --- /dev/null +++ b/tests/data/meetings/delete_recording_not_found.json @@ -0,0 +1,5 @@ +{ + "message": "Could not find recording", + "name": "NotFoundError", + "status": 404 +} \ No newline at end of file diff --git a/tests/data/meetings/delete_theme_in_use.json b/tests/data/meetings/delete_theme_in_use.json new file mode 100644 index 00000000..b4ebb38b --- /dev/null +++ b/tests/data/meetings/delete_theme_in_use.json @@ -0,0 +1,8 @@ +{ + "message": "could not delete theme", + "name": "BadRequestError", + "errors": [ + "Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room" + ], + "status": 400 +} \ No newline at end of file diff --git a/tests/data/meetings/empty_themes.json b/tests/data/meetings/empty_themes.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/tests/data/meetings/empty_themes.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/data/meetings/get_recording.json b/tests/data/meetings/get_recording.json new file mode 100644 index 00000000..3f7c28dd --- /dev/null +++ b/tests/data/meetings/get_recording.json @@ -0,0 +1,12 @@ +{ + "id": "e5b73c98-c087-4ee5-b61b-0ea08204fc65", + "session_id": "1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4", + "started_at": "2023-01-25T02:38:31.000Z", + "ended_at": "2023-01-25T02:38:40.000Z", + "status": "uploaded", + "_links": { + "url": { + "href": "https://prod-meetings-recordings.s3.amazonaws.com/46339892/e5b73c98-c087-4ee5-b61b-0ea08204fc65/archive.mp4?AWSAccessKeyId=ASIA5NAYMMB6PXEIQICC&Expires=1674687032&Signature=RosB66sKsizUgoRz%2FWlQD7wUUJY%3D&response-content-disposition=attachment%3B%20filename%3D%22test_recording_room_2023-01-25T02%253A38%253A31.000Z.mp4%22&response-content-type=video%2Fmp4&x-amz-security-token=IQoJb3JpZ2luX2VjEKH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJHMEUCIQC5%2FrQRRq%2FzlJCqfgI9MN4Bq9kqmJTMPgZCo2KyaJ79IAIgdVVs9eYiuxB%2Bcc7QYJz7X4XQjSPcofAsves5rrjrsDgqiwQIGhAAGgw5MjEzMjE2Mjc3NzIiDHpbvFCfDDlkhb%2FFCyroA7OrSBCZr7MyZHXnHHPHOB99ctR%2F7XMzr3GAqnWfZTR5DY1zSYLkAKavjbS6Uw%2FMZW1PeETwLUrdwvLcvkpkU6EaXh2PZV07ty8AB9wIEyazRR9%2BqrCP9o23dlN1yMKPDnHGKO%2FGvxNFrHC9xJeFmaKrOz3f4oeRlFzJ%2FcMnXOI3vnuMa5jFf2GHDQGYkCWF7ertH%2FnIrdmj80%2BNOGsCb2O5%2BezLLlbJAd12MNj8C4m4xw%2BY0fNHZKKAjrG4UTE8%2BBdZ%2FQrMfbKfHaz736be3mln4ArCL1vUWRdQOQFP8impDXRDSMGS56qIdKgsYhEr6fcT%2Fy5KpYuVXxL4Z8TzVgrWwfcmlTxAJuvDgA6HxQAzY8BN8eQLxbaBWaiq%2BD0z6hDPIHKhZDYLQ4CSsxDRfL1DN%2FB155Os9kmombG5rZb%2BR1poIYTrlC16LjIN2JWgCovI8fRPgcjJ3JYEIdi0oE6Jq%2B0PdXbjbSZlekpkQRny3eOPuKhheFlmpweiEjwabVPq3kGyUeC03ZvOZ6giN34LdtM3m2gblmcO9t%2F1KvJwm49t2LJYqGMX9JnqOf6pSWqAZXERCoeAaE0SLzsI15pIix07e9fSY8HZJ49OL2%2FYdz%2BlY4rmkTTVo2v2iELhXS57S3ihkz3lMLS6xZ4GOqUB%2BLq1xiXSn3RAfctKCWM6fGctNnh77Tmn9cwKd8LZtXSZUIxGNYESIu4k%2BbgPZh%2B%2BLf%2BNAsgj6DtnpicEwoSxnbeMwrM6PWw8IH7GE%2FeHN8HFZqGwWiWSogeOv1YeFGL%2BWlXVS%2F5mx7uNTJx%2FHryd2JSYu3kn3MpY7cBU67jcTyesZQzR%2BTHIhLnNgTMNhdQ4st7lD4RaCsvTGqFkv6EnOV%2BU17hv" + } + } +} \ No newline at end of file diff --git a/tests/data/meetings/get_recording_not_found.json b/tests/data/meetings/get_recording_not_found.json new file mode 100644 index 00000000..9c2f8d66 --- /dev/null +++ b/tests/data/meetings/get_recording_not_found.json @@ -0,0 +1,5 @@ +{ + "message": "Recording not-a-real-recording-id was not found", + "name": "NotFoundError", + "status": 404 +} \ No newline at end of file diff --git a/tests/data/meetings/get_session_recordings.json b/tests/data/meetings/get_session_recordings.json new file mode 100644 index 00000000..782effe3 --- /dev/null +++ b/tests/data/meetings/get_session_recordings.json @@ -0,0 +1,18 @@ +{ + "_embedded": { + "recordings": [ + { + "id": "e5b73c98-c087-4ee5-b61b-0ea08204fc65", + "session_id": "1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4", + "started_at": "2023-01-25T02:38:31.000Z", + "ended_at": "2023-01-25T02:38:40.000Z", + "status": "uploaded", + "_links": { + "url": { + "href": "https://prod-meetings-recordings.s3.amazonaws.com/46339892/e5b73c98-c087-4ee5-b61b-0ea08204fc65/archive.mp4?AWSAccessKeyId=ASIA5NAYMMB6JPDOLPNO&Expires=1674688058&Signature=0IzgnyLJFMP1TDkOyoBT4M54Le8%3D&response-content-disposition=attachment%3B%20filename%3D%22test_recording_room_2023-01-25T02%253A38%253A31.000Z.mp4%22&response-content-type=video%2Fmp4&x-amz-security-token=IQoJb3JpZ2luX2VjEKL%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJHMEUCIBE0ejVJPxkEDjAF6cMuDC9nIeOU%2BUnUTSnfhi2prlHtAiEA1wiXNTR96lN%2Bgsb2yeQPM%2BF%2F4e6%2BA6%2B5CylWsM1gW%2BMqiwQIGhAAGgw5MjEzMjE2Mjc3NzIiDHEAZDKegwDhiMhj3CroA1%2B2SNg3m%2B%2FCmq3ELZnnEx8t9oYXmlY0dDRovuKNBdy5n4d%2FUhhR5DaoxOj8cAY7Yu8xZRM1oYQCbO2Qrgiy2Nki7FgHNljLldhbMN6txOnf7%2BP8r2XWD6x0D7ZN8hhA4LAoeTGaF4N7ZT3Oabti%2F6z5qw%2Bp85dak9CMd%2BToeUzqcmKlRhB56SrMgTofr2B8BOXgxxmFfdmrKllmJxsi2og5iLwWWdHNExV87fPout%2FMlQ0u5D1vj3F%2FtQGfAjkPnf1RSol%2BIxwIHPmKEiqpUSu0hwtPai8Ra1l4tml2Zv9SGZ1E8AZEAtmROL2fM4rl%2BtUOEAXTUWGO3G%2BcjGs6cPZB4ihzo6TqIFyGJoQ95pPFu6yiRa%2F31Z5DEHcom5Ux4%2Fxs0TMimuLP2CJ%2BuqKRiAb9w20wchouM9MaGjVYvTXs%2BJsVXQIwdGdJACIkK9CKZXNkYAbKnYfAkz8bi7rfFoJT1mZ6hSxG%2BNGp%2FYr7Vk%2FTSvoLO4%2F4%2FpiSyJs2Y7r6QbohXgmCkZTejJW3KxC4tCRGheVwyVwRC%2F%2FCMQpm34wEb0FL0sEfqxhl7Kbkm0RT1HwOlb3N4GmLERJcEcIpmmegEIRPQcbM2ohGq%2BbMrWvD8lmu1qyfu01cSZkl9xe1NtLtEFpxoxVSMOPFxZ4GOqUB69If14rWCah8hqaxxteZbVoDSmXJo7CrMDc8uaRgFQbNR6tj4WC2t21q%2BhTBRd3C%2F5zYTAAbILP3jkDDRt3SanOamRICcOKqOJFlRa6aCz2G%2F175CWu0Bz1wDGokaGAwz3G1CL%2B2t91JH8aPUHQkX87%2FGxJliywZfL2od%2FbyCR6bM%2FGbVPRuPX8fSXsTQZPjCMCt4GJv5%2Fq%2F3h9t0lf0AfCG9Hx%2F" + } + } + } + ] + } +} \ No newline at end of file diff --git a/tests/data/meetings/get_session_recordings_not_found.json b/tests/data/meetings/get_session_recordings_not_found.json new file mode 100644 index 00000000..11ebca44 --- /dev/null +++ b/tests/data/meetings/get_session_recordings_not_found.json @@ -0,0 +1,5 @@ +{ + "message": "Failed to find session recordings by id: not-a-real-session-id", + "name": "NotFoundError", + "status": 404 +} \ No newline at end of file diff --git a/tests/data/meetings/list_dial_in_numbers.json b/tests/data/meetings/list_dial_in_numbers.json new file mode 100644 index 00000000..b2d4e165 --- /dev/null +++ b/tests/data/meetings/list_dial_in_numbers.json @@ -0,0 +1,12 @@ +[ + { + "number": "541139862166", + "locale": "es-AR", + "display_name": "Argentina" + }, + { + "number": "442381924626", + "locale": "en-GB", + "display_name": "United Kingdom" + } +] \ No newline at end of file diff --git a/tests/data/meetings/list_logo_upload_urls.json b/tests/data/meetings/list_logo_upload_urls.json new file mode 100644 index 00000000..8f4526b5 --- /dev/null +++ b/tests/data/meetings/list_logo_upload_urls.json @@ -0,0 +1,47 @@ +[ + { + "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", + "fields": { + "Content-Type": "image/png", + "key": "auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25", + "logoType": "white", + "bucket": "roomservice-whitelabel-logos-prod", + "X-Amz-Algorithm": "AWS4-HMAC-SHA256", + "X-Amz-Credential": "some-credential", + "X-Amz-Date": "20230127T024303Z", + "X-Amz-Security-Token": "some-token", + "Policy": "some-policy", + "X-Amz-Signature": "some-signature" + } + }, + { + "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", + "fields": { + "Content-Type": "image/png", + "key": "auto-expiring-temp/logos/colored/c4e00bac-781b-4bf0-bd5f-b9ff2cbc1b6c", + "logoType": "colored", + "bucket": "roomservice-whitelabel-logos-prod", + "X-Amz-Algorithm": "AWS4-HMAC-SHA256", + "X-Amz-Credential": "some-credential", + "X-Amz-Date": "20230127T024303Z", + "X-Amz-Security-Token": "some-token", + "Policy": "some-policy", + "X-Amz-Signature": "some-signature" + } + }, + { + "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", + "fields": { + "Content-Type": "image/png", + "key": "auto-expiring-temp/logos/favicon/d7a81477-38f7-460c-b51f-1462b8426df5", + "logoType": "favicon", + "bucket": "roomservice-whitelabel-logos-prod", + "X-Amz-Algorithm": "AWS4-HMAC-SHA256", + "X-Amz-Credential": "some-credential", + "X-Amz-Date": "20230127T024303Z", + "X-Amz-Security-Token": "some-token", + "Policy": "some-policy", + "X-Amz-Signature": "some-signature" + } + } +] \ No newline at end of file diff --git a/tests/data/meetings/list_rooms_theme_id_not_found.json b/tests/data/meetings/list_rooms_theme_id_not_found.json new file mode 100644 index 00000000..410a75c4 --- /dev/null +++ b/tests/data/meetings/list_rooms_theme_id_not_found.json @@ -0,0 +1,5 @@ +{ + "message": "Failed to get rooms because theme id 90a21428-b74a-4221-adc3-783935d654dc not found", + "name": "NotFoundError", + "status": 404 +} \ No newline at end of file diff --git a/tests/data/meetings/list_rooms_with_theme_id.json b/tests/data/meetings/list_rooms_with_theme_id.json new file mode 100644 index 00000000..1e791055 --- /dev/null +++ b/tests/data/meetings/list_rooms_with_theme_id.json @@ -0,0 +1,57 @@ +{ + "page_size": 5, + "_embedded": [ + { + "id": "33791484-231c-421b-8349-96e1a44e27d2", + "display_name": "test_long_term_room", + "metadata": null, + "type": "long_term", + "expires_at": "2023-01-30T00:47:04.000Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "613804614", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/updated_company_url/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0OTYzODg4fQ.46AYaDgMu_IdNPkmToKFGB_CqWYKM2xFpKU0vc3-E_E" + }, + "guest_url": { + "href": "https://meetings.vonage.com/updated_company_url/613804614" + } + }, + "created_at": "2023-01-25T00:50:37.722Z", + "is_available": true, + "expire_after_use": false, + "theme_id": "90a21428-b74a-4221-adc3-783935d654db", + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": false, + "is_chat_available": false, + "is_whiteboard_available": false, + "is_locale_switcher_available": false + } + } + ], + "_links": { + "first": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20" + }, + "self": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009870" + }, + "prev": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2009869" + }, + "next": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009871" + } + }, + "total_items": 1 +} \ No newline at end of file diff --git a/tests/data/meetings/list_themes.json b/tests/data/meetings/list_themes.json new file mode 100644 index 00000000..dc8b5f98 --- /dev/null +++ b/tests/data/meetings/list_themes.json @@ -0,0 +1,34 @@ +[ + { + "theme_id": "1fc39568-bc50-464f-82dc-01e13bed0908", + "theme_name": "my_other_theme", + "domain": "VCP", + "account_id": "1234", + "application_id": "5678", + "main_color": "#FF0000", + "short_company_url": "my-other-company", + "brand_text": "My Other Company", + "brand_image_colored": null, + "brand_image_white": null, + "branded_favicon": null, + "brand_image_white_url": null, + "brand_image_colored_url": null, + "branded_favicon_url": null + }, + { + "theme_id": "90a21428-b74a-4221-adc3-783935d654db", + "theme_name": "my_theme", + "domain": "VCP", + "account_id": "1234", + "application_id": "5678", + "main_color": "#12f64e", + "short_company_url": "my-company", + "brand_text": "My Company", + "brand_image_colored": null, + "brand_image_white": null, + "branded_favicon": null, + "brand_image_white_url": null, + "brand_image_colored_url": null, + "branded_favicon_url": null + } +] \ No newline at end of file diff --git a/tests/data/meetings/logo_key_error.json b/tests/data/meetings/logo_key_error.json new file mode 100644 index 00000000..ea9c9b18 --- /dev/null +++ b/tests/data/meetings/logo_key_error.json @@ -0,0 +1,11 @@ +{ + "message": "could not finalize logos", + "name": "BadRequestError", + "errors": [ + { + "logoKey": "not-a-key", + "code": "key_not_found" + } + ], + "status": 400 +} \ No newline at end of file diff --git a/tests/data/meetings/long_term_room.json b/tests/data/meetings/long_term_room.json new file mode 100644 index 00000000..b73fcb09 --- /dev/null +++ b/tests/data/meetings/long_term_room.json @@ -0,0 +1,37 @@ +{ + "id": "33791484-231c-421b-8349-96e1a44e27d2", + "display_name": "test_long_term_room", + "metadata": null, + "type": "long_term", + "expires_at": "2023-01-30T00:47:04.000Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "613804614", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0NjA3ODM3fQ.fm7q551LKnZaUcvZ30AmU62jRnvL94Do2sJKU0mHUmE" + }, + "guest_url": { + "href": "https://meetings.vonage.com/613804614" + } + }, + "created_at": "2023-01-25T00:50:37.722Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } +} \ No newline at end of file diff --git a/tests/data/meetings/long_term_room_with_theme.json b/tests/data/meetings/long_term_room_with_theme.json new file mode 100644 index 00000000..eb8ec86e --- /dev/null +++ b/tests/data/meetings/long_term_room_with_theme.json @@ -0,0 +1,37 @@ +{ + "id": "33791484-231c-421b-8349-96e1a44e27d2", + "display_name": "test_long_term_room", + "metadata": null, + "type": "long_term", + "expires_at": "2023-01-30T00:47:04.000Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "613804614", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/updated_company_url/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0Nzg2NDYwfQ.XFjcJFNZU9Ez_4x-uGIj079TTvttNHkkfA54JTDqglM" + }, + "guest_url": { + "href": "https://meetings.vonage.com/updated_company_url/613804614" + } + }, + "created_at": "2023-01-25T00:50:37.722Z", + "is_available": true, + "expire_after_use": false, + "theme_id": "90a21428-b74a-4221-adc3-783935d654db", + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": false, + "is_chat_available": false, + "is_whiteboard_available": false, + "is_locale_switcher_available": false + } +} \ No newline at end of file diff --git a/tests/data/meetings/meeting_room.json b/tests/data/meetings/meeting_room.json new file mode 100644 index 00000000..a0b134f7 --- /dev/null +++ b/tests/data/meetings/meeting_room.json @@ -0,0 +1,38 @@ +{ + "id": "b3142c46-d1c1-4405-baa6-85683827ed69", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:30:38.629Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "412958792", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=412958792&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiM2ExNWFkZmYtNDFmYy00NWFjLTg3Y2QtZmM2YjYyYjAwMTczIiwiaWF0IjoxNjc0NTMwNDM4fQ.Q0BPbu3ZyISYf1QaW2bLVNOrZ1tjQJCQ7nsOP_0us1E" + }, + "guest_url": { + "href": "https://meetings.vonage.com/412958792" + } + }, + "created_at": "2023-01-24T03:20:38.629Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false, + "is_captions_available": false + } +} \ No newline at end of file diff --git a/tests/data/meetings/multiple_fewer_rooms.json b/tests/data/meetings/multiple_fewer_rooms.json new file mode 100644 index 00000000..4a650eb0 --- /dev/null +++ b/tests/data/meetings/multiple_fewer_rooms.json @@ -0,0 +1,94 @@ +{ + "page_size": 2, + "_embedded": [ + { + "id": "4814804d-7c2d-4846-8c7d-4f6fae1f910a", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:25:23.341Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "697975707", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=697975707&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZDIxMzM0YmMtNjljNi00MGI2LWE4NmYtNDVjYzRlNmQ5MDVlIiwiaWF0IjoxNjc0NTcyODg1fQ.qOmyuJL1eVqUzdTlAGKZX-h5Q-dTZnoKG4Jto5AzWHs" + }, + "guest_url": { + "href": "https://meetings.vonage.com/697975707" + } + }, + "created_at": "2023-01-24T03:15:23.342Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + }, + { + "id": "de34416a-2a4c-4a59-a16a-8cd7d3121ea0", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:26:46.521Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "254629696", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=254629696&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZGFhYzQ3YjEtMDZhNS00ZjA0LThjYmEtNDg1Y2VhZDdhYzYxIiwiaWF0IjoxNjc0NTcyODg1fQ.LOyItIhYtKHvhlGNmGFoE6diMH-dODckBVI0OraLB6A" + }, + "guest_url": { + "href": "https://meetings.vonage.com/254629696" + } + }, + "created_at": "2023-01-24T03:16:46.521Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + } + ], + "_links": { + "first": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20" + }, + "self": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006648" + }, + "prev": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2006647" + }, + "next": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006655" + } + }, + "total_items": 2 +} \ No newline at end of file diff --git a/tests/data/meetings/multiple_rooms.json b/tests/data/meetings/multiple_rooms.json new file mode 100644 index 00000000..66258c3c --- /dev/null +++ b/tests/data/meetings/multiple_rooms.json @@ -0,0 +1,205 @@ +{ + "page_size": 20, + "_embedded": [ + { + "id": "4814804d-7c2d-4846-8c7d-4f6fae1f910a", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:25:23.341Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "697975707", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=697975707&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZDIxMzM0YmMtNjljNi00MGI2LWE4NmYtNDVjYzRlNmQ5MDVlIiwiaWF0IjoxNjc0NTcyODg1fQ.qOmyuJL1eVqUzdTlAGKZX-h5Q-dTZnoKG4Jto5AzWHs" + }, + "guest_url": { + "href": "https://meetings.vonage.com/697975707" + } + }, + "created_at": "2023-01-24T03:15:23.342Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + }, + { + "id": "de34416a-2a4c-4a59-a16a-8cd7d3121ea0", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:26:46.521Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "254629696", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=254629696&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZGFhYzQ3YjEtMDZhNS00ZjA0LThjYmEtNDg1Y2VhZDdhYzYxIiwiaWF0IjoxNjc0NTcyODg1fQ.LOyItIhYtKHvhlGNmGFoE6diMH-dODckBVI0OraLB6A" + }, + "guest_url": { + "href": "https://meetings.vonage.com/254629696" + } + }, + "created_at": "2023-01-24T03:16:46.521Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + }, + { + "id": "d44529db-d1fa-48d5-bba0-43034bf91ae4", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:28:32.740Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "659359326", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=659359326&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiNjU5MjZjMTUtMzcwYi00YjlmLWI2MDMtZjZkODFlNzIxNWFkIiwiaWF0IjoxNjc0NTcyODg1fQ.TYjAbWOYdlt7UsjyQh-Y7Qr0hfWElIDrJQTNrOQuLSg" + }, + "guest_url": { + "href": "https://meetings.vonage.com/659359326" + } + }, + "created_at": "2023-01-24T03:18:32.741Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + }, + { + "id": "4f7dc750-6049-42ef-a25f-e7afa4953e32", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:30:21.506Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "752928832", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=752928832&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiMTMzYTY1MTctMDdhYS00NWUxLTg0OGMtNzVhZDM0YzUwODVkIiwiaWF0IjoxNjc0NTcyODg1fQ.afMtFPyLAgZvGsR66pPj0op7sgnNjfj4BHxhU1OP8_w" + }, + "guest_url": { + "href": "https://meetings.vonage.com/752928832" + } + }, + "created_at": "2023-01-24T03:20:21.508Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + }, + { + "id": "b3142c46-d1c1-4405-baa6-85683827ed69", + "display_name": "my_test_room", + "metadata": null, + "type": "instant", + "expires_at": "2023-01-24T03:30:38.629Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "412958792", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=412958792&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiM2ExNWFkZmYtNDFmYy00NWFjLTg3Y2QtZmM2YjYyYjAwMTczIiwiaWF0IjoxNjc0NTcyODg1fQ.hCAmGR3dxnV7LkSyCXYyUXlXYr-LBfAANMjipm6PumM" + }, + "guest_url": { + "href": "https://meetings.vonage.com/412958792" + } + }, + "created_at": "2023-01-24T03:20:38.629Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": true, + "is_chat_available": true, + "is_whiteboard_available": true, + "is_locale_switcher_available": false + } + } + ], + "_links": { + "first": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20" + }, + "self": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006648" + }, + "prev": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2006647" + }, + "next": { + "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006655" + } + }, + "total_items": 5 +} \ No newline at end of file diff --git a/tests/data/meetings/theme.json b/tests/data/meetings/theme.json new file mode 100644 index 00000000..63bf8494 --- /dev/null +++ b/tests/data/meetings/theme.json @@ -0,0 +1,16 @@ +{ + "theme_id": "90a21428-b74a-4221-adc3-783935d654db", + "theme_name": "my_theme", + "domain": "VCP", + "account_id": "1234", + "application_id": "5678", + "main_color": "#12f64e", + "short_company_url": "my-company", + "brand_text": "My Company", + "brand_image_colored": null, + "brand_image_white": null, + "branded_favicon": null, + "brand_image_white_url": null, + "brand_image_colored_url": null, + "branded_favicon_url": null +} \ No newline at end of file diff --git a/tests/data/meetings/theme_name_in_use.json b/tests/data/meetings/theme_name_in_use.json new file mode 100644 index 00000000..f374bb11 --- /dev/null +++ b/tests/data/meetings/theme_name_in_use.json @@ -0,0 +1,5 @@ +{ + "message": "theme_name already exists in application", + "name": "ConflictError", + "status": 409 +} \ No newline at end of file diff --git a/tests/data/meetings/theme_not_found.json b/tests/data/meetings/theme_not_found.json new file mode 100644 index 00000000..d15a28cb --- /dev/null +++ b/tests/data/meetings/theme_not_found.json @@ -0,0 +1,5 @@ +{ + "message": "could not find theme 90a21428-b74a-4221-adc3-783935d654dc", + "name": "NotFoundError", + "status": 404 +} \ No newline at end of file diff --git a/tests/data/meetings/transparent_logo.png b/tests/data/meetings/transparent_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..36f9b729a396b95eaa2b9a05fa9f2eba69ca8e09 GIT binary patch literal 8843 zcmcIo_dnHt^nW8O*{keqT}f8f^+~_Sezi+*Lfs6(C1+Y|~7_5sJG63ht3{2`DpCnwiWMO5@gIN$Bja`Q2C(OSR zZT8SeO0Y?`u#^A8J6+lk8NRu(aq{h?Z7FZ*^mEt{eGDU$aQg2e1fXF0L|O4WZ<-N| z(AyqE$_ksi#K-Wv|APnlM*!F&3k!`3#HpQ<)c69VfZv#eAXCrJi?|kJVJm1 zTB0F&ZVG&oF#p5?e7XwY%I+B6&eq)g_4yCTx0=rEVhrxo z3;cO6&;rb`B{GB0R{((HX!!lPyxQ}n-wQu~yDuR(_dXV#du{pht6txpJ#TwPdI?~- zH?%qFxVSh@=GQ}l3+nVLw*@9Gfd{ik_R>?-uWT-V2|r2>lRs7GDR^!CNtv7LG7Zg! zkjv|K$zRE;7t%j{=aQrTg3|M;@GaoKT=kqPd~w{_f6hL^)uyRS{g*Uv`y4C@ocBD5 zK1&rFo|k!RC(fF*4Nq(~<~QsKduN)ieZ3bo&$JRPwauG-Mx7Z#3t;1o@b?-i8~Ty> zs6Qm6$#n3MU_d=;_vSMWnsk7{Lq;m>001&OkJ8j3B*5Sc%r5|FTfS;nmc?$+O$7kj zuVQcDG$^mUXTjp>^4@c|;n_)@DYVtE2;!9)G$|HhxGulGnyqoo9IyEOQUi{R;}v7> zPlo#Ux8-S<=9uha*ZkNioURspxY`y^eM#dJ6|?ho#RNu|)bISCsn~N;FY!CWWHMqf zd@BhoCj1{i`C~}SH4*9dFoB(${wxY9cIwB^r#|sXC7mX<>E6zYtu-P0N*bj8u8fx> zsryyHaG(smeN1+d*Dyytk4j8qQR(pA<)AFwhwEU4|6J#}ULfQkLdWcw2@|C2%~-g1`MS_^DLooJFW409 zCdLe-I>O+YXCS=x+`IS30#$7cmhVM+({uXgMj0o)T?D8udw{i?h$VXq~d@`5( zCdHZK@sj?fuLbR30XOE6uX=~%3=)8)D6`Q*@bze)l&ekpEwhh-D z_qQZl6Zd*)=we%Yv~Cbge!>}iE5g7`E1<$1>z?kO|NFl|61wiUb6W}%dHSEPf2RE$ zag*$(+ReA)h${E0sH!`Y;gcPcE*t(EY#VEnn?Zj0>iOdN`-NhP*BBx;60Jb=m8sXY=sZ zHBh5tPD_$3?4uIi5U<#NHw{BWE0Qg(Lnl$yd2Fdr8>#0Imx za@|{o>qi3z20n>TW4~j2*Hab7^1KS1J0{wuPE($|f#!YGPnJvW$)3!9*iW8qn0wI>l7Obf1Upd{q_Dk#67^p#67MQsPxc3FlZnc>DRV~`6<&?PAKnMvGU;bcpZ?x zE6ZMT_jy0li{KaRX5OVqr9Oq>{isEbKP@8-qx;3^R`hh$g!k8(0hN!Dl_!Q0XwF{y zE|pDA3$a?XdG_*bdwFJ#r_K_auuX?XeuO zb!1*-yq9t=y`hvPPa$umxOGL9Rr3lptu?QIrZ1j-hx5o)dfG!gcbK)0DxcMc^H_2~ z*nnr8F*SaOYL+3&x8%rRWuTENVNMsB(9G+3ErRE5Nb|@06wxjsC$NSOn4(>AH??j%luFjtzZ!2;F|c6v!+{y^**;_)bts{1rgD zgv^!BTgWxf4s@+voGyCD{(I$fe?f?{wXF5?hw@KeGQFr(Ta4q=G`@A!XtC7Ckehku zG5U!Sw4*4c$mDHPv%Yf1?|e(tU-=oONUh@s@kTm9-O|)XrjW4oYZ>nZb`34H$A4gl zCjOhasgRYIl~-&^>zQ2-`6k&U+GOMofn?Gyg#GsJDb8v-v7L>wdm^BtY|s?P2?KoN+U*?t62n_4XWpC$yDKAlZyP zMB=u+Y0Ldk&#jTj{s;ZF*=NS{a8rvNMA-CBs%hNFn>SjJM^-YZ@~qx|pXxoEcdo3? zsQVV1h!3<$%BAqY6#W*_F}{=Njogq1KbsqLA2b%S@pzg&ZQWYeq) zUge9bY#%K=%KTM&zqBC#o|?+noawLwgVP)@dlUP153QjBHTJ+i22G9!y}Lh_((Jd| z{Py?{T&9GY?pCQ(tLR4go&AK>*!r%y9rSNlDOU&X8LlY}Z;nPEBk*GZ-wwY?*cXPi zZ+5S9Hu(t8a5W3H>>mdI-Cz&7{KsJ0K0Gb7=Cot-Y?bnuel@!O{H+__d+yK)?=;k9 zO}|M;{j}gb>tgij&k@}L-7x_jft0j-US3XGj`p4VpEG}!)|N64^+j1;gslZsF3q0J zpWGdSpMKw>IZ7VttPU&R_1~;4uTks~=;3Yq9Hz2ea$5CyDsr=Vvt_oYoi|7HRQ=p| zclg+~r}T@_f$Gx3^V3@gk9SXQ>dFs+O(vf!%+wG7!i51K<}m;qgF`XP01zYz0IN;_ zp!gC1*!}WaOq4+ZwI9qn007v8h(8iNQ^74zh}F~9Fb~b!mXWgX6d#EI)|i}>>V`@^!A(Pu25h3ERe>RQL~l+jxI)%#CyRV?>WUdC;;iFNW)W)p0fZ&yDrk^8?hSU3-k#K z9+$TZxoecWd!iY*%AGfI@#^hTjOh=bk-0NpyxZ;*+04~Nz=04ENqCx_)WRp|$i6Qj z+P!T@5odQ1+k}AY3Q9WFAe9%Ep(AyITTI)9>@}yMbMHaPU6SZ~PG5Kwygv;mW0pkc z)gTT>RB+#aGC)K~(l4!z9@~h&oPhZiMHDK8Y`t$9#f<~*LXx3rr%th~40>HWglHY* zx{W!~`cWM4*}!RwC+FUN9d|m5{oPX!0;JQ{m}Jr${)<^2sNuSUWYwNWYMpv?Nk>SO zQEkYHi#Ijwqi3tNr+Vq-_SG!I&mNn#aFAw03 z)ohde-6tIGn@guVa2cI?yoc<@1nJ(;DRrcNjh(&i{oN9(y}B#8T}W1wH(GIZMg}`3 zwnRQ>G+B`VQ~4`&EV6!*S2~y(y8rDpa~2U(a}(;}5IX(?w#2b!3@6zbOzLPaMKtB6_N`Th8>U z+^S&Ve!3{emBHMs-GXNN!>7SCqjy2?OUK6Tf7;--(!oTCFh>tYELu=tMJM%}HgU&> zt6Q@}W7+s`MU>Z}aEe3AJwC2{`nHvQUfahA%Ee`gjdaK}gs@bGYeMI^N}Yzt$CZ7^ zb^HOd{DYcjn-!Y;8?9-FYtxVy!8TmU@%su+?q5hEJoK$tnLLd=AxVF}d&Y`$u;I&7 zDX-a6~T1V#^B^Lu|oip_0VJ{y|DsbvkAfVf5&B&mZ{B_S%AY=~+0qsqvbqk=Yt z!m~rK2?+0q3v}2LTZsEXDSU8vk)G@wn-3ahLWw^dNc~L7lg2}EDx~}&)Pasz@Gs8Q z%h9*l#-x!WRc6KdJ!#1d`8(ttCJ1HjrUjJ=ol)(z_^Kr(3#+clyhGBkMHz5`b)(?< z`PHfUv~vxWjB<1^-%LwqDrjNsVn5QZ;v|9xwQQgLSM0^)Y}bs}aY=RbVxaGYP( zU|?x$;^rToFdq0onfu5?p(;_ue1wT6bC9aDhAesKCC69TLQB{|xA8dh5z8D%*Lp9# zIp}$)p`=_98kspekt0)G9 zJ3@KheFo1kIAMrR3I7d|b3+dmJ8M{bof$}iI}Oni919)Zy)SbwoFBvK=fc8Yn+#&C z8?T>F?hUBQxt++=#FH-?2yPU2RV8DnMiV+E+IOCt491s5fQ}P7d%U={Oae0<$Mnk% zNC#Q&0odt#4-XDLMVL<&CaPOALmgi~Wrf;qKm72>KdQR!lHY?pK0#_%QLnY50N4ZQ zu)|GKWvx?tyui?_!Zj)`!_$W4oB~T0?~f+^#ZCQ;)4{n02O9mr|EO;_61q@H#2#UC=1)JKxO$v(XbwdSmP zRU0`-(%)6N`As^WqZov>otT#1-5Z9bxn95di9B2xNG6?4%6D$F+s9QgtJjhk^&!`A-@o<}gp5*`UK3$8qCLDDQ(8><)7zpi2WB%> z%bD@mKB2Q)x$ceO#3Z{ymGdjJ#9JL!9nR*`dP69Jqoc#S@iKRAy*JL@`lr-eeTH&E znS^H|=aV}FK5}kr1~+JF`dXAW9)Qz(tO<@*4)12c>37SWQF~_>L~?Ujf@-rz+J`M( zMb`#Nya7|zD#fr!7X*E6%wYaZY5QBIl|%P`MaxdZqZ||vw<__N;vz~u;p`Hu-g`6A z2SpDc$s|LJ(d^rwkQIjRLcT=OQo^T7kTZH4)N5oQj20?C*d8CoL+A{5MmpEKtbuX2 zn2Vg>6ebdVqy)+2wA#auFc9X&I3`Ey4&Aicb zt3P#OIr3Afhh9C>dZ(HKW1zNnZhiux>c-m;xEl~h!PnZ^YRpKFH+CH8oG zPEF#U5*+V1DEufvw%>i{Ofs#1+PkBFNMqheWv@HbnSbVA!09t;0RW}1VVN1*Fv0XQQk}t8wFho z&*S>Q&^>_5{*p4IO2RZ2Xwy5L=@zIV>7N7_;W)RyC3pyb&lb`IiUA9@*Wb7gH3Tg`Ew;Wh#8Kdn(7vY%|V|=CYxB0|-YhYo#_7+XoW7n0aLKk}bzM=V|ksTwiJN8*b(IWeVX|`zRhiW`( zPgbn{5M2kDfg!q#TPBf#>tzN;(@cZzHjPX?Y(-i9ps=hJLtnw&++G@&Zbh39S~C~m z03J*(%;wSUtbM_`H<%Es1Ck4Fm4p#gJ^xAozDUlExq|oQkidCcxX_e^x__a(;`hzO z&Z}*vBXkl$5YM9JWWhEt&J2(Azt?0LTD`{BNCt6eS=$a7K_N-Pi;VM|E)Em@W$r>s zxre*UsCV}}I&4M@aIdhXSmZ2J5Q9;3vQyvhXVCiuub3$+-gI}+^HJJ@4Wgq>P`%+cc10s|L~NA!aFrG?w1YB3UPoB zg-;UC{} zo<0qktrxQ`=7yez?evMy(a5>o)ynK-(hM5Ww>fCXq^Pxyy*yCxmZv-n0F#U~#B^MH zgxkAs9M;Md9i?1XZa6VAyeA?a^DV{`tg)C<>`#y|BHjvig`b2+W5;18|dOpT~wXNLTD02iTj_pb8q#4hD zS}r_znM?QdXsb^NUjgDW^E864!c}`&+i%9QWZgsW~+xX?m z^8Ll(?$4ZVLl$mqy@+_3d%IC8k9JCl33qXptjV6fj7k05aY|MG^HjL*_#Ld-XJMvj z3FY0O%nG6Eda!%3S3iZi=O-INvYLboW`FvZnha)G`FA1alQ2pzEkzyIh||pmm?nho zh{s%9KX<9?yV>2)J0V6Wq_0t?6!$S}+(R9XM847+CX@V%ab5XXi+@ivEGv{|mv1|y z*I`#QS9Ef7Gf|D#A&mF1O>a!f*dah7&ulMX;w$jfJFbV3cTQ)hn!8;8j3+U0nlq6FOaka~1G5vR?&s zr;~MFIqNZY3pioXo4I`^vlnl!G#u#Y|e<3_}wJ4zj zP4|L5_66(c5P#UTIQ;! zDdx?Sn4W~r{vWmnvF-~IL}+a!*!yOU?q8V4KdJJku+NA+yG~v#>I)sV0>dWEnkO`~ zxph4mKhnVK#zV(%<}# zq}ZI>YHPnLjre?!Wa3oeVJXiL(#fpx_XxA)(vLSk5-}degPlzB#A|{?(IfyROy}Z} z>zg>~SQK}ffZG)F1>si?HF&aId*J2|^YvSkj-fp2^Ads`iJ^-W#ksNt9F_---RB=> zKJ8PDSWIdUwzza^gncPi8-r$Dn-QuDBR`s$q4@E zQkyj_P56pWDY|M@)+DgQkr6bYf4<3JQgOUnDVkMJ)}+2xY}=3^9Rbz{Wdyv@2XmJE z_nU^R!LZ;wI>G9XbcJ>LR~#~E$ErF%uKQ)KMa)5hub_j%cnQ+Yw5#@jq3V{6Axr+I zHgUECX*QTNb7ZRC|G1`eagUm<6w3+~F}oQjp7HE0-s?*Ai%1H%48ku_A>$t$lJ1GD zD!cFxwRgzmBOXUbv5n0~lRni2?*ruKcOdBtSm^#}^81j9rr05hRESWt)~Q*VM`NRy z?Zn@Zoc1fAz$7@gS904OwK1;w=iRu7>~gxvE$c_)G+Jnfo1dZ6b$dd$rp+ zG~&N4xT(J|h!zsUIc8woRWao=(CH`AO(%5C3#j`Ci-19pmN|#X$01Vu%k#8B;`Y@& zFtam5z~?#a-@y|?T z5+Oc>HC(Q`I%=i4?N7FyLV%2dJ#HNMPW3b&BuT+=_=c=W7Zbe|k=OnWCWH4$zTz-G zj@QABf3L~8Ws;k8Wwh8E5Y^xXd&4f{t_($Q9b!0Hb$v{l(X>9n`B~ zOw=p3-VsLRjxEQiTBwJDE6$%g{4VrmYTe;YJL*pxs@WG96hp|REsi67;h@eIo zM6X=esW*lJH5{o#ClZqgScyl3WW`rLl7MrE9u9&B-XwmK5trI|m`kt3a%2atMG&Rn ztwV0*3Ha%1;q=eFrBhjk;o!UAj68~%WG3+P=vuuR5Utr8@2UMEuvw!4PbybqAf}V6 z|N3+d+PjL|-XIzeI(kB^2MTS zm2;f<8^Wl}Nl@$;1tZRmCC)B)?tQcldq}oD@DcoM0Y9Bw%Y$W7efS-6=)`@E@BH^F zUMSA~Uryx#kt~q2UUy>zb=O+Z6z0)bQ9!& zavfT4QScf9?sh2U7D>BK7JR!rPY_C5d89w3?jnI}0Hq2D_+YRgQI%U{oVn)rGBELD zcAbJm92aF#%wn`9@ng!BzySkjbm?A$jo z;B(2cQM6z|ix%QbLvo=lt0C1A8@edFXKo=bi&ajcNG&?v(z(M>GeCMy4#yts=v9Bk z?bI@jF^@q&$Te-$c~s)A#%e^A$Jr!*DXUBJYN7&S*J9ht3xuIDuW7x0E|akOut=sN zf?~JLIdfAl4x@crUc}gzWqWmSgvtiXXOPKa<&{V;^!6KkQ5qLWHp#R$&TkJYeud_* z&P>tZckyKAyWItt@LDF%DbdJVEa`UQEGhtFdT{yw(=#^lQ9R{>vgF#|G-lV+1n@B* N(1YI7uGVyV^gpd}1$zJh literal 0 HcmV?d00001 diff --git a/tests/data/meetings/unauthorized.json b/tests/data/meetings/unauthorized.json new file mode 100644 index 00000000..b6813760 --- /dev/null +++ b/tests/data/meetings/unauthorized.json @@ -0,0 +1,4 @@ +{ + "title": "Unauthorized", + "detail": "You did not provide correct credentials" +} \ No newline at end of file diff --git a/tests/data/meetings/update_application_theme.json b/tests/data/meetings/update_application_theme.json new file mode 100644 index 00000000..2fc0df4a --- /dev/null +++ b/tests/data/meetings/update_application_theme.json @@ -0,0 +1,5 @@ +{ + "application_id": "my-application-id", + "account_id": "my-account-id", + "default_theme_id": "90a21428-b74a-4221-adc3-783935d654db" +} \ No newline at end of file diff --git a/tests/data/meetings/update_application_theme_id_not_found.json b/tests/data/meetings/update_application_theme_id_not_found.json new file mode 100644 index 00000000..329d8a96 --- /dev/null +++ b/tests/data/meetings/update_application_theme_id_not_found.json @@ -0,0 +1,5 @@ +{ + "message": "Failed to update application because theme id not-a-real-theme-id not found", + "name": "BadRequestError", + "status": 400 +} \ No newline at end of file diff --git a/tests/data/meetings/update_no_keys.json b/tests/data/meetings/update_no_keys.json new file mode 100644 index 00000000..0b9fc5ce --- /dev/null +++ b/tests/data/meetings/update_no_keys.json @@ -0,0 +1,5 @@ +{ + "message": "\"update_details\" must have at least 1 key", + "name": "InputValidationError", + "status": 400 +} \ No newline at end of file diff --git a/tests/data/meetings/update_room.json b/tests/data/meetings/update_room.json new file mode 100644 index 00000000..64926ff5 --- /dev/null +++ b/tests/data/meetings/update_room.json @@ -0,0 +1,37 @@ +{ + "id": "33791484-231c-421b-8349-96e1a44e27d2", + "display_name": "test_long_term_room", + "metadata": null, + "type": "long_term", + "expires_at": "2023-01-30T00:47:04.000Z", + "recording_options": { + "auto_record": false, + "record_only_owner": false + }, + "meeting_code": "613804614", + "_links": { + "host_url": { + "href": "https://meetings.vonage.com/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0NjA3ODM3fQ.fm7q551LKnZaUcvZ30AmU62jRnvL94Do2sJKU0mHUmE" + }, + "guest_url": { + "href": "https://meetings.vonage.com/613804614" + } + }, + "created_at": "2023-01-25T00:50:37.722Z", + "is_available": true, + "expire_after_use": false, + "theme_id": null, + "initial_join_options": { + "microphone_state": "default" + }, + "join_approval_level": "none", + "ui_settings": { + "language": "default" + }, + "available_features": { + "is_recording_available": false, + "is_chat_available": false, + "is_whiteboard_available": false, + "is_locale_switcher_available": false + } +} \ No newline at end of file diff --git a/tests/data/meetings/update_room_type_error.json b/tests/data/meetings/update_room_type_error.json new file mode 100644 index 00000000..d70fb112 --- /dev/null +++ b/tests/data/meetings/update_room_type_error.json @@ -0,0 +1,5 @@ +{ + "message": "The room with id: b3142c46-d1c1-4405-baa6-85683827ed69 could not be updated because of its type: temporary", + "name": "BadRequestError", + "status": 400 +} \ No newline at end of file diff --git a/tests/data/meetings/update_theme_already_exists.json b/tests/data/meetings/update_theme_already_exists.json new file mode 100644 index 00000000..f374bb11 --- /dev/null +++ b/tests/data/meetings/update_theme_already_exists.json @@ -0,0 +1,5 @@ +{ + "message": "theme_name already exists in application", + "name": "ConflictError", + "status": 409 +} \ No newline at end of file diff --git a/tests/data/meetings/updated_theme.json b/tests/data/meetings/updated_theme.json new file mode 100644 index 00000000..514b7652 --- /dev/null +++ b/tests/data/meetings/updated_theme.json @@ -0,0 +1,16 @@ +{ + "theme_id": "90a21428-b74a-4221-adc3-783935d654db", + "theme_name": "updated_theme", + "domain": "VCP", + "account_id": "1234", + "application_id": "5678", + "main_color": "#FF0000", + "short_company_url": "updated_company_url", + "brand_text": "My Updated Company Name", + "brand_image_colored": null, + "brand_image_white": null, + "branded_favicon": null, + "brand_image_white_url": null, + "brand_image_colored_url": null, + "branded_favicon_url": null +} \ No newline at end of file diff --git a/tests/data/meetings/upload_to_aws_error.xml b/tests/data/meetings/upload_to_aws_error.xml new file mode 100644 index 00000000..467b8984 --- /dev/null +++ b/tests/data/meetings/upload_to_aws_error.xml @@ -0,0 +1 @@ +\nSignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.ASIA5NAYMMB6M7A2QEARb2f311449e26692a174ab2c7ca2afab24bd19c509cc611a4cef7cb2c5bb2ea9a5ZS7MSFN46X89NXAf+HV7uSpeawLv5lFvN+QiYP6swbiTMd/XaJeVGC+/pqKHlwlgKZ6vg+qBjV/ufb1e5WS/bxBM/Y= \ No newline at end of file diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 3880d6c3..d1c34451 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -1,5 +1,8 @@ from time import time from unittest.mock import patch +from pytest import raises + +from vonage import Client, ClientError now = int(time()) @@ -38,3 +41,11 @@ def test_create_jwt_auth_string(client): headers['Authorization'] = client._create_jwt_auth_string() assert headers['Accept'] == 'application/json' assert headers['Authorization'] == b'Bearer ' + test_jwt + + +def test_create_jwt_error_no_application_id_or_private_key(): + empty_client = Client() + + with raises(ClientError) as err: + empty_client._generate_application_jwt() + assert str(err.value) == 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' diff --git a/tests/test_meetings.py b/tests/test_meetings.py new file mode 100644 index 00000000..7257c1f7 --- /dev/null +++ b/tests/test_meetings.py @@ -0,0 +1,783 @@ +from util import * +from vonage.errors import MeetingsError, ClientError, ServerError + +import responses +import json +from pytest import raises + + +@responses.activate +def test_create_instant_room(meetings, dummy_data): + stub( + responses.POST, + "https://api-eu.vonage.com/beta/meetings/rooms", + fixture_path='meetings/meeting_room.json', + ) + + params = {'display_name': 'my_test_room'} + meeting = meetings.create_room(params) + + assert isinstance(meeting, dict) + assert request_user_agent() == dummy_data.user_agent + assert meeting['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' + assert meeting['display_name'] == 'my_test_room' + assert meeting['expires_at'] == '2023-01-24T03:30:38.629Z' + assert meeting['join_approval_level'] == 'none' + + +def test_create_instant_room_error_expiry(meetings, dummy_data): + params = {'display_name': 'my_test_room', 'expires_at': '2023-01-24T03:30:38.629Z'} + with raises(MeetingsError) as err: + meetings.create_room(params) + assert str(err.value) == 'Cannot set "expires_at" for an instant room.' + + +@responses.activate +def test_create_long_term_room(meetings, dummy_data): + stub( + responses.POST, + "https://api-eu.vonage.com/beta/meetings/rooms", + fixture_path='meetings/long_term_room.json', + ) + + params = { + 'display_name': 'test_long_term_room', + 'type': 'long_term', + 'expires_at': '2023-01-30T00:47:04+0000', + } + meeting = meetings.create_room(params) + + assert isinstance(meeting, dict) + assert request_user_agent() == dummy_data.user_agent + assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' + assert meeting['display_name'] == 'test_long_term_room' + assert meeting['expires_at'] == '2023-01-30T00:47:04.000Z' + + +def test_create_room_error(meetings): + with raises(MeetingsError) as err: + meetings.create_room() + assert ( + str(err.value) + == 'You must include a value for display_name as a field in the params dict when creating a meeting room.' + ) + + +def test_create_long_term_room_error(meetings): + params = { + 'display_name': 'test_long_term_room', + 'type': 'long_term', + } + with raises(MeetingsError) as err: + meetings.create_room(params) + assert str(err.value) == 'You must set a value for "expires_at" for a long-term room.' + + +@responses.activate +def test_get_room(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', + fixture_path='meetings/meeting_room.json', + ) + meeting = meetings.get_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69') + + assert isinstance(meeting, dict) + assert meeting['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' + assert meeting['display_name'] == 'my_test_room' + assert meeting['expires_at'] == '2023-01-24T03:30:38.629Z' + assert meeting['join_approval_level'] == 'none' + assert meeting['ui_settings']['language'] == 'default' + assert meeting['available_features']['is_locale_switcher_available'] == False + assert meeting['available_features']['is_captions_available'] == False + + +def test_get_room_error_no_room_specified(meetings): + with raises(TypeError): + meetings.get_room() + + +@responses.activate +def test_list_rooms(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/rooms', + fixture_path='meetings/multiple_rooms.json', + ) + response = meetings.list_rooms() + + assert isinstance(response, dict) + assert response['_embedded'][0]['id'] == '4814804d-7c2d-4846-8c7d-4f6fae1f910a' + assert response['_embedded'][1]['id'] == 'de34416a-2a4c-4a59-a16a-8cd7d3121ea0' + assert response['_embedded'][2]['id'] == 'd44529db-d1fa-48d5-bba0-43034bf91ae4' + assert response['_embedded'][3]['id'] == '4f7dc750-6049-42ef-a25f-e7afa4953e32' + assert response['_embedded'][4]['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' + assert response['total_items'] == 5 + + +@responses.activate +def test_list_rooms_with_page_size(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/rooms', + fixture_path='meetings/multiple_fewer_rooms.json', + ) + response = meetings.list_rooms(page_size=2) + + assert isinstance(response, dict) + assert response['_embedded'][0]['id'] == '4814804d-7c2d-4846-8c7d-4f6fae1f910a' + assert response['_embedded'][1]['id'] == 'de34416a-2a4c-4a59-a16a-8cd7d3121ea0' + assert response['page_size'] == 2 + assert response['total_items'] == 2 + + +@responses.activate +def test_error_unauthorized(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/rooms', + fixture_path='meetings/unauthorized.json', + status_code=401, + ) + with raises(ClientError) as err: + meetings.list_rooms() + assert str(err.value) == 'Authentication failed.' + + +@responses.activate +def test_update_room(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', + fixture_path='meetings/update_room.json', + ) + + params = { + 'update_details': { + "available_features": { + "is_recording_available": False, + "is_chat_available": False, + "is_whiteboard_available": False, + } + } + } + meeting = meetings.update_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69', params=params) + + assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' + assert meeting['available_features']['is_recording_available'] == False + assert meeting['available_features']['is_chat_available'] == False + assert meeting['available_features']['is_whiteboard_available'] == False + + +@responses.activate +def test_add_theme_to_room(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', + fixture_path='meetings/long_term_room_with_theme.json', + ) + + meeting = meetings.add_theme_to_room( + room_id='33791484-231c-421b-8349-96e1a44e27d2', + theme_id='90a21428-b74a-4221-adc3-783935d654db', + ) + + assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' + assert meeting['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' + + +@responses.activate +def test_update_room_error_no_room_specified(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', + fixture_path='meetings/update_room_type_error.json', + status_code=400, + ) + with raises(ClientError) as err: + meetings.update_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69', params={}) + assert ( + str(err.value) + == 'Status Code 400: BadRequestError: The room with id: b3142c46-d1c1-4405-baa6-85683827ed69 could not be updated because of its type: temporary' + ) + + +@responses.activate +def test_update_room_error_no_params_specified(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', + fixture_path='meetings/update_room_type_error.json', + status_code=400, + ) + with raises(TypeError) as err: + meetings.update_room(room_id='33791484-231c-421b-8349-96e1a44e27d2') + assert "update_room() missing 1 required positional argument: 'params'" in str(err.value) + + +@responses.activate +def test_get_recording(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', + fixture_path='meetings/get_recording.json', + ) + + recording = meetings.get_recording(recording_id='e5b73c98-c087-4ee5-b61b-0ea08204fc65') + assert ( + recording['session_id'] + == '1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4' + ) + assert recording['started_at'] == '2023-01-25T02:38:31.000Z' + assert recording['status'] == 'uploaded' + + +@responses.activate +def test_get_recording_not_found(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/recordings/not-a-real-recording-id', + fixture_path='meetings/get_recording_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.get_recording(recording_id='not-a-real-recording-id') + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: Recording not-a-real-recording-id was not found' + ) + + +@responses.activate +def test_delete_recording(meetings): + stub( + responses.DELETE, + 'https://api-eu.vonage.com/beta/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', + fixture_path='no_content.json', + ) + + assert meetings.delete_recording(recording_id='e5b73c98-c087-4ee5-b61b-0ea08204fc65') == None + + +@responses.activate +def test_delete_recording_not_uploaded(meetings, client): + stub( + responses.DELETE, + 'https://api-eu.vonage.com/beta/meetings/recordings/881f0dbe-3d91-4fd6-aeea-0eca4209b512', + fixture_path='meetings/delete_recording_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.delete_recording(recording_id='881f0dbe-3d91-4fd6-aeea-0eca4209b512') + assert str(err.value) == 'Status Code 404: NotFoundError: Could not find recording' + + +@responses.activate +def test_get_session_recordings(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/sessions/1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4/recordings', + fixture_path='meetings/get_session_recordings.json', + ) + + session = meetings.get_session_recordings( + session_id='1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4' + ) + assert session['_embedded']['recordings'][0]['id'] == 'e5b73c98-c087-4ee5-b61b-0ea08204fc65' + assert session['_embedded']['recordings'][0]['started_at'] == '2023-01-25T02:38:31.000Z' + assert session['_embedded']['recordings'][0]['status'] == 'uploaded' + + +@responses.activate +def test_get_session_recordings_not_found(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/sessions/not-a-real-session-id/recordings', + fixture_path='meetings/get_session_recordings_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.get_session_recordings(session_id='not-a-real-session-id') + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: Failed to find session recordings by id: not-a-real-session-id' + ) + + +@responses.activate +def test_list_dial_in_numbers(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/dial-in-numbers', + fixture_path='meetings/list_dial_in_numbers.json', + ) + + numbers = meetings.list_dial_in_numbers() + assert numbers[0]['number'] == '541139862166' + assert numbers[0]['display_name'] == 'Argentina' + assert numbers[1]['number'] == '442381924626' + assert numbers[1]['locale'] == 'en-GB' + + +@responses.activate +def test_list_themes(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/themes', + fixture_path='meetings/list_themes.json', + ) + + themes = meetings.list_themes() + assert themes[0]['theme_id'] == '1fc39568-bc50-464f-82dc-01e13bed0908' + assert themes[0]['main_color'] == '#FF0000' + assert themes[0]['brand_text'] == 'My Other Company' + assert themes[1]['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' + assert themes[1]['main_color'] == '#12f64e' + assert themes[1]['brand_text'] == 'My Company' + + +@responses.activate +def test_list_themes_no_themes(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/themes', + fixture_path='meetings/empty_themes.json', + ) + + assert meetings.list_themes() == {} + + +@responses.activate +def test_create_theme(meetings): + stub( + responses.POST, + "https://api-eu.vonage.com/beta/meetings/themes", + fixture_path='meetings/theme.json', + ) + + params = { + 'theme_name': 'my_theme', + 'main_color': '#12f64e', + 'brand_text': 'My Company', + 'short_company_url': 'my-company', + } + + theme = meetings.create_theme(params) + assert theme['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' + assert theme['main_color'] == '#12f64e' + assert theme['brand_text'] == 'My Company' + assert theme['domain'] == 'VCP' + + +def test_create_theme_missing_required_params(meetings): + with raises(MeetingsError) as err: + meetings.create_theme({}) + assert str(err.value) == 'Values for "main_color" and "brand_text" must be specified' + + +@responses.activate +def test_create_theme_name_already_in_use(meetings): + stub( + responses.POST, + "https://api-eu.vonage.com/beta/meetings/themes", + fixture_path='meetings/theme_name_in_use.json', + status_code=409, + ) + + params = { + 'theme_name': 'my_theme', + 'main_color': '#12f64e', + 'brand_text': 'My Company', + } + + with raises(ClientError) as err: + meetings.create_theme(params) + assert ( + str(err.value) == 'Status Code 409: ConflictError: theme_name already exists in application' + ) + + +@responses.activate +def test_get_theme(meetings): + stub( + responses.GET, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + fixture_path='meetings/theme.json', + ) + + theme = meetings.get_theme('90a21428-b74a-4221-adc3-783935d654db') + assert theme['main_color'] == '#12f64e' + assert theme['brand_text'] == 'My Company' + + +@responses.activate +def test_get_theme_not_found(meetings): + stub( + responses.GET, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", + fixture_path='meetings/theme_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.get_theme('90a21428-b74a-4221-adc3-783935d654dc') + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' + ) + + +@responses.activate +def test_delete_theme(meetings): + stub( + responses.DELETE, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + fixture_path='no_content.json', + ) + + theme = meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') + assert theme == None + + +@responses.activate +def test_delete_theme_not_found(meetings): + stub( + responses.DELETE, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", + fixture_path='meetings/theme_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654dc') + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' + ) + + +@responses.activate +def test_delete_theme_in_use(meetings): + stub( + responses.DELETE, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + fixture_path='meetings/delete_theme_in_use.json', + status_code=400, + ) + + with raises(ClientError) as err: + meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') + assert ( + str(err.value) + == 'Status Code 400: BadRequestError: could not delete theme, error: Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room' + ) + + +@responses.activate +def test_update_theme(meetings): + stub( + responses.PATCH, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + fixture_path='meetings/updated_theme.json', + ) + + params = { + 'update_details': { + 'theme_name': 'updated_theme', + 'main_color': '#FF0000', + 'brand_text': 'My Updated Company Name', + 'short_company_url': 'updated_company_url', + } + } + + theme = meetings.update_theme('90a21428-b74a-4221-adc3-783935d654db', params) + assert theme['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' + assert theme['main_color'] == '#FF0000' + assert theme['brand_text'] == 'My Updated Company Name' + assert theme['short_company_url'] == 'updated_company_url' + + +@responses.activate +def test_update_theme_no_keys(meetings): + stub( + responses.PATCH, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + fixture_path='meetings/update_no_keys.json', + status_code=400, + ) + + with raises(ClientError) as err: + meetings.update_theme('90a21428-b74a-4221-adc3-783935d654db', {'update_details': {}}) + assert ( + str(err.value) + == 'Status Code 400: InputValidationError: "update_details" must have at least 1 key' + ) + + +@responses.activate +def test_update_theme_not_found(meetings): + stub( + responses.PATCH, + "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", + fixture_path='meetings/theme_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.update_theme( + '90a21428-b74a-4221-adc3-783935d654dc', + {'update_details': {'theme_name': 'my_new_name'}}, + ) + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' + ) + + +@responses.activate +def test_update_theme_name_already_exists(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db', + fixture_path='meetings/update_theme_already_exists.json', + status_code=409, + ) + + with raises(ClientError) as err: + meetings.update_theme( + '90a21428-b74a-4221-adc3-783935d654db', + {'update_details': {'theme_name': 'my_other_theme'}}, + ) + assert ( + str(err.value) == 'Status Code 409: ConflictError: theme_name already exists in application' + ) + + +@responses.activate +def test_list_rooms_with_options(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/rooms', + fixture_path='meetings/list_rooms_with_theme_id.json', + ) + + rooms = meetings.list_rooms_with_theme_id( + '90a21428-b74a-4221-adc3-783935d654db', + page_size=5, + start_id=0, + end_id=99999999, + ) + assert rooms['_embedded'][0]['id'] == '33791484-231c-421b-8349-96e1a44e27d2' + assert rooms['_embedded'][0]['display_name'] == 'test_long_term_room' + assert rooms['_embedded'][0]['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' + assert rooms['page_size'] == 5 + assert ( + rooms['_links']['self']['href'] + == 'api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009870' + ) + + +@responses.activate +def test_list_rooms_with_theme_id_not_found(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/rooms', + fixture_path='meetings/list_rooms_theme_id_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings.list_rooms_with_theme_id( + '90a21428-b74a-4221-adc3-783935d654dc', start_id=0, end_id=99999999 + ) + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: Failed to get rooms because theme id 90a21428-b74a-4221-adc3-783935d654dc not found' + ) + + +@responses.activate +def test_update_application_theme(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/applications', + fixture_path='meetings/update_application_theme.json', + ) + + response = meetings.update_application_theme(theme_id='90a21428-b74a-4221-adc3-783935d654db') + assert response['application_id'] == 'my-application-id' + assert response['account_id'] == 'my-account-id' + assert response['default_theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' + + +@responses.activate +def test_update_application_theme_bad_request(meetings): + stub( + responses.PATCH, + 'https://api-eu.vonage.com/beta/meetings/applications', + fixture_path='meetings/update_application_theme_id_not_found.json', + status_code=400, + ) + + with raises(ClientError) as err: + meetings.update_application_theme(theme_id='not-a-real-theme-id') + assert ( + str(err.value) + == 'Status Code 400: BadRequestError: Failed to update application because theme id not-a-real-theme-id not found' + ) + + +@responses.activate +def test_upload_logo_to_theme(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/themes/logos-upload-urls', + fixture_path='meetings/list_logo_upload_urls.json', + ) + stub( + responses.POST, + 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod', + fixture_path='no_content.json', + status_code=204, + ) + stub_bytes( + responses.PUT, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', + body=b'OK', + ) + + response = meetings.upload_logo_to_theme( + theme_id='90a21428-b74a-4221-adc3-783935d654db', + path_to_image='tests/data/meetings/transparent_logo.png', + logo_type='white', + ) + assert response == 'Logo upload to theme: 90a21428-b74a-4221-adc3-783935d654db was successful.' + + +@responses.activate +def test_get_logo_upload_url(meetings): + stub( + responses.GET, + 'https://api-eu.vonage.com/beta/meetings/themes/logos-upload-urls', + fixture_path='meetings/list_logo_upload_urls.json', + ) + + url_w = meetings._get_logo_upload_url('white') + assert url_w['url'] == 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod' + assert url_w['fields']['X-Amz-Credential'] == 'some-credential' + assert ( + url_w['fields']['key'] + == 'auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25' + ) + assert url_w['fields']['logoType'] == 'white' + url_c = meetings._get_logo_upload_url('colored') + assert ( + url_c['fields']['key'] + == 'auto-expiring-temp/logos/colored/c4e00bac-781b-4bf0-bd5f-b9ff2cbc1b6c' + ) + assert url_c['fields']['logoType'] == 'colored' + url_f = meetings._get_logo_upload_url('favicon') + assert ( + url_f['fields']['key'] + == 'auto-expiring-temp/logos/favicon/d7a81477-38f7-460c-b51f-1462b8426df5' + ) + assert url_f['fields']['logoType'] == 'favicon' + + with raises(MeetingsError) as err: + meetings._get_logo_upload_url('not-a-valid-option') + assert str(err.value) == 'Cannot find the upload URL for the specified logo type.' + + +@responses.activate +def test_upload_to_aws(meetings): + stub( + responses.POST, + 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod', + fixture_path='no_content.json', + status_code=204, + ) + + with open('tests/data/meetings/list_logo_upload_urls.json') as file: + urls = json.load(file) + params = urls[0] + meetings._upload_to_aws(params, 'tests/data/meetings/transparent_logo.png') + + +@responses.activate +def test_upload_to_aws_error(meetings): + stub( + responses.POST, + 'https://s3.amazonaws.com/not-a-valid-url', + status_code=403, + fixture_path='meetings/upload_to_aws_error.xml', + ) + + with open('tests/data/meetings/list_logo_upload_urls.json') as file: + urls = json.load(file) + + params = urls[0] + params['url'] = 'https://s3.amazonaws.com/not-a-valid-url' + with raises(MeetingsError) as err: + meetings._upload_to_aws(params, 'tests/data/meetings/transparent_logo.png') + assert ( + str(err.value) + == 'Logo upload process failed. b\'\\\\nSignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.ASIA5NAYMMB6M7A2QEARb2f311449e26692a174ab2c7ca2afab24bd19c509cc611a4cef7cb2c5bb2ea9a5ZS7MSFN46X89NXAf+HV7uSpeawLv5lFvN+QiYP6swbiTMd/XaJeVGC+/pqKHlwlgKZ6vg+qBjV/ufb1e5WS/bxBM/Y=\'' + ) + + +@responses.activate +def test_add_logo_to_theme(meetings): + stub_bytes( + responses.PUT, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', + body=b'OK', + ) + + response = meetings._add_logo_to_theme( + theme_id='90a21428-b74a-4221-adc3-783935d654db', + key='auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25', + ) + assert response == b'OK' + + +@responses.activate +def test_add_logo_to_theme_key_error(meetings): + stub( + responses.PUT, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', + fixture_path='meetings/logo_key_error.json', + status_code=400, + ) + + with raises(ClientError) as err: + meetings._add_logo_to_theme( + theme_id='90a21428-b74a-4221-adc3-783935d654dc', + key='an-invalid-key', + ) + assert ( + str(err.value) + == "Status Code 400: BadRequestError: could not finalize logos, error: {'logoKey': 'not-a-key', 'code': 'key_not_found'}" + ) + + +@responses.activate +def test_add_logo_to_theme_not_found_error(meetings): + stub( + responses.PUT, + 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', + fixture_path='meetings/theme_not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + meetings._add_logo_to_theme( + theme_id='90a21428-b74a-4221-adc3-783935d654dc', + key='auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25', + ) + assert ( + str(err.value) + == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' + ) diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py index 614880b2..56fdb550 100644 --- a/tests/test_rest_calls.py +++ b/tests/test_rest_calls.py @@ -1,4 +1,5 @@ from util import * +from vonage.errors import InvalidAuthenticationTypeError @responses.activate @@ -81,6 +82,53 @@ def test_delete_with_header_auth(client, dummy_data): assert request_user_agent() == dummy_data.user_agent assert_basic_auth() + +@responses.activate +def test_patch(client, dummy_data): + stub(responses.PATCH, "https://api.nexmo.com/v1/applications") + host = "api.nexmo.com" + request_uri = "/v1/applications" + params = {"aaa": "xxx", "bbb": "yyy"} + response = client.patch(host, request_uri, params=params, auth_type='jwt') + assert request_headers()['Content-Type'] == 'application/json' + assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None + assert isinstance(response, dict) + assert request_user_agent() == dummy_data.user_agent + assert b"aaa" in request_body() + assert b"xxx" in request_body() + assert b"bbb" in request_body() + assert b"yyy" in request_body() + + +@responses.activate +def test_patch_no_content(client, dummy_data): + stub( + responses.PATCH, + f"https://api.nexmo.com/v2/project", + status_code=204, + fixture_path='no_content.json', + ) + host = "api.nexmo.com" + request_uri = "/v2/project" + params = {"test_param_1": "test1", "test_param_2": "test2"} + client.patch(host, request_uri, params=params, auth_type='jwt') + assert request_headers()['Content-Type'] == 'application/json' + assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None + assert request_user_agent() == dummy_data.user_agent + assert b"test_param_1" in request_body() + assert b"test1" in request_body() + assert b"test_param_2" in request_body() + assert b"test2" in request_body() + + +def test_patch_invalid_auth_type(client): + host = "api.nexmo.com" + request_uri = "/v2/project" + params = {"test_param_1": "test1", "test_param_2": "test2"} + with pytest.raises(InvalidAuthenticationTypeError): + client.patch(host, request_uri, params=params, auth_type='params') + + @responses.activate def test_get_with_jwt_auth(client, dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls") diff --git a/tests/test_voice.py b/tests/test_voice.py index 9433d4be..25aba2b5 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -192,8 +192,7 @@ def test_authorization_with_private_key_object(voice, dummy_data): @responses.activate def test_get_recording(voice, dummy_data): stub_bytes( - responses.GET, - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", + responses.GET, "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", body=b'THISISANMP3' ) assert isinstance( diff --git a/tests/util.py b/tests/util.py index 8756d761..e9c8f289 100644 --- a/tests/util.py +++ b/tests/util.py @@ -42,8 +42,8 @@ def stub(method, url, fixture_path=None, status_code=200): responses.add(method, url, body=body, status=status_code, content_type="application/json") -def stub_bytes(method, url): - responses.add(method, url, body=b"THISISANMP3", status=200) +def stub_bytes(method, url, body): + responses.add(method, url, body, status=200) def assert_re(pattern, string): From 15b4b1c8de72afb3911fc1c6cdb21bf9ab613c05 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 21 Jun 2023 21:00:29 +0100 Subject: [PATCH 246/401] Add Proactive Connect (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add list_lists elements and tests * creating proactive connect class, adding list methods and tests * add items and events endpoints, mocks and tests * adding proactive connect info to README.md and renaming some methods * using python 3.7 syntax for type hint * sending "text/csv" as partial mime type in csv upload * using python 3.7 type hint * test why request is failing * using requests version that doesn't use breaking urllib3 version * returning empty response and changing version numbers for requests * updating changelog * Bump version: 3.6.0 → 3.7.0 * fix long string formatting * improving logging statements --- .bumpversion.cfg | 2 +- CHANGES.md | 5 + README.md | 89 +++ setup.py | 2 +- src/vonage/__init__.py | 2 +- src/vonage/client.py | 80 ++- src/vonage/errors.py | 4 + src/vonage/proactive_connect.py | 187 +++++ tests/conftest.py | 7 + tests/data/null.json | 0 .../proactive_connect/create_list_400.json | 10 + .../proactive_connect/create_list_basic.json | 16 + .../proactive_connect/create_list_manual.json | 28 + .../create_list_salesforce.json | 30 + .../data/proactive_connect/csv_to_upload.csv | 4 + .../proactive_connect/fetch_list_400.json | 6 + tests/data/proactive_connect/get_list.json | 28 + tests/data/proactive_connect/item.json | 11 + tests/data/proactive_connect/item_400.json | 9 + tests/data/proactive_connect/list_404.json | 6 + .../proactive_connect/list_all_items.json | 40 ++ tests/data/proactive_connect/list_events.json | 70 ++ tests/data/proactive_connect/list_items.csv | 4 + tests/data/proactive_connect/list_lists.json | 84 +++ tests/data/proactive_connect/not_found.json | 6 + tests/data/proactive_connect/update_item.json | 11 + tests/data/proactive_connect/update_list.json | 29 + .../update_list_salesforce.json | 28 + .../proactive_connect/upload_from_csv.json | 3 + tests/test_meetings.py | 4 +- tests/test_proactive_connect.py | 649 ++++++++++++++++++ 31 files changed, 1433 insertions(+), 21 deletions(-) create mode 100644 src/vonage/proactive_connect.py create mode 100644 tests/data/null.json create mode 100644 tests/data/proactive_connect/create_list_400.json create mode 100644 tests/data/proactive_connect/create_list_basic.json create mode 100644 tests/data/proactive_connect/create_list_manual.json create mode 100644 tests/data/proactive_connect/create_list_salesforce.json create mode 100644 tests/data/proactive_connect/csv_to_upload.csv create mode 100644 tests/data/proactive_connect/fetch_list_400.json create mode 100644 tests/data/proactive_connect/get_list.json create mode 100644 tests/data/proactive_connect/item.json create mode 100644 tests/data/proactive_connect/item_400.json create mode 100644 tests/data/proactive_connect/list_404.json create mode 100644 tests/data/proactive_connect/list_all_items.json create mode 100644 tests/data/proactive_connect/list_events.json create mode 100644 tests/data/proactive_connect/list_items.csv create mode 100644 tests/data/proactive_connect/list_lists.json create mode 100644 tests/data/proactive_connect/not_found.json create mode 100644 tests/data/proactive_connect/update_item.json create mode 100644 tests/data/proactive_connect/update_list.json create mode 100644 tests/data/proactive_connect/update_list_salesforce.json create mode 100644 tests/data/proactive_connect/upload_from_csv.json create mode 100644 tests/test_proactive_connect.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1eb9d0d0..1d73a52d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.6.0 +current_version = 3.7.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index a047b2d3..c8a6fce5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +# 3.7.0 +- Adding support for the [Vonage Meetings API](https://developer.vonage.com/en/meetings/overview) +- Adding partial support for the [Vonage Proactive Connect API](https://developer.vonage.com/en/proactive-connect/overview) - supporting API methods relating to `lists`, `items` and `events` +- Returning a more descriptive (non-internal) error message if invalid values are provided for `application_id` and/or `private_key` when instantiating a Vonage client object + # 3.6.0 - Adding support for the [Vonage Subaccounts API](https://developer.vonage.com/en/account/subaccounts/overview) diff --git a/README.md b/README.md index 39d4c442..38586894 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [Verify V1 API](#verify-v1-api) - [Meetings API](#meetings-api) - [Number Insight API](#number-insight-api) +- [Proactive Connect API](#proactive-connect-api) - [Account API](#account-api) - [Subaccounts API](#subaccounts-api) - [Number Management API](#number-management-api) @@ -744,6 +745,94 @@ client.number_insight.get_advanced_number_insight(number='447700900000') Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +## Proactive Connect API + +Full documentation for the [Proactive Connect API](https://developer.vonage.com/en/proactive-connect/overview) is available here. + +These methods help you manage lists of contacts when using the API: + +### Find all lists +```python +client.proactive_connect.list_all_lists() +``` + +### Create a list +Lists can be created manually or imported from Salesforce. + +```python +params = {'name': 'my list', 'description': 'my description', 'tags': ['vip']} +client.proactive_connect.create_list(params) +``` + +### Get a list +```python +client.proactive_connect.get_list(LIST_ID) +``` + +### Update a list +```python +params = {'name': 'my list', 'tags': ['sport', 'football']} +client.proactive_connect.update_list(LIST_ID, params) +``` + +### Delete a list +```python +client.proactive_connect.delete_list(LIST_ID) +``` + +### Sync a list from an external datasource +```python +params = {'name': 'my list', 'tags': ['sport', 'football']} +client.proactive_connect.sync_list_from_datasource(LIST_ID) +``` + +These methods help you work with individual items in a list: +### Find all items in a list +```python +client.proactive_connect.list_all_items(LIST_ID) +``` + +### Create a new list item +```python +data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} +client.proactive_connect.create_item(LIST_ID, data) +``` + +### Get a list item +```python +client.proactive_connect.get_item(LIST_ID, ITEM_ID) +``` + +### Update a list item +```python +data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '447007000000'} +client.proactive_connect.update_item(LIST_ID, ITEM_ID, data) +``` + +### Delete a list item +```python +client.proactive_connect.delete_item(LIST_ID, ITEM_ID) +``` + +### Download all items in a list as a .csv file +```python +FILE_PATH = 'path/to/the/downloaded/file/location' +client.proactive_connect.download_list_items(LIST_ID, FILE_PATH) +``` + +### Upload items from a .csv file into a list +```python +FILE_PATH = 'path/to/the/file/to/upload/location' +client.proactive_connect.upload_list_items(LIST_ID, FILE_PATH) +``` + +This method helps you work with events emitted by the Proactive Connect API when in use: + +### List all events +```python +client.proactive_connect.list_events() +``` + ## Account API ### Get your account balance diff --git a/setup.py b/setup.py index 151a3e59..338684d3 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.6.0", + version="3.7.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index a5a5f623..dba80a04 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.6.0" +__version__ = "3.7.0" diff --git a/src/vonage/client.py b/src/vonage/client.py index b800c6b6..72f0d4aa 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -8,6 +8,7 @@ from .messages import Messages from .number_insight import NumberInsight from .number_management import Numbers +from .proactive_connect import ProactiveConnect from .redact import Redact from .short_codes import ShortCodes from .sms import Sms @@ -102,6 +103,7 @@ def __init__( self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" self._meetings_api_host = "api-eu.vonage.com/beta/meetings" + self._proactive_connect_host = "api-eu.vonage.com" user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" @@ -116,6 +118,7 @@ def __init__( self.messages = Messages(self) self.number_insight = NumberInsight(self) self.numbers = Numbers(self) + self.proactive_connect = ProactiveConnect(self) self.short_codes = ShortCodes(self) self.sms = Sms(self) self.subaccounts = Subaccounts(self) @@ -152,6 +155,12 @@ def meetings_api_host(self, value=None): else: self._meetings_api_host = value + def proactive_connect_host(self, value=None): + if value is None: + return self._proactive_connect_host + else: + self._proactive_connect_host = value + def auth(self, params=None, **kwargs): self._jwt_claims = params or kwargs @@ -198,12 +207,25 @@ def get(self, host, request_uri, params=None, auth_type=None): f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) - logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") + logger.debug( + f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" + ) return self.parse( - host, self.session.get(uri, params=params, headers=self._request_headers, timeout=self.timeout) + host, + self.session.get( + uri, params=params, headers=self._request_headers, timeout=self.timeout + ), ) - def post(self, host, request_uri, params, auth_type=None, body_is_json=True, supports_signature_auth=False): + def post( + self, + host, + request_uri, + params, + auth_type=None, + body_is_json=True, + supports_signature_auth=False, + ): """ Low-level method to make a post request to an API server. This method automatically adds authentication, picking the first applicable authentication method from the following: @@ -229,14 +251,22 @@ def post(self, host, request_uri, params, auth_type=None, body_is_json=True, sup f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) - logger.debug(f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") + logger.debug( + f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" + ) if body_is_json: return self.parse( - host, self.session.post(uri, json=params, headers=self._request_headers, timeout=self.timeout) + host, + self.session.post( + uri, json=params, headers=self._request_headers, timeout=self.timeout + ), ) else: return self.parse( - host, self.session.post(uri, data=params, headers=self._request_headers, timeout=self.timeout) + host, + self.session.post( + uri, data=params, headers=self._request_headers, timeout=self.timeout + ), ) def put(self, host, request_uri, params, auth_type=None): @@ -252,9 +282,14 @@ def put(self, host, request_uri, params, auth_type=None): f'Invalid authentication type. Must be one of "jwt", "header" or "params".' ) - logger.debug(f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") + logger.debug( + f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" + ) # All APIs that currently use put methods require a json-formatted body so don't need to check this - return self.parse(host, self.session.put(uri, json=params, headers=self._request_headers, timeout=self.timeout)) + return self.parse( + host, + self.session.put(uri, json=params, headers=self._request_headers, timeout=self.timeout), + ) def patch(self, host, request_uri, params, auth_type=None): uri = f"https://{host}{request_uri}" @@ -267,7 +302,9 @@ def patch(self, host, request_uri, params, auth_type=None): else: raise InvalidAuthenticationTypeError(f"""Invalid authentication type.""") - logger.debug(f"PATCH to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}") + logger.debug( + f"PATCH to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" + ) # Only newer APIs (that expect json-bodies) currently use this method, so we will always send a json-formatted body return self.parse(host, self.session.patch(uri, json=params, headers=self._request_headers)) @@ -288,7 +325,10 @@ def delete(self, host, request_uri, params=None, auth_type=None): if params is not None: logger.debug(f"DELETE call has params {repr(params)}") return self.parse( - host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout, params=params) + host, + self.session.delete( + uri, headers=self._request_headers, timeout=self.timeout, params=params + ), ) def parse(self, host, response: Response): @@ -322,25 +362,33 @@ def parse(self, host, response: Response): title = error_data["title"] detail = error_data["detail"] type = error_data["type"] - message = f"{title}: {detail} ({type})" + message = f"{title}: {detail} ({type}){self._add_individual_errors(error_data)}" elif 'status' in error_data and 'message' in error_data and 'name' in error_data: - message = f'Status Code {error_data["status"]}: {error_data["name"]}: {error_data["message"]}' - if 'errors' in error_data: - for error in error_data['errors']: - message += f', error: {error}' + message = ( + f'Status Code {error_data["status"]}: {error_data["name"]}: {error_data["message"]}' + f'{self._add_individual_errors(error_data)}' + ) else: message = error_data except JSONDecodeError: pass raise ClientError(message) + elif 500 <= response.status_code < 600: logger.warning(f"Server error: {response.status_code} {repr(response.content)}") message = f"{response.status_code} response from {host}" raise ServerError(message) + def _add_individual_errors(self, error_data): + message = '' + if 'errors' in error_data: + for error in error_data["errors"]: + message += f"\nError: {error}" + return message + def _create_jwt_auth_string(self): return b"Bearer " + self._generate_application_jwt() - + def _generate_application_jwt(self): try: return self._jwt_client.generate_application_jwt(self._jwt_claims) diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 1c3d11fb..f3f0f8da 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -46,3 +46,7 @@ class Verify2Error(ClientError): class SubaccountsError(ClientError): """An error relating to the Subaccounts API.""" + + +class ProactiveConnectError(ClientError): + """An error relating to the Proactive Connect API.""" diff --git a/src/vonage/proactive_connect.py b/src/vonage/proactive_connect.py new file mode 100644 index 00000000..a9197a17 --- /dev/null +++ b/src/vonage/proactive_connect.py @@ -0,0 +1,187 @@ +from .errors import ProactiveConnectError + +import requests +import logging +from typing import List + +logger = logging.getLogger("vonage") + + +class ProactiveConnect: + def __init__(self, client): + self._client = client + self._auth_type = 'jwt' + + def list_all_lists(self, page: int = None, page_size: int = None): + params = self._check_pagination_params(page, page_size) + return self._client.get( + self._client.proactive_connect_host(), + '/v0.1/bulk/lists', + params, + auth_type=self._auth_type, + ) + + def create_list(self, params: dict): + self._validate_list_params(params) + return self._client.post( + self._client.proactive_connect_host(), + '/v0.1/bulk/lists', + params, + auth_type=self._auth_type, + ) + + def get_list(self, list_id: str): + return self._client.get( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}', + auth_type=self._auth_type, + ) + + def update_list(self, list_id: str, params: dict): + self._validate_list_params(params) + return self._client.put( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}', + params, + auth_type=self._auth_type, + ) + + def delete_list(self, list_id: str): + return self._client.delete( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}', + auth_type=self._auth_type, + ) + + def clear_list(self, list_id: str): + return self._client.post( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/clear', + params=None, + auth_type=self._auth_type, + ) + + def sync_list_from_datasource(self, list_id: str): + return self._client.post( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/fetch', + params=None, + auth_type=self._auth_type, + ) + + def list_all_items(self, list_id: str, page: int = None, page_size: int = None): + params = self._check_pagination_params(page, page_size) + return self._client.get( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/items', + params, + auth_type=self._auth_type, + ) + + def create_item(self, list_id: str, data: dict): + params = {'data': data} + return self._client.post( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/items', + params, + auth_type=self._auth_type, + ) + + def get_item(self, list_id: str, item_id: str): + return self._client.get( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/items/{item_id}', + auth_type=self._auth_type, + ) + + def update_item(self, list_id: str, item_id: str, data: dict): + params = {'data': data} + return self._client.put( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/items/{item_id}', + params, + auth_type=self._auth_type, + ) + + def delete_item(self, list_id: str, item_id: str): + return self._client.delete( + self._client.proactive_connect_host(), + f'/v0.1/bulk/lists/{list_id}/items/{item_id}', + auth_type=self._auth_type, + ) + + def download_list_items(self, list_id: str, file_path: str) -> List[dict]: + uri = f'https://{self._client.proactive_connect_host()}/v0.1/bulk/lists/{list_id}/items/download' + logger.debug( + f'GET request with Proactive Connect to {repr(uri)}, downloading items from list {list_id} to file {file_path}' + ) + headers = {**self._client.headers, 'Authorization': self._client._create_jwt_auth_string()} + response = requests.get( + uri, + headers=headers, + ) + if 200 <= response.status_code < 300: + with open(file_path, 'wb') as file: + file.write(response.content) + else: + return self._client.parse(self._client.proactive_connect_host(), response) + + def upload_list_items(self, list_id: str, file_path: str): + uri = f'https://{self._client.proactive_connect_host()}/v0.1/bulk/lists/{list_id}/items/import' + with open(file_path, 'rb') as csv_file: + logger.debug( + f'POST request with Proactive Connect uploading {file_path} to {repr(uri)}' + ) + headers = { + **self._client.headers, + 'Authorization': self._client._create_jwt_auth_string(), + } + response = requests.post( + uri, + headers=headers, + files={'file': ('list_items.csv', csv_file, 'text/csv')}, + ) + return self._client.parse(self._client.proactive_connect_host(), response) + + def list_events(self, page: int = None, page_size: int = None): + params = self._check_pagination_params(page, page_size) + return self._client.get( + self._client.proactive_connect_host(), + '/v0.1/bulk/events', + params, + auth_type=self._auth_type, + ) + + def _check_pagination_params(self, page: int = None, page_size: int = None) -> dict: + params = {} + if page is not None: + if type(page) == int and page > 0: + params['page'] = page + elif page <= 0: + raise ProactiveConnectError('"page" must be an int > 0.') + if page_size is not None: + if type(page_size) == int and page_size > 0: + params['page_size'] = page_size + elif page_size and page_size <= 0: + raise ProactiveConnectError('"page_size" must be an int > 0.') + return params + + def _validate_list_params(self, params: dict): + if 'name' not in params: + raise ProactiveConnectError('You must supply a name for the new list.') + if ( + 'datasource' in params + and 'type' in params['datasource'] + and params['datasource']['type'] == 'salesforce' + ): + self._check_salesforce_params_correct(params['datasource']) + + def _check_salesforce_params_correct(self, datasource): + if 'integration_id' not in datasource or 'soql' not in datasource: + raise ProactiveConnectError( + 'You must supply a value for "integration_id" and "soql" when creating a list with Salesforce.' + ) + if type(datasource['integration_id']) is not str or type(datasource['soql']) is not str: + raise ProactiveConnectError( + 'You must supply values for "integration_id" and "soql" as strings.' + ) diff --git a/tests/conftest.py b/tests/conftest.py index 6e3a0790..0290bc72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -132,3 +132,10 @@ def meetings(client): import vonage return vonage.Meetings(client) + + +@pytest.fixture +def proc(client): + import vonage + + return vonage.ProactiveConnect(client) diff --git a/tests/data/null.json b/tests/data/null.json new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/proactive_connect/create_list_400.json b/tests/data/proactive_connect/create_list_400.json new file mode 100644 index 00000000..307817dd --- /dev/null +++ b/tests/data/proactive_connect/create_list_400.json @@ -0,0 +1,10 @@ +{ + "type": "https://developer.vonage.com/en/api-errors", + "title": "Request data did not validate", + "detail": "Bad Request", + "instance": "b6740287-41ad-41de-b950-f4e2d54cee86", + "errors": [ + "name must be longer than or equal to 1 and shorter than or equal to 255 characters", + "name must be a string" + ] +} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_basic.json b/tests/data/proactive_connect/create_list_basic.json new file mode 100644 index 00000000..ea3e77c2 --- /dev/null +++ b/tests/data/proactive_connect/create_list_basic.json @@ -0,0 +1,16 @@ +{ + "items_count": 0, + "datasource": { + "type": "manual" + }, + "id": "6994fd17-7691-4463-be16-172ab1430d97", + "sync_status": { + "value": "configured", + "metadata_modified": false, + "data_modified": false, + "dirty": false + }, + "name": "my_list", + "created_at": "2023-04-28T13:42:49.031Z", + "updated_at": "2023-04-28T13:42:49.031Z" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_manual.json b/tests/data/proactive_connect/create_list_manual.json new file mode 100644 index 00000000..1241af8e --- /dev/null +++ b/tests/data/proactive_connect/create_list_manual.json @@ -0,0 +1,28 @@ +{ + "items_count": 0, + "datasource": { + "type": "manual" + }, + "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", + "sync_status": { + "value": "configured", + "metadata_modified": false, + "data_modified": false, + "dirty": false + }, + "name": "my_list", + "description": "my description", + "tags": [ + "vip", + "sport" + ], + "attributes": [ + { + "key": false, + "name": "phone_number", + "alias": "phone" + } + ], + "created_at": "2023-04-28T13:56:12.920Z", + "updated_at": "2023-04-28T13:56:12.920Z" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_salesforce.json b/tests/data/proactive_connect/create_list_salesforce.json new file mode 100644 index 00000000..c7729f78 --- /dev/null +++ b/tests/data/proactive_connect/create_list_salesforce.json @@ -0,0 +1,30 @@ +{ + "items_count": 0, + "datasource": { + "type": "salesforce", + "integration_id": "salesforce_credentials", + "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact" + }, + "id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", + "sync_status": { + "value": "configured", + "metadata_modified": true, + "data_modified": true, + "dirty": true + }, + "name": "my_salesforce_list", + "description": "my salesforce description", + "tags": [ + "vip", + "sport" + ], + "attributes": [ + { + "key": false, + "name": "phone_number", + "alias": "phone" + } + ], + "created_at": "2023-04-28T14:16:49.375Z", + "updated_at": "2023-04-28T14:16:49.375Z" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/csv_to_upload.csv b/tests/data/proactive_connect/csv_to_upload.csv new file mode 100644 index 00000000..06cbdfbb --- /dev/null +++ b/tests/data/proactive_connect/csv_to_upload.csv @@ -0,0 +1,4 @@ +user,phone +alice,1234 +bob,5678 +charlie,9012 diff --git a/tests/data/proactive_connect/fetch_list_400.json b/tests/data/proactive_connect/fetch_list_400.json new file mode 100644 index 00000000..7e68f7f6 --- /dev/null +++ b/tests/data/proactive_connect/fetch_list_400.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/en/api-errors", + "title": "Request data did not validate", + "detail": "Cannot Fetch a manual list", + "instance": "4c34affd-df25-4bdc-b7c0-30076d3df003" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/get_list.json b/tests/data/proactive_connect/get_list.json new file mode 100644 index 00000000..2920e6f9 --- /dev/null +++ b/tests/data/proactive_connect/get_list.json @@ -0,0 +1,28 @@ +{ + "items_count": 0, + "datasource": { + "type": "manual" + }, + "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", + "created_at": "2023-04-28T13:56:12.920Z", + "updated_at": "2023-04-28T13:56:12.920Z", + "name": "my_list", + "description": "my description", + "tags": [ + "vip", + "sport" + ], + "attributes": [ + { + "key": false, + "name": "phone_number", + "alias": "phone" + } + ], + "sync_status": { + "value": "configured", + "metadata_modified": false, + "data_modified": false, + "dirty": false + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/item.json b/tests/data/proactive_connect/item.json new file mode 100644 index 00000000..5c5ecf96 --- /dev/null +++ b/tests/data/proactive_connect/item.json @@ -0,0 +1,11 @@ +{ + "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", + "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", + "data": { + "firstName": "John", + "lastName": "Doe", + "phone": "123456789101" + }, + "created_at": "2023-05-02T21:07:25.790Z", + "updated_at": "2023-05-02T21:07:25.790Z" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/item_400.json b/tests/data/proactive_connect/item_400.json new file mode 100644 index 00000000..778065b0 --- /dev/null +++ b/tests/data/proactive_connect/item_400.json @@ -0,0 +1,9 @@ +{ + "type": "https://developer.vonage.com/en/api-errors", + "title": "Request data did not validate", + "detail": "Bad Request", + "instance": "8e2dd3f1-1718-48fc-98de-53e1d289d0b4", + "errors": [ + "data must be an object" + ] +} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_404.json b/tests/data/proactive_connect/list_404.json new file mode 100644 index 00000000..80ba7ad7 --- /dev/null +++ b/tests/data/proactive_connect/list_404.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/en/api-errors", + "title": "The requested resource does not exist", + "detail": "Not Found", + "instance": "3e661bd2-e429-4887-b0d4-8f37352ab1d3" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_all_items.json b/tests/data/proactive_connect/list_all_items.json new file mode 100644 index 00000000..28a6a795 --- /dev/null +++ b/tests/data/proactive_connect/list_all_items.json @@ -0,0 +1,40 @@ +{ + "total_items": 2, + "page": 1, + "page_size": 100, + "order": "asc", + "_embedded": { + "items": [ + { + "id": "04c7498c-bae9-40f9-bdcb-c4eabb0418fe", + "created_at": "2023-05-02T21:04:47.507Z", + "updated_at": "2023-05-02T21:04:47.507Z", + "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", + "data": { + "test": 0, + "test2": 1 + } + }, + { + "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", + "created_at": "2023-05-02T21:07:25.790Z", + "updated_at": "2023-05-02T21:07:25.790Z", + "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", + "data": { + "phone": "123456789101", + "lastName": "Doe", + "firstName": "John" + } + } + ] + }, + "total_pages": 1, + "_links": { + "first": { + "href": "https://api-eu.vonage.com/v0.1/bulk/lists/246d17c4-79e6-4a25-8b4e-b777a83f6c30/items?page_size=100&order=asc&page=1" + }, + "self": { + "href": "https://api-eu.vonage.com/v0.1/bulk/lists/246d17c4-79e6-4a25-8b4e-b777a83f6c30/items?page_size=100&order=asc&page=1" + } + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_events.json b/tests/data/proactive_connect/list_events.json new file mode 100644 index 00000000..b00ef80c --- /dev/null +++ b/tests/data/proactive_connect/list_events.json @@ -0,0 +1,70 @@ +{ + "total_items": 1, + "page": 1, + "page_size": 100, + "total_pages": 1, + "_links": { + "self": { + "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" + }, + "prev": { + "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" + }, + "next": { + "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" + }, + "first": { + "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" + } + }, + "_embedded": { + "events": [ + { + "occurred_at": "2022-08-07T13:18:21.970Z", + "type": "action-call-succeeded", + "id": "e8e1eb4d-61e0-4099-8fa7-c96f1c0764ba", + "job_id": "c68e871a-c239-474d-a905-7b95f4563b7e", + "src_ctx": "et-e4ab4b75-9e7c-4f26-9328-394a5b842648", + "action_id": "26c5bbe2-113e-4201-bd93-f69e0a03d17f", + "data": { + "url": "https://postman-echo.com/post", + "args": {}, + "data": { + "from": "" + }, + "form": {}, + "json": { + "from": "" + }, + "files": {}, + "headers": { + "host": "postman-echo.com", + "user-agent": "got (https://github.com/sindresorhus/got)", + "content-type": "application/json", + "content-length": "11", + "accept-encoding": "gzip, deflate, br", + "x-amzn-trace-id": "Root=1-62efbb9e-53636b7b794accb87a3d662f", + "x-forwarded-port": "443", + "x-nexmo-trace-id": "8a6fed94-7296-4a39-9c52-348f12b4d61a", + "x-forwarded-proto": "https" + } + }, + "run_id": "7d0d4e5f-6453-4c63-87cf-f95b04377324", + "recipient_id": "14806904549" + }, + { + "occurred_at": "2022-08-07T13:18:20.289Z", + "type": "recipient-response", + "id": "8c8e9894-81be-4f6e-88d4-046b6c70ff8c", + "job_id": "c68e871a-c239-474d-a905-7b95f4563b7e", + "src_ctx": "et-e4ab4b75-9e7c-4f26-9328-394a5b842648", + "data": { + "from": "441632960411", + "text": "hello there" + }, + "run_id": "7d0d4e5f-6453-4c63-87cf-f95b04377324", + "recipient_id": "441632960758" + } + ] + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_items.csv b/tests/data/proactive_connect/list_items.csv new file mode 100644 index 00000000..0c167367 --- /dev/null +++ b/tests/data/proactive_connect/list_items.csv @@ -0,0 +1,4 @@ +"favourite_number","least_favourite_number" +0,1 +1,0 +0,0 diff --git a/tests/data/proactive_connect/list_lists.json b/tests/data/proactive_connect/list_lists.json new file mode 100644 index 00000000..c734e99e --- /dev/null +++ b/tests/data/proactive_connect/list_lists.json @@ -0,0 +1,84 @@ +{ + "page": 1, + "page_size": 100, + "total_items": 2, + "total_pages": 1, + "_links": { + "self": { + "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" + }, + "prev": { + "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" + }, + "next": { + "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" + }, + "first": { + "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" + } + }, + "_embedded": { + "lists": [ + { + "name": "Recipients for demo", + "description": "List of recipients for demo", + "tags": [ + "vip" + ], + "attributes": [ + { + "name": "firstName" + }, + { + "name": "lastName", + "key": false + }, + { + "name": "number", + "alias": "Phone", + "key": true + } + ], + "datasource": { + "type": "manual" + }, + "items_count": 1000, + "sync_status": { + "value": "configured", + "dirty": false, + "data_modified": false, + "metadata_modified": false + }, + "id": "af8a84b6-c712-4252-ac8d-6e28ac9317ce", + "created_at": "2022-06-23T13:13:16.491Z", + "updated_at": "2022-06-23T13:13:16.491Z" + }, + { + "name": "Salesforce contacts", + "description": "Salesforce contacts for campaign", + "tags": [ + "salesforce" + ], + "attributes": [ + { + "name": "Id", + "key": false + }, + { + "name": "Phone", + "key": true + }, + { + "name": "Email", + "key": false + } + ], + "datasource": { + "type": "salesforce", + "integration_id": "salesforce", + "soql": "SELECT Id, LastName, FirstName, Phone, Email, OtherCountry FROM Contact" + } + } + ] + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/not_found.json b/tests/data/proactive_connect/not_found.json new file mode 100644 index 00000000..02f8ec01 --- /dev/null +++ b/tests/data/proactive_connect/not_found.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/en/api-errors", + "title": "The requested resource does not exist", + "detail": "Not Found", + "instance": "04730b29-c292-4899-9419-f8cad88ec288" +} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_item.json b/tests/data/proactive_connect/update_item.json new file mode 100644 index 00000000..7166b458 --- /dev/null +++ b/tests/data/proactive_connect/update_item.json @@ -0,0 +1,11 @@ +{ + "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", + "created_at": "2023-05-02T21:07:25.790Z", + "updated_at": "2023-05-03T19:50:33.207Z", + "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", + "data": { + "first_name": "John", + "last_name": "Doe", + "phone": "447007000000" + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_list.json b/tests/data/proactive_connect/update_list.json new file mode 100644 index 00000000..99df4c42 --- /dev/null +++ b/tests/data/proactive_connect/update_list.json @@ -0,0 +1,29 @@ +{ + "items_count": 0, + "datasource": { + "type": "manual" + }, + "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", + "created_at": "2023-04-28T13:56:12.920Z", + "updated_at": "2023-04-28T21:39:17.825Z", + "name": "my_list", + "description": "my updated description", + "tags": [ + "vip", + "sport", + "football" + ], + "attributes": [ + { + "key": false, + "name": "phone_number", + "alias": "phone" + } + ], + "sync_status": { + "value": "configured", + "metadata_modified": false, + "data_modified": false, + "dirty": false + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_list_salesforce.json b/tests/data/proactive_connect/update_list_salesforce.json new file mode 100644 index 00000000..7e9eef48 --- /dev/null +++ b/tests/data/proactive_connect/update_list_salesforce.json @@ -0,0 +1,28 @@ +{ + "items_count": 0, + "datasource": { + "type": "manual" + }, + "id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", + "created_at": "2023-04-28T14:16:49.375Z", + "updated_at": "2023-04-28T22:23:37.054Z", + "name": "my_list", + "description": "my updated description", + "tags": [ + "music" + ], + "attributes": [ + { + "key": false, + "name": "phone_number", + "alias": "phone" + } + ], + "sync_status": { + "value": "configured", + "metadata_modified": false, + "data_modified": false, + "details": "failed to get secret: salesforce_credentials", + "dirty": false + } +} \ No newline at end of file diff --git a/tests/data/proactive_connect/upload_from_csv.json b/tests/data/proactive_connect/upload_from_csv.json new file mode 100644 index 00000000..bbdfa8fc --- /dev/null +++ b/tests/data/proactive_connect/upload_from_csv.json @@ -0,0 +1,3 @@ +{ + "inserted": 3 +} \ No newline at end of file diff --git a/tests/test_meetings.py b/tests/test_meetings.py index 7257c1f7..ced694e7 100644 --- a/tests/test_meetings.py +++ b/tests/test_meetings.py @@ -472,7 +472,7 @@ def test_delete_theme_in_use(meetings): meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') assert ( str(err.value) - == 'Status Code 400: BadRequestError: could not delete theme, error: Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room' + == 'Status Code 400: BadRequestError: could not delete theme\nError: Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room' ) @@ -759,7 +759,7 @@ def test_add_logo_to_theme_key_error(meetings): ) assert ( str(err.value) - == "Status Code 400: BadRequestError: could not finalize logos, error: {'logoKey': 'not-a-key', 'code': 'key_not_found'}" + == "Status Code 400: BadRequestError: could not finalize logos\nError: {'logoKey': 'not-a-key', 'code': 'key_not_found'}" ) diff --git a/tests/test_proactive_connect.py b/tests/test_proactive_connect.py new file mode 100644 index 00000000..dfbf631e --- /dev/null +++ b/tests/test_proactive_connect.py @@ -0,0 +1,649 @@ +from vonage.errors import ProactiveConnectError, ClientError +from util import * + +import responses +from pytest import raises +import csv + + +@responses.activate +def test_list_all_lists(proc, dummy_data): + stub( + responses.GET, + 'https://api-eu.vonage.com/v0.1/bulk/lists', + fixture_path='proactive_connect/list_lists.json', + ) + + lists = proc.list_all_lists() + assert request_user_agent() == dummy_data.user_agent + assert lists['total_items'] == 2 + assert lists['_embedded']['lists'][0]['name'] == 'Recipients for demo' + assert lists['_embedded']['lists'][0]['id'] == 'af8a84b6-c712-4252-ac8d-6e28ac9317ce' + assert lists['_embedded']['lists'][1]['name'] == 'Salesforce contacts' + assert lists['_embedded']['lists'][1]['datasource']['type'] == 'salesforce' + + +@responses.activate +def test_list_all_lists_options(proc): + stub( + responses.GET, + 'https://api-eu.vonage.com/v0.1/bulk/lists', + fixture_path='proactive_connect/list_lists.json', + ) + + lists = proc.list_all_lists(page=1, page_size=5) + assert lists['total_items'] == 2 + assert lists['_embedded']['lists'][0]['name'] == 'Recipients for demo' + assert lists['_embedded']['lists'][0]['id'] == 'af8a84b6-c712-4252-ac8d-6e28ac9317ce' + assert lists['_embedded']['lists'][1]['name'] == 'Salesforce contacts' + + +def test_pagination_errors(proc): + with raises(ProactiveConnectError) as err: + proc.list_all_lists(page=-1) + assert str(err.value) == '"page" must be an int > 0.' + + with raises(ProactiveConnectError) as err: + proc.list_all_lists(page_size=-1) + assert str(err.value) == '"page_size" must be an int > 0.' + + +@responses.activate +def test_create_list_basic(proc): + stub( + responses.POST, + 'https://api-eu.vonage.com/v0.1/bulk/lists', + fixture_path='proactive_connect/create_list_basic.json', + status_code=201, + ) + + list = proc.create_list({'name': 'my_list'}) + assert list['id'] == '6994fd17-7691-4463-be16-172ab1430d97' + assert list['name'] == 'my_list' + + +@responses.activate +def test_create_list_manual(proc): + stub( + responses.POST, + 'https://api-eu.vonage.com/v0.1/bulk/lists', + fixture_path='proactive_connect/create_list_manual.json', + status_code=201, + ) + + params = { + "name": "my name", + "description": "my description", + "tags": ["vip", "sport"], + "attributes": [{"name": "phone_number", "alias": "phone"}], + "datasource": {"type": "manual"}, + } + + list = proc.create_list(params) + assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + assert list['name'] == 'my_list' + assert list['description'] == 'my description' + assert list['tags'] == ['vip', 'sport'] + assert list['attributes'][0]['name'] == 'phone_number' + + +@responses.activate +def test_create_list_salesforce(proc): + stub( + responses.POST, + 'https://api-eu.vonage.com/v0.1/bulk/lists', + fixture_path='proactive_connect/create_list_salesforce.json', + status_code=201, + ) + + params = { + "name": "my name", + "description": "my description", + "tags": ["vip", "sport"], + "attributes": [{"name": "phone_number", "alias": "phone"}], + "datasource": { + "type": "salesforce", + "integration_id": "salesforce_credentials", + "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact", + }, + } + + list = proc.create_list(params) + assert list['id'] == '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + assert list['name'] == 'my_salesforce_list' + assert list['description'] == 'my salesforce description' + assert list['datasource']['type'] == 'salesforce' + assert list['datasource']['integration_id'] == 'salesforce_credentials' + assert list['datasource']['soql'] == 'select Id, LastName, FirstName, Phone, Email FROM Contact' + + +def test_create_list_errors(proc): + params = { + "name": "my name", + "datasource": { + "type": "salesforce", + "integration_id": 1234, + "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact", + }, + } + + with raises(ProactiveConnectError) as err: + proc.create_list({}) + assert str(err.value) == 'You must supply a name for the new list.' + + with raises(ProactiveConnectError) as err: + proc.create_list(params) + assert str(err.value) == 'You must supply values for "integration_id" and "soql" as strings.' + + with raises(ProactiveConnectError) as err: + params['datasource'].pop('integration_id') + proc.create_list(params) + assert ( + str(err.value) + == 'You must supply a value for "integration_id" and "soql" when creating a list with Salesforce.' + ) + + +@responses.activate +def test_create_list_invalid_name_error(proc): + stub( + responses.POST, + 'https://api-eu.vonage.com/v0.1/bulk/lists', + fixture_path='proactive_connect/create_list_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + proc.create_list({'name': 1234}) + assert ( + str(err.value) + == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: name must be longer than or equal to 1 and shorter than or equal to 255 characters\nError: name must be a string' + ) + + +@responses.activate +def test_get_list(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', + fixture_path='proactive_connect/get_list.json', + ) + + list = proc.get_list(list_id) + assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + assert list['name'] == 'my_list' + assert list['tags'] == ['vip', 'sport'] + + +@responses.activate +def test_get_list_404(proc): + list_id = 'a508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.get_list(list_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_update_list(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.PUT, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', + fixture_path='proactive_connect/update_list.json', + ) + + params = {'name': 'my_list', 'tags': ['vip', 'sport', 'football']} + list = proc.update_list(list_id, params) + assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + assert list['tags'] == ['vip', 'sport', 'football'] + assert list['description'] == 'my updated description' + assert list['updated_at'] == '2023-04-28T21:39:17.825Z' + + +@responses.activate +def test_update_list_salesforce(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.PUT, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', + fixture_path='proactive_connect/update_list_salesforce.json', + ) + + params = {'name': 'my_list', 'tags': ['music']} + list = proc.update_list(list_id, params) + assert list['id'] == list_id + assert list['tags'] == ['music'] + assert list['updated_at'] == '2023-04-28T22:23:37.054Z' + + +def test_update_list_name_error(proc): + with raises(ProactiveConnectError) as err: + proc.update_list( + '9508e7b8-fe99-4fdf-b022-65d7e461db2d', {'description': 'my new description'} + ) + assert str(err.value) == 'You must supply a name for the new list.' + + +@responses.activate +def test_delete_list(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.DELETE, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', + fixture_path='null.json', + status_code=204, + ) + + assert proc.delete_list(list_id) == None + + +@responses.activate +def test_delete_list_404(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.DELETE, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + with raises(ClientError) as err: + proc.delete_list(list_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_clear_list(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', + fixture_path='null.json', + status_code=202, + ) + + assert proc.clear_list(list_id) == None + + +@responses.activate +def test_clear_list_404(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + with raises(ClientError) as err: + proc.clear_list(list_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_sync_list_from_datasource(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', + fixture_path='null.json', + status_code=202, + ) + + assert proc.sync_list_from_datasource(list_id) == None + + +@responses.activate +def test_sync_list_manual_datasource_error(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', + fixture_path='proactive_connect/fetch_list_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + proc.sync_list_from_datasource(list_id) == None + assert ( + str(err.value) + == 'Request data did not validate: Cannot Fetch a manual list (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_sync_list_from_datasource_404(proc): + list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + with raises(ClientError) as err: + proc.clear_list(list_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_list_all_items(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', + fixture_path='proactive_connect/list_all_items.json', + ) + + items = proc.list_all_items(list_id, page=1, page_size=10) + assert items['total_items'] == 2 + assert items['_embedded']['items'][0]['id'] == '04c7498c-bae9-40f9-bdcb-c4eabb0418fe' + assert items['_embedded']['items'][1]['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + + +@responses.activate +def test_list_all_items_error_not_found(proc): + list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.list_all_items(list_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_create_item(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', + fixture_path='proactive_connect/item.json', + status_code=201, + ) + + data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} + item = proc.create_item(list_id, data) + + assert item['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + assert item['data']['phone'] == '123456789101' + + +@responses.activate +def test_create_item_error_invalid_data(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', + fixture_path='proactive_connect/item_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + proc.create_item(list_id, {'data': 1234}) + assert ( + str(err.value) + == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: data must be an object' + ) + + +@responses.activate +def test_create_item_error_not_found(proc): + list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} + with raises(ClientError) as err: + proc.create_item(list_id, data) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_download_list_items(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/download', + fixture_path='proactive_connect/list_items.csv', + ) + + proc.download_list_items( + list_id, os.path.join(os.path.dirname(__file__), 'data/proactive_connect/list_items.csv') + ) + items = _read_csv_file( + os.path.join(os.path.dirname(__file__), 'data/proactive_connect/list_items.csv') + ) + assert items[0]['favourite_number'] == '0' + assert items[1]['least_favourite_number'] == '0' + + +@responses.activate +def test_download_list_items_error_not_found(proc): + list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/download', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.download_list_items(list_id, 'data/proactive_connect_list_items.csv') + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_get_item(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='proactive_connect/item.json', + ) + + item = proc.get_item(list_id, item_id) + assert item['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + assert item['data']['phone'] == '123456789101' + + +@responses.activate +def test_get_item_404(proc): + list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + stub( + responses.GET, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.get_item(list_id, item_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_update_item(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + data = {'first_name': 'John', 'last_name': 'Doe', 'phone': '447007000000'} + stub( + responses.PUT, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='proactive_connect/update_item.json', + ) + + updated_item = proc.update_item(list_id, item_id, data) + + assert updated_item['id'] == item_id + assert updated_item['data'] == data + assert updated_item['updated_at'] == '2023-05-03T19:50:33.207Z' + + +@responses.activate +def test_update_item_error_invalid_data(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + data = 'asdf' + stub( + responses.PUT, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='proactive_connect/item_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + proc.update_item(list_id, item_id, data) + assert ( + str(err.value) + == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: data must be an object' + ) + + +@responses.activate +def test_update_item_404(proc): + list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + data = {'first_name': 'John', 'last_name': 'Doe', 'phone': '447007000000'} + stub( + responses.PUT, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.update_item(list_id, item_id, data) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_delete_item(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' + stub( + responses.DELETE, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='null.json', + status_code=204, + ) + + response = proc.delete_item(list_id, item_id) + assert response is None + + +@responses.activate +def test_delete_item_404(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + item_id = 'e91c39ed-7c34-4803-a139-34bb4b7c6d53' + stub( + responses.DELETE, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.delete_item(list_id, item_id) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_upload_list_items_from_csv(proc): + list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' + file_path = os.path.join(os.path.dirname(__file__), 'data/proactive_connect/csv_to_upload.csv') + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/import', + fixture_path='proactive_connect/upload_from_csv.json', + ) + + response = proc.upload_list_items(list_id, file_path) + assert response['inserted'] == 3 + + +@responses.activate +def test_upload_list_items_from_csv_404(proc): + list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' + file_path = os.path.join(os.path.dirname(__file__), 'data/proactive_connect/csv_to_upload.csv') + stub( + responses.POST, + f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/import', + fixture_path='proactive_connect/not_found.json', + status_code=404, + ) + + with raises(ClientError) as err: + proc.upload_list_items(list_id, file_path) + assert ( + str(err.value) + == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' + ) + + +@responses.activate +def test_list_events(proc): + stub( + responses.GET, + 'https://api-eu.vonage.com/v0.1/bulk/events', + fixture_path='proactive_connect/list_events.json', + ) + + lists = proc.list_events() + assert lists['total_items'] == 1 + assert lists['_embedded']['events'][0]['occurred_at'] == '2022-08-07T13:18:21.970Z' + assert lists['_embedded']['events'][0]['type'] == 'action-call-succeeded' + assert lists['_embedded']['events'][0]['run_id'] == '7d0d4e5f-6453-4c63-87cf-f95b04377324' + + +def _read_csv_file(path): + with open(os.path.join(os.path.dirname(__file__), path)) as csv_file: + reader = csv.DictReader(csv_file) + dict_list = [row for row in reader] + return dict_list From 13402d2c016c761ac167bec9e926323658c65a75 Mon Sep 17 00:00:00 2001 From: Will Croft Date: Thu, 6 Jul 2023 14:03:37 +0100 Subject: [PATCH 247/401] Avoid breaking changes in Pydantic 2.0 (#266) * Pin pydantic avoiding breaking changes in 2.0 * Remove git noise --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4fbefed0..506f0036 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pytest==7.2.0 responses==0.22.0 coverage -pydantic +pydantic==1.10.10 bump2version build From f7b8c227e7a0f9f0248aa49a0061571e3f85ec07 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 6 Jul 2023 14:15:51 +0100 Subject: [PATCH 248/401] adding fixed dependency range --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 506f0036..1ef61c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pytest==7.2.0 responses==0.22.0 coverage -pydantic==1.10.10 +pydantic>=1.10.10,<2 bump2version build From 5ae87068ab35674e2f578a8525e66a7a4416e4bf Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 6 Jul 2023 14:17:20 +0100 Subject: [PATCH 249/401] update changelog --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index c8a6fce5..033c59be 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.7.1 +- Fixing dependency version to a specific major version + # 3.7.0 - Adding support for the [Vonage Meetings API](https://developer.vonage.com/en/meetings/overview) - Adding partial support for the [Vonage Proactive Connect API](https://developer.vonage.com/en/proactive-connect/overview) - supporting API methods relating to `lists`, `items` and `events` From 552ae0c60409f033e16f471ec81b788c36239690 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 6 Jul 2023 14:17:33 +0100 Subject: [PATCH 250/401] =?UTF-8?q?Bump=20version:=203.7.0=20=E2=86=92=203?= =?UTF-8?q?.7.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1d73a52d..6a9174da 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.7.0 +current_version = 3.7.1 commit = True tag = False diff --git a/setup.py b/setup.py index 338684d3..707eeeb2 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.7.0", + version="3.7.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index dba80a04..6b725ab8 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.7.0" +__version__ = "3.7.1" From 7930960133c576698901153db7d90d02c9849bb9 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 6 Jul 2023 15:01:50 +0100 Subject: [PATCH 251/401] adding range to setup.py --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1ef61c02..680153d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pytest==7.2.0 responses==0.22.0 coverage -pydantic>=1.10.10,<2 +pydantic>=1.10,==1.* bump2version build diff --git a/setup.py b/setup.py index 707eeeb2..68f71406 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ "requests>=2.4.2", "pytz>=2018.5", "Deprecated", - "pydantic>=1.10.2", + "pydantic>=1.10,==1.*", ], python_requires=">=3.7", tests_require=["cryptography>=2.3.1"], From 03b2741cff4c68a3ca288d3f2b8df1768ae145b1 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 14 Aug 2023 15:57:10 +0100 Subject: [PATCH 252/401] Add Users (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add users api and tests * updated README * updated changelog * Bump version: 3.7.1 → 3.8.0 --- .bumpversion.cfg | 2 +- CHANGES.md | 3 + README.md | 37 +++ setup.py | 2 +- src/vonage/__init__.py | 2 +- src/vonage/client.py | 2 + src/vonage/errors.py | 4 + src/vonage/users.py | 72 ++++ tests/data/users/invalid_content_type.json | 13 + tests/data/users/list_users_400.json | 13 + tests/data/users/list_users_404.json | 7 + tests/data/users/list_users_500.json | 7 + tests/data/users/list_users_basic.json | 43 +++ tests/data/users/list_users_options.json | 37 +++ tests/data/users/rate_limit.json | 7 + tests/data/users/user_400.json | 13 + tests/data/users/user_404.json | 7 + tests/data/users/user_basic.json | 13 + tests/data/users/user_options.json | 69 ++++ tests/data/users/user_updated.json | 19 ++ tests/test_users.py | 369 +++++++++++++++++++++ 21 files changed, 738 insertions(+), 3 deletions(-) create mode 100644 src/vonage/users.py create mode 100644 tests/data/users/invalid_content_type.json create mode 100644 tests/data/users/list_users_400.json create mode 100644 tests/data/users/list_users_404.json create mode 100644 tests/data/users/list_users_500.json create mode 100644 tests/data/users/list_users_basic.json create mode 100644 tests/data/users/list_users_options.json create mode 100644 tests/data/users/rate_limit.json create mode 100644 tests/data/users/user_400.json create mode 100644 tests/data/users/user_404.json create mode 100644 tests/data/users/user_basic.json create mode 100644 tests/data/users/user_options.json create mode 100644 tests/data/users/user_updated.json create mode 100644 tests/test_users.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6a9174da..cbe68d89 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.7.1 +current_version = 3.8.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index 033c59be..37cd65cb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.8.0 +- Adding support for the [Users component of the Vonage Application API](https://developer.vonage.com/en/api/application.v2#User) + # 3.7.1 - Fixing dependency version to a specific major version diff --git a/README.md b/README.md index 38586894..298e41fd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [Pricing API](#pricing-api) - [Managing Secrets](#managing-secrets) - [Application API](#application-api) +- [Users API](#users-api) - [Validating Webhook Signatures](#validate-webhook-signatures) - [JWT Parameters](#jwt-parameters) - [Overriding API Attributes](#overriding-api-attributes) @@ -1082,6 +1083,42 @@ response = client.application.delete_application(uuid) Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) + +## Users API + +These API methods are part of the [Application (v2) API](https://developer.vonage.com/en/application/overview) but are a in separate module in the SDK. [See the API reference for more details](https://developer.vonage.com/en/api/application.v2#User). + +### List all Users + +```python +client.users.list_users() +``` + +### Create a new user + +```python +client.users.create_user() # Default values generated +client.users.create_user(params={...}) # Specify custom values +``` + +### Get detailed information about a user + +```python +client.users.get_user('USER_ID') +``` + +### Update user details + +```python +client.users.update_user('USER_ID', params={...}) +``` + +### Delete a user + +```python +client.users.delete_user('USER_ID') +``` + ## Validate webhook signatures ```python diff --git a/setup.py b/setup.py index 68f71406..6a6d5456 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.7.1", + version="3.8.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 6b725ab8..144013f4 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.7.1" +__version__ = "3.8.0" diff --git a/src/vonage/client.py b/src/vonage/client.py index 72f0d4aa..6aa43d62 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -13,6 +13,7 @@ from .short_codes import ShortCodes from .sms import Sms from .subaccounts import Subaccounts +from .users import Users from .ussd import Ussd from .voice import Voice from .verify import Verify @@ -122,6 +123,7 @@ def __init__( self.short_codes = ShortCodes(self) self.sms = Sms(self) self.subaccounts = Subaccounts(self) + self.users = Users(self) self.ussd = Ussd(self) self.verify = Verify(self) self.verify2 = Verify2(self) diff --git a/src/vonage/errors.py b/src/vonage/errors.py index f3f0f8da..677aa366 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -50,3 +50,7 @@ class SubaccountsError(ClientError): class ProactiveConnectError(ClientError): """An error relating to the Proactive Connect API.""" + + +class UsersError(ClientError): + """An error relating to the Users API.""" diff --git a/src/vonage/users.py b/src/vonage/users.py new file mode 100644 index 00000000..444e03d5 --- /dev/null +++ b/src/vonage/users.py @@ -0,0 +1,72 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vonage import Client + +from .errors import UsersError +from ._internal import set_auth_type + + +class Users: + """Class containing methods for user management as part of the Application API.""" + + def __init__(self, client: Client): + self._client = client + self._auth_type = set_auth_type(self._client) + + def list_users( + self, + page_size: int = None, + order: str = 'asc', + cursor: str = None, + name: str = None, + ): + """ + Lists the name and user id of all users associated with the account. + For complete information on a user, call Users.get_user, passing in the user id. + """ + + if order.lower() not in ('asc', 'desc'): + raise UsersError( + 'Invalid order parameter. Must be one of: "asc", "desc", "ASC", "DESC".' + ) + + params = {'page_size': page_size, 'order': order.lower(), 'cursor': cursor, 'name': name} + return self._client.get( + self._client.api_host(), + '/v1/users', + params, + auth_type=self._auth_type, + ) + + def create_user(self, params: dict = None): + self._client.headers['Content-Type'] = 'application/json' + return self._client.post( + self._client.api_host(), + '/v1/users', + params, + auth_type=self._auth_type, + ) + + def get_user(self, user_id: str): + return self._client.get( + self._client.api_host(), + f'/v1/users/{user_id}', + auth_type=self._auth_type, + ) + + def update_user(self, user_id: str, params: dict): + return self._client.patch( + self._client.api_host(), + f'/v1/users/{user_id}', + params, + auth_type=self._auth_type, + ) + + def delete_user(self, user_id: str): + return self._client.delete( + self._client.api_host(), + f'/v1/users/{user_id}', + auth_type=self._auth_type, + ) diff --git a/tests/data/users/invalid_content_type.json b/tests/data/users/invalid_content_type.json new file mode 100644 index 00000000..d818e1a2 --- /dev/null +++ b/tests/data/users/invalid_content_type.json @@ -0,0 +1,13 @@ +{ + "title": "Bad request.", + "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", + "code": "http:error:validation-fail", + "detail": "Invalid Content-Type.", + "instance": "9d0e245d-fac0-450e-811f-52343041df61", + "invalid_parameters": [ + { + "name": "content-type", + "reason": "content-type \"application/octet-stream\" is not supported. Supported versions are [application/json]" + } + ] +} \ No newline at end of file diff --git a/tests/data/users/list_users_400.json b/tests/data/users/list_users_400.json new file mode 100644 index 00000000..10b68249 --- /dev/null +++ b/tests/data/users/list_users_400.json @@ -0,0 +1,13 @@ +{ + "title": "Bad request.", + "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", + "code": "http:error:validation-fail", + "detail": "Input validation failure.", + "instance": "04ee4d32-78c9-4acf-bdc1-b7d1fa860c92", + "invalid_parameters": [ + { + "name": "page_size", + "reason": "\"page_size\" must be a number" + } + ] +} \ No newline at end of file diff --git a/tests/data/users/list_users_404.json b/tests/data/users/list_users_404.json new file mode 100644 index 00000000..7e985e23 --- /dev/null +++ b/tests/data/users/list_users_404.json @@ -0,0 +1,7 @@ +{ + "title": "Not found.", + "type": "https://developer.nexmo.com/api/conversation#user:error:not-found", + "code": "user:error:not-found", + "detail": "User does not exist, or you do not have access.", + "instance": "29c78817-eeb9-4de0-b2f9-a5ca816bc907" +} \ No newline at end of file diff --git a/tests/data/users/list_users_500.json b/tests/data/users/list_users_500.json new file mode 100644 index 00000000..25aa46c5 --- /dev/null +++ b/tests/data/users/list_users_500.json @@ -0,0 +1,7 @@ +{ + "title": "Internal Error.", + "type": "https://developer.nexmo.com/api/conversation#system:error:internal-error", + "code": "system:error:internal-error", + "detail": "Something went wrong.", + "instance": "00a5916655d650e920ccf0daf40ef4ee" +} \ No newline at end of file diff --git a/tests/data/users/list_users_basic.json b/tests/data/users/list_users_basic.json new file mode 100644 index 00000000..ec7bf4ad --- /dev/null +++ b/tests/data/users/list_users_basic.json @@ -0,0 +1,43 @@ +{ + "page_size": 10, + "_embedded": { + "users": [ + { + "id": "USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8", + "name": "NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8" + } + } + }, + { + "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", + "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" + } + } + }, + { + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", + "name": "my_user_name", + "display_name": "My User Name", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" + } + } + } + ] + }, + "_links": { + "first": { + "href": "https://api-us-3.vonage.com/v1/users?order=asc&page_size=10" + }, + "self": { + "href": "https://api-us-3.vonage.com/v1/users?order=asc&page_size=10&cursor=QAuYbTXFALruTxAIRAKiHvdCAqJQjTuYkDNhN9PYWcDajgUTgd9lQPo%3D" + } + } +} \ No newline at end of file diff --git a/tests/data/users/list_users_options.json b/tests/data/users/list_users_options.json new file mode 100644 index 00000000..3c2e74d8 --- /dev/null +++ b/tests/data/users/list_users_options.json @@ -0,0 +1,37 @@ +{ + "page_size": 2, + "_embedded": { + "users": [ + { + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", + "name": "my_user_name", + "display_name": "My User Name", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" + } + } + }, + { + "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", + "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" + } + } + } + ] + }, + "_links": { + "first": { + "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2" + }, + "self": { + "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2&cursor=Tw2iIH8ISR4SuJRJUrK9xC78rhfI10HHRKOZ20zBN9A8SDiczcOqBj8%3D" + }, + "next": { + "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2&cursor=FBWj1Oxid%2FVkxP6BT%2FCwMZZ2C0uOby0QXCrebkNoNo4A3PU%2FQTOOoD%2BWHib6ewsVLygsQBJy7di8HI9m30A3ujVuv1578w4Lqitgbv6CAnxdzPMeLCcAxNYWxl8%3D" + } + } +} \ No newline at end of file diff --git a/tests/data/users/rate_limit.json b/tests/data/users/rate_limit.json new file mode 100644 index 00000000..679dc079 --- /dev/null +++ b/tests/data/users/rate_limit.json @@ -0,0 +1,7 @@ +{ + "title": "Too Many Requests.", + "type": "https://developer.nexmo.com/api/conversation#http:error:too-many-request", + "code": "http:error:too-many-request", + "detail": "You have exceeded your request limit. You can try again shortly.", + "instance": "00a5916655d650e920ccf0daf40ef4ee" +} \ No newline at end of file diff --git a/tests/data/users/user_400.json b/tests/data/users/user_400.json new file mode 100644 index 00000000..6269d4f7 --- /dev/null +++ b/tests/data/users/user_400.json @@ -0,0 +1,13 @@ +{ + "title": "Bad request.", + "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", + "code": "http:error:validation-fail", + "detail": "Input validation failure.", + "instance": "00a5916655d650e920ccf0daf40ef4ee", + "invalid_parameters": [ + { + "name": "name", + "reason": "\"name\" must be a string" + } + ] +} \ No newline at end of file diff --git a/tests/data/users/user_404.json b/tests/data/users/user_404.json new file mode 100644 index 00000000..cde74ea9 --- /dev/null +++ b/tests/data/users/user_404.json @@ -0,0 +1,7 @@ +{ + "title": "Not found.", + "type": "https://developer.nexmo.com/api/conversation#user:error:not-found", + "code": "user:error:not-found", + "detail": "User does not exist, or you do not have access.", + "instance": "9b3b0ea8-987a-4117-b75a-8425e04910c4" +} \ No newline at end of file diff --git a/tests/data/users/user_basic.json b/tests/data/users/user_basic.json new file mode 100644 index 00000000..794c3102 --- /dev/null +++ b/tests/data/users/user_basic.json @@ -0,0 +1,13 @@ +{ + "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", + "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", + "properties": { + "custom_data": {} + }, + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" + } + }, + "channels": {} +} \ No newline at end of file diff --git a/tests/data/users/user_options.json b/tests/data/users/user_options.json new file mode 100644 index 00000000..1f1f0ce3 --- /dev/null +++ b/tests/data/users/user_options.json @@ -0,0 +1,69 @@ +{ + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", + "name": "my_user_name", + "image_url": "https://example.com/image.png", + "display_name": "My User Name", + "properties": { + "custom_data": { + "custom_key": "custom_value" + } + }, + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" + } + }, + "channels": { + "pstn": [ + { + "number": 123457 + } + ], + "sip": [ + { + "uri": "sip:4442138907@sip.example.com;transport=tls", + "username": "New SIP", + "password": "Password" + } + ], + "vbc": [ + { + "extension": "403" + } + ], + "websocket": [ + { + "uri": "wss://example.com/socket", + "content-type": "audio/l16;rate=16000", + "headers": { + "customer_id": "ABC123" + } + } + ], + "sms": [ + { + "number": "447700900000" + } + ], + "mms": [ + { + "number": "447700900000" + } + ], + "whatsapp": [ + { + "number": "447700900000" + } + ], + "viber": [ + { + "number": "447700900000" + } + ], + "messenger": [ + { + "id": "12345abcd" + } + ] + } +} \ No newline at end of file diff --git a/tests/data/users/user_updated.json b/tests/data/users/user_updated.json new file mode 100644 index 00000000..be4b884d --- /dev/null +++ b/tests/data/users/user_updated.json @@ -0,0 +1,19 @@ +{ + "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", + "name": "updated_name", + "properties": { + "custom_data": {} + }, + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" + } + }, + "channels": { + "whatsapp": [ + { + "number": "447700900000" + } + ] + } +} \ No newline at end of file diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 00000000..3a468548 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,369 @@ +from vonage import Client, Users +from util import * +from vonage.errors import UsersError, ClientError, ServerError + +from pytest import raises +import responses + +client = Client() +users = Users(client) +host = client.api_host() + + +@responses.activate +def test_list_users_basic(): + stub( + responses.GET, + f'https://{host}/v1/users', + fixture_path='users/list_users_basic.json', + ) + + all_users = users.list_users() + assert all_users['page_size'] == 10 + assert all_users['_embedded']['users'][0]['name'] == 'NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c' + assert all_users['_embedded']['users'][1]['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' + assert all_users['_embedded']['users'][2]['name'] == 'my_user_name' + + +@responses.activate +def test_list_users_options(): + stub( + responses.GET, + f'https://{host}/v1/users', + fixture_path='users/list_users_options.json', + ) + + all_users = users.list_users(page_size=2, order='desc') + assert all_users['page_size'] == 2 + assert all_users['_embedded']['users'][0]['name'] == 'my_user_name' + assert all_users['_embedded']['users'][1]['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' + + +def test_list_users_order_error(): + with raises(UsersError) as err: + users.list_users(order='Why, ascending of course!') + assert ( + str(err.value) == 'Invalid order parameter. Must be one of: "asc", "desc", "ASC", "DESC".' + ) + + +@responses.activate +def test_list_users_400(): + stub( + responses.GET, + f'https://{host}/v1/users', + fixture_path='users/list_users_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + users.list_users(page_size='asdf') + assert 'Input validation failure.' in str(err.value) + + +@responses.activate +def test_list_users_404(): + stub( + responses.GET, + f'https://{host}/v1/users', + fixture_path='users/list_users_404.json', + status_code=404, + ) + + with raises(ClientError) as err: + users.list_users(name='asdf') + assert 'User does not exist, or you do not have access.' in str(err.value) + + +@responses.activate +def test_list_users_429(): + stub( + responses.GET, + f'https://{host}/v1/users', + fixture_path='users/rate_limit.json', + status_code=429, + ) + + with raises(ClientError) as err: + users.list_users() + assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) + + +@responses.activate +def test_list_users_500(): + stub( + responses.GET, + f'https://{host}/v1/users', + fixture_path='users/list_users_500.json', + status_code=500, + ) + + with raises(ServerError) as err: + users.list_users() + assert str(err.value) == '500 response from api.nexmo.com' + + +@responses.activate +def test_create_user_basic(): + stub( + responses.POST, + f'https://{host}/v1/users', + fixture_path='users/user_basic.json', + status_code=201, + ) + + user = users.create_user() + assert user['id'] == 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + assert user['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' + assert ( + user['_links']['self']['href'] + == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + ) + + +@responses.activate +def test_create_user_options(): + stub( + responses.POST, + f'https://{host}/v1/users', + fixture_path='users/user_options.json', + status_code=201, + ) + + params = { + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", + "name": "my_user_name", + "image_url": "https://example.com/image.png", + "display_name": "My User Name", + "properties": {"custom_data": {"custom_key": "custom_value"}}, + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" + } + }, + "channels": { + "pstn": [{"number": 123457}], + "sip": [ + { + "uri": "sip:4442138907@sip.example.com;transport=tls", + "username": "New SIP", + "password": "Password", + } + ], + "vbc": [{"extension": "403"}], + "websocket": [ + { + "uri": "wss://example.com/socket", + "content-type": "audio/l16;rate=16000", + "headers": {"customer_id": "ABC123"}, + } + ], + "sms": [{"number": "447700900000"}], + "mms": [{"number": "447700900000"}], + "whatsapp": [{"number": "447700900000"}], + "viber": [{"number": "447700900000"}], + "messenger": [{"id": "12345abcd"}], + }, + } + + user = users.create_user(params) + assert user['id'] == 'USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' + assert user['name'] == 'my_user_name' + assert user['display_name'] == 'My User Name' + assert user['properties']['custom_data']['custom_key'] == 'custom_value' + assert ( + user['_links']['self']['href'] + == 'https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' + ) + assert user['channels']['vbc'][0]['extension'] == '403' + + +@responses.activate +def test_create_user_400(): + stub( + responses.POST, + f'https://{host}/v1/users', + fixture_path='users/user_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + users.create_user(params={'name': 1234}) + assert 'Input validation failure.' in str(err.value) + + +@responses.activate +def test_create_user_429(): + stub( + responses.POST, + f'https://{host}/v1/users', + fixture_path='users/rate_limit.json', + status_code=429, + ) + + with raises(ClientError) as err: + users.create_user() + assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) + + +@responses.activate +def test_get_user(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.GET, + f'https://{host}/v1/users/{user_id}', + fixture_path='users/user_basic.json', + ) + + user = users.get_user(user_id) + assert user['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' + assert user['properties']['custom_data'] == {} + assert ( + user['_links']['self']['href'] + == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + ) + + +@responses.activate +def test_get_user_404(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.GET, + f'https://{host}/v1/users/{user_id}', + status_code=404, + fixture_path='users/user_404.json', + ) + + with raises(ClientError) as err: + users.get_user(user_id) + assert 'User does not exist, or you do not have access.' in str(err.value) + + +@responses.activate +def test_get_user_429(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.GET, + f'https://{host}/v1/users/{user_id}', + fixture_path='users/rate_limit.json', + status_code=429, + ) + + with raises(ClientError) as err: + users.get_user(user_id) + assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) + + +@responses.activate +def test_update_user(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.PATCH, + f'https://{host}/v1/users/{user_id}', + fixture_path='users/user_updated.json', + ) + + params = { + 'name': 'updated_name', + 'channels': { + 'whatsapp': [ + {'number': '447700900000'}, + ] + }, + } + user = users.update_user(user_id, params) + assert user['name'] == 'updated_name' + assert ( + user['_links']['self']['href'] + == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + ) + assert user['channels']['whatsapp'][0]['number'] == '447700900000' + + +@responses.activate +def test_update_user_400(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.PATCH, + f'https://{host}/v1/users/{user_id}', + fixture_path='users/user_400.json', + status_code=400, + ) + + with raises(ClientError) as err: + users.update_user(user_id, params={'name': 1234}) + assert 'Input validation failure.' in str(err.value) + + +@responses.activate +def test_update_user_404(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.PATCH, + f'https://{host}/v1/users/{user_id}', + status_code=404, + fixture_path='users/user_404.json', + ) + + with raises(ClientError) as err: + users.update_user(user_id, params={'name': 'updated_user_name'}) + assert 'User does not exist, or you do not have access.' in str(err.value) + + +@responses.activate +def test_update_user_429(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.PATCH, + f'https://{host}/v1/users/{user_id}', + fixture_path='users/rate_limit.json', + status_code=429, + ) + + with raises(ClientError) as err: + users.update_user(user_id, params={'name': 'updated_user_name'}) + assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) + + +@responses.activate +def test_delete_user(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.DELETE, + f'https://{host}/v1/users/{user_id}', + status_code=204, + fixture_path='no_content.json', + ) + + response = users.delete_user(user_id) + assert response == None + + +@responses.activate +def test_delete_user_404(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.DELETE, + f'https://{host}/v1/users/{user_id}', + status_code=404, + fixture_path='users/user_404.json', + ) + + with raises(ClientError) as err: + users.delete_user(user_id) + assert 'User does not exist, or you do not have access.' in str(err.value) + + +@responses.activate +def test_delete_user_429(): + user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' + stub( + responses.DELETE, + f'https://{host}/v1/users/{user_id}', + fixture_path='users/rate_limit.json', + status_code=429, + ) + + with raises(ClientError) as err: + users.delete_user(user_id) + assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) From cbf169567e8135f92d38f9e195b9f875ee8f143b Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 15 Aug 2023 17:14:01 +0100 Subject: [PATCH 253/401] Add new .mend config (#282) --- .mend | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .mend diff --git a/.mend b/.mend new file mode 100644 index 00000000..24f3b3e5 --- /dev/null +++ b/.mend @@ -0,0 +1,6 @@ +{ + "settingsInheritedFrom": "Vonage/whitesource-config@main", + "scanSettings": { + "enableIaC": false + } +} \ No newline at end of file From 4378054c21096c9a8fa27fc2d9e8675238ba9be4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 21 Aug 2023 16:22:30 +0100 Subject: [PATCH 254/401] drop python 3.7 support --- .bumpversion.cfg | 2 +- .github/workflows/build.yml | 2 +- .pre-commit-config.yaml | 4 ++-- CHANGES.md | 3 +++ setup.py | 5 ++--- src/vonage/__init__.py | 2 +- tox.ini | 2 +- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index cbe68d89..87a8a380 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.8.0 +current_version = 3.9.0 commit = True tag = False diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ad1e94f..af6c130c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python: ["3.8", "3.9", "3.10", "3.11"] os: ["ubuntu-latest", "macos-latest"] steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6fdd7365..0ed51c20 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,9 @@ repos: rev: v2.5.4 hooks: - id: trailing-whitespace - language_version: python3.7 + language_version: python3.11 - repo: https://github.com/ambv/black rev: 18.6b4 hooks: - id: black - language_version: python3.7 + language_version: python3.11 diff --git a/CHANGES.md b/CHANGES.md index 37cd65cb..28486b0b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.9.0 +- Dropped support for Python 3.7 as it's end-of-life and no longer receiving security updates + # 3.8.0 - Adding support for the [Users component of the Vonage Application API](https://developer.vonage.com/en/api/application.v2#User) diff --git a/setup.py b/setup.py index 6a6d5456..a9563f35 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.8.0", + version="3.9.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", @@ -27,12 +27,11 @@ "Deprecated", "pydantic>=1.10,==1.*", ], - python_requires=">=3.7", + python_requires=">=3.8", tests_require=["cryptography>=2.3.1"], classifiers=[ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 144013f4..115f22a3 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.8.0" +__version__ = "3.9.0" diff --git a/tox.ini b/tox.ini index e8e90f72..efeb1ff9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3.7, py3.10, coverage-report +envlist = py3.8, py3.11, coverage-report [testenv] deps = -r requirements.txt From b214ed78b3e423a4ab22f008a9973cd20336cacb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 21 Aug 2023 17:21:21 +0100 Subject: [PATCH 255/401] updating codecov action --- .github/workflows/build.yml | 2 +- codecov.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 codecov.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index af6c130c..3bfea3a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,4 +21,4 @@ jobs: - name: Run tests run: make coverage - name: Run codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 36d95711..00000000 --- a/codecov.yml +++ /dev/null @@ -1 +0,0 @@ -secret:TQv+70MO2TqSNLEjdr8xzi3HXRxXAWkmg+S02SgWlHIDrMj9rNOjPV/C6+Ou97XmyyruLrI+FdX6/6oVcT+DGdB5HHtK5frAk2YP8HVMDTc= From aff9d5957cc23a74415087fd3458d32f70222310 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 21 Aug 2023 17:25:48 +0100 Subject: [PATCH 256/401] adding specific permissions to github actions --- .github/workflows/build.yml | 14 ++++++++++++++ .github/workflows/mutation-test.yml | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3bfea3a0..19237e55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,19 @@ name: Build on: [push] + +permissions: + actions: write + checks: write + contents: read + deployments: read + issues: write + discussions: write + packages: read + pages: write + pull-requests: write + security-events: write + statuses: write + jobs: test: name: Test diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml index af0f3bc7..478cf5b4 100644 --- a/.github/workflows/mutation-test.yml +++ b/.github/workflows/mutation-test.yml @@ -1,6 +1,19 @@ name: Mutation Test on: workflow_dispatch +permissions: + actions: write + checks: write + contents: read + deployments: read + issues: write + discussions: write + packages: read + pages: write + pull-requests: write + security-events: write + statuses: write + jobs: mutation: name: run mutation test From 35bed09e2cea0e3b2b327b2cf7807042363574e9 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 23 Aug 2023 17:26:37 +0100 Subject: [PATCH 257/401] updating readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 298e41fd..6dbd8da5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Vonage Server SDK for Python -Nexmo is now known as Vonage +Vonage [![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) [![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) @@ -1137,7 +1137,7 @@ your account before you can validate webhook signatures. ## JWT parameters -By default, the library generates 15-minute tokens for JWT authentication. +By default, the library generates tokens for JWT authentication that have an expiry time of 15 minutes. You should set the expiry time (`exp`) to an appropriate value for your organisation's own policies and/or your use case. Use the `auth` method of the client class to specify custom parameters: From 29e1d8a24cdccfacf6d8e48e8a2e474d71348f33 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 24 Aug 2023 16:29:14 +0100 Subject: [PATCH 258/401] update readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6dbd8da5..588d6666 100644 --- a/README.md +++ b/README.md @@ -1200,11 +1200,13 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | +| Procative Connect API | General Availability | ✅ (partially supported) | | Redact API | Developer Preview | ❌ | | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | | Subaccounts API | General Availability | ✅ | -| Verify API | General Availability | ✅ | +| Verify API v2 | General Availability | ✅ | +| Verify API v1 (Legacy)| General Availability | ✅ | | Voice API | General Availability | ✅ | ### asyncio Support From feec0e5fa2f12102f157fa5084e21f51849ebd71 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 24 Aug 2023 18:37:56 +0100 Subject: [PATCH 259/401] Add .pre-commit-config black settings, blacken SDK (#284) * adding pre-commit hook * blackening the sdk * update readme --- .github/workflows/build.yml | 3 +- .pre-commit-config.yaml | 7 +- README.md | 72 ++++++++++--------- pyproject.toml | 5 ++ requirements.txt | 3 +- setup.cfg | 2 +- src/vonage/account.py | 36 ++++++---- src/vonage/application.py | 9 ++- src/vonage/client.py | 69 ++++++++++++++---- src/vonage/ncco_builder/ncco.py | 13 +++- src/vonage/ncco_builder/pay_prompts.py | 12 +++- src/vonage/number_insight.py | 40 ++++++++--- src/vonage/number_management.py | 16 +++-- src/vonage/redact.py | 12 +++- src/vonage/short_codes.py | 23 ++++-- src/vonage/sms.py | 17 +++-- src/vonage/ussd.py | 10 ++- src/vonage/verify.py | 5 +- src/vonage/voice.py | 69 +++++++++--------- tests/test_account.py | 20 +++--- tests/test_application.py | 10 +-- tests/test_client.py | 1 + tests/test_getters_setters.py | 1 + tests/test_jwt.py | 7 +- tests/test_messages_send_message.py | 5 +- tests/test_messages_validate_input.py | 63 +++++++++++++--- .../ncco_samples/ncco_action_samples.py | 8 +-- .../ncco_samples/ncco_builder_samples.py | 29 ++++++-- .../test_connect_endpoints.py | 12 +++- tests/test_ncco_builder/test_input_types.py | 6 +- tests/test_ncco_builder/test_ncco_actions.py | 48 ++++++++++--- tests/test_ncco_builder/test_pay_prompts.py | 27 +++++-- tests/test_number_insight.py | 1 + tests/test_packages.py | 4 +- tests/test_redact.py | 1 + tests/test_short_codes.py | 2 +- tests/test_sms.py | 4 +- tests/test_ussd.py | 4 +- tests/test_verify.py | 68 +++++++++++++----- tests/test_voice.py | 22 ++++-- 40 files changed, 532 insertions(+), 234 deletions(-) create mode 100644 pyproject.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19237e55..261af26d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ name: Build -on: [push] +on: push permissions: actions: write @@ -23,7 +23,6 @@ jobs: matrix: python: ["3.8", "3.9", "3.10", "3.11"] os: ["ubuntu-latest", "macos-latest"] - steps: - uses: actions/setup-python@v4 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ed51c20..25636872 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,10 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.4 + rev: v4.4.0 hooks: - - id: trailing-whitespace - language_version: python3.11 + - id: check-yaml - repo: https://github.com/ambv/black - rev: 18.6b4 + rev: 23.7.0 hooks: - id: black language_version: python3.11 diff --git a/README.md b/README.md index 588d6666..7bb3a883 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The delivery receipt URL can be unset by sending an empty string. ## Messages API -The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. Call the API from your Python code by +The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. Call the API from your Python code by passing a dict of parameters into the `client.messages.send_message()` method. It accepts JWT or API key/secret authentication. @@ -174,9 +174,9 @@ Some basic samples are below. For more detailed information and code snippets, p ### Send an SMS ```python responseData = client.messages.send_message({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '447123456789', + 'channel': 'sms', + 'message_type': 'text', + 'to': '447123456789', 'from': 'Vonage', 'text': 'Hello from Vonage' }) @@ -187,9 +187,9 @@ Note: only available in the US. You will need a 10DLC number to send an MMS mess ```python client.messages.send_message({ - 'channel': 'mms', - 'message_type': 'image', - 'to': '11112223333', + 'channel': 'mms', + 'message_type': 'image', + 'to': '11112223333', 'from': '1223345567', 'image': {'url': 'https://example.com/image.jpg', 'caption': 'Test Image'} }) @@ -203,9 +203,9 @@ type to a user if they have messaged your business number in the last 24 hours. ```python client.messages.send_message({ - 'channel': 'whatsapp', - 'message_type': 'audio', - 'to': '447123456789', + 'channel': 'whatsapp', + 'message_type': 'audio', + 'to': '447123456789', 'from': '440123456789', 'audio': {'url': 'https://example.com/audio.mp3'} }) @@ -218,9 +218,9 @@ You will need to link your Facebook business page to your Vonage account in the ```python client.messages.send_message({ - 'channel': 'messenger', - 'message_type': 'video', - 'to': '594123123123123', + 'channel': 'messenger', + 'message_type': 'video', + 'to': '594123123123123', 'from': '1012312312312', 'video': {'url': 'https://example.com/video.mp4'} }) @@ -420,7 +420,7 @@ You can also verify a user by WhatsApp Interactive Message or by Silent Authenti ```python params = { - 'brand': 'ACME, Inc', + 'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}] } verify_request = verify2.new_request(params) @@ -430,7 +430,7 @@ verify_request = verify2.new_request(params) ```python params = { - 'brand': 'ACME, Inc', + 'brand': 'ACME, Inc', 'workflow': [ {'channel': 'silent_auth', 'to': '447700900000'}, {'channel': 'email', 'to': 'customer@example.com', 'from': 'business@example.com'} @@ -459,8 +459,8 @@ This feature is only enabled if you have requested for it to be added to your ac ```python params = { - 'brand': 'ACME, Inc', - 'fraud_check': False, + 'brand': 'ACME, Inc', + 'fraud_check': False, 'workflow': [{'channel': 'sms', 'to': '447700900000'}] } verify_request = verify2.new_request(params) @@ -845,7 +845,7 @@ client.account.get_balance() This feature is only enabled when you enable auto-reload for your account in the dashboard. ```python # trx is the reference from when auto-reload was enabled and money was added -client.account.topup(trx=transaction_reference) +client.account.topup(trx=transaction_reference) ``` ## Subaccounts API @@ -867,8 +867,8 @@ client.subaccounts.create_subaccount(name='my subaccount') # With options client.subaccounts.create_subaccount( - name='my subaccount', - secret='Password123', + name='my subaccount', + secret='Password123', use_primary_account_balance=False, ) ``` @@ -898,7 +898,7 @@ All fields are optional. If `start_date` or `end_date` are used, the dates must client.subaccounts.list_credit_transfers( start_date='2022-03-29T14:16:56Z', end_date='2023-06-12T17:20:01Z', - subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key + subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key ) ``` @@ -908,9 +908,9 @@ Transferring credit is only possible for postpaid accounts, i.e. accounts that c ```python client.subaccounts.transfer_credit( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - amount=0.50, + from_=FROM_ACCOUNT, + to=TO_ACCOUNT, + amount=0.50, reference='test credit transfer', ) ``` @@ -923,7 +923,7 @@ All fields are optional. If `start_date` or `end_date` are used, the dates must client.subaccounts.list_balance_transfers( start_date='2022-03-29T14:16:56Z', end_date='2023-06-12T17:20:01Z', - subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key + subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key ) ``` @@ -931,9 +931,9 @@ client.subaccounts.list_balance_transfers( ```python client.subaccounts.transfer_balance( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - amount=0.50, + from_=FROM_ACCOUNT, + to=TO_ACCOUNT, + amount=0.50, reference='test balance transfer', ) ``` @@ -942,9 +942,9 @@ client.subaccounts.transfer_balance( ```python client.subaccounts.transfer_balance( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - number=NUMBER_TO_TRANSFER, + from_=FROM_ACCOUNT, + to=TO_ACCOUNT, + number=NUMBER_TO_TRANSFER, country='US', ) ``` @@ -1217,7 +1217,7 @@ We don't currently support asyncio in the Python SDK but we are planning to do s ## Contributing -We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@vonage.com) first! +We :heart: contributions! But if you plan to work on something big or controversial, please contact us by raising an issue first! We recommend working on `vonage-python-sdk` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: @@ -1231,6 +1231,14 @@ The tests are all written with pytest. You run them with: make test ``` +We use [Black](https://black.readthedocs.io/en/stable/index.html) for code formatting, with our config in the `pyproject.toml` file. To ensure a PR follows the right format, you can set up and use our pre-commit settings with + +```bash +pre-commit install +``` + +Then when you commit code, if it's not in the right format, it will be automatically fixed for you. After that, just commit again and everything should work as expected! + ## License This library is released under the [Apache License][license]. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..e0286062 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.black] +color = true +line-length = 100 +target-version = ['py311'] +skip-string-normalization = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 680153d8..ba35aaa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ pydantic>=1.10,==1.* bump2version build -twine \ No newline at end of file +twine +pre-commit diff --git a/setup.cfg b/setup.cfg index 45444f0b..a8c354a5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,7 +4,7 @@ addopts=--tb=short -p no:doctest norecursedirs = bin dist docs htmlcov .* {args} [pycodestyle] -max-line-length=120 +max-line-length=100 [coverage:run] # TODO: Change this to True: diff --git a/src/vonage/account.py b/src/vonage/account.py index 4185decd..32968dcc 100644 --- a/src/vonage/account.py +++ b/src/vonage/account.py @@ -1,6 +1,8 @@ from .errors import PricingTypeError from deprecated import deprecated + + class Account: account_auth_type = 'params' pricing_auth_type = 'params' @@ -12,13 +14,15 @@ def __init__(self, client): self._client = client def get_balance(self): - return self._client.get(self._client.host(), "/account/get-balance", auth_type=Account.account_auth_type) + return self._client.get( + self._client.host(), "/account/get-balance", auth_type=Account.account_auth_type + ) def topup(self, params=None, **kwargs): return self._client.post( - self._client.host(), + self._client.host(), "/account/top-up", - params or kwargs, + params or kwargs, auth_type=Account.account_auth_type, body_is_json=False, ) @@ -26,22 +30,24 @@ def topup(self, params=None, **kwargs): def get_country_pricing(self, country_code: str, type: str = 'sms'): self._check_allowed_pricing_type(type) return self._client.get( - self._client.host(), - f"/account/get-pricing/outbound/{type}", + self._client.host(), + f"/account/get-pricing/outbound/{type}", {"country": country_code}, - auth_type=Account.pricing_auth_type + auth_type=Account.pricing_auth_type, ) def get_all_countries_pricing(self, type: str = 'sms'): self._check_allowed_pricing_type(type) return self._client.get( - self._client.host(), f"/account/get-full-pricing/outbound/{type}", auth_type=Account.pricing_auth_type + self._client.host(), + f"/account/get-full-pricing/outbound/{type}", + auth_type=Account.pricing_auth_type, ) def get_prefix_pricing(self, prefix: str, type: str = 'sms'): self._check_allowed_pricing_type(type) return self._client.get( - self._client.host(), + self._client.host(), f"/account/get-prefix-pricing/outbound/{type}", {"prefix": prefix}, auth_type=Account.pricing_auth_type, @@ -50,8 +56,8 @@ def get_prefix_pricing(self, prefix: str, type: str = 'sms'): @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') def get_sms_pricing(self, number: str): return self._client.get( - self._client.host(), - "/account/get-phone-pricing/outbound/sms", + self._client.host(), + "/account/get-phone-pricing/outbound/sms", {"phone": number}, auth_type=Account.pricing_auth_type, ) @@ -59,16 +65,16 @@ def get_sms_pricing(self, number: str): @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') def get_voice_pricing(self, number: str): return self._client.get( - self._client.host(), - "/account/get-phone-pricing/outbound/voice", + self._client.host(), + "/account/get-phone-pricing/outbound/voice", {"phone": number}, auth_type=Account.pricing_auth_type, ) def update_default_sms_webhook(self, params=None, **kwargs): return self._client.post( - self._client.host(), - "/account/settings", + self._client.host(), + "/account/settings", params or kwargs, auth_type=Account.account_auth_type, body_is_json=False, @@ -91,7 +97,7 @@ def get_secret(self, api_key, secret_id): def create_secret(self, api_key, secret): body = {"secret": secret} return self._client.post( - self._client.api_host(), + self._client.api_host(), f"/accounts/{api_key}/secrets", body, auth_type=Account.secrets_auth_type, diff --git a/src/vonage/application.py b/src/vonage/application.py index 68997e25..c5398a76 100644 --- a/src/vonage/application.py +++ b/src/vonage/application.py @@ -1,7 +1,11 @@ from deprecated import deprecated -@deprecated(version='3.0.0', reason='Renamed to Application as V1 is out of support and this new \ - naming is in line with other APIs. Please use Application instead.') + +@deprecated( + version='3.0.0', + reason='Renamed to Application as V1 is out of support and this new \ + naming is in line with other APIs. Please use Application instead.', +) class ApplicationV2: auth_type = 'header' @@ -83,6 +87,7 @@ def list_applications(self, page_size=None, page=None): auth_type=ApplicationV2.auth_type, ) + class Application: auth_type = 'header' diff --git a/src/vonage/client.py b/src/vonage/client.py index 6aa43d62..e6a72499 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -94,7 +94,12 @@ def __init__( self.signature_secret = signature_secret or os.environ.get("VONAGE_SIGNATURE_SECRET", None) self.signature_method = signature_method or os.environ.get("VONAGE_SIGNATURE_METHOD", None) - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: + if self.signature_method in { + "md5", + "sha1", + "sha256", + "sha512", + }: self.signature_method = getattr(hashlib, signature_method) if private_key is not None and application_id is not None: @@ -111,7 +116,10 @@ def __init__( if app_name and app_version: user_agent += f" {app_name}/{app_version}" - self.headers = {"User-Agent": user_agent, "Accept": "application/json"} + self.headers = { + "User-Agent": user_agent, + "Accept": "application/json", + } self.account = Account(self) self.application = Application(self) @@ -132,7 +140,9 @@ def __init__( self.timeout = timeout self.session = Session() self.adapter = HTTPAdapter( - pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + max_retries=max_retries, ) self.session.mount("https://", self.adapter) @@ -173,7 +183,10 @@ def check_signature(self, params): def signature(self, params): if self.signature_method: - hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method) + hasher = hmac.new( + self.signature_secret.encode(), + digestmod=self.signature_method, + ) else: hasher = hashlib.md5() @@ -201,7 +214,11 @@ def get(self, host, request_uri, params=None, auth_type=None): if auth_type == 'jwt': self._request_headers['Authorization'] = self._create_jwt_auth_string() elif auth_type == 'params': - params = dict(params or {}, api_key=self.api_key, api_secret=self.api_secret) + params = dict( + params or {}, + api_key=self.api_key, + api_secret=self.api_secret, + ) elif auth_type == 'header': self._request_headers['Authorization'] = self._create_header_auth_string() else: @@ -215,7 +232,10 @@ def get(self, host, request_uri, params=None, auth_type=None): return self.parse( host, self.session.get( - uri, params=params, headers=self._request_headers, timeout=self.timeout + uri, + params=params, + headers=self._request_headers, + timeout=self.timeout, ), ) @@ -245,7 +265,11 @@ def post( elif auth_type == 'jwt': self._request_headers['Authorization'] = self._create_jwt_auth_string() elif auth_type == 'params': - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) + params = dict( + params, + api_key=self.api_key, + api_secret=self.api_secret, + ) elif auth_type == 'header': self._request_headers['Authorization'] = self._create_header_auth_string() else: @@ -260,14 +284,20 @@ def post( return self.parse( host, self.session.post( - uri, json=params, headers=self._request_headers, timeout=self.timeout + uri, + json=params, + headers=self._request_headers, + timeout=self.timeout, ), ) else: return self.parse( host, self.session.post( - uri, data=params, headers=self._request_headers, timeout=self.timeout + uri, + data=params, + headers=self._request_headers, + timeout=self.timeout, ), ) @@ -290,7 +320,12 @@ def put(self, host, request_uri, params, auth_type=None): # All APIs that currently use put methods require a json-formatted body so don't need to check this return self.parse( host, - self.session.put(uri, json=params, headers=self._request_headers, timeout=self.timeout), + self.session.put( + uri, + json=params, + headers=self._request_headers, + timeout=self.timeout, + ), ) def patch(self, host, request_uri, params, auth_type=None): @@ -308,7 +343,14 @@ def patch(self, host, request_uri, params, auth_type=None): f"PATCH to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" ) # Only newer APIs (that expect json-bodies) currently use this method, so we will always send a json-formatted body - return self.parse(host, self.session.patch(uri, json=params, headers=self._request_headers)) + return self.parse( + host, + self.session.patch( + uri, + json=params, + headers=self._request_headers, + ), + ) def delete(self, host, request_uri, params=None, auth_type=None): uri = f"https://{host}{request_uri}" @@ -329,7 +371,10 @@ def delete(self, host, request_uri, params=None, auth_type=None): return self.parse( host, self.session.delete( - uri, headers=self._request_headers, timeout=self.timeout, params=params + uri, + headers=self._request_headers, + timeout=self.timeout, + params=params, ), ) diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py index 2a5698e9..de630129 100644 --- a/src/vonage/ncco_builder/ncco.py +++ b/src/vonage/ncco_builder/ncco.py @@ -107,9 +107,13 @@ def ensure_url_in_list(cls, v): @validator('advancedMachineDetection') def validate_advancedMachineDetection(cls, v): if 'behavior' in v and v['behavior'] not in ('continue', 'hangup'): - raise ValueError('advancedMachineDetection["behavior"] must be one of: "continue", "hangup".') + raise ValueError( + 'advancedMachineDetection["behavior"] must be one of: "continue", "hangup".' + ) if 'mode' in v and v['mode'] not in ('detect, detect_beep'): - raise ValueError('advancedMachineDetection["mode"] must be one of: "detect", "detect_beep".') + raise ValueError( + 'advancedMachineDetection["mode"] must be one of: "detect", "detect_beep".' + ) return v class Config: @@ -145,7 +149,10 @@ class Input(Action): action = Field('input', const=True) type: Union[ - Literal['dtmf', 'speech'], List[Literal['dtmf']], List[Literal['speech']], List[Literal['dtmf', 'speech']] + Literal['dtmf', 'speech'], + List[Literal['dtmf']], + List[Literal['speech']], + List[Literal['dtmf', 'speech']], ] dtmf: Optional[Union[InputTypes.Dtmf, dict]] speech: Optional[Union[InputTypes.Speech, dict]] diff --git a/src/vonage/ncco_builder/pay_prompts.py b/src/vonage/ncco_builder/pay_prompts.py index 4463a7f8..11ecb404 100644 --- a/src/vonage/ncco_builder/pay_prompts.py +++ b/src/vonage/ncco_builder/pay_prompts.py @@ -12,7 +12,13 @@ class TextPrompt(BaseModel): type: Literal['CardNumber', 'ExpirationDate', 'SecurityCode'] text: str errors: Dict[ - Literal['InvalidCardType', 'InvalidCardNumber', 'InvalidExpirationDate', 'InvalidSecurityCode', 'Timeout'], + Literal[ + 'InvalidCardType', + 'InvalidCardNumber', + 'InvalidExpirationDate', + 'InvalidSecurityCode', + 'Timeout', + ], Dict[Literal['text'], str], ] @@ -32,7 +38,9 @@ def check_valid_error_format(cls, v, values): def check_allowed_values(errors, allowed_values, prompt_type): for key in errors: if key not in allowed_values: - raise ValueError(f'Value "{key}" is not a valid error for the "{prompt_type}" prompt type.') + raise ValueError( + f'Value "{key}" is not a valid error for the "{prompt_type}" prompt type.' + ) @classmethod def create_voice_model(cls, dict) -> VoicePrompt: diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py index b63dde13..2b57dfb3 100644 --- a/src/vonage/number_insight.py +++ b/src/vonage/number_insight.py @@ -1,28 +1,48 @@ from .errors import CallbackRequiredError + class NumberInsight: auth_type = 'params' - + def __init__(self, client): self._client = client def get_basic_number_insight(self, params=None, **kwargs): - return self._client.get(self._client.api_host(), "/ni/basic/json", params or kwargs, auth_type=NumberInsight.auth_type) + return self._client.get( + self._client.api_host(), + "/ni/basic/json", + params or kwargs, + auth_type=NumberInsight.auth_type, + ) def get_standard_number_insight(self, params=None, **kwargs): - return self._client.get(self._client.api_host(), "/ni/standard/json", params or kwargs, auth_type=NumberInsight.auth_type) + return self._client.get( + self._client.api_host(), + "/ni/standard/json", + params or kwargs, + auth_type=NumberInsight.auth_type, + ) def get_advanced_number_insight(self, params=None, **kwargs): - return self._client.get(self._client.api_host(), "/ni/advanced/json", params or kwargs, auth_type=NumberInsight.auth_type) + return self._client.get( + self._client.api_host(), + "/ni/advanced/json", + params or kwargs, + auth_type=NumberInsight.auth_type, + ) def get_async_advanced_number_insight(self, params=None, **kwargs): argoparams = params or kwargs - if "callback" in argoparams and type(argoparams["callback"]) == str and argoparams["callback"] != "": + if ( + "callback" in argoparams + and type(argoparams["callback"]) == str + and argoparams["callback"] != "" + ): return self._client.get( - self._client.api_host(), "/ni/advanced/async/json", params or kwargs, auth_type=NumberInsight.auth_type + self._client.api_host(), + "/ni/advanced/async/json", + params or kwargs, + auth_type=NumberInsight.auth_type, ) else: - raise CallbackRequiredError( - "A callback is needed for async advanced number insight" - ) - \ No newline at end of file + raise CallbackRequiredError("A callback is needed for async advanced number insight") diff --git a/src/vonage/number_management.py b/src/vonage/number_management.py index 65f04ae3..41645372 100644 --- a/src/vonage/number_management.py +++ b/src/vonage/number_management.py @@ -6,7 +6,9 @@ def __init__(self, client): self._client = client def get_account_numbers(self, params=None, **kwargs): - return self._client.get(self._client.host(), "/account/numbers", params or kwargs, auth_type=Numbers.auth_type) + return self._client.get( + self._client.host(), "/account/numbers", params or kwargs, auth_type=Numbers.auth_type + ) def get_available_numbers(self, country_code, params=None, **kwargs): return self._client.get( @@ -17,10 +19,16 @@ def get_available_numbers(self, country_code, params=None, **kwargs): ) def buy_number(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults) + return self._client.post( + self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults + ) def cancel_number(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults) + return self._client.post( + self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults + ) def update_number(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/number/update", params or kwargs, **Numbers.defaults) + return self._client.post( + self._client.host(), "/number/update", params or kwargs, **Numbers.defaults + ) diff --git a/src/vonage/redact.py b/src/vonage/redact.py index 1e1fb5f4..c1ec18f5 100644 --- a/src/vonage/redact.py +++ b/src/vonage/redact.py @@ -2,7 +2,11 @@ from deprecated import deprecated -@deprecated(version='3.0.0', reason='This is a dev preview product and as such is not supported in this SDK.') + +@deprecated( + version='3.0.0', + reason='This is a dev preview product and as such is not supported in this SDK.', +) class Redact: auth_type = 'header' @@ -16,10 +20,12 @@ def redact_transaction(self, id: str, product: str, type=None): params = {"id": id, "product": product} if type is not None: params["type"] = type - return self._client.post(self._client.api_host(), "/v1/redact/transaction", params, auth_type=Redact.auth_type) + return self._client.post( + self._client.api_host(), "/v1/redact/transaction", params, auth_type=Redact.auth_type + ) def _check_allowed_product_name(self, product): if product not in self.allowed_product_names: raise RedactError( f'Invalid product name in redact request. Must be one of {self.allowed_product_names}.' - ) \ No newline at end of file + ) diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py index 43464b6b..03150804 100644 --- a/src/vonage/short_codes.py +++ b/src/vonage/short_codes.py @@ -6,18 +6,29 @@ def __init__(self, client): self._client = client def send_2fa_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/sc/us/2fa/json", params or kwargs, **ShortCodes.defaults) + return self._client.post( + self._client.host(), "/sc/us/2fa/json", params or kwargs, **ShortCodes.defaults + ) def send_event_alert_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/sc/us/alert/json", params or kwargs, **ShortCodes.defaults) + return self._client.post( + self._client.host(), "/sc/us/alert/json", params or kwargs, **ShortCodes.defaults + ) def send_marketing_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/sc/us/marketing/json", params or kwargs, **ShortCodes.defaults) + return self._client.post( + self._client.host(), "/sc/us/marketing/json", params or kwargs, **ShortCodes.defaults + ) def get_event_alert_numbers(self): - return self._client.get(self._client.host(), "/sc/us/alert/opt-in/query/json", auth_type=ShortCodes.auth_type) + return self._client.get( + self._client.host(), "/sc/us/alert/opt-in/query/json", auth_type=ShortCodes.auth_type + ) def resubscribe_event_alert_number(self, params=None, **kwargs): return self._client.post( - self._client.host(), "/sc/us/alert/opt-in/manage/json", params or kwargs, **ShortCodes.defaults) - \ No newline at end of file + self._client.host(), + "/sc/us/alert/opt-in/manage/json", + params or kwargs, + **ShortCodes.defaults, + ) diff --git a/src/vonage/sms.py b/src/vonage/sms.py index ab676fc2..1eee72ac 100644 --- a/src/vonage/sms.py +++ b/src/vonage/sms.py @@ -2,12 +2,13 @@ from datetime import datetime from ._internal import _format_date_param + class Sms: defaults = {'auth_type': 'params', 'body_is_json': False} def __init__(self, client): self._client = client - + def send_message(self, params): """ Send an SMS message. @@ -15,13 +16,13 @@ def send_message(self, params): :param dict params: A dict of values described at `Send an SMS `_ """ return self._client.post( - self._client.host(), - "/sms/json", - params, + self._client.host(), + "/sms/json", + params, supports_signature_auth=True, **Sms.defaults, ) - + def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): """ Notify Vonage that an SMS was successfully received. @@ -37,8 +38,10 @@ def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): params = { "message-id": message_id, "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc) + "timestamp": timestamp or datetime.now(pytz.utc), } # Ensure timestamp is a string: _format_date_param(params, "timestamp") - return self._client.post(self._client.api_host(), "/conversions/sms", params, **Sms.defaults) + return self._client.post( + self._client.api_host(), "/conversions/sms", params, **Sms.defaults + ) diff --git a/src/vonage/ussd.py b/src/vonage/ussd.py index addde45e..98ade213 100644 --- a/src/vonage/ussd.py +++ b/src/vonage/ussd.py @@ -1,11 +1,15 @@ class Ussd: defaults = {'auth_type': 'params', 'body_is_json': False} - + def __init__(self, client): self._client = client def send_ussd_push_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/ussd/json", params or kwargs, **Ussd.defaults) + return self._client.post( + self._client.host(), "/ussd/json", params or kwargs, **Ussd.defaults + ) def send_ussd_prompt_message(self, params=None, **kwargs): - return self._client.post(self._client.host(), "/ussd-prompt/json", params or kwargs, **Ussd.defaults) + return self._client.post( + self._client.host(), "/ussd-prompt/json", params or kwargs, **Ussd.defaults + ) diff --git a/src/vonage/verify.py b/src/vonage/verify.py index 76d5d05e..1c7168b7 100644 --- a/src/vonage/verify.py +++ b/src/vonage/verify.py @@ -23,7 +23,10 @@ def check(self, request_id, params=None, **kwargs): def search(self, request_id): return self._client.get( - self._client.api_host(), "/verify/search/json", {"request_id": request_id}, auth_type=Verify.auth_type + self._client.api_host(), + "/verify/search/json", + {"request_id": request_id}, + auth_type=Verify.auth_type, ) def cancel(self, request_id): diff --git a/src/vonage/voice.py b/src/vonage/voice.py index e138f3b3..74a9f2b5 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -1,5 +1,6 @@ from urllib.parse import urlparse + class Voice: auth_type = 'jwt' @@ -9,13 +10,13 @@ def __init__(self, client): # Creates a new call session def create_call(self, params, **kwargs): """ - Adding Random From Number Feature for the Voice API, - if set to `True`, the from number will be randomly selected - from the pool of numbers available to the application making - the call. + Adding Random From Number Feature for the Voice API, + if set to `True`, the from number will be randomly selected + from the pool of numbers available to the application making + the call. + + :param params is a dictionary that holds the 'from' and 'random_from_number' - :param params is a dictionary that holds the 'from' and 'random_from_number' - """ if not params: params = kwargs @@ -24,74 +25,68 @@ def create_call(self, params, **kwargs): if key not in params: params['random_from_number'] = True + return self._client.post( + self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type + ) - return self._client.post(self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type) - # Get call history paginated. Pass start and end dates to filter the retrieved information def get_calls(self, params=None, **kwargs): return self._client.get( - self._client.api_host(), - "/v1/calls", - params or kwargs, - auth_type=Voice.auth_type + self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type ) - + # Get a single call record by identifier def get_call(self, uuid): return self._client.get( - self._client.api_host(), - f"/v1/calls/{uuid}", - auth_type=Voice.auth_type + self._client.api_host(), f"/v1/calls/{uuid}", auth_type=Voice.auth_type ) - + # Update call data using custom ncco def update_call(self, uuid, params=None, **kwargs): return self._client.put( self._client.api_host(), - f"/v1/calls/{uuid}", - params or kwargs, - auth_type=Voice.auth_type + f"/v1/calls/{uuid}", + params or kwargs, + auth_type=Voice.auth_type, ) - + # Plays audio streaming into call in progress - stream_url parameter is required def send_audio(self, uuid, params=None, **kwargs): return self._client.put( self._client.api_host(), - f"/v1/calls/{uuid}/stream", + f"/v1/calls/{uuid}/stream", params or kwargs, - auth_type=Voice.auth_type + auth_type=Voice.auth_type, ) - + # Play an speech into specified call - text parameter (text to speech) is required def send_speech(self, uuid, params=None, **kwargs): return self._client.put( self._client.api_host(), - f"/v1/calls/{uuid}/talk", + f"/v1/calls/{uuid}/talk", params or kwargs, - auth_type=Voice.auth_type + auth_type=Voice.auth_type, ) - + # plays DTMF tones into the specified call def send_dtmf(self, uuid, params=None, **kwargs): return self._client.put( self._client.api_host(), - f"/v1/calls/{uuid}/dtmf", + f"/v1/calls/{uuid}/dtmf", params or kwargs, - auth_type=Voice.auth_type + auth_type=Voice.auth_type, ) - + # Stops audio recently played into specified call def stop_audio(self, uuid): - return self._client.delete(self._client.api_host(), - f"/v1/calls/{uuid}/stream", - auth_type=Voice.auth_type + return self._client.delete( + self._client.api_host(), f"/v1/calls/{uuid}/stream", auth_type=Voice.auth_type ) - + # Stop a speech recently played into specified call def stop_speech(self, uuid): - return self._client.delete(self._client.api_host(), - f"/v1/calls/{uuid}/talk", - auth_type=Voice.auth_type + return self._client.delete( + self._client.api_host(), f"/v1/calls/{uuid}/talk", auth_type=Voice.auth_type ) def get_recording(self, url): diff --git a/tests/test_account.py b/tests/test_account.py index 191d9357..142bbbcf 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -70,9 +70,7 @@ def test_get_sms_pricing(account, dummy_data): @responses.activate def test_get_voice_pricing(account, dummy_data): - stub( - responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" - ) + stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice") assert isinstance(account.get_voice_pricing("447525856424"), dict) assert request_user_agent() == dummy_data.user_agent @@ -132,7 +130,8 @@ def test_list_secrets_missing(account): account.list_secrets("myaccountid") assert_basic_auth() assert ( - str(ce.value) == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" + str(ce.value) + == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" ) @@ -175,7 +174,8 @@ def test_create_secret_max_secrets(account): account.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - str(ce.value) == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" + str(ce.value) + == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" ) @@ -192,15 +192,14 @@ def test_create_secret_validation(account): account.create_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - str(ce.value) == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" + str(ce.value) + == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" ) @responses.activate def test_delete_secret(account): - stub( - responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" - ) + stub(responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret") account.revoke_secret("meaccountid", "mahsecret") assert_basic_auth() @@ -218,5 +217,6 @@ def test_delete_secret_last_secret(account): account.revoke_secret("meaccountid", "mahsecret") assert_basic_auth() assert ( - str(ce.value) == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" + str(ce.value) + == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" ) diff --git a/tests/test_application.py b/tests/test_application.py index 7fe168be..101f0513 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -3,6 +3,7 @@ import vonage + @responses.activate def test_deprecated_list_applications(application_v2, dummy_data): stub( @@ -112,9 +113,7 @@ def test_deprecated_client_error(application_v2): ) with pytest.raises(vonage.ClientError) as exc_info: application_v2.delete_application("xx-xx-xx-xx") - assert ( - str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - ) + assert str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" @responses.activate @@ -141,7 +140,6 @@ def test_deprecated_server_error(application_v2): application_v2.delete_application("xx-xx-xx-xx") - @responses.activate def test_list_applications(client, dummy_data): stub( @@ -251,9 +249,7 @@ def test_client_error(client): ) with pytest.raises(vonage.ClientError) as exc_info: client.application.delete_application("xx-xx-xx-xx") - assert ( - str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - ) + assert str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" @responses.activate diff --git a/tests/test_client.py b/tests/test_client.py index bdedbf62..cb73ea44 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,6 +23,7 @@ def test_invalid_auth_type_raises_error(client): with pytest.raises(InvalidAuthenticationTypeError): client.get(client.host(), 'my/request/uri', auth_type='magic') + @responses.activate def test_timeout_is_set_on_client_calls(dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py index f1edfe3e..d11caa3b 100644 --- a/tests/test_getters_setters.py +++ b/tests/test_getters_setters.py @@ -2,6 +2,7 @@ def test_getters(client, dummy_data): assert client.host() == dummy_data.host assert client.api_host() == dummy_data.api_host + def test_setters(client, dummy_data): try: client.host('host.vonage.com') diff --git a/tests/test_jwt.py b/tests/test_jwt.py index d1c34451..fc51336d 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -45,7 +45,10 @@ def test_create_jwt_auth_string(client): def test_create_jwt_error_no_application_id_or_private_key(): empty_client = Client() - + with raises(ClientError) as err: empty_client._generate_application_jwt() - assert str(err.value) == 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' + assert ( + str(err.value) + == 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' + ) diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py index a993eaa2..242ccbc7 100644 --- a/tests/test_messages_send_message.py +++ b/tests/test_messages_send_message.py @@ -36,4 +36,7 @@ def test_send_whatsapp_image_with_messages_api(messages, dummy_data): assert request_user_agent() == dummy_data.user_agent assert b'"from": "440123456789"' in request_body() assert b'"to": "447123456789"' in request_body() - assert b'"image": {"url": "https://example.com/image.jpg", "caption": "fake test image"}' in request_body() + assert ( + b'"image": {"url": "https://example.com/image.jpg", "caption": "fake test image"}' + in request_body() + ) diff --git a/tests/test_messages_validate_input.py b/tests/test_messages_validate_input.py index 9b346fd0..6ee2810d 100644 --- a/tests/test_messages_validate_input.py +++ b/tests/test_messages_validate_input.py @@ -5,7 +5,9 @@ def test_invalid_send_message_params_object(messages): with pytest.raises(MessagesError) as err: messages.send_message('hi') - assert str(err.value) == 'Parameters to the send_message method must be specified as a dictionary.' + assert ( + str(err.value) == 'Parameters to the send_message method must be specified as a dictionary.' + ) def test_invalid_message_channel(messages): @@ -25,7 +27,13 @@ def test_invalid_message_channel(messages): def test_invalid_message_type(messages): with pytest.raises(MessagesError) as err: messages.send_message( - {'channel': 'sms', 'message_type': 'video', 'to': '12345678', 'from': 'vonage', 'video': 'my_url.com'} + { + 'channel': 'sms', + 'message_type': 'video', + 'to': '12345678', + 'from': 'vonage', + 'video': 'my_url.com', + } ) assert '"video" is not a valid message type for channel "sms".' in str(err.value) @@ -33,7 +41,13 @@ def test_invalid_message_type(messages): def test_invalid_recipient_not_string(messages): with pytest.raises(MessagesError) as err: messages.send_message( - {'channel': 'sms', 'message_type': 'text', 'to': 12345678, 'from': 'vonage', 'text': 'my important message'} + { + 'channel': 'sms', + 'message_type': 'text', + 'to': 12345678, + 'from': 'vonage', + 'text': 'my important message', + } ) assert str(err.value) == 'Message recipient ("to=12345678") not in a valid format.' @@ -55,7 +69,13 @@ def test_invalid_recipient_number(messages): def test_invalid_messenger_recipient(messages): with pytest.raises(MessagesError) as err: messages.send_message( - {'channel': 'messenger', 'message_type': 'text', 'to': '', 'from': 'vonage', 'text': 'my important message'} + { + 'channel': 'messenger', + 'message_type': 'text', + 'to': '', + 'from': 'vonage', + 'text': 'my important message', + } ) assert str(err.value) == 'Message recipient ID ("to=") not in a valid format.' @@ -71,7 +91,10 @@ def test_invalid_sender(messages): 'text': 'my important message', } ) - assert str(err.value) == 'Message sender ("frm=1234") set incorrectly. Set a valid name or number for the sender.' + assert ( + str(err.value) + == 'Message sender ("frm=1234") set incorrectly. Set a valid name or number for the sender.' + ) def test_set_client_ref(messages): @@ -154,7 +177,12 @@ def test_viber_service_video(messages): 'caption': 'Look at this video', 'thumb_url': 'https://example.com/thumbnail.jpg', }, - 'viber_service': {'category': 'transaction', 'duration': '120', 'ttl': 30, 'type': 'string'}, + 'viber_service': { + 'category': 'transaction', + 'duration': '120', + 'ttl': 30, + 'type': 'string', + }, } ) @@ -197,7 +225,10 @@ def test_viber_service_image_action_button(messages): 'message_type': 'image', 'to': '44123456789', 'from': 'vonage', - 'image': {'url': 'https://example.com/image.jpg', 'caption': 'Check out this new promotion'}, + 'image': { + 'url': 'https://example.com/image.jpg', + 'caption': 'Check out this new promotion', + }, 'viber_service': { 'category': 'transaction', 'ttl': 30, @@ -219,7 +250,10 @@ def test_incomplete_input(messages): 'text': 'my important message', } ) - assert str(err.value) == 'You must specify all required properties for message channel "viber_service".' + assert ( + str(err.value) + == 'You must specify all required properties for message channel "viber_service".' + ) def test_whatsapp_sticker_id(messages): @@ -257,7 +291,9 @@ def test_whatsapp_sticker_invalid_input_error(messages): 'from': 'vonage', } ) - assert str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' + assert ( + str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' + ) def test_whatsapp_sticker_exclusive_keys_error(messages): @@ -266,9 +302,14 @@ def test_whatsapp_sticker_exclusive_keys_error(messages): { 'channel': 'whatsapp', 'message_type': 'sticker', - 'sticker': {'id': '13aaecab-2485-4255-a0a7-97a2be6906b9', 'url': 'https://example.com/sticker1.webp'}, + 'sticker': { + 'id': '13aaecab-2485-4255-a0a7-97a2be6906b9', + 'url': 'https://example.com/sticker1.webp', + }, 'to': '44123456789', 'from': 'vonage', } ) - assert str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' + assert ( + str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' + ) diff --git a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py index ddf73411..85a11568 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py @@ -38,12 +38,12 @@ input_dtmf_and_speech_full = '{"action": "input", "type": ["dtmf", "speech"], "dtmf": {"timeOut": 5, "maxDigits": 12, "submitOnHash": true}, "speech": {"uuid": "my-uuid", "endOnSilence": 2.5, "language": "en-GB", "context": ["sales", "billing"], "startTimeout": 20, "maxDuration": 30, "saveAudio": true}, "eventUrl": ["http://example.com/speech"], "eventMethod": "PUT"}' -notify_basic = '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}' - -notify_full = ( - '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"], "eventMethod": "POST"}' +notify_basic = ( + '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}' ) +notify_full = '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"], "eventMethod": "POST"}' + pay_basic = '{"action": "pay", "amount": 10.0}' pay_voice_full = '{"action": "pay", "amount": 99.99, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "voice": {"language": "en-GB", "style": 1}}' diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py index 07559aef..85177fc4 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py @@ -25,9 +25,13 @@ talk_minimal = Ncco.Talk(text='hello') -talk = Ncco.Talk(text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True) +talk = Ncco.Talk( + text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True +) -stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10) +stream = Ncco.Stream( + streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 +) input = Ncco.Input( type=['dtmf', 'speech'], @@ -45,7 +49,9 @@ eventMethod='put', ) -notify = Ncco.Notify(payload={"message": "world"}, eventUrl=["http://example.com"], eventMethod='PUT') +notify = Ncco.Notify( + payload={"message": "world"}, eventUrl=["http://example.com"], eventMethod='PUT' +) pay_voice_prompt = Ncco.Pay( amount=99.99, @@ -61,7 +67,11 @@ prompts=PayPrompts.TextPrompt( type='CardNumber', text='Enter your card number.', - errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + errors={ + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, ), ) @@ -134,7 +144,12 @@ }, 'type': ['dtmf', 'speech'], }, - {'action': 'notify', 'eventMethod': 'PUT', 'eventUrl': ['http://example.com'], 'payload': {'message': 'world'}}, + { + 'action': 'notify', + 'eventMethod': 'PUT', + 'eventUrl': ['http://example.com'], + 'payload': {'message': 'world'}, + }, { 'action': 'pay', 'amount': 99.99, @@ -149,7 +164,9 @@ 'eventUrl': ['https://example.com/payment'], 'prompts': { 'errors': { - 'InvalidCardType': {'text': 'The card you are trying ' 'to use is not valid for ' 'this purchase.'} + 'InvalidCardType': { + 'text': 'The card you are trying ' 'to use is not valid for ' 'this purchase.' + } }, 'text': 'Enter your card number.', 'type': 'CardNumber', diff --git a/tests/test_ncco_builder/test_connect_endpoints.py b/tests/test_ncco_builder/test_connect_endpoints.py index c263d92c..44cc608a 100644 --- a/tests/test_ncco_builder/test_connect_endpoints.py +++ b/tests/test_ncco_builder/test_connect_endpoints.py @@ -14,7 +14,10 @@ def test_connect_all_endpoints_from_model(): phone = ConnectEndpoints.PhoneEndpoint( number='447000000000', dtmfAnswer='1p2p3p#**903#', - onAnswer={"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}, + onAnswer={ + "url": "https://example.com/answer", + "ringbackTone": "http://example.com/ringbackTone.wav", + }, ) connect_phone = Ncco.Connect(endpoint=phone) assert json.dumps(_action_as_dict(connect_phone)) == nas.connect_phone @@ -24,13 +27,16 @@ def test_connect_all_endpoints_from_model(): assert json.dumps(_action_as_dict(connect_app)) == nas.connect_app websocket = ConnectEndpoints.WebsocketEndpoint( - uri='ws://example.com/socket', contentType='audio/l16;rate=8000', headers={"language": "en-GB"} + uri='ws://example.com/socket', + contentType='audio/l16;rate=8000', + headers={"language": "en-GB"}, ) connect_websocket = Ncco.Connect(endpoint=websocket) assert json.dumps(_action_as_dict(connect_websocket)) == nas.connect_websocket sip = ConnectEndpoints.SipEndpoint( - uri='sip:rebekka@sip.mcrussell.com', headers={"location": "New York City", "occupation": "developer"} + uri='sip:rebekka@sip.mcrussell.com', + headers={"location": "New York City", "occupation": "developer"}, ) connect_sip = Ncco.Connect(endpoint=sip) assert json.dumps(_action_as_dict(connect_sip)) == nas.connect_sip diff --git a/tests/test_ncco_builder/test_input_types.py b/tests/test_ncco_builder/test_input_types.py index f32d4016..7572ae1a 100644 --- a/tests/test_ncco_builder/test_input_types.py +++ b/tests/test_ncco_builder/test_input_types.py @@ -40,4 +40,8 @@ def test_create_speech_model_from_dict(): speech_dict = {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} speech_model = InputTypes.create_speech_model(speech_dict) assert type(speech_model) == InputTypes.Speech - assert speech_model.dict(exclude_none=True) == {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} + assert speech_model.dict(exclude_none=True) == { + 'uuid': 'my-uuid', + 'endOnSilence': 2.5, + 'maxDuration': 30, + } diff --git a/tests/test_ncco_builder/test_ncco_actions.py b/tests/test_ncco_builder/test_ncco_actions.py index c0379e85..b7bd028a 100644 --- a/tests/test_ncco_builder/test_ncco_actions.py +++ b/tests/test_ncco_builder/test_ncco_actions.py @@ -84,7 +84,10 @@ def test_connect_phone_endpoint_from_dict(): "type": "phone", "number": "447000000000", "dtmfAnswer": "1p2p3p#**903#", - "onAnswer": {"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}, + "onAnswer": { + "url": "https://example.com/answer", + "ringbackTone": "http://example.com/ringbackTone.wav", + }, } ) assert type(connect) is Ncco.Connect @@ -142,7 +145,10 @@ def test_connect_random_from_number_error(): with pytest.raises(ValueError) as err: Ncco.Connect(endpoint=endpoint, from_='447400000000', randomFromNumber=True) - assert 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' in str(err.value) + assert ( + 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' + in str(err.value) + ) def test_connect_validation_errors(): @@ -168,7 +174,9 @@ def test_talk_basic(): def test_talk_optional_params(): - talk = Ncco.Talk(text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True) + talk = Ncco.Talk( + text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True + ) assert json.dumps(_action_as_dict(talk)) == nas.talk_full @@ -184,7 +192,9 @@ def test_stream_basic(): def test_stream_full(): - stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10) + stream = Ncco.Stream( + streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 + ) assert json.dumps(_action_as_dict(stream)) == nas.stream_full @@ -211,7 +221,11 @@ def test_input_dtmf_and_speech_options(): saveAudio=True, ) input = Ncco.Input( - type=['dtmf', 'speech'], dtmf=dtmf, speech=speech, eventUrl='http://example.com/speech', eventMethod='put' + type=['dtmf', 'speech'], + dtmf=dtmf, + speech=speech, + eventUrl='http://example.com/speech', + eventMethod='put', ) assert json.dumps(_action_as_dict(input)) == nas.input_dtmf_and_speech_full @@ -234,7 +248,9 @@ def test_notify_basic_str_in_event_url(): def test_notify_full(): - notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST') + notify = Ncco.Notify( + payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST' + ) assert type(notify) == Ncco.Notify assert json.dumps(_action_as_dict(notify)) == nas.notify_full @@ -252,7 +268,9 @@ def test_pay_voice_basic(): def test_pay_voice_full(): voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) - pay = Ncco.Pay(amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings) + pay = Ncco.Pay( + amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings + ) assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full @@ -260,9 +278,15 @@ def test_pay_text(): text_prompts = PayPrompts.TextPrompt( type='CardNumber', text='Enter your card number.', - errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + errors={ + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, + ) + pay = Ncco.Pay( + amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts ) - pay = Ncco.Pay(amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts) assert json.dumps(_action_as_dict(pay)) == nas.pay_text @@ -270,7 +294,11 @@ def test_pay_text_multiple_prompts(): card_prompt = PayPrompts.TextPrompt( type='CardNumber', text='Enter your card number.', - errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + errors={ + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, ) expiration_date_prompt = PayPrompts.TextPrompt( type='ExpirationDate', diff --git a/tests/test_ncco_builder/test_pay_prompts.py b/tests/test_ncco_builder/test_pay_prompts.py index c5628bd7..abb949ce 100644 --- a/tests/test_ncco_builder/test_pay_prompts.py +++ b/tests/test_ncco_builder/test_pay_prompts.py @@ -19,7 +19,11 @@ def test_create_text_model(): text_prompt = PayPrompts.TextPrompt( type='CardNumber', text='Enter your card number.', - errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + errors={ + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, ) assert type(text_prompt) == PayPrompts.TextPrompt @@ -28,7 +32,11 @@ def test_create_text_model_from_dict(): text_dict = { 'type': 'CardNumber', 'text': 'Enter your card number.', - 'errors': {'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + 'errors': { + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, } text_prompt = PayPrompts.create_text_model(text_dict) assert type(text_prompt) == PayPrompts.TextPrompt @@ -39,7 +47,9 @@ def test_error_message_not_in_subdictionary(): PayPrompts.TextPrompt( type='CardNumber', text='Enter your card number.', - errors={'InvalidCardType': 'The card you are trying to use is not valid for this purchase.'}, + errors={ + 'InvalidCardType': 'The card you are trying to use is not valid for this purchase.' + }, ) @@ -48,7 +58,14 @@ def test_invalid_error_type_for_prompt(): PayPrompts.TextPrompt( type='SecurityCode', text='Enter your card number.', - errors={'InvalidCardType': {'text': 'The card you are trying to use is not valid for this purchase.'}}, + errors={ + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, ) - assert 'Value "InvalidCardType" is not a valid error for the "SecurityCode" prompt type.' in str(err.value) + assert ( + 'Value "InvalidCardType" is not a valid error for the "SecurityCode" prompt type.' + in str(err.value) + ) diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py index 75cedc88..40ab9d19 100644 --- a/tests/test_number_insight.py +++ b/tests/test_number_insight.py @@ -40,6 +40,7 @@ def test_get_async_advanced_number_insight(number_insight, dummy_data): assert "number=447525856424" in request_query() assert "callback=https%3A%2F%2Fexample.com" in request_query() + def test_callback_required_error_async_advanced_number_insight(number_insight, dummy_data): stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") diff --git a/tests/test_packages.py b/tests/test_packages.py index d9cbceb6..49d5dbcf 100644 --- a/tests/test_packages.py +++ b/tests/test_packages.py @@ -3,7 +3,9 @@ def test_subdirectories_are_python_packages(): subdirs = [ - os.path.join('src/vonage', o) for o in os.listdir('src/vonage') if os.path.isdir(os.path.join('src/vonage', o)) + os.path.join('src/vonage', o) + for o in os.listdir('src/vonage') + if os.path.isdir(os.path.join('src/vonage', o)) ] for subdir in subdirs: if '__pycache__' in subdir or os.path.isfile(f'{subdir}/__init__.py'): diff --git a/tests/test_redact.py b/tests/test_redact.py index 834f89f6..609e5d9e 100644 --- a/tests/test_redact.py +++ b/tests/test_redact.py @@ -6,6 +6,7 @@ def test_redact_invalid_product_name(redact): with pytest.raises(RedactError): redact.redact_transaction(id='not-a-real-id', product='fake-product') + @responses.activate def test_redact_transaction(redact, dummy_data): responses.add( diff --git a/tests/test_short_codes.py b/tests/test_short_codes.py index 1a6b04ea..212dfcf6 100644 --- a/tests/test_short_codes.py +++ b/tests/test_short_codes.py @@ -61,4 +61,4 @@ def test_resubscribe_event_alert_number(short_codes, dummy_data): assert isinstance(short_codes.resubscribe_event_alert_number(params), dict) assert request_user_agent() == dummy_data.user_agent - assert "msisdn=441632960960" in request_body() \ No newline at end of file + assert "msisdn=441632960960" in request_body() diff --git a/tests/test_sms.py b/tests/test_sms.py index b02a651a..cdb8cb36 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -43,9 +43,7 @@ def test_server_error(sms): @responses.activate def test_submit_sms_conversion(sms): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) + responses.add(responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK") sms.submit_sms_conversion("a-message-id") assert "message-id=a-message-id" in request_body() diff --git a/tests/test_ussd.py b/tests/test_ussd.py index 9eb4db00..27fb41a7 100644 --- a/tests/test_ussd.py +++ b/tests/test_ussd.py @@ -1,5 +1,6 @@ from util import * + @responses.activate def test_send_ussd_push_message(ussd, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd/json") @@ -12,6 +13,7 @@ def test_send_ussd_push_message(ussd, dummy_data): assert "to=447525856424" in request_body() assert "text=Hello" in request_body() + @responses.activate def test_send_ussd_prompt_message(ussd, dummy_data): stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") @@ -22,4 +24,4 @@ def test_send_ussd_prompt_message(ussd, dummy_data): assert request_user_agent() == dummy_data.user_agent assert "from=long-virtual-number" in request_body() assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() \ No newline at end of file + assert "text=Hello" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py index adb7ec27..576dbf62 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1,5 +1,6 @@ from util import * + @responses.activate def test_start_verification(verify, dummy_data): stub(responses.POST, "https://api.nexmo.com/verify/json") @@ -65,8 +66,10 @@ def test_start_psd2_verification(verify, dummy_data): @responses.activate def test_start_verification_blacklisted_error_with_network(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_network.json" + stub( + responses.POST, + "https://api.nexmo.com/verify/json", + fixture_path="verify/blocked_with_network.json", ) params = {"number": "447525856424", "brand": "MyApp"} @@ -78,13 +81,18 @@ def test_start_verification_blacklisted_error_with_network(client, dummy_data): assert "brand=MyApp" in request_body() assert response["status"] == "7" assert response["network"] == "25503" - assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + assert ( + response["error_text"] + == "The number you are trying to verify is blacklisted for verification" + ) @responses.activate def test_start_verification_blacklisted_error_with_request_id(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_request_id.json" + stub( + responses.POST, + "https://api.nexmo.com/verify/json", + fixture_path="verify/blocked_with_request_id.json", ) params = {"number": "447525856424", "brand": "MyApp"} @@ -96,13 +104,18 @@ def test_start_verification_blacklisted_error_with_request_id(client, dummy_data assert "brand=MyApp" in request_body() assert response["status"] == "7" assert response["request_id"] == "12345678" - assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + assert ( + response["error_text"] + == "The number you are trying to verify is blacklisted for verification" + ) @responses.activate def test_start_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_network_and_request_id.json" + stub( + responses.POST, + "https://api.nexmo.com/verify/json", + fixture_path="verify/blocked_with_network_and_request_id.json", ) params = {"number": "447525856424", "brand": "MyApp"} @@ -115,12 +128,18 @@ def test_start_verification_blacklisted_error_with_network_and_request_id(client assert response["status"] == "7" assert response["network"] == "25503" assert response["request_id"] == "12345678" - assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + assert ( + response["error_text"] + == "The number you are trying to verify is blacklisted for verification" + ) + @responses.activate def test_start_psd2_verification_blacklisted_error_with_network(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_network.json" + stub( + responses.POST, + "https://api.nexmo.com/verify/psd2/json", + fixture_path="verify/blocked_with_network.json", ) params = {"number": "447525856424", "brand": "MyApp"} @@ -132,13 +151,18 @@ def test_start_psd2_verification_blacklisted_error_with_network(client, dummy_da assert "brand=MyApp" in request_body() assert response["status"] == "7" assert response["network"] == "25503" - assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + assert ( + response["error_text"] + == "The number you are trying to verify is blacklisted for verification" + ) @responses.activate def test_start_psd2_verification_blacklisted_error_with_request_id(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_request_id.json" + stub( + responses.POST, + "https://api.nexmo.com/verify/psd2/json", + fixture_path="verify/blocked_with_request_id.json", ) params = {"number": "447525856424", "brand": "MyApp"} @@ -150,13 +174,18 @@ def test_start_psd2_verification_blacklisted_error_with_request_id(client, dummy assert "brand=MyApp" in request_body() assert response["status"] == "7" assert response["request_id"] == "12345678" - assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + assert ( + response["error_text"] + == "The number you are trying to verify is blacklisted for verification" + ) @responses.activate def test_start_psd2_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_network_and_request_id.json" + stub( + responses.POST, + "https://api.nexmo.com/verify/psd2/json", + fixture_path="verify/blocked_with_network_and_request_id.json", ) params = {"number": "447525856424", "brand": "MyApp"} @@ -169,4 +198,7 @@ def test_start_psd2_verification_blacklisted_error_with_network_and_request_id(c assert response["status"] == "7" assert response["network"] == "25503" assert response["request_id"] == "12345678" - assert response["error_text"] == "The number you are trying to verify is blacklisted for verification" + assert ( + response["error_text"] + == "The number you are trying to verify is blacklisted for verification" + ) diff --git a/tests/test_voice.py b/tests/test_voice.py index 25aba2b5..a81cb847 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -43,7 +43,13 @@ def test_create_call_with_ncco_builder(voice, dummy_data): stub(responses.POST, "https://api.nexmo.com/v1/calls") talk = Ncco.Talk( - text='Hello from Vonage!', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True + text='Hello from Vonage!', + bargeIn=True, + loop=3, + level=0.5, + language='en-GB', + style=1, + premium=True, ) ncco = Ncco.build_ncco(talk) voice.create_call( @@ -147,7 +153,7 @@ def test_user_provided_authorization(dummy_data): nbf = int(time.time()) exp = nbf + 3600 - + client.auth(nbf=nbf, exp=exp) client.voice.get_call("xx-xx-xx-xx") @@ -175,7 +181,9 @@ def test_authorization_with_private_key_path(dummy_data): voice = vonage.Voice(client) voice.get_call("xx-xx-xx-xx") - token = jwt.decode(request_authorization().split()[1], dummy_data.public_key, algorithms="RS256") + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" + ) assert token["application_id"] == dummy_data.application_id @@ -185,14 +193,18 @@ def test_authorization_with_private_key_object(voice, dummy_data): voice.get_call("xx-xx-xx-xx") - token = jwt.decode(request_authorization().split()[1], dummy_data.public_key, algorithms="RS256") + token = jwt.decode( + request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" + ) assert token["application_id"] == dummy_data.application_id @responses.activate def test_get_recording(voice, dummy_data): stub_bytes( - responses.GET, "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", body=b'THISISANMP3' + responses.GET, + "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", + body=b'THISISANMP3', ) assert isinstance( From 077358d5ca7cd205bedc6ebca14c921e96d8ffb5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 24 Aug 2023 18:40:02 +0100 Subject: [PATCH 260/401] adding whitespace check --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25636872..d58af5cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v4.4.0 hooks: - id: check-yaml + - id: trailing-whitespace - repo: https://github.com/ambv/black rev: 23.7.0 hooks: From 0dbd941fa5c7d44a348f62c7d75b2a64997fcc2c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 27 Aug 2023 04:34:01 +0100 Subject: [PATCH 261/401] changing meetings api url to v1 endpoint --- src/vonage/client.py | 2 +- tests/test_meetings.py | 82 +++++++++++++++++++++--------------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/vonage/client.py b/src/vonage/client.py index e6a72499..194140a4 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -108,7 +108,7 @@ def __init__( self._jwt_claims = {} self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" - self._meetings_api_host = "api-eu.vonage.com/beta/meetings" + self._meetings_api_host = "api-eu.vonage.com/v1/meetings" self._proactive_connect_host = "api-eu.vonage.com" user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" diff --git a/tests/test_meetings.py b/tests/test_meetings.py index ced694e7..74182836 100644 --- a/tests/test_meetings.py +++ b/tests/test_meetings.py @@ -1,5 +1,5 @@ from util import * -from vonage.errors import MeetingsError, ClientError, ServerError +from vonage.errors import MeetingsError, ClientError import responses import json @@ -10,7 +10,7 @@ def test_create_instant_room(meetings, dummy_data): stub( responses.POST, - "https://api-eu.vonage.com/beta/meetings/rooms", + "https://api-eu.vonage.com/v1/meetings/rooms", fixture_path='meetings/meeting_room.json', ) @@ -36,7 +36,7 @@ def test_create_instant_room_error_expiry(meetings, dummy_data): def test_create_long_term_room(meetings, dummy_data): stub( responses.POST, - "https://api-eu.vonage.com/beta/meetings/rooms", + "https://api-eu.vonage.com/v1/meetings/rooms", fixture_path='meetings/long_term_room.json', ) @@ -77,7 +77,7 @@ def test_create_long_term_room_error(meetings): def test_get_room(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', + 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', fixture_path='meetings/meeting_room.json', ) meeting = meetings.get_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69') @@ -101,7 +101,7 @@ def test_get_room_error_no_room_specified(meetings): def test_list_rooms(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/rooms', + 'https://api-eu.vonage.com/v1/meetings/rooms', fixture_path='meetings/multiple_rooms.json', ) response = meetings.list_rooms() @@ -119,7 +119,7 @@ def test_list_rooms(meetings): def test_list_rooms_with_page_size(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/rooms', + 'https://api-eu.vonage.com/v1/meetings/rooms', fixture_path='meetings/multiple_fewer_rooms.json', ) response = meetings.list_rooms(page_size=2) @@ -135,7 +135,7 @@ def test_list_rooms_with_page_size(meetings): def test_error_unauthorized(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/rooms', + 'https://api-eu.vonage.com/v1/meetings/rooms', fixture_path='meetings/unauthorized.json', status_code=401, ) @@ -148,7 +148,7 @@ def test_error_unauthorized(meetings): def test_update_room(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', + 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', fixture_path='meetings/update_room.json', ) @@ -173,7 +173,7 @@ def test_update_room(meetings): def test_add_theme_to_room(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', + 'https://api-eu.vonage.com/v1/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', fixture_path='meetings/long_term_room_with_theme.json', ) @@ -190,7 +190,7 @@ def test_add_theme_to_room(meetings): def test_update_room_error_no_room_specified(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', + 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', fixture_path='meetings/update_room_type_error.json', status_code=400, ) @@ -206,7 +206,7 @@ def test_update_room_error_no_room_specified(meetings): def test_update_room_error_no_params_specified(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', + 'https://api-eu.vonage.com/v1/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', fixture_path='meetings/update_room_type_error.json', status_code=400, ) @@ -219,7 +219,7 @@ def test_update_room_error_no_params_specified(meetings): def test_get_recording(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', + 'https://api-eu.vonage.com/v1/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', fixture_path='meetings/get_recording.json', ) @@ -236,7 +236,7 @@ def test_get_recording(meetings): def test_get_recording_not_found(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/recordings/not-a-real-recording-id', + 'https://api-eu.vonage.com/v1/meetings/recordings/not-a-real-recording-id', fixture_path='meetings/get_recording_not_found.json', status_code=404, ) @@ -253,7 +253,7 @@ def test_get_recording_not_found(meetings): def test_delete_recording(meetings): stub( responses.DELETE, - 'https://api-eu.vonage.com/beta/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', + 'https://api-eu.vonage.com/v1/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', fixture_path='no_content.json', ) @@ -264,7 +264,7 @@ def test_delete_recording(meetings): def test_delete_recording_not_uploaded(meetings, client): stub( responses.DELETE, - 'https://api-eu.vonage.com/beta/meetings/recordings/881f0dbe-3d91-4fd6-aeea-0eca4209b512', + 'https://api-eu.vonage.com/v1/meetings/recordings/881f0dbe-3d91-4fd6-aeea-0eca4209b512', fixture_path='meetings/delete_recording_not_found.json', status_code=404, ) @@ -278,7 +278,7 @@ def test_delete_recording_not_uploaded(meetings, client): def test_get_session_recordings(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/sessions/1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4/recordings', + 'https://api-eu.vonage.com/v1/meetings/sessions/1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4/recordings', fixture_path='meetings/get_session_recordings.json', ) @@ -294,7 +294,7 @@ def test_get_session_recordings(meetings): def test_get_session_recordings_not_found(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/sessions/not-a-real-session-id/recordings', + 'https://api-eu.vonage.com/v1/meetings/sessions/not-a-real-session-id/recordings', fixture_path='meetings/get_session_recordings_not_found.json', status_code=404, ) @@ -311,7 +311,7 @@ def test_get_session_recordings_not_found(meetings): def test_list_dial_in_numbers(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/dial-in-numbers', + 'https://api-eu.vonage.com/v1/meetings/dial-in-numbers', fixture_path='meetings/list_dial_in_numbers.json', ) @@ -326,7 +326,7 @@ def test_list_dial_in_numbers(meetings): def test_list_themes(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/themes', + 'https://api-eu.vonage.com/v1/meetings/themes', fixture_path='meetings/list_themes.json', ) @@ -343,7 +343,7 @@ def test_list_themes(meetings): def test_list_themes_no_themes(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/themes', + 'https://api-eu.vonage.com/v1/meetings/themes', fixture_path='meetings/empty_themes.json', ) @@ -354,7 +354,7 @@ def test_list_themes_no_themes(meetings): def test_create_theme(meetings): stub( responses.POST, - "https://api-eu.vonage.com/beta/meetings/themes", + "https://api-eu.vonage.com/v1/meetings/themes", fixture_path='meetings/theme.json', ) @@ -382,7 +382,7 @@ def test_create_theme_missing_required_params(meetings): def test_create_theme_name_already_in_use(meetings): stub( responses.POST, - "https://api-eu.vonage.com/beta/meetings/themes", + "https://api-eu.vonage.com/v1/meetings/themes", fixture_path='meetings/theme_name_in_use.json', status_code=409, ) @@ -404,7 +404,7 @@ def test_create_theme_name_already_in_use(meetings): def test_get_theme(meetings): stub( responses.GET, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", fixture_path='meetings/theme.json', ) @@ -417,7 +417,7 @@ def test_get_theme(meetings): def test_get_theme_not_found(meetings): stub( responses.GET, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", fixture_path='meetings/theme_not_found.json', status_code=404, ) @@ -434,7 +434,7 @@ def test_get_theme_not_found(meetings): def test_delete_theme(meetings): stub( responses.DELETE, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", fixture_path='no_content.json', ) @@ -446,7 +446,7 @@ def test_delete_theme(meetings): def test_delete_theme_not_found(meetings): stub( responses.DELETE, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", fixture_path='meetings/theme_not_found.json', status_code=404, ) @@ -463,7 +463,7 @@ def test_delete_theme_not_found(meetings): def test_delete_theme_in_use(meetings): stub( responses.DELETE, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", fixture_path='meetings/delete_theme_in_use.json', status_code=400, ) @@ -480,7 +480,7 @@ def test_delete_theme_in_use(meetings): def test_update_theme(meetings): stub( responses.PATCH, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", fixture_path='meetings/updated_theme.json', ) @@ -504,7 +504,7 @@ def test_update_theme(meetings): def test_update_theme_no_keys(meetings): stub( responses.PATCH, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", fixture_path='meetings/update_no_keys.json', status_code=400, ) @@ -521,7 +521,7 @@ def test_update_theme_no_keys(meetings): def test_update_theme_not_found(meetings): stub( responses.PATCH, - "https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", + "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", fixture_path='meetings/theme_not_found.json', status_code=404, ) @@ -541,7 +541,7 @@ def test_update_theme_not_found(meetings): def test_update_theme_name_already_exists(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db', fixture_path='meetings/update_theme_already_exists.json', status_code=409, ) @@ -560,7 +560,7 @@ def test_update_theme_name_already_exists(meetings): def test_list_rooms_with_options(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/rooms', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/rooms', fixture_path='meetings/list_rooms_with_theme_id.json', ) @@ -584,7 +584,7 @@ def test_list_rooms_with_options(meetings): def test_list_rooms_with_theme_id_not_found(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/rooms', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/rooms', fixture_path='meetings/list_rooms_theme_id_not_found.json', status_code=404, ) @@ -603,7 +603,7 @@ def test_list_rooms_with_theme_id_not_found(meetings): def test_update_application_theme(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/applications', + 'https://api-eu.vonage.com/v1/meetings/applications', fixture_path='meetings/update_application_theme.json', ) @@ -617,7 +617,7 @@ def test_update_application_theme(meetings): def test_update_application_theme_bad_request(meetings): stub( responses.PATCH, - 'https://api-eu.vonage.com/beta/meetings/applications', + 'https://api-eu.vonage.com/v1/meetings/applications', fixture_path='meetings/update_application_theme_id_not_found.json', status_code=400, ) @@ -634,7 +634,7 @@ def test_update_application_theme_bad_request(meetings): def test_upload_logo_to_theme(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/themes/logos-upload-urls', + 'https://api-eu.vonage.com/v1/meetings/themes/logos-upload-urls', fixture_path='meetings/list_logo_upload_urls.json', ) stub( @@ -645,7 +645,7 @@ def test_upload_logo_to_theme(meetings): ) stub_bytes( responses.PUT, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', body=b'OK', ) @@ -661,7 +661,7 @@ def test_upload_logo_to_theme(meetings): def test_get_logo_upload_url(meetings): stub( responses.GET, - 'https://api-eu.vonage.com/beta/meetings/themes/logos-upload-urls', + 'https://api-eu.vonage.com/v1/meetings/themes/logos-upload-urls', fixture_path='meetings/list_logo_upload_urls.json', ) @@ -732,7 +732,7 @@ def test_upload_to_aws_error(meetings): def test_add_logo_to_theme(meetings): stub_bytes( responses.PUT, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', body=b'OK', ) @@ -747,7 +747,7 @@ def test_add_logo_to_theme(meetings): def test_add_logo_to_theme_key_error(meetings): stub( responses.PUT, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', fixture_path='meetings/logo_key_error.json', status_code=400, ) @@ -767,7 +767,7 @@ def test_add_logo_to_theme_key_error(meetings): def test_add_logo_to_theme_not_found_error(meetings): stub( responses.PUT, - 'https://api-eu.vonage.com/beta/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', + 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', fixture_path='meetings/theme_not_found.json', status_code=404, ) From f419c25e791b44bade608c7b2430d3d40bc77166 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 30 Aug 2023 02:25:08 +0100 Subject: [PATCH 262/401] =?UTF-8?q?Bump=20version:=203.9.0=20=E2=86=92=203?= =?UTF-8?q?.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 87a8a380..b1df7a90 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.0 +current_version = 3.9.1 commit = True tag = False diff --git a/setup.py b/setup.py index a9563f35..3a199d4a 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.9.0", + version="3.9.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 115f22a3..74363016 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.9.0" +__version__ = "3.9.1" From c57adffc52ad1fc98fb5dd86be312a026583e791 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 30 Aug 2023 02:26:41 +0100 Subject: [PATCH 263/401] updating changelog --- CHANGES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 28486b0b..e4bbefdf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.9.1 +- Updating Meetings API url to a `/v1` endpoint + # 3.9.0 - Dropped support for Python 3.7 as it's end-of-life and no longer receiving security updates @@ -83,7 +86,7 @@ Enhancements: - Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. # 2.7.0 -- Moved some client methods into their own classes: `account.py, application.py, +- Moved some client methods into their own classes: `account.py, application.py, message_search.py, number_insight.py, numbers.py, short_codes.py, ussd.py` - Deprecated the corresponding client methods. These will be removed in a major release that's coming soon. - Client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` From 57e0c08575783e9ae17ac2bf46c10d06f0155f2a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 4 Oct 2023 13:28:18 +0100 Subject: [PATCH 264/401] supporting python 3.12 --- CHANGES.md | 3 +++ setup.py | 1 + 2 files changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index e4bbefdf..13db68e2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.10.0 +- Indicating support for Python 3.12 + # 3.9.1 - Updating Meetings API url to a `/v1` endpoint diff --git a/setup.py b/setup.py index 3a199d4a..378db219 100644 --- a/setup.py +++ b/setup.py @@ -36,5 +36,6 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], ) From 577f905aa94557d802cf35af4cbd8620d74afc3c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 4 Oct 2023 13:29:57 +0100 Subject: [PATCH 265/401] =?UTF-8?q?Bump=20version:=203.9.1=20=E2=86=92=203?= =?UTF-8?q?.10.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b1df7a90..3dfe74ff 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.1 +current_version = 3.10.0 commit = True tag = False diff --git a/setup.py b/setup.py index 378db219..5119fbef 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.9.1", + version="3.10.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 74363016..843bf701 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.9.1" +__version__ = "3.10.0" From a8b07165acdd74eb07e1a1381fa541d5941407d8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 4 Oct 2023 13:34:35 +0100 Subject: [PATCH 266/401] adding 3.12 as a test runner --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 261af26d..715918af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: ["ubuntu-latest", "macos-latest"] steps: - uses: actions/setup-python@v4 From 1806bc05e1661c13dc71a173094524b833349372 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 19 Oct 2023 15:30:53 +0100 Subject: [PATCH 267/401] Add jwt signing (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add method to check jwt signatures to voice api * Bump version: 3.10.0 → 3.11.0 --- .bumpversion.cfg | 2 +- CHANGES.md | 3 +++ README.md | 12 ++++++++++++ requirements.txt | 2 +- setup.py | 4 ++-- src/vonage/__init__.py | 2 +- src/vonage/voice.py | 4 ++++ tests/test_voice.py | 23 +++++++++++++++++------ 8 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3dfe74ff..a2a5b6a0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.10.0 +current_version = 3.11.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index 13db68e2..10028b69 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.11.0 +- Add method to check JWT signatures of Voice API webhooks: `vonage.Voice.verify_signature` + # 3.10.0 - Indicating support for Python 3.12 diff --git a/README.md b/README.md index 7bb3a883..2c80f7e5 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,18 @@ client.voice.send_dtmf(response['uuid'], digits='1234') response = client.get_recording(RECORDING_URL) ``` +### Verify the Signature of a Webhook Sent by Vonage + +If signed webhooks are enabled (the default), Vonage will sign webhooks with the signature secret found in the [API Settings](https://dashboard.nexmo.com/settings) section of the Vonage Developer Dashboard. + +```python +if client.voice.verify_signature('JWT_RECEIVED_FROM_VONAGE', 'MY_VONAGE_SIGNATURE_SECRET'): + print('Signature is valid!') +else: + print('Signature is invalid!') +``` + + ## NCCO Builder The SDK contains a builder to help you create Call Control Objects (NCCOs) for use with the Vonage Voice API. diff --git a/requirements.txt b/requirements.txt index ba35aaa2..32c7341d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -e . -pytest==7.2.0 +pytest==7.4.2 responses==0.22.0 coverage pydantic>=1.10,==1.* diff --git a/setup.py b/setup.py index 5119fbef..730d8f40 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.10.0", + version="3.11.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", @@ -21,7 +21,7 @@ package_dir={"": "src"}, platforms=["any"], install_requires=[ - "vonage-jwt>=1.0.0", + "vonage-jwt>=1.1.0", "requests>=2.4.2", "pytz>=2018.5", "Deprecated", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 843bf701..98bec541 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.10.0" +__version__ = "3.11.0" diff --git a/src/vonage/voice.py b/src/vonage/voice.py index 74a9f2b5..08e2e116 100644 --- a/src/vonage/voice.py +++ b/src/vonage/voice.py @@ -1,4 +1,5 @@ from urllib.parse import urlparse +from vonage_jwt.verify_jwt import verify_signature class Voice: @@ -94,3 +95,6 @@ def get_recording(self, url): headers = self._client.headers headers['Authorization'] = self._client._create_jwt_auth_string() return self._client.parse(hostname, self._client.session.get(url, headers=headers)) + + def verify_signature(self, token: str, signature: str) -> bool: + return verify_signature(token, signature) diff --git a/tests/test_voice.py b/tests/test_voice.py index a81cb847..22f26aab 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -1,10 +1,9 @@ import os.path import time - import jwt +from unittest.mock import patch -import vonage -from vonage import Ncco +from vonage import Client, Voice, Ncco from util import * @@ -149,7 +148,7 @@ def test_user_provided_authorization(dummy_data): stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") application_id = "different-application-id" - client = vonage.Client(application_id=application_id, private_key=dummy_data.private_key) + client = Client(application_id=application_id, private_key=dummy_data.private_key) nbf = int(time.time()) exp = nbf + 3600 @@ -172,13 +171,13 @@ def test_authorization_with_private_key_path(dummy_data): private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - client = vonage.Client( + client = Client( key=dummy_data.api_key, secret=dummy_data.api_secret, application_id=dummy_data.application_id, private_key=private_key, ) - voice = vonage.Voice(client) + voice = Voice(client) voice.get_call("xx-xx-xx-xx") token = jwt.decode( @@ -212,3 +211,15 @@ def test_get_recording(voice, dummy_data): bytes, ) assert request_user_agent() == dummy_data.user_agent + + +def test_verify_jwt_signature(voice: Voice): + with patch('vonage.Voice.verify_signature') as mocked_verify_signature: + mocked_verify_signature.return_value = True + assert voice.verify_signature('valid_token', 'valid_signature') + + +def test_verify_jwt_invalid_signature(voice: Voice): + with patch('vonage.Voice.verify_signature') as mocked_verify_signature: + mocked_verify_signature.return_value = False + assert voice.verify_signature('token', 'invalid_signature') is False From df352227ffb6e32da06847420cc233ddce12eeb8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 23 Oct 2023 17:27:52 +0100 Subject: [PATCH 268/401] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c80f7e5..3d88ee62 100644 --- a/README.md +++ b/README.md @@ -1212,7 +1212,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | -| Procative Connect API | General Availability | ✅ (partially supported) | +| Proactive Connect API | General Availability | ✅ (partially supported) | | Redact API | Developer Preview | ❌ | | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | From 36ddbe4383d91fcfcf02e580b5cb3b4d07899840 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 25 Oct 2023 03:35:49 +0100 Subject: [PATCH 269/401] add check_url to silent_auth test and readme --- README.md | 1 + tests/data/verify2/create_request_silent_auth.json | 4 ++++ tests/test_verify2.py | 8 ++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 tests/data/verify2/create_request_silent_auth.json diff --git a/README.md b/README.md index 3d88ee62..b9f74360 100644 --- a/README.md +++ b/README.md @@ -449,6 +449,7 @@ params = { ] } verify_request = verify2.new_request(params) +check_url = verify_request['check_url'] # URL to continue with the silent auth workflow ``` ### Send a verification code with custom options, including a custom code diff --git a/tests/data/verify2/create_request_silent_auth.json b/tests/data/verify2/create_request_silent_auth.json new file mode 100644 index 00000000..d313581a --- /dev/null +++ b/tests/data/verify2/create_request_silent_auth.json @@ -0,0 +1,4 @@ +{ + "request_id": "b3a2f4bd-7bda-4e5e-978a-81514702d2ce", + "check_url": "https://api-eu-3.vonage.com/v2/verify/b3a2f4bd-7bda-4e5e-978a-81514702d2ce/silent-auth/redirect" +} \ No newline at end of file diff --git a/tests/test_verify2.py b/tests/test_verify2.py index 71714f41..ef0b4ad0 100644 --- a/tests/test_verify2.py +++ b/tests/test_verify2.py @@ -392,14 +392,18 @@ def test_new_request_silent_auth(): stub( responses.POST, 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', + fixture_path='verify2/create_request_silent_auth.json', status_code=202, ) params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'silent_auth', 'to': '447700900000'}]} verify_request = verify2.new_request(params) - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' + assert verify_request['request_id'] == 'b3a2f4bd-7bda-4e5e-978a-81514702d2ce' + assert ( + verify_request['check_url'] + == 'https://api-eu-3.vonage.com/v2/verify/b3a2f4bd-7bda-4e5e-978a-81514702d2ce/silent-auth/redirect' + ) @responses.activate From e5b16823a49c57b7c08c06fbd02ad904cefb8d4d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 29 Nov 2023 12:38:53 +0000 Subject: [PATCH 270/401] add checks for silent auth workflow parameters --- CHANGES.md | 3 +++ src/vonage/verify2.py | 12 +++++++++++- tests/test_verify2.py | 44 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 10028b69..e0e660d5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.11.1 +- Add checks for silent auth workflow optional parameters `redirect_url` and `sandbox` + # 3.11.0 - Add method to check JWT signatures of Voice API webhooks: `vonage.Voice.verify_signature` diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py index 33e0822f..33cffb44 100644 --- a/src/vonage/verify2.py +++ b/src/vonage/verify2.py @@ -84,6 +84,8 @@ def check_valid_workflow(cls, v): Verify2._check_app_hash(workflow) if workflow['channel'] == 'whatsapp' and 'from' in workflow: Verify2._check_whatsapp_sender(workflow) + if workflow['channel'] == 'silent_auth': + Verify2._check_silent_auth_workflow(workflow) def _check_valid_channel(workflow): if 'channel' not in workflow or workflow['channel'] not in Verify2.valid_channels: @@ -113,4 +115,12 @@ def _check_app_hash(workflow): def _check_whatsapp_sender(workflow): if not re.search(r'^[1-9]\d{6,14}$', workflow['from']): - raise Verify2Error(f'You must specify a valid "from" value if included.') + raise Verify2Error('You must specify a valid "from" value if included.') + + def _check_silent_auth_workflow(workflow): + if 'redirect_url' in workflow: + if type(workflow['redirect_url']) != str: + raise Verify2Error('"redirect_url" must be a string if specified.') + if 'sandbox' in workflow: + if type(workflow['sandbox']) != bool: + raise Verify2Error('"sandbox" must be a boolean if specified.') diff --git a/tests/test_verify2.py b/tests/test_verify2.py index ef0b4ad0..5a586b28 100644 --- a/tests/test_verify2.py +++ b/tests/test_verify2.py @@ -396,7 +396,17 @@ def test_new_request_silent_auth(): status_code=202, ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'silent_auth', 'to': '447700900000'}]} + params = { + 'brand': 'ACME, Inc', + 'workflow': [ + { + 'channel': 'silent_auth', + 'to': '447000000000', + 'redirect_url': 'https://acme-app.com/sa/redirect', + 'sandbox': False, + } + ], + } verify_request = verify2.new_request(params) assert verify_request['request_id'] == 'b3a2f4bd-7bda-4e5e-978a-81514702d2ce' @@ -406,6 +416,38 @@ def test_new_request_silent_auth(): ) +def test_silent_auth_redirect_url_error(): + params = { + 'brand': 'ACME, Inc', + 'workflow': [ + { + 'channel': 'silent_auth', + 'to': '447000000000', + 'redirect_url': ['https://acme-app.com/sa/redirect'], + } + ], + } + with raises(Verify2Error) as err: + verify2.new_request(params) + assert str(err.value) == '"redirect_url" must be a string if specified.' + + +def test_silent_auth_sandbox_error(): + params = { + 'brand': 'ACME, Inc', + 'workflow': [ + { + 'channel': 'silent_auth', + 'to': '447000000000', + 'sandbox': 'true', + } + ], + } + with raises(Verify2Error) as err: + verify2.new_request(params) + assert str(err.value) == '"sandbox" must be a boolean if specified.' + + @responses.activate def test_new_request_error_conflict(): stub( From 13808e175cb072828fc98a2f8311415088882d6e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 29 Nov 2023 12:39:19 +0000 Subject: [PATCH 271/401] =?UTF-8?q?Bump=20version:=203.11.0=20=E2=86=92=20?= =?UTF-8?q?3.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a2a5b6a0..49b97918 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.11.0 +current_version = 3.11.1 commit = True tag = False diff --git a/setup.py b/setup.py index 730d8f40..829016e7 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.11.0", + version="3.11.1", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index 98bec541..a2db0aff 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.11.0" +__version__ = "3.11.1" From cf5195963d1252be2299a4de21fdc0a4bb9f5d88 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Dec 2023 17:02:25 +0000 Subject: [PATCH 272/401] Add video api (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * move video module * add video errors * add video host and imports to client * fix tests * Update changelog and readme * Bump version: 3.11.1 → 3.12.0 * add coming soon notice --- .bumpversion.cfg | 2 +- CHANGES.md | 3 + OPENTOK_TO_VONAGE_MIGRATION.md | 1 + README.md | 5 + setup.py | 2 +- src/vonage/__init__.py | 2 +- src/vonage/client.py | 11 + src/vonage/errors.py | 16 + src/vonage/video.py | 353 +++++++++ tests/data/null.json | 0 tests/data/video/broadcast.json | 39 + tests/data/video/create_archive.json | 17 + tests/data/video/create_session.json | 19 + tests/data/video/create_sip_call.json | 5 + .../video/disable_mute_multiple_streams.json | 7 + tests/data/video/get_archive.json | 18 + tests/data/video/get_stream.json | 8 + tests/data/video/list_archives.json | 28 + tests/data/video/list_broadcasts.json | 44 ++ tests/data/video/list_streams.json | 13 + tests/data/video/mute_multiple_streams.json | 7 + tests/data/video/mute_specific_stream.json | 7 + tests/data/video/null.json | 1 + tests/data/video/play_dtmf_invalid_error.json | 4 + tests/data/video/stop_archive.json | 15 + tests/test_proactive_connect.py | 8 +- tests/test_video.py | 678 ++++++++++++++++++ 27 files changed, 1306 insertions(+), 7 deletions(-) create mode 100644 OPENTOK_TO_VONAGE_MIGRATION.md create mode 100644 src/vonage/video.py delete mode 100644 tests/data/null.json create mode 100644 tests/data/video/broadcast.json create mode 100644 tests/data/video/create_archive.json create mode 100644 tests/data/video/create_session.json create mode 100644 tests/data/video/create_sip_call.json create mode 100644 tests/data/video/disable_mute_multiple_streams.json create mode 100644 tests/data/video/get_archive.json create mode 100644 tests/data/video/get_stream.json create mode 100644 tests/data/video/list_archives.json create mode 100644 tests/data/video/list_broadcasts.json create mode 100644 tests/data/video/list_streams.json create mode 100644 tests/data/video/mute_multiple_streams.json create mode 100644 tests/data/video/mute_specific_stream.json create mode 100644 tests/data/video/null.json create mode 100644 tests/data/video/play_dtmf_invalid_error.json create mode 100644 tests/data/video/stop_archive.json create mode 100644 tests/test_video.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 49b97918..b10ee53e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.11.1 +current_version = 3.12.0 commit = True tag = False diff --git a/CHANGES.md b/CHANGES.md index e0e660d5..bad43e6f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.12.0 +- Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) + # 3.11.1 - Add checks for silent auth workflow optional parameters `redirect_url` and `sandbox` diff --git a/OPENTOK_TO_VONAGE_MIGRATION.md b/OPENTOK_TO_VONAGE_MIGRATION.md new file mode 100644 index 00000000..3014a68f --- /dev/null +++ b/OPENTOK_TO_VONAGE_MIGRATION.md @@ -0,0 +1 @@ +Coming soon! \ No newline at end of file diff --git a/README.md b/README.md index b9f74360..9c5d8684 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [NCCO Builder](#ncco-builder) - [Verify V2 API](#verify-v2-api) - [Verify V1 API](#verify-v1-api) +- [Video API](#video-api) - [Meetings API](#meetings-api) - [Number Insight API](#number-insight-api) - [Proactive Connect API](#proactive-connect-api) @@ -595,6 +596,10 @@ else: print("Error: %s" % response["error_text"]) ``` +## Video API + +You can make calls to the Vonage Video API from this SDK. See the [Vonage Video API documentation](https://developer.vonage.com/en/video/overview) for detailed information and instructions on how to use the Vonage Python SDK with the Vonage Video API. Have a look at the SDK's [OpenTok to Vonage migration guide](OPENTOK_TO_VONAGE_MIGRATION.md) if you've previously used OpenTok. + ## Meetings API Full docs for the [Meetings API are available here](https://developer.vonage.com/en/meetings/overview). diff --git a/setup.py b/setup.py index 829016e7..d7a503cf 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.11.1", + version="3.12.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index a2db0aff..e173a655 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.11.1" +__version__ = "3.12.0" diff --git a/src/vonage/client.py b/src/vonage/client.py index 194140a4..af97036d 100644 --- a/src/vonage/client.py +++ b/src/vonage/client.py @@ -15,6 +15,7 @@ from .subaccounts import Subaccounts from .users import Users from .ussd import Ussd +from .video import Video from .voice import Voice from .verify import Verify from .verify2 import Verify2 @@ -91,6 +92,8 @@ def __init__( self.api_key = key or os.environ.get("VONAGE_API_KEY", None) self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) + self.application_id = application_id + self.signature_secret = signature_secret or os.environ.get("VONAGE_SIGNATURE_SECRET", None) self.signature_method = signature_method or os.environ.get("VONAGE_SIGNATURE_METHOD", None) @@ -108,6 +111,7 @@ def __init__( self._jwt_claims = {} self._host = "rest.nexmo.com" self._api_host = "api.nexmo.com" + self._video_host = "video.api.vonage.com" self._meetings_api_host = "api-eu.vonage.com/v1/meetings" self._proactive_connect_host = "api-eu.vonage.com" @@ -135,6 +139,7 @@ def __init__( self.ussd = Ussd(self) self.verify = Verify(self) self.verify2 = Verify2(self) + self.video = Video(self) self.voice = Voice(self) self.timeout = timeout @@ -160,6 +165,12 @@ def api_host(self, value=None): else: self._api_host = value + def video_host(self, value=None): + if value is None: + return self._video_host + else: + self._video_host = value + # Gets and sets _meetings_api_host attribute def meetings_api_host(self, value=None): if value is None: diff --git a/src/vonage/errors.py b/src/vonage/errors.py index 677aa366..27e4f8dd 100644 --- a/src/vonage/errors.py +++ b/src/vonage/errors.py @@ -52,5 +52,21 @@ class ProactiveConnectError(ClientError): """An error relating to the Proactive Connect API.""" +class VideoError(ClientError): + """An error relating to the Video API.""" + + class UsersError(ClientError): """An error relating to the Users API.""" + + +class InvalidRoleError(ClientError): + """The specified role was invalid.""" + + +class TokenExpiryError(ClientError): + """The specified token expiry time was invalid.""" + + +class SipError(ClientError): + """Error related to usage of SIP calls.""" diff --git a/src/vonage/video.py b/src/vonage/video.py new file mode 100644 index 00000000..d1f84419 --- /dev/null +++ b/src/vonage/video.py @@ -0,0 +1,353 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vonage import Client + +from .errors import ( + InvalidRoleError, + TokenExpiryError, + SipError, + VideoError, +) + +import re +from time import time +from uuid import uuid4 + + +class Video: + auth_type = 'jwt' + archive_mode_values = {'manual', 'always'} + media_mode_values = {'routed', 'relayed'} + token_roles = {'subscriber', 'publisher', 'moderator'} + + def __init__(self, client: Client): + self._client = client + + def create_session(self, session_options: dict = None): + if session_options is None: + session_options = {} + + params = {'archiveMode': 'manual', 'p2p.preference': 'disabled', 'location': None} + if ( + 'archive_mode' in session_options + and session_options['archive_mode'] not in Video.archive_mode_values + ): + raise VideoError( + f'Invalid archive_mode value. Must be one of {Video.archive_mode_values}.' + ) + elif 'archive_mode' in session_options: + params['archiveMode'] = session_options['archive_mode'] + if ( + 'media_mode' in session_options + and session_options['media_mode'] not in Video.media_mode_values + ): + raise VideoError(f'Invalid media_mode value. Must be one of {Video.media_mode_values}.') + elif 'media_mode' in session_options: + if session_options['media_mode'] == 'routed': + params['p2p.preference'] = 'disabled' + elif session_options['media_mode'] == 'relayed': + if params['archiveMode'] == 'always': + raise VideoError( + 'Invalid combination: cannot specify "archive_mode": "always" and "media_mode": "relayed".' + ) + else: + params['p2p.preference'] = 'enabled' + if 'location' in session_options: + params['location'] = session_options['location'] + + session = self._client.post( + self._client.video_host(), + '/session/create', + params, + auth_type=Video.auth_type, + body_is_json=False, + )[0] + + media_mode = self.get_media_mode(params['p2p.preference']) + session_info = { + 'session_id': session['session_id'], + 'archive_mode': params['archiveMode'], + 'media_mode': media_mode, + 'location': params['location'], + } + + return session_info + + def get_media_mode(self, p2p_preference): + if p2p_preference == 'disabled': + return 'routed' + elif p2p_preference == 'enabled': + return 'relayed' + + def get_stream(self, session_id, stream_id): + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/stream/{stream_id}', + auth_type=Video.auth_type, + ) + + def list_streams(self, session_id): + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/stream', + auth_type=Video.auth_type, + ) + + def set_stream_layout(self, session_id, items): + return self._client.put( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/stream', + items, + auth_type=Video.auth_type, + ) + + def send_signal(self, session_id, type, data, connection_id=None): + if connection_id: + request_uri = f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}/signal' + else: + request_uri = f'/v2/project/{self._client.application_id}/session/{session_id}/signal' + + params = {'type': type, 'data': data} + + return self._client.post( + self._client.video_host(), request_uri, params, auth_type=Video.auth_type + ) + + def disconnect_client(self, session_id, connection_id): + return self._client.delete( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}', + auth_type=Video.auth_type, + ) + + def mute_stream(self, session_id, stream_id): + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/stream/{stream_id}/mute', + params=None, + auth_type=Video.auth_type, + ) + + def mute_all_streams(self, session_id, active=True, excluded_stream_ids: list = []): + params = {'active': active, 'excludedStreamIds': excluded_stream_ids} + + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/mute', + params, + auth_type=Video.auth_type, + ) + + def disable_mute_all_streams(self, session_id, excluded_stream_ids: list = []): + return self.mute_all_streams( + session_id, active=False, excluded_stream_ids=excluded_stream_ids + ) + + def list_archives(self, filter_params=None, **filter_kwargs): + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive', + filter_params or filter_kwargs, + auth_type=Video.auth_type, + ) + + def create_archive(self, params=None, **kwargs): + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive', + params or kwargs, + auth_type=Video.auth_type, + ) + + def get_archive(self, archive_id): + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive/{archive_id}', + auth_type=Video.auth_type, + ) + + def delete_archive(self, archive_id): + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive/{archive_id}', + auth_type=Video.auth_type, + ) + + def add_stream_to_archive(self, archive_id, stream_id, has_audio=True, has_video=True): + params = {'addStream': stream_id, 'hasAudio': has_audio, 'hasvideo': has_video} + + return self._client.patch( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive/{archive_id}/streams', + params, + auth_type=Video.auth_type, + ) + + def remove_stream_from_archive(self, archive_id, stream_id): + params = {'removeStream': stream_id} + + return self._client.patch( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive/{archive_id}/streams', + params, + auth_type=Video.auth_type, + ) + + def stop_archive(self, archive_id): + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive/{archive_id}/stop', + params=None, + auth_type=Video.auth_type, + ) + + def change_archive_layout(self, archive_id, params=None, **kwargs): + return self._client.put( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/archive/{archive_id}/layout', + params or kwargs, + auth_type=Video.auth_type, + ) + + def create_sip_call(self, session_id: str, token: str, sip: dict): + if 'uri' not in sip: + raise SipError('You must specify a uri when creating a SIP call.') + + params = {'sessionId': session_id, 'token': token, 'sip': sip} + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/dial', + params, + auth_type=Video.auth_type, + ) + + def play_dtmf(self, session_id: str, digits: str, connection_id: str = None): + if not re.search('^[0-9*#p]+$', digits): + raise VideoError('Only digits 0-9, *, #, and "p" are allowed.') + + params = {'digits': digits} + + if connection_id is not None: + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}/play-dtmf', + params, + auth_type=Video.auth_type, + ) + + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/session/{session_id}/play-dtmf', + params, + auth_type=Video.auth_type, + ) + + def list_broadcasts(self, offset: int = None, count: int = None, session_id: str = None): + if offset is not None and (type(offset) != int or offset < 0): + raise VideoError('Offset must be an int >= 0.') + if count is not None and (type(count) != int or count < 0 or count > 1000): + raise VideoError('Count must be an int between 0 and 1000.') + + params = {'offset': str(offset), 'count': str(count), 'sessionId': session_id} + + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast', + params, + auth_type=Video.auth_type, + ) + + def start_broadcast(self, params: dict): + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast', + params, + auth_type=Video.auth_type, + ) + + def get_broadcast(self, broadcast_id: str): + return self._client.get( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}', + auth_type=Video.auth_type, + ) + + def stop_broadcast(self, broadcast_id: str): + return self._client.post( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}', + params={}, + auth_type=Video.auth_type, + ) + + def change_broadcast_layout(self, broadcast_id: str, params: dict): + return self._client.put( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/layout', + params=params, + auth_type=Video.auth_type, + ) + + def add_stream_to_broadcast( + self, broadcast_id: str, stream_id: str, has_audio=True, has_video=True + ): + params = {'addStream': stream_id, 'hasAudio': has_audio, 'hasvideo': has_video} + + return self._client.patch( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/streams', + params, + auth_type=Video.auth_type, + ) + + def remove_stream_from_broadcast(self, broadcast_id: str, stream_id: str): + params = {'removeStream': stream_id} + + return self._client.patch( + self._client.video_host(), + f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/streams', + params, + auth_type=Video.auth_type, + ) + + def generate_client_token(self, session_id, token_options={}): + now = int(time()) + claims = { + 'scope': 'session.connect', + 'session_id': session_id, + 'role': 'publisher', + 'initial_layout_class_list': '', + 'jti': str(uuid4()), + 'iat': now, + } + if 'role' in token_options: + claims['role'] = token_options['role'] + if 'data' in token_options: + claims['data'] = token_options['data'] + if 'initialLayoutClassList' in token_options: + claims['initial_layout_class_list'] = token_options['initialLayoutClassList'] + if 'expireTime' in token_options and token_options['expireTime'] > now: + claims['exp'] = token_options['expireTime'] + if 'jti' in token_options: + claims['jti'] = token_options['jti'] + if 'iat' in token_options: + claims['iat'] = token_options['iat'] + if 'subject' in token_options: + claims['subject'] = token_options['subject'] + if 'acl' in token_options: + claims['acl'] = token_options['acl'] + + self.validate_client_token_options(claims) + self._client.auth(claims) + return self._client._generate_application_jwt() + + def validate_client_token_options(self, claims): + now = int(time()) + if claims['role'] not in Video.token_roles: + raise InvalidRoleError( + f'Invalid role specified for the client token. Valid values are: {Video.token_roles}' + ) + if 'exp' in claims and claims['exp'] > now + 3600 * 24 * 30: + raise TokenExpiryError('Token expiry date must be less than 30 days from now.') diff --git a/tests/data/null.json b/tests/data/null.json deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/video/broadcast.json b/tests/data/video/broadcast.json new file mode 100644 index 00000000..25122cf5 --- /dev/null +++ b/tests/data/video/broadcast.json @@ -0,0 +1,39 @@ +{ + "id": "1748b7070a81464c9759c46ad10d3734", + "sessionId": "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4", + "multiBroadcastTag": "broadcast_tag_provided", + "applicationId": "abc123", + "createdAt": 1437676551000, + "updatedAt": 1437676551000, + "maxDuration": 5400, + "maxBitrate": 2000000, + "broadcastUrls": { + "hls": "hlsurl", + "rtmp": [ + { + "id": "abc123", + "status": "abc123", + "serverUrl": "abc123", + "streamName": "abc123" + } + ] + }, + "settings": { + "hls": { + "lowLatency": false, + "dvr": false + } + }, + "resolution": "640x480", + "hasAudio": true, + "hasVideo": true, + "streamMode": "auto", + "status": "started", + "streams": [ + { + "streamId": "70a81464c9759c46ad10d3734", + "hasAudio": true, + "hasVideo": true + } + ] +} \ No newline at end of file diff --git a/tests/data/video/create_archive.json b/tests/data/video/create_archive.json new file mode 100644 index 00000000..725c8eb3 --- /dev/null +++ b/tests/data/video/create_archive.json @@ -0,0 +1,17 @@ +{ + "createdAt" : 1384221730555, + "duration" : 0, + "hasAudio" : true, + "hasVideo" : true, + "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", + "name" : "my_new_archive", + "outputMode" : "composed", + "projectId" : 234567, + "reason" : "", + "resolution" : "640x480", + "sessionId" : "my_session_id", + "size" : 0, + "status" : "started", + "streamMode" : "auto", + "url" : null +} \ No newline at end of file diff --git a/tests/data/video/create_session.json b/tests/data/video/create_session.json new file mode 100644 index 00000000..7c6f4b35 --- /dev/null +++ b/tests/data/video/create_session.json @@ -0,0 +1,19 @@ +[ + { + "session_id": "my_session_id", + "project_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "partner_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "create_dt": "Tue Aug 09 09:10:17 PDT 2022", + "session_status": null, + "status_invalid": null, + "media_server_hostname": null, + "messaging_server_url": null, + "messaging_url": null, + "symphony_address": null, + "properties": null, + "ice_server": null, + "session_segment_id": "b8c32a6d-faf9-4ec4-a648-a6d382cd650b", + "ice_servers": null, + "ice_credential_expiration": 86100 + } +] diff --git a/tests/data/video/create_sip_call.json b/tests/data/video/create_sip_call.json new file mode 100644 index 00000000..29cbbab6 --- /dev/null +++ b/tests/data/video/create_sip_call.json @@ -0,0 +1,5 @@ +{ + "id": "b0a5a8c7-dc38-459f-a48d-a7f2008da853", + "connectionId": "e9f8c166-6c67-440d-994a-04fb6dfed007", + "streamId": "482bce73-f882-40fd-8ca5-cb74ff416036" +} \ No newline at end of file diff --git a/tests/data/video/disable_mute_multiple_streams.json b/tests/data/video/disable_mute_multiple_streams.json new file mode 100644 index 00000000..878f6eaa --- /dev/null +++ b/tests/data/video/disable_mute_multiple_streams.json @@ -0,0 +1,7 @@ +{ + "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", + "status": "ACTIVE", + "name": "Joe Montana", + "environment": "standard", + "createdAt": 1414642898000 +} \ No newline at end of file diff --git a/tests/data/video/get_archive.json b/tests/data/video/get_archive.json new file mode 100644 index 00000000..be0ece31 --- /dev/null +++ b/tests/data/video/get_archive.json @@ -0,0 +1,18 @@ +{ + "createdAt" : 1384221730000, + "duration" : 5049, + "hasAudio" : true, + "hasVideo" : true, + "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", + "name" : "Foo", + "outputMode" : "composed", + "projectId" : 123456, + "reason" : "", + "resolution" : "640x480", + "sessionId" : "2_MX40NzIwMzJ-flR1ZSBPY3QgMjkgMTI6MTM6MjMgUERUIDIwMTN-MC45NDQ2MzE2NH4", + "size" : 247748791, + "status" : "available", + "streamMode" : "auto", + "streams" : [], + "url" : "https://tokbox.com.archive2.s3.amazonaws.com/123456/09141e29-8770-439b-b180-337d7e637545/archive.mp4" +} \ No newline at end of file diff --git a/tests/data/video/get_stream.json b/tests/data/video/get_stream.json new file mode 100644 index 00000000..5e8fb98d --- /dev/null +++ b/tests/data/video/get_stream.json @@ -0,0 +1,8 @@ +{ + "id": "8b732909-0a06-46a2-8ea8-074e64d43422", + "videoType": "camera", + "name": "", + "layoutClassList": [ + "full" + ] +} \ No newline at end of file diff --git a/tests/data/video/list_archives.json b/tests/data/video/list_archives.json new file mode 100644 index 00000000..140d59c9 --- /dev/null +++ b/tests/data/video/list_archives.json @@ -0,0 +1,28 @@ +{ + "count": 1, + "items": [ + { + "createdAt": 1384221730000, + "duration": 5049, + "hasAudio": true, + "hasVideo": true, + "id": "b40ef09b-3811-4726-b508-e41a0f96c68f", + "name": "Foo", + "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", + "reason": "", + "resolution": "abc123", + "sessionId": "my_session_id", + "size": 247748791, + "status": "available", + "streamMode": "manual", + "streams": [ + { + "streamId": "abc123", + "hasAudio": true, + "hasVideo": true + } + ], + "url": "https://tokbox.com.archive2.s3.amazonaws.com/123456/09141e29-8770-439b-b180-337d7e637545/archive.mp4" + } + ] +} \ No newline at end of file diff --git a/tests/data/video/list_broadcasts.json b/tests/data/video/list_broadcasts.json new file mode 100644 index 00000000..2d82a315 --- /dev/null +++ b/tests/data/video/list_broadcasts.json @@ -0,0 +1,44 @@ +{ + "count": "1", + "items": [ + { + "id": "1748b7070a81464c9759c46ad10d3734", + "sessionId": "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4", + "multiBroadcastTag": "broadcast_tag_provided", + "applicationId": "abc123", + "createdAt": 1437676551000, + "updatedAt": 1437676551000, + "maxDuration": 5400, + "maxBitrate": 2000000, + "broadcastUrls": { + "hls": "hlsurl", + "rtmp": [ + { + "id": "abc123", + "status": "abc123", + "serverUrl": "abc123", + "streamName": "abc123" + } + ] + }, + "settings": { + "hls": { + "lowLatency": false, + "dvr": false + } + }, + "resolution": "abc123", + "hasAudio": false, + "hasVideo": false, + "streamMode": "manual", + "status": "abc123", + "streams": [ + { + "streamId": "abc123", + "hasAudio": "abc123", + "hasVideo": "abc123" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/video/list_streams.json b/tests/data/video/list_streams.json new file mode 100644 index 00000000..fe50c8d0 --- /dev/null +++ b/tests/data/video/list_streams.json @@ -0,0 +1,13 @@ +{ + "count": 1, + "items": [ + { + "id": "8b732909-0a06-46a2-8ea8-074e64d43422", + "videoType": "camera", + "name": "", + "layoutClassList": [ + "full" + ] + } + ] + } \ No newline at end of file diff --git a/tests/data/video/mute_multiple_streams.json b/tests/data/video/mute_multiple_streams.json new file mode 100644 index 00000000..878f6eaa --- /dev/null +++ b/tests/data/video/mute_multiple_streams.json @@ -0,0 +1,7 @@ +{ + "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", + "status": "ACTIVE", + "name": "Joe Montana", + "environment": "standard", + "createdAt": 1414642898000 +} \ No newline at end of file diff --git a/tests/data/video/mute_specific_stream.json b/tests/data/video/mute_specific_stream.json new file mode 100644 index 00000000..878f6eaa --- /dev/null +++ b/tests/data/video/mute_specific_stream.json @@ -0,0 +1,7 @@ +{ + "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", + "status": "ACTIVE", + "name": "Joe Montana", + "environment": "standard", + "createdAt": 1414642898000 +} \ No newline at end of file diff --git a/tests/data/video/null.json b/tests/data/video/null.json new file mode 100644 index 00000000..ec747fa4 --- /dev/null +++ b/tests/data/video/null.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/tests/data/video/play_dtmf_invalid_error.json b/tests/data/video/play_dtmf_invalid_error.json new file mode 100644 index 00000000..b783100c --- /dev/null +++ b/tests/data/video/play_dtmf_invalid_error.json @@ -0,0 +1,4 @@ +{ + "code": 400, + "message": "One of the properties digits or sessionId is invalid." +} \ No newline at end of file diff --git a/tests/data/video/stop_archive.json b/tests/data/video/stop_archive.json new file mode 100644 index 00000000..630b1d8b --- /dev/null +++ b/tests/data/video/stop_archive.json @@ -0,0 +1,15 @@ +{ + "createdAt" : 1384221730555, + "duration" : 60, + "hasAudio" : true, + "hasVideo" : true, + "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", + "name" : "my_new_archive", + "projectId" : 234567, + "reason" : "", + "resolution" : "640x480", + "sessionId" : "flR1ZSBPY3QgMjkgMTI6MTM6MjMgUERUIDIwMTN", + "size" : 0, + "status" : "stopped", + "url" : null +} \ No newline at end of file diff --git a/tests/test_proactive_connect.py b/tests/test_proactive_connect.py index dfbf631e..9105d31c 100644 --- a/tests/test_proactive_connect.py +++ b/tests/test_proactive_connect.py @@ -241,7 +241,7 @@ def test_delete_list(proc): stub( responses.DELETE, f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='null.json', + fixture_path='no_content.json', status_code=204, ) @@ -271,7 +271,7 @@ def test_clear_list(proc): stub( responses.POST, f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='null.json', + fixture_path='no_content.json', status_code=202, ) @@ -301,7 +301,7 @@ def test_sync_list_from_datasource(proc): stub( responses.POST, f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', - fixture_path='null.json', + fixture_path='no_content.json', status_code=202, ) @@ -567,7 +567,7 @@ def test_delete_item(proc): stub( responses.DELETE, f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='null.json', + fixture_path='no_content.json', status_code=204, ) diff --git a/tests/test_video.py b/tests/test_video.py new file mode 100644 index 00000000..d1c98945 --- /dev/null +++ b/tests/test_video.py @@ -0,0 +1,678 @@ +from util import * +from vonage import Client +from vonage.errors import ( + ClientError, + VideoError, + InvalidRoleError, + TokenExpiryError, + SipError, +) + +import jwt +from time import time + + +session_id = 'my_session_id' +stream_id = 'my_stream_id' +connection_id = '1234-5678' +archive_id = '1234-abcd' +broadcast_id = '1748b7070a81464c9759c46ad10d3734' + + +@responses.activate +def test_create_default_session(client: Client, dummy_data): + stub( + responses.POST, + "https://video.api.vonage.com/session/create", + fixture_path="video/create_session.json", + ) + + session_info = client.video.create_session() + assert isinstance(session_info, dict) + assert request_user_agent() == dummy_data.user_agent + assert session_info['session_id'] == session_id + assert session_info['archive_mode'] == 'manual' + assert session_info['media_mode'] == 'routed' + assert session_info['location'] == None + + +@responses.activate +def test_create_session_custom_archive_mode_and_location(client: Client): + stub( + responses.POST, + "https://video.api.vonage.com/session/create", + fixture_path="video/create_session.json", + ) + + session_options = {'archive_mode': 'always', 'location': '192.0.1.1', 'media_mode': 'routed'} + session_info = client.video.create_session(session_options) + assert isinstance(session_info, dict) + assert session_info['session_id'] == session_id + assert session_info['archive_mode'] == 'always' + assert session_info['media_mode'] == 'routed' + assert session_info['location'] == '192.0.1.1' + + +@responses.activate +def test_create_session_custom_media_mode(client: Client): + stub( + responses.POST, + "https://video.api.vonage.com/session/create", + fixture_path="video/create_session.json", + ) + + session_options = {'media_mode': 'relayed'} + session_info = client.video.create_session(session_options) + assert isinstance(session_info, dict) + assert session_info['session_id'] == session_id + assert session_info['archive_mode'] == 'manual' + assert session_info['media_mode'] == 'relayed' + assert session_info['location'] == None + + +def test_create_session_invalid_archive_mode(client: Client): + session_options = {'archive_mode': 'invalid_option'} + with pytest.raises(VideoError) as excinfo: + client.video.create_session(session_options) + assert 'Invalid archive_mode value. Must be one of ' in str(excinfo.value) + + +def test_create_session_invalid_media_mode(client: Client): + session_options = {'media_mode': 'invalid_option'} + with pytest.raises(VideoError) as excinfo: + client.video.create_session(session_options) + assert 'Invalid media_mode value. Must be one of ' in str(excinfo.value) + + +def test_create_session_invalid_mode_combination(client: Client): + session_options = {'archive_mode': 'always', 'media_mode': 'relayed'} + with pytest.raises(VideoError) as excinfo: + client.video.create_session(session_options) + assert ( + str(excinfo.value) + == 'Invalid combination: cannot specify "archive_mode": "always" and "media_mode": "relayed".' + ) + + +def test_generate_client_token_all_defaults(client: Client): + token = client.video.generate_client_token(session_id) + decoded_token = jwt.decode(token, algorithms='RS256', options={'verify_signature': False}) + assert decoded_token['application_id'] == 'nexmo-application-id' + assert decoded_token['scope'] == 'session.connect' + assert decoded_token['session_id'] == 'my_session_id' + assert decoded_token['role'] == 'publisher' + assert decoded_token['initial_layout_class_list'] == '' + + +def test_generate_client_token_custom_options(client: Client): + now = int(time()) + token_options = { + 'role': 'moderator', + 'data': 'some token data', + 'initialLayoutClassList': ['1234', '5678', '9123'], + 'expireTime': now + 60, + 'jti': 1234, + 'iat': now, + 'subject': 'test_subject', + 'acl': ['1', '2', '3'], + } + + token = client.video.generate_client_token(session_id, token_options) + decoded_token = jwt.decode(token, algorithms='RS256', options={'verify_signature': False}) + assert decoded_token['application_id'] == 'nexmo-application-id' + assert decoded_token['scope'] == 'session.connect' + assert decoded_token['session_id'] == 'my_session_id' + assert decoded_token['role'] == 'moderator' + assert decoded_token['initial_layout_class_list'] == ['1234', '5678', '9123'] + assert decoded_token['data'] == 'some token data' + assert decoded_token['jti'] == 1234 + assert decoded_token['subject'] == 'test_subject' + assert decoded_token['acl'] == ['1', '2', '3'] + + +def test_check_client_token_headers(client: Client): + token = client.video.generate_client_token(session_id) + headers = jwt.get_unverified_header(token) + assert headers['alg'] == 'RS256' + assert headers['typ'] == 'JWT' + + +def test_generate_client_token_invalid_role(client: Client): + with pytest.raises(InvalidRoleError): + client.video.generate_client_token(session_id, {'role': 'observer'}) + + +def test_generate_client_token_invalid_expire_time(client: Client): + now = int(time()) + with pytest.raises(TokenExpiryError): + client.video.generate_client_token(session_id, {'expireTime': now + 3600 * 24 * 30 + 1}) + + +@responses.activate +def test_get_stream(client: Client): + stub( + responses.GET, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream/{stream_id}", + fixture_path="video/get_stream.json", + ) + + stream = client.video.get_stream(session_id, stream_id) + assert isinstance(stream, dict) + assert stream['videoType'] == 'camera' + + +@responses.activate +def test_list_streams( + client: Client, +): + stub( + responses.GET, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream", + fixture_path="video/list_streams.json", + ) + + stream_list = client.video.list_streams(session_id) + assert isinstance(stream_list, dict) + assert stream_list['items'][0]['videoType'] == 'camera' + + +@responses.activate +def test_change_stream_layout(client: Client): + stub( + responses.PUT, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream", + ) + + items = [{'id': 'stream-1234', 'layoutClassList': ["full"]}] + + assert isinstance(client.video.set_stream_layout(session_id, items), dict) + assert request_content_type() == "application/json" + + +@responses.activate +def test_send_signal_to_all_participants(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/signal", + ) + + assert isinstance( + client.video.send_signal(session_id, type='chat', data='hello from a test case'), dict + ) + assert request_content_type() == "application/json" + + +@responses.activate +def test_send_signal_to_single_participant(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/{connection_id}/signal", + ) + + assert isinstance( + client.video.send_signal( + session_id, type='chat', data='hello from a test case', connection_id=connection_id + ), + dict, + ) + assert request_content_type() == "application/json" + + +@responses.activate +def test_disconnect_client(client: Client): + stub( + responses.DELETE, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/{connection_id}", + ) + + assert isinstance(client.video.disconnect_client(session_id, connection_id=connection_id), dict) + + +@responses.activate +def test_mute_specific_stream(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream/{stream_id}/mute", + fixture_path="video/mute_specific_stream.json", + ) + + response = client.video.mute_stream(session_id, stream_id) + assert isinstance(response, dict) + assert response['createdAt'] == 1414642898000 + + +@responses.activate +def test_mute_all_streams(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", + fixture_path="video/mute_multiple_streams.json", + ) + + response = client.video.mute_all_streams(session_id) + assert isinstance(response, dict) + assert response['createdAt'] == 1414642898000 + + +@responses.activate +def test_mute_all_streams_except_excluded_list(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", + fixture_path="video/mute_multiple_streams.json", + ) + + response = client.video.mute_all_streams( + session_id, excluded_stream_ids=['excluded_stream_id_1', 'excluded_stream_id_2'] + ) + assert isinstance(response, dict) + assert response['createdAt'] == 1414642898000 + + +@responses.activate +def test_disable_mute_all_streams(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", + fixture_path="video/disable_mute_multiple_streams.json", + ) + + response = client.video.disable_mute_all_streams( + session_id, excluded_stream_ids=['excluded_stream_id_1', 'excluded_stream_id_2'] + ) + assert isinstance(response, dict) + assert ( + request_body() + == b'{"active": false, "excludedStreamIds": ["excluded_stream_id_1", "excluded_stream_id_2"]}' + ) + assert response['createdAt'] == 1414642898000 + + +@responses.activate +def test_list_archives_with_filters_applied(client: Client): + stub( + responses.GET, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive", + fixture_path="video/list_archives.json", + ) + + response = client.video.list_archives(offset=0, count=1, session_id=session_id) + assert isinstance(response, dict) + assert response['items'][0]['createdAt'] == 1384221730000 + assert response['items'][0]['streams'][0]['streamId'] == 'abc123' + + +@responses.activate +def test_create_new_archive(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive", + fixture_path="video/create_archive.json", + ) + + response = client.video.create_archive( + session_id=session_id, name='my_new_archive', outputMode='individual' + ) + assert isinstance(response, dict) + assert response['name'] == 'my_new_archive' + assert response['createdAt'] == 1384221730555 + + +@responses.activate +def test_get_archive(client: Client): + stub( + responses.GET, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}", + fixture_path="video/get_archive.json", + ) + + response = client.video.get_archive(archive_id=archive_id) + assert isinstance(response, dict) + assert response['duration'] == 5049 + assert response['size'] == 247748791 + assert response['streams'] == [] + + +@responses.activate +def test_delete_archive(client: Client): + stub( + responses.GET, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}", + status_code=204, + fixture_path='no_content.json', + ) + + assert client.video.delete_archive(archive_id=archive_id) == None + + +@responses.activate +def test_add_stream_to_archive(client: Client): + stub( + responses.PATCH, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/streams", + status_code=204, + fixture_path='no_content.json', + ) + + assert ( + client.video.add_stream_to_archive( + archive_id=archive_id, stream_id='1234', has_audio=True, has_video=True + ) + == None + ) + + +@responses.activate +def test_remove_stream_from_archive(client: Client): + stub( + responses.PATCH, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/streams", + status_code=204, + fixture_path='no_content.json', + ) + + assert client.video.remove_stream_from_archive(archive_id=archive_id, stream_id='1234') == None + + +@responses.activate +def test_stop_archive(client: Client): + stub( + responses.POST, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/stop", + fixture_path="video/stop_archive.json", + ) + + response = client.video.stop_archive(archive_id=archive_id) + assert response['name'] == 'my_new_archive' + assert response['createdAt'] == 1384221730555 + assert response['status'] == 'stopped' + + +@responses.activate +def test_change_archive_layout(client: Client): + stub( + responses.PUT, + f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/layout", + ) + + params = {'type': 'bestFit', 'screenshareType': 'horizontalPresentation'} + + assert isinstance(client.video.change_archive_layout(archive_id, params), dict) + assert request_content_type() == "application/json" + + +@responses.activate +def test_create_sip_call(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/dial', + fixture_path='video/create_sip_call.json', + ) + + sip = {'uri': 'sip:user@sip.partner.com;transport=tls'} + + sip_call = client.video.create_sip_call(session_id, 'my_token', sip) + assert sip_call['id'] == 'b0a5a8c7-dc38-459f-a48d-a7f2008da853' + assert sip_call['connectionId'] == 'e9f8c166-6c67-440d-994a-04fb6dfed007' + assert sip_call['streamId'] == '482bce73-f882-40fd-8ca5-cb74ff416036' + + +@responses.activate +def test_create_sip_call_not_found_error(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/dial', + status_code=404, + ) + sip = {'uri': 'sip:user@sip.partner.com;transport=tls'} + with pytest.raises(ClientError): + client.video.create_sip_call('an-invalid-session-id', 'my_token', sip) + + +def test_create_sip_call_no_uri_error(client): + sip = {} + with pytest.raises(SipError) as err: + client.video.create_sip_call(session_id, 'my_token', sip) + + assert str(err.value) == 'You must specify a uri when creating a SIP call.' + + +@responses.activate +def test_play_dtmf(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/play-dtmf', + fixture_path='no_content.json', + ) + + assert client.video.play_dtmf(session_id, '1234') == None + + +@responses.activate +def test_play_dtmf_specific_connection(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/my-connection-id/play-dtmf', + fixture_path='no_content.json', + ) + + assert client.video.play_dtmf(session_id, '1234', connection_id='my-connection-id') == None + + +@responses.activate +def test_play_dtmf_invalid_session_id_error(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/play-dtmf', + fixture_path='video/play_dtmf_invalid_error.json', + status_code=400, + ) + + with pytest.raises(ClientError) as err: + client.video.play_dtmf(session_id, '1234') + assert 'One of the properties digits or sessionId is invalid.' in str(err.value) + + +def test_play_dtmf_invalid_input_error(client): + with pytest.raises(VideoError) as err: + client.video.play_dtmf(session_id, '!@£$%^&()asdfghjkl;') + + assert str(err.value) == 'Only digits 0-9, *, #, and "p" are allowed.' + + +@responses.activate +def test_list_broadcasts(client): + stub( + responses.GET, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', + fixture_path='video/list_broadcasts.json', + ) + + broadcasts = client.video.list_broadcasts() + assert broadcasts['count'] == '1' + assert broadcasts['items'][0]['id'] == '1748b7070a81464c9759c46ad10d3734' + assert broadcasts['items'][0]['applicationId'] == 'abc123' + + +@responses.activate +def test_list_broadcasts_options(client): + stub( + responses.GET, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', + fixture_path='video/list_broadcasts.json', + ) + + broadcasts = client.video.list_broadcasts( + count=1, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' + ) + assert broadcasts['count'] == '1' + assert broadcasts['items'][0]['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' + assert broadcasts['items'][0]['id'] == '1748b7070a81464c9759c46ad10d3734' + assert broadcasts['items'][0]['applicationId'] == 'abc123' + + +@responses.activate +def test_list_broadcasts_invalid_options_errors(client): + stub( + responses.GET, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', + fixture_path='video/list_broadcasts.json', + ) + + with pytest.raises(VideoError) as err: + client.video.list_broadcasts(offset=-2, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') + assert str(err.value) == 'Offset must be an int >= 0.' + + with pytest.raises(VideoError) as err: + client.video.list_broadcasts(count=9999, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') + assert str(err.value) == 'Count must be an int between 0 and 1000.' + + with pytest.raises(VideoError) as err: + client.video.list_broadcasts(offset='10', session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') + assert str(err.value) == 'Offset must be an int >= 0.' + + +@responses.activate +def test_start_broadcast_required_params(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', + fixture_path='video/broadcast.json', + ) + + params = { + "sessionId": "2_MX40NTMyODc3Mn5-fg", + "outputs": { + "rtmp": [ + { + "id": "foo", + "serverUrl": "rtmps://myfooserver/myfooapp", + "streamName": "myfoostream", + } + ] + }, + } + + broadcast = client.video.start_broadcast(params) + assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' + assert broadcast['createdAt'] == 1437676551000 + assert broadcast['maxBitrate'] == 2000000 + assert broadcast['broadcastUrls']['rtmp'][0]['id'] == 'abc123' + + +@responses.activate +def test_start_broadcast_all_params(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', + fixture_path='video/broadcast.json', + ) + + params = { + "sessionId": "2_MX40NTMyODc3Mn5-fg", + "layout": { + "type": "custom", + "stylesheet": "the layout stylesheet (only used with type == custom)", + "screenshareType": "horizontalPresentation", + }, + "maxDuration": 5400, + "outputs": { + "rtmp": [ + { + "id": "foo", + "serverUrl": "rtmps://myfooserver/myfooapp", + "streamName": "myfoostream", + } + ] + }, + "resolution": "1920x1080", + "streamMode": "manual", + "multiBroadcastTag": "foo", + } + + broadcast = client.video.start_broadcast(params) + assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' + assert broadcast['createdAt'] == 1437676551000 + assert broadcast['maxBitrate'] == 2000000 + assert broadcast['broadcastUrls']['rtmp'][0]['id'] == 'abc123' + + +@responses.activate +def test_get_broadcast(client): + stub( + responses.GET, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}', + fixture_path='video/broadcast.json', + ) + + broadcast = client.video.get_broadcast(broadcast_id) + assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' + assert broadcast['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' + assert broadcast['updatedAt'] == 1437676551000 + assert broadcast['resolution'] == '640x480' + + +@responses.activate +def test_stop_broadcast(client): + stub( + responses.POST, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}', + fixture_path='video/broadcast.json', + ) + + broadcast = client.video.stop_broadcast(broadcast_id) + assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' + assert broadcast['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' + assert broadcast['updatedAt'] == 1437676551000 + assert broadcast['resolution'] == '640x480' + + +@responses.activate +def test_change_broadcast_layout(client): + stub( + responses.PUT, + f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/layout', + fixture_path='no_content.json', + ) + + params = { + "type": "bestFit", + "stylesheet": "stream.instructor {position: absolute; width: 100%; height:50%;}", + "screenshareType": "pip", + } + + assert client.video.change_broadcast_layout(broadcast_id, params) == None + + +@responses.activate +def test_add_stream_to_broadcast(client: Client, dummy_data): + stub( + responses.PATCH, + f"https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/streams", + status_code=204, + fixture_path='no_content.json', + ) + + assert ( + client.video.add_stream_to_broadcast( + broadcast_id=broadcast_id, stream_id='1234', has_audio=True, has_video=True + ) + == None + ) + assert request_user_agent() == dummy_data.user_agent + + +@responses.activate +def test_remove_stream_from_broadcast(client: Client, dummy_data): + stub( + responses.PATCH, + f"https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/streams", + status_code=204, + fixture_path='no_content.json', + ) + + assert ( + client.video.remove_stream_from_broadcast(broadcast_id=broadcast_id, stream_id='1234') + == None + ) + assert request_user_agent() == dummy_data.user_agent From c09833c7c5440d8a6f27abe02bbe5717327c97e6 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 12 Dec 2023 18:06:51 +0000 Subject: [PATCH 273/401] added OpenTok migration info --- OPENTOK_TO_VONAGE_MIGRATION.md | 93 +++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/OPENTOK_TO_VONAGE_MIGRATION.md b/OPENTOK_TO_VONAGE_MIGRATION.md index 3014a68f..87dd1938 100644 --- a/OPENTOK_TO_VONAGE_MIGRATION.md +++ b/OPENTOK_TO_VONAGE_MIGRATION.md @@ -1 +1,92 @@ -Coming soon! \ No newline at end of file +# Migration guide from OpenTok Python SDK to Vonage Python SDK + +## Installation + +You can now interact with Vonage's Video API using the `vonage` PyPI package rather than the `opentok` PyPI package. To do this, create a virtual environment and install the `vonage` package in your virtual environment using this command: + +```bash +python3 -m venv venv-vonage-video +. ./venv-vonage-video/bin/activate +pip install vonage +``` + +Note: not all the Video API features are yet supported in the `vonage` package. There is a full list of [Supported Features](#supported-features) later in this document. + +## Setup + +Whereas the `opentok` package used an `api_key` and `api_secret` for Authorization, the Video API implementation in the `vonage` package uses a JWT. The SDK handles JWT generation in the background for you, but will require an `application_id` and `private_key` as credentials in order to generate the token. You can obtain these by setting up a Vonage Application, which you can create via the [Developer Dashboard](https://dashboard.nexmo.com/applications). (The Vonage Application is also where you can set other settings such as callback URLs, storage preferences, etc). + +These credentials are then passed in when instantiating a `Client` object (the example below assumes you have these set as environment variables): + +```python +import vonage + +client = vonage.Client( + application_id='VONAGE_APPLICATION_ID', + private_key='VONAGE_PRIVATE_KEY_PATH', +) +``` + +You can access the Video API via the `Video` class stored at `Client.video`. To call methods related to the Video API, use this syntax: + +```python +client.video.video_api_method... +``` + +You can interact with the Vonage Video API via various methods, for example: + +- Create a Session + +```python +# Pass options for the session as a Python dictionary in SESSION_OPTIONS +session = client.video.create_session(SESSION_OPTIONS) +``` + +- Retrieve a List of Archive Recordings + +```python +archive_list = client.video.list_archives(FILTER_OPTIONS) +``` + +## Changed Methods + +There are some changes to methods between the `opentok` SDK and the Video API implementation in the `vonage` SDK. + +- Any positional parameters in method signatures have been replaced with keyword parameters in the `vonage` package. +- Methods now return the response as a Python dictionary. +- Some methods have been renamed, for clarity and/or to better reflect what the method does. These are listed below: + +| OpenTok Method Name | Vonage Video Method Name | +|---|---| +| `opentok.generate_token` | `video.generate_client_token` | +| `opentok.start_archive` | `video.create_archive` | +| `opentok.add_archive_stream` | `video.add_stream_to_archive` | +| `opentok.remove_archive_stream` | `video.remove_stream_from_archive` | +| `opentok.set_archive_layout` | `video.change_archive_layout` | +| `opentok.add_broadcast_stream` | `video.add_stream_to_broadcast` | +| `opentok.remove_broadcast_stream` | `video.remove_stream_from_broadcast` | +| `opentok.set_broadcast_layout` | `video.change_broadcast_layout` | +| `opentok.set_stream_class_lists` | `video.set_stream_layout` | +| `opentok.force_disconnect` | `video.disconnect_client` | +| `opentok.mute_all` | `video.mute_all_streams` | +| `opentok.disable_force_mute` | `video.disable_mute_all_streams`| +| `opentok.dial` | `video.create_sip_call`| + +## Supported Features + +The following is a list of Vonage Video APIs and whether the SDK provides support for them: + +| API | Supported? +|----------|:-------------:| +| Session Creation | ✅ | +| Stream Management | ✅ | +| Signaling | ✅ | +| Moderation | ✅ | +| Archiving | ✅ | +| Live Streaming Broadcasts | ✅ | +| SIP Interconnect | ✅ | +| Account Management | ❌ | +| Experience Composer | ❌ | +| Audio Connector | ❌ | +| Live Captions | ❌ | +| Custom S3/Azure buckets | ❌ | \ No newline at end of file From 0a8d217a178f989275e128c54acd3bb544a4b082 Mon Sep 17 00:00:00 2001 From: Dom DiPasquale Date: Fri, 22 Dec 2023 11:29:47 -0800 Subject: [PATCH 274/401] Migrate to pydantic 2 (#291) * Upgrade & fix models & tests * address deprecated pydantic warnings * remove print statments, uncomment test * fix optional -> None fields --- requirements.txt | 2 +- setup.py | 2 +- src/vonage/ncco_builder/connect_endpoints.py | 49 +++-- src/vonage/ncco_builder/input_types.py | 26 +-- src/vonage/ncco_builder/ncco.py | 205 ++++++++++-------- src/vonage/ncco_builder/pay_prompts.py | 19 +- src/vonage/subaccounts.py | 20 +- src/vonage/verify2.py | 37 ++-- .../ncco_samples/ncco_builder_samples.py | 48 ++-- .../test_connect_endpoints.py | 2 +- tests/test_ncco_builder/test_input_types.py | 8 +- tests/test_ncco_builder/test_ncco_actions.py | 35 +-- tests/test_ncco_builder/test_ncco_builder.py | 4 +- tests/test_verify2.py | 4 +- tests/test_voice.py | 1 - 15 files changed, 260 insertions(+), 202 deletions(-) diff --git a/requirements.txt b/requirements.txt index 32c7341d..6b622a6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pytest==7.4.2 responses==0.22.0 coverage -pydantic>=1.10,==1.* +pydantic==2.5.2 bump2version build diff --git a/setup.py b/setup.py index d7a503cf..a57d0cfc 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ "requests>=2.4.2", "pytz>=2018.5", "Deprecated", - "pydantic>=1.10,==1.*", + "pydantic>=2.5.2", ], python_requires=">=3.8", tests_require=["cryptography>=2.3.1"], diff --git a/src/vonage/ncco_builder/connect_endpoints.py b/src/vonage/ncco_builder/connect_endpoints.py index f9b84ff6..d77a0b8c 100644 --- a/src/vonage/ncco_builder/connect_endpoints.py +++ b/src/vonage/ncco_builder/connect_endpoints.py @@ -1,49 +1,62 @@ -from pydantic import BaseModel, HttpUrl, AnyUrl, Field, constr -from typing import Optional, Dict +from pydantic import BaseModel, HttpUrl, AnyUrl, constr, field_serializer +from typing import Dict from typing_extensions import Literal class ConnectEndpoints: class Endpoint(BaseModel): - type: str = None + type: Literal['phone', 'app', 'websocket', 'sip', 'vbc'] = None class PhoneEndpoint(Endpoint): - type = Field('phone', const=True) - number: constr(regex=r'^[1-9]\d{6,14}$') - dtmfAnswer: Optional[constr(regex='^[0-9*#p]+$')] - onAnswer: Optional[Dict[str, HttpUrl]] + type: Literal['phone'] = 'phone' + + number: constr(pattern=r'^[1-9]\d{6,14}$') + dtmfAnswer: constr(pattern='^[0-9*#p]+$') = None + onAnswer: Dict[str, HttpUrl] = None + + @field_serializer('onAnswer') + def serialize_dt(self, oa: Dict[str, HttpUrl], _info): + if oa is None: + return oa + + return {k: str(v) for k, v in oa.items()} class AppEndpoint(Endpoint): - type = Field('app', const=True) + type: Literal['app'] = 'app' user: str class WebsocketEndpoint(Endpoint): - type = Field('websocket', const=True) + type: Literal['websocket'] = 'websocket' + uri: AnyUrl contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] - headers: Optional[dict] + headers: dict = None + + @field_serializer('uri') + def serialize_uri(self, uri: AnyUrl, _info): + return str(uri) class SipEndpoint(Endpoint): - type = Field('sip', const=True) + type: Literal['sip'] = 'sip' uri: str - headers: Optional[dict] + headers: dict = None class VbcEndpoint(Endpoint): - type = Field('vbc', const=True) + type: Literal['vbc'] = 'vbc' extension: str @classmethod def create_endpoint_model_from_dict(cls, d) -> Endpoint: if d['type'] == 'phone': - return cls.PhoneEndpoint.parse_obj(d) + return cls.PhoneEndpoint.model_validate(d) elif d['type'] == 'app': - return cls.AppEndpoint.parse_obj(d) + return cls.AppEndpoint.model_validate(d) elif d['type'] == 'websocket': - return cls.WebsocketEndpoint.parse_obj(d) + return cls.WebsocketEndpoint.model_validate(d) elif d['type'] == 'sip': - return cls.WebsocketEndpoint.parse_obj(d) + return cls.WebsocketEndpoint.model_validate(d) elif d['type'] == 'vbc': - return cls.WebsocketEndpoint.parse_obj(d) + return cls.WebsocketEndpoint.model_validate(d) else: raise ValueError( 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' diff --git a/src/vonage/ncco_builder/input_types.py b/src/vonage/ncco_builder/input_types.py index 761ba31d..56737f24 100644 --- a/src/vonage/ncco_builder/input_types.py +++ b/src/vonage/ncco_builder/input_types.py @@ -1,26 +1,26 @@ from pydantic import BaseModel, confloat, conint -from typing import Optional, List +from typing import List class InputTypes: class Dtmf(BaseModel): - timeOut: Optional[conint(ge=0, le=10)] - maxDigits: Optional[conint(ge=1, le=20)] - submitOnHash: Optional[bool] + timeOut: conint(ge=0, le=10) = None + maxDigits: conint(ge=1, le=20) = None + submitOnHash: bool = None class Speech(BaseModel): - uuid: Optional[str] - endOnSilence: Optional[confloat(ge=0.4, le=10.0)] - language: Optional[str] - context: Optional[List[str]] - startTimeout: Optional[conint(ge=1, le=60)] - maxDuration: Optional[conint(ge=1, le=60)] - saveAudio: Optional[bool] + uuid: str = None + endOnSilence: confloat(ge=0.4, le=10.0) = None + language: str = None + context: List[str] = None + startTimeout: conint(ge=1, le=60) = None + maxDuration: conint(ge=1, le=60) = None + saveAudio: bool = None @classmethod def create_dtmf_model(cls, dict) -> Dtmf: - return cls.Dtmf.parse_obj(dict) + return cls.Dtmf.model_validate(dict) @classmethod def create_speech_model(cls, dict) -> Speech: - return cls.Speech.parse_obj(dict) + return cls.Speech.model_validate(dict) diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py index de630129..ed1c96d1 100644 --- a/src/vonage/ncco_builder/ncco.py +++ b/src/vonage/ncco_builder/ncco.py @@ -1,6 +1,6 @@ -from pydantic import BaseModel, Field, validator, constr, confloat, conint -from typing import Optional, Union, List -from typing_extensions import Literal +from pydantic import BaseModel, Field, ValidationInfo, field_validator, constr, confloat, conint +from typing import Any, Dict, Union, List +from typing_extensions import Annotated, Literal from .connect_endpoints import ConnectEndpoints from .input_types import InputTypes @@ -11,29 +11,33 @@ class Ncco: class Action(BaseModel): - action: str = None + action: Literal['record', 'conversation', 'connect', + 'talk', 'stream', 'input', 'notify', 'pay'] = None class Record(Action): """Use the record action to record a call or part of a call.""" - action = Field('record', const=True) - format: Optional[Literal['mp3', 'wav', 'ogg']] - split: Optional[Literal['conversation']] - channels: Optional[conint(ge=1, le=32)] - endOnSilence: Optional[conint(ge=3, le=10)] - endOnKey: Optional[constr(regex='^[0-9*#]$')] - timeOut: Optional[conint(ge=3, le=7200)] - beepStart: Optional[bool] - eventUrl: Optional[Union[List[str], str]] - eventMethod: Optional[constr(to_upper=True)] - - @validator('channels') - def enable_split(cls, v, values): + action: Literal['record'] = 'record' + format: Literal['mp3', 'wav', 'ogg'] = None + split: Literal['conversation'] = None + channels: conint(ge=1, le=32) = None + endOnSilence: conint(ge=3, le=10) = None + endOnKey: constr(pattern='^[0-9*#]$') = None + timeOut: conint(ge=3, le=7200) = None + beepStart: bool = None + eventUrl: Union[List[str], str] = None + eventMethod: constr(to_upper=True) = None + + @field_validator('channels') + @classmethod + def enable_split(cls, v, info: ValidationInfo): + values = info.data if values['split'] is None: values['split'] = 'conversation' return v - @validator('eventUrl') + @field_validator('eventUrl') + @classmethod def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) @@ -42,22 +46,25 @@ class Conversation(Action): while preserving the communication context. Using conversation with the same name reuses the same persisted conversation.""" - action = Field('conversation', const=True) + action: Literal['conversation'] = 'conversation' name: str - musicOnHoldUrl: Optional[Union[List[str], str]] - startOnEnter: Optional[bool] - endOnExit: Optional[bool] - record: Optional[bool] - canSpeak: Optional[List[str]] - canHear: Optional[List[str]] - mute: Optional[bool] - - @validator('musicOnHoldUrl') - def ensure_url_in_list(cls, v): + musicOnHoldUrl: Union[List[str], str] = None + startOnEnter: bool = None + endOnExit: bool = None + record: bool = None + canSpeak: List[str] = None + canHear: List[str] = None + mute: bool = None + + @field_validator('musicOnHoldUrl') + @classmethod + def ensure_url_in_list(cls, v: Any): return Ncco._ensure_object_in_list(v) - @validator('mute') - def can_mute(cls, v, values): + @field_validator('mute') + @classmethod + def can_mute(cls, v, info: ValidationInfo): + values = info.data if 'canSpeak' in values and values['canSpeak'] is not None: raise ValueError('Cannot use mute option if canSpeak option is specified.') return v @@ -65,21 +72,25 @@ def can_mute(cls, v, values): class Connect(Action): """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" - action = Field('connect', const=True) - endpoint: Union[dict, ConnectEndpoints.Endpoint, List[dict]] - from_: Optional[constr(regex=r'^[1-9]\d{6,14}$')] - randomFromNumber: Optional[bool] - eventType: Optional[Literal['synchronous']] - timeout: Optional[int] - limit: Optional[conint(le=7200)] - machineDetection: Optional[Literal['continue', 'hangup']] - advancedMachineDetection: Optional[dict] - eventUrl: Optional[Union[List[str], str]] - eventMethod: Optional[constr(to_upper=True)] - ringbackTone: Optional[str] - - @validator('endpoint') - def validate_endpoint(cls, v): + action: Literal['connect'] = 'connect' + endpoint: Union[dict, ConnectEndpoints.Endpoint, List] + from_: Annotated[str, Field(alias='from_', serialization_alias='from', + pattern=r'^[1-9]\d{6,14}$')] = None + + randomFromNumber: bool = None + eventType: Literal['synchronous'] = None + timeout: int = None + limit: conint(le=7200) = None + machineDetection: Literal['continue', 'hangup'] = None + advancedMachineDetection: dict = None + eventUrl: Union[List[str], str] = None + eventMethod: constr(to_upper=True) = None + ringbackTone: str = None + + @field_validator('endpoint') + @classmethod + def validate_endpoint(cls, v: Any): + if type(v) is dict: return [ConnectEndpoints.create_endpoint_model_from_dict(v)] elif type(v) is list: @@ -87,24 +98,24 @@ def validate_endpoint(cls, v): else: return [v] - @validator('from_') - def set_from_field(cls, v, values): - values['from'] = v - - @validator('randomFromNumber') - def check_from_not_set(cls, v, values): - if v is True and 'from' in values: - if values['from'] is not None: + @field_validator('randomFromNumber') + @classmethod + def check_from_not_set(cls, v, info: ValidationInfo): + values = info.data + if v is True and 'from_' in values: + if values['from_'] is not None: raise ValueError( 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' ) return v - @validator('eventUrl') + @field_validator('eventUrl') + @classmethod def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) - @validator('advancedMachineDetection') + @field_validator('advancedMachineDetection') + @classmethod def validate_advancedMachineDetection(cls, v): if 'behavior' in v and v['behavior'] not in ('continue', 'hangup'): raise ValueError( @@ -116,61 +127,63 @@ def validate_advancedMachineDetection(cls, v): ) return v - class Config: - smart_union = True - class Talk(Action): """The talk action sends synthesized speech to a Conversation.""" - action = Field('talk', const=True) + action: Literal['talk'] = 'talk' text: constr(max_length=1500) - bargeIn: Optional[bool] - loop: Optional[conint(ge=0)] - level: Optional[confloat(ge=-1, le=1)] - language: Optional[str] - style: Optional[int] - premium: Optional[bool] + bargeIn: bool = None + loop: conint(ge=0) = None + level: confloat(ge=-1, le=1) = None + language: str = None + style: int = None + premium: bool = None class Stream(Action): """The stream action allows you to send an audio stream to a Conversation.""" - action = Field('stream', const=True) + action: Literal['stream'] = 'stream' streamUrl: Union[List[str], str] - level: Optional[confloat(ge=-1, le=1)] - bargeIn: Optional[bool] - loop: Optional[conint(ge=0)] + level: confloat(ge=-1, le=1) = None + bargeIn: bool = None + loop: conint(ge=0) = None - @validator('streamUrl') + @field_validator('streamUrl') + @classmethod def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) class Input(Action): """Collect digits or speech input by the person you are are calling.""" - action = Field('input', const=True) + action: Literal['input'] = 'input' + type: Union[ Literal['dtmf', 'speech'], List[Literal['dtmf']], List[Literal['speech']], List[Literal['dtmf', 'speech']], ] - dtmf: Optional[Union[InputTypes.Dtmf, dict]] - speech: Optional[Union[InputTypes.Speech, dict]] - eventUrl: Optional[Union[List[str], str]] - eventMethod: Optional[constr(to_upper=True)] + dtmf: Union[InputTypes.Dtmf, dict] = None + speech: Union[InputTypes.Speech, dict] = None + eventUrl: Union[List[str], str] = None + eventMethod: constr(to_upper=True) = None - @validator('type', 'eventUrl') + @field_validator('type', 'eventUrl') + @classmethod def ensure_value_in_list(cls, v): return Ncco._ensure_object_in_list(v) - @validator('dtmf') + @field_validator('dtmf') + @classmethod def ensure_input_object_is_dtmf_model(cls, v): if type(v) is dict: return InputTypes.create_dtmf_model(v) else: return v - @validator('speech') + @field_validator('speech') + @classmethod def ensure_input_object_is_speech_model(cls, v): if type(v) is dict: return InputTypes.create_speech_model(v) @@ -180,12 +193,14 @@ def ensure_input_object_is_speech_model(cls, v): class Notify(Action): """Use the notify action to send a custom payload to your event URL.""" - action = Field('notify', const=True) + action: Literal['notify'] = 'notify' + payload: dict eventUrl: Union[List[str], str] - eventMethod: Optional[constr(to_upper=True)] + eventMethod: constr(to_upper=True) = None - @validator('eventUrl') + @field_validator('eventUrl') + @classmethod def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) @@ -193,29 +208,33 @@ def ensure_url_in_list(cls, v): class Pay(Action): """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" - action = Field('pay', const=True) + action: Literal['pay'] = 'pay' amount: confloat(ge=0) - currency: Optional[constr(to_lower=True)] - eventUrl: Optional[Union[List[str], str]] - prompts: Optional[Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict]] - voice: Optional[Union[PayPrompts.VoicePrompt, dict]] + currency: constr(to_lower=True) = None + eventUrl: Union[List[str], str] = None + prompts: Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict] = None + voice: Union[PayPrompts.VoicePrompt, dict] = None - @validator('amount') + @field_validator('amount') + @classmethod def round_amount(cls, v): return round(v, 2) - @validator('eventUrl') + @field_validator('eventUrl') + @classmethod def ensure_url_in_list(cls, v): return Ncco._ensure_object_in_list(v) - @validator('prompts') + @field_validator('prompts') + @classmethod def ensure_text_model(cls, v): if type(v) is dict: return PayPrompts.create_text_model(v) else: return v - @validator('voice') + @field_validator('voice') + @classmethod def ensure_voice_model(cls, v): if type(v) is dict: return PayPrompts.create_voice_model(v) @@ -227,9 +246,9 @@ def build_ncco(*args: Action, actions: List[Action] = None) -> str: ncco = [] if actions is not None: for action in actions: - ncco.append(action.dict(exclude_none=True)) + ncco.append(action.model_dump(exclude_none=True, by_alias=True)) for action in args: - ncco.append(action.dict(exclude_none=True)) + ncco.append(action.model_dump(exclude_none=True, by_alias=True)) return ncco @staticmethod diff --git a/src/vonage/ncco_builder/pay_prompts.py b/src/vonage/ncco_builder/pay_prompts.py index 11ecb404..116acd66 100644 --- a/src/vonage/ncco_builder/pay_prompts.py +++ b/src/vonage/ncco_builder/pay_prompts.py @@ -1,12 +1,12 @@ -from pydantic import BaseModel, validator -from typing import Optional, Dict +from pydantic import BaseModel, ValidationInfo, field_validator, validator +from typing import Dict from typing_extensions import Literal class PayPrompts: class VoicePrompt(BaseModel): - language: Optional[str] - style: Optional[int] + language: str = None + style: int = None class TextPrompt(BaseModel): type: Literal['CardNumber', 'ExpirationDate', 'SecurityCode'] @@ -22,8 +22,11 @@ class TextPrompt(BaseModel): Dict[Literal['text'], str], ] - @validator('errors') - def check_valid_error_format(cls, v, values): + @field_validator('errors') + @classmethod + def check_valid_error_format(cls, v, info: ValidationInfo): + values = info.data + if values['type'] == 'CardNumber': allowed_values = {'InvalidCardType', 'InvalidCardNumber', 'Timeout'} cls.check_allowed_values(v, allowed_values, values['type']) @@ -44,8 +47,8 @@ def check_allowed_values(errors, allowed_values, prompt_type): @classmethod def create_voice_model(cls, dict) -> VoicePrompt: - return cls.VoicePrompt.parse_obj(dict) + return cls.VoicePrompt.model_validate(dict) @classmethod def create_text_model(cls, dict) -> TextPrompt: - return cls.TextPrompt.parse_obj(dict) + return cls.TextPrompt.model_validate(dict) diff --git a/src/vonage/subaccounts.py b/src/vonage/subaccounts.py index bc88fa14..c731f100 100644 --- a/src/vonage/subaccounts.py +++ b/src/vonage/subaccounts.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Union from .errors import SubaccountsError @@ -28,8 +28,8 @@ def list_subaccounts(self): def create_subaccount( self, name: str, - secret: Optional[str] = None, - use_primary_account_balance: Optional[bool] = None, + secret: str = None, + use_primary_account_balance: bool = None, ): params = {'name': name, 'secret': secret} if self._is_boolean(use_primary_account_balance): @@ -52,9 +52,9 @@ def get_subaccount(self, subaccount_key: str): def modify_subaccount( self, subaccount_key: str, - suspended: Optional[bool] = None, - use_primary_account_balance: Optional[bool] = None, - name: Optional[str] = None, + suspended: bool = None, + use_primary_account_balance: bool = None, + name: str = None, ): params = {'name': name} if self._is_boolean(suspended): @@ -72,8 +72,8 @@ def modify_subaccount( def list_credit_transfers( self, start_date: str = default_start_date, - end_date: Optional[str] = None, - subaccount: Optional[str] = None, + end_date: str = None, + subaccount: str = None, ): params = { 'start_date': start_date, @@ -112,8 +112,8 @@ def transfer_credit( def list_balance_transfers( self, start_date: str = default_start_date, - end_date: Optional[str] = None, - subaccount: Optional[str] = None, + end_date: str = None, + subaccount: str = None, ): params = { 'start_date': start_date, diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py index 33cffb44..cb13a353 100644 --- a/src/vonage/verify2.py +++ b/src/vonage/verify2.py @@ -1,11 +1,12 @@ from __future__ import annotations from typing import TYPE_CHECKING +from typing_extensions import Annotated if TYPE_CHECKING: from vonage import Client -from pydantic import BaseModel, ValidationError, validator, conint, constr -from typing import Optional, List +from pydantic import BaseModel, StringConstraints, ValidationError, field_validator, conint +from typing import List import copy import re @@ -32,7 +33,7 @@ def new_request(self, params: dict): self._remove_unnecessary_fraud_check(params) try: params_to_verify = copy.deepcopy(params) - Verify2.VerifyRequest.parse_obj(params_to_verify) + Verify2.VerifyRequest.model_validate(params_to_verify) except (ValidationError, Verify2Error) as err: raise err @@ -67,16 +68,26 @@ def _remove_unnecessary_fraud_check(self, params): class VerifyRequest(BaseModel): brand: str workflow: List[dict] - locale: Optional[str] - channel_timeout: Optional[conint(ge=60, le=900)] - client_ref: Optional[str] - code_length: Optional[conint(ge=4, le=10)] - fraud_check: Optional[bool] - code: Optional[ - constr(min_length=4, max_length=10, regex='^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$') - ] - - @validator('workflow') + locale: str = None + channel_timeout: conint(ge=60, le=900) = None + client_ref: str = None + code_length: conint(ge=4, le=10) = None + fraud_check: bool = None + code: Annotated[str, StringConstraints( + min_length=4, max_length=10 + )] = None + + @field_validator('code') + @classmethod + def regex_check(cls, c: str): + re_for_code: re.Pattern[str] = re.compile('^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$') + + if not re_for_code.match(c): + raise ValueError("string does not match regex") + return c + + @field_validator('workflow') + @classmethod def check_valid_workflow(cls, v): for workflow in v: Verify2._check_valid_channel(workflow) diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py index 85177fc4..7e3c012a 100644 --- a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py +++ b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py @@ -1,3 +1,4 @@ +import pytest from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts record = Ncco.Record(eventUrl='http://example.com/events') @@ -53,27 +54,34 @@ payload={"message": "world"}, eventUrl=["http://example.com"], eventMethod='PUT' ) -pay_voice_prompt = Ncco.Pay( - amount=99.99, - currency='gbp', - eventUrl='https://example.com/payment', - voice=PayPrompts.VoicePrompt(language='en-GB', style=1), -) -pay_text_prompt = Ncco.Pay( - amount=12.345, - currency='gbp', - eventUrl='https://example.com/payment', - prompts=PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ), -) +def get_pay_voice_prompt(): + with pytest.deprecated_call(): + return Ncco.Pay( + amount=99.99, + currency='gbp', + eventUrl='https://example.com/payment', + voice=PayPrompts.VoicePrompt(language='en-GB', style=1), + ) + + +def get_pay_text_prompt(): + with pytest.deprecated_call(): + return Ncco.Pay( + amount=12.345, + currency='gbp', + eventUrl='https://example.com/payment', + prompts=PayPrompts.TextPrompt( + type='CardNumber', + text='Enter your card number.', + errors={ + 'InvalidCardType': { + 'text': 'The card you are trying to use is not valid for this purchase.' + } + }, + ), + ) + basic_ncco = [{"action": "talk", "text": "hello"}] diff --git a/tests/test_ncco_builder/test_connect_endpoints.py b/tests/test_ncco_builder/test_connect_endpoints.py index 44cc608a..6fa378e2 100644 --- a/tests/test_ncco_builder/test_connect_endpoints.py +++ b/tests/test_ncco_builder/test_connect_endpoints.py @@ -7,7 +7,7 @@ def _action_as_dict(action: Ncco.Action): - return action.dict(exclude_none=True) + return action.model_dump(exclude_none=True) def test_connect_all_endpoints_from_model(): diff --git a/tests/test_ncco_builder/test_input_types.py b/tests/test_ncco_builder/test_input_types.py index 7572ae1a..592e0518 100644 --- a/tests/test_ncco_builder/test_input_types.py +++ b/tests/test_ncco_builder/test_input_types.py @@ -4,14 +4,14 @@ def test_create_dtmf_model(): dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=2, submitOnHash=True) assert type(dtmf) == InputTypes.Dtmf - assert dtmf.dict() == {'maxDigits': 2, 'submitOnHash': True, 'timeOut': 5} + assert dtmf.model_dump() == {'maxDigits': 2, 'submitOnHash': True, 'timeOut': 5} def test_create_dtmf_model_from_dict(): dtmf_dict = {'timeOut': 3, 'maxDigits': 4, 'submitOnHash': True} dtmf_model = InputTypes.create_dtmf_model(dtmf_dict) assert type(dtmf_model) == InputTypes.Dtmf - assert dtmf_model.dict() == {'maxDigits': 4, 'submitOnHash': True, 'timeOut': 3} + assert dtmf_model.model_dump() == {'maxDigits': 4, 'submitOnHash': True, 'timeOut': 3} def test_create_speech_model(): @@ -25,7 +25,7 @@ def test_create_speech_model(): saveAudio=True, ) assert type(speech) == InputTypes.Speech - assert speech.dict() == { + assert speech.model_dump() == { 'uuid': 'my-uuid', 'endOnSilence': 2.5, 'language': 'en-GB', @@ -40,7 +40,7 @@ def test_create_speech_model_from_dict(): speech_dict = {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} speech_model = InputTypes.create_speech_model(speech_dict) assert type(speech_model) == InputTypes.Speech - assert speech_model.dict(exclude_none=True) == { + assert speech_model.model_dump(exclude_none=True) == { 'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30, diff --git a/tests/test_ncco_builder/test_ncco_actions.py b/tests/test_ncco_builder/test_ncco_actions.py index b7bd028a..361e8bbd 100644 --- a/tests/test_ncco_builder/test_ncco_actions.py +++ b/tests/test_ncco_builder/test_ncco_actions.py @@ -7,7 +7,7 @@ def _action_as_dict(action: Ncco.Action): - return action.dict(exclude_none=True) + return action.model_dump(exclude_none=True, by_alias=True) def test_record_full(): @@ -261,17 +261,19 @@ def test_notify_validation_error(): def test_pay_voice_basic(): - pay = Ncco.Pay(amount='10.00') - assert type(pay) == Ncco.Pay - assert json.dumps(_action_as_dict(pay)) == nas.pay_basic + with pytest.deprecated_call(): + pay = Ncco.Pay(amount='10.00') + assert type(pay) == Ncco.Pay + assert json.dumps(_action_as_dict(pay)) == nas.pay_basic def test_pay_voice_full(): voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) - pay = Ncco.Pay( - amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings - ) - assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full + with pytest.deprecated_call(): + pay = Ncco.Pay( + amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings + ) + assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full def test_pay_text(): @@ -284,10 +286,11 @@ def test_pay_text(): } }, ) - pay = Ncco.Pay( - amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts - ) - assert json.dumps(_action_as_dict(pay)) == nas.pay_text + with pytest.deprecated_call(): + pay = Ncco.Pay( + amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts + ) + assert json.dumps(_action_as_dict(pay)) == nas.pay_text def test_pay_text_multiple_prompts(): @@ -318,10 +321,12 @@ def test_pay_text_multiple_prompts(): ) text_prompts = [card_prompt, expiration_date_prompt, security_code_prompt] - pay = Ncco.Pay(amount=12, prompts=text_prompts) - assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts + with pytest.deprecated_call(): + pay = Ncco.Pay(amount=12, prompts=text_prompts) + assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts def test_pay_validation_error(): with pytest.raises(ValidationError): - Ncco.Pay(amount='not-valid') + with pytest.deprecated_call(): + Ncco.Pay(amount='not-valid') diff --git a/tests/test_ncco_builder/test_ncco_builder.py b/tests/test_ncco_builder/test_ncco_builder.py index fbabd42c..f3850b5d 100644 --- a/tests/test_ncco_builder/test_ncco_builder.py +++ b/tests/test_ncco_builder/test_ncco_builder.py @@ -33,8 +33,8 @@ def test_build_insane_ncco(): nbs.stream, nbs.input, nbs.notify, - nbs.pay_voice_prompt, - nbs.pay_text_prompt, + nbs.get_pay_voice_prompt(), + nbs.get_pay_text_prompt(), ] ncco = Ncco.build_ncco(actions=action_list) assert ncco == nbs.insane_ncco diff --git a/tests/test_verify2.py b/tests/test_verify2.py index 5a586b28..601a78ee 100644 --- a/tests/test_verify2.py +++ b/tests/test_verify2.py @@ -101,7 +101,7 @@ def test_new_request_sms_custom_code_length_error(): with raises(ValidationError) as err: verify2.new_request(params) - assert 'ensure this value has at least 4 characters' in str(err.value) + assert 'String should have at least 4 characters' in str(err.value) def test_new_request_sms_custom_code_character_error(): @@ -141,7 +141,7 @@ def test_new_request_code_length_error(): with raises(ValidationError) as err: verify2.new_request(params) - assert 'ensure this value is less than or equal to 10' in str(err.value) + assert 'Input should be less than or equal to 10' in str(err.value) def test_new_request_to_error(): diff --git a/tests/test_voice.py b/tests/test_voice.py index 22f26aab..cc6dda52 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -159,7 +159,6 @@ def test_user_provided_authorization(dummy_data): token = request_authorization().split()[1] token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") - print(token) assert token["application_id"] == application_id assert token["nbf"] == nbf assert token["exp"] == exp From 09d57ae1287569a4a628ef5ccaacdc4d508bf97c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 22 Dec 2023 19:37:28 +0000 Subject: [PATCH 275/401] update changelog --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index bad43e6f..3cc7a200 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# 3.13.0 +- Migrating to use Pydantic v2 as a dependency + # 3.12.0 - Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) From b5432e4cf5d5dc2ff59fa745ebd9425fe3874c9d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 22 Dec 2023 19:37:32 +0000 Subject: [PATCH 276/401] =?UTF-8?q?Bump=20version:=203.12.0=20=E2=86=92=20?= =?UTF-8?q?3.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- src/vonage/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b10ee53e..13da7fa5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.12.0 +current_version = 3.13.0 commit = True tag = False diff --git a/setup.py b/setup.py index a57d0cfc..5b75759a 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="vonage", - version="3.12.0", + version="3.13.0", description="Vonage Server SDK for Python", long_description=long_description, long_description_content_type="text/markdown", diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py index e173a655..50e2d8dd 100644 --- a/src/vonage/__init__.py +++ b/src/vonage/__init__.py @@ -1,4 +1,4 @@ from .client import * from .ncco_builder.ncco import * -__version__ = "3.12.0" +__version__ = "3.13.0" From 19168f9b4b1ba56366a649c8d145c713103bcebb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 6 Nov 2024 17:58:05 +0000 Subject: [PATCH 277/401] 4.x (#307) * squash merge * remove unused tests * add NI v2, refactoring, new tests * restructuring utils directory * preparing packages for release * preparing for alpha release * add sms * add signature and error handling * working on signing requests * update signing and add tests * fix mocks, signatures and sig auth, add tests * Sms structuring and tests * add conversion method, SMS tests, refactoring * linting * update dependency versions * adding users api structure and list endpoint, refactoring and testing * add new models, change structure, start implementing pagination * add users api endpoints and tests * finish Users API implementation * finish Users API implementation * start working on verify api * adding start verification methods and starting check_code * add verify controls * add verify controls * finish verify implementation and prepare for release * start adding verify v2 * verify logic, models and tests * finish verify v2 and prepare for release * create messages package * create message models * test message models * prepare for messages release * start adding voice api and ncco builder * finish adding NCCO actions and add tests * finish ncco testing, start with voice endpoints * get list_calls working * add more voice methods * finish voice api, updating for release * update readme for release * start adding number insight * finish NI module * update dependency versions, prep for NI release * start creating application package * creating more application models and refactoring common components * finish create method, add list method and tests, refactoring * add other applications endpoints * prepare for application api release * add new dependency versions * start migration of jwt module * refactor jwt package, get tests passing * prepare for new release * 4.x gnp (#300) * add camara auth module * start adding gnp sim swap * adding new gnp packages * update camara auth * add network packages * finish adding network apis and prepare for release * adjust verify v2 channel timeout and prepare for release * start adding account api * add account api, prepare for new releases * add new structure * add rcs message type, revoke rcs message, mark whatsapp as read * add RCS support, revoke RCS message, mark WhatsApp as read * add subaccounts api * update `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 * add numbers api * linting * add video package and token methods * linting * start adding video api * test streaming methods, add signaling * add signaling and moderation endpoints, start live captions * add captioning, start audio connector * finish adding audio connector * start adding experience composer * finish adding experience composer, start adding archive models * add archive methods and tests * start adding broadcast * finish broadcast, refactoring common code * add sip endpoints * add SIP video endpoints, start release prep * add docstrings to data models * add more docstrings for data models * finish docstrings for data models * Stop tracking _dev_scripts folder * update global readme * prepare for alpha release * support Python 3.9+ and use inbuilt types in type hints * start adding number verification, remove network api beta, add to main package * add number verification to network auth * prepare for beta release * writing migration guides/readmes * prepare for v4 beta release * rename number insight methods * rename verify -> verify_legacy and verify_v2 -> verify * add SimSwapCheckRequest for SimSwap.check method * update migration guide * update application parameter name and make users models more easily accessible * update pants version * update jwt method, update dependency * update package versions * add pricing methods * add pricing api testing * update readmes and versioning --- .bumpversion.cfg | 8 - .editorconfig | 24 - .github/workflows/build.yml | 34 +- .gitignore | 7 + .pre-commit-config.yaml | 7 +- .pyup.yml | 4 - BUILD | 3 + CONTRIBUTING.md | 19 - LICENSE.txt => LICENSE | 0 MANIFEST.in | 16 - Makefile | 29 +- OPENTOK_TO_VONAGE_MIGRATION.md | 92 - README.md | 1581 ++++++++++------- V3_TO_V4_SDK_MIGRATION_GUIDE.md | 255 +++ account/BUILD | 16 + account/CHANGES.md | 12 + account/README.md | 94 + account/pyproject.toml | 32 + account/src/vonage_account/BUILD | 1 + account/src/vonage_account/__init__.py | 27 + account/src/vonage_account/_version.py | 1 + account/src/vonage_account/account.py | 253 +++ account/src/vonage_account/errors.py | 5 + account/src/vonage_account/requests.py | 44 + account/src/vonage_account/responses.py | 127 ++ account/tests/BUILD | 1 + .../data/create_secret_error_max_number.json | 6 + account/tests/data/get_balance.json | 4 + account/tests/data/get_country_pricing.json | 79 + .../data/get_multiple_countries_pricing.json | 47 + account/tests/data/list_secrets.json | 20 + account/tests/data/revoke_secret_error.json | 6 + account/tests/data/secret.json | 9 + account/tests/data/top_up.json | 4 + .../data/update_default_sms_webhook.json | 7 + account/tests/test_account.py | 240 +++ application/BUILD | 16 + application/CHANGES.md | 15 + application/README.md | 77 + application/pyproject.toml | 32 + application/src/vonage_application/BUILD | 1 + .../src/vonage_application/__init__.py | 45 + .../src/vonage_application/_version.py | 1 + .../src/vonage_application/application.py | 125 ++ application/src/vonage_application/common.py | 230 +++ application/src/vonage_application/enums.py | 16 + application/src/vonage_application/errors.py | 5 + .../src/vonage_application/requests.py | 29 + .../src/vonage_application/responses.py | 64 + application/tests/BUILD | 1 + .../tests/data/create_application_basic.json | 17 + .../data/create_application_options.json | 75 + application/tests/data/get_application.json | 36 + .../tests/data/list_applications_basic.json | 48 + .../list_applications_multiple_pages.json | 100 ++ .../tests/data/update_application.json | 13 + application/tests/test_application.py | 357 ++++ http_client/BUILD | 16 + http_client/CHANGES.md | 34 + http_client/README.md | 85 + http_client/pyproject.toml | 34 + http_client/src/vonage_http_client/BUILD | 1 + .../src/vonage_http_client/__init__.py | 28 + .../src/vonage_http_client/_version.py | 1 + http_client/src/vonage_http_client/auth.py | 161 ++ http_client/src/vonage_http_client/errors.py | 141 ++ .../src/vonage_http_client/http_client.py | 286 +++ http_client/tests/BUILD | 1 + http_client/tests/data/400.json | 1 + http_client/tests/data/400.txt | 1 + http_client/tests/data/401.json | 6 + http_client/tests/data/403.json | 6 + http_client/tests/data/404.json | 6 + .../tests/data/429.json | 2 +- http_client/tests/data/500.json | 5 + .../tests/data/dummy_private_key.txt | 0 .../tests/data/dummy_public_key.txt | 0 http_client/tests/data/example_get.json | 3 + http_client/tests/data/example_post.json | 3 + http_client/tests/test_auth.py | 183 ++ http_client/tests/test_http_client.py | 252 +++ jwt/BUILD | 16 + jwt/CHANGES.md | 18 + jwt/README.md | 60 + jwt/pyproject.toml | 28 + jwt/src/vonage_jwt/BUILD | 1 + jwt/src/vonage_jwt/__init__.py | 5 + jwt/src/vonage_jwt/_version.py | 1 + jwt/src/vonage_jwt/errors.py | 9 + jwt/src/vonage_jwt/jwt.py | 66 + jwt/src/vonage_jwt/verify_jwt.py | 15 + jwt/tests/BUILD | 1 + jwt/tests/data/private_key.txt | 28 + jwt/tests/data/public_key.txt | 9 + jwt/tests/test_jwt_generator.py | 68 + jwt/tests/test_verify_jwt.py | 21 + messages/BUILD | 16 + messages/CHANGES.md | 21 + messages/README.md | 110 ++ messages/pyproject.toml | 32 + messages/src/vonage_messages/BUILD | 1 + messages/src/vonage_messages/__init__.py | 5 + messages/src/vonage_messages/_version.py | 1 + messages/src/vonage_messages/messages.py | 86 + messages/src/vonage_messages/models/BUILD | 1 + .../src/vonage_messages/models/__init__.py | 104 ++ .../vonage_messages/models/base_message.py | 22 + messages/src/vonage_messages/models/enums.py | 39 + .../src/vonage_messages/models/messenger.py | 137 ++ messages/src/vonage_messages/models/mms.py | 105 ++ messages/src/vonage_messages/models/rcs.py | 120 ++ messages/src/vonage_messages/models/sms.py | 52 + messages/src/vonage_messages/models/viber.py | 270 +++ .../src/vonage_messages/models/whatsapp.py | 363 ++++ messages/src/vonage_messages/responses.py | 11 + messages/tests/BUILD | 1 + messages/tests/data/invalid_error.json | 12 + messages/tests/data/low_balance_error.json | 6 + messages/tests/data/not_found.json | 6 + messages/tests/data/send_message.json | 3 + messages/tests/test_messages.py | 147 ++ messages/tests/test_messenger_models.py | 224 +++ messages/tests/test_mms_models.py | 210 +++ messages/tests/test_rcs_models.py | 128 ++ messages/tests/test_sms_models.py | 54 + messages/tests/test_viber_models.py | 243 +++ messages/tests/test_whatsapp_models.py | 384 ++++ network_auth/BUILD | 16 + network_auth/CHANGES.md | 12 + network_auth/README.md | 27 + network_auth/pyproject.toml | 32 + network_auth/src/vonage_network_auth/BUILD | 1 + .../src/vonage_network_auth/__init__.py | 10 + .../src/vonage_network_auth/_version.py | 1 + .../src/vonage_network_auth/network_auth.py | 165 ++ .../src/vonage_network_auth/requests.py | 20 + .../src/vonage_network_auth/responses.py | 33 + network_auth/tests/BUILD | 1 + network_auth/tests/data/oidc_request.json | 5 + .../data/oidc_request_permissions_error.json | 6 + network_auth/tests/data/token_request.json | 5 + network_auth/tests/test_network_auth.py | 143 ++ network_number_verification/BUILD | 16 + network_number_verification/CHANGES.md | 5 + network_number_verification/README.md | 63 + network_number_verification/pyproject.toml | 33 + .../vonage_network_number_verification/BUILD | 1 + .../__init__.py | 12 + .../_version.py | 1 + .../errors.py | 5 + .../number_verification.py | 83 + .../requests.py | 35 + .../responses.py | 14 + network_number_verification/tests/BUILD | 1 + .../tests/data/token_request.json | 5 + .../tests/data/verify_number.json | 3 + .../tests/test_number_verification.py | 114 ++ network_sim_swap/BUILD | 16 + network_sim_swap/CHANGES.md | 14 + network_sim_swap/README.md | 39 + network_sim_swap/pyproject.toml | 33 + .../src/vonage_network_sim_swap/BUILD | 1 + .../src/vonage_network_sim_swap/__init__.py | 5 + .../src/vonage_network_sim_swap/_version.py | 1 + .../src/vonage_network_sim_swap/requests.py | 17 + .../src/vonage_network_sim_swap/responses.py | 22 + .../src/vonage_network_sim_swap/sim_swap.py | 73 + network_sim_swap/tests/BUILD | 1 + .../tests/data/check_sim_swap.json | 3 + .../tests/data/get_swap_date.json | 3 + network_sim_swap/tests/test_sim_swap.py | 52 + number_insight/BUILD | 16 + number_insight/CHANGES.md | 14 + number_insight/README.md | 58 + number_insight/pyproject.toml | 32 + .../src/vonage_number_insight/BUILD | 1 + .../src/vonage_number_insight/__init__.py | 33 + .../src/vonage_number_insight/_version.py | 1 + .../src/vonage_number_insight/errors.py | 5 + .../vonage_number_insight/number_insight.py | 153 ++ .../src/vonage_number_insight/requests.py | 51 + .../src/vonage_number_insight/responses.py | 212 +++ number_insight/tests/BUILD | 1 + .../tests/data/advanced_async_insight.json | 7 + .../data/advanced_async_insight_error.json | 4 + .../advanced_async_insight_partial_error.json | 8 + .../tests/data/advanced_sync_insight.json | 44 + number_insight/tests/data/basic_insight.json | 11 + .../tests/data/basic_insight_error.json | 4 + .../tests/data/standard_insight.json | 36 + number_insight/tests/test_number_insight.py | 159 ++ number_insight_v2/BUILD | 16 + number_insight_v2/CHANGES.md | 5 + number_insight_v2/README.md | 23 + number_insight_v2/pyproject.toml | 29 + .../src/vonage_number_insight_v2/BUILD | 1 + .../src/vonage_number_insight_v2/__init__.py | 7 + .../number_insight_v2.py | 93 + number_insight_v2/tests/BUILD | 1 + number_insight_v2/tests/data/default.json | 19 + number_insight_v2/tests/data/fraud_score.json | 15 + number_insight_v2/tests/data/sim_swap.json | 11 + .../tests/test_number_insight_v2.py | 98 + number_management/BUILD | 16 + number_management/CHANGES.md | 11 + number_management/README.md | 82 + number_management/pyproject.toml | 32 + number_management/src/vonage_numbers/BUILD | 1 + .../src/vonage_numbers/__init__.py | 25 + .../src/vonage_numbers/_version.py | 1 + number_management/src/vonage_numbers/enums.py | 22 + .../src/vonage_numbers/errors.py | 5 + .../src/vonage_numbers/number_management.py | 182 ++ .../src/vonage_numbers/requests.py | 155 ++ .../src/vonage_numbers/responses.py | 71 + number_management/tests/BUILD | 1 + .../tests/data/list_owned_numbers_basic.json | 25 + .../tests/data/list_owned_numbers_filter.json | 17 + .../tests/data/list_owned_numbers_subset.json | 13 + number_management/tests/data/no_number.json | 4 + .../tests/data/nothing.json | 0 number_management/tests/data/number.json | 4 + .../data/search_available_numbers_basic.json | 32 + .../search_available_numbers_end_of_list.json | 15 + .../data/search_available_numbers_filter.json | 14 + number_management/tests/test_numbers.py | 272 +++ pants.ci.toml | 5 + pants.toml | 70 + pyproject.toml | 5 - requirements.txt | 36 +- setup.cfg | 19 - setup.py | 41 - sms/BUILD | 16 + sms/CHANGES.md | 23 + sms/README.md | 24 + sms/pyproject.toml | 32 + sms/src/vonage_sms/BUILD | 1 + sms/src/vonage_sms/__init__.py | 13 + sms/src/vonage_sms/_version.py | 1 + sms/src/vonage_sms/errors.py | 17 + sms/src/vonage_sms/requests.py | 85 + sms/src/vonage_sms/responses.py | 43 + sms/src/vonage_sms/sms.py | 124 ++ sms/tests/BUILD | 1 + sms/tests/data/conversion_not_enabled.html | 12 + .../delete.json => sms/tests/data/null | 0 sms/tests/data/send_long_sms.json | 23 + sms/tests/data/send_sms.json | 13 + sms/tests/data/send_sms_error.json | 9 + sms/tests/data/send_sms_partial_error.json | 17 + sms/tests/test_sms.py | 178 ++ src/vonage/__init__.py | 4 - src/vonage/_internal.py | 31 - src/vonage/account.py | 116 -- src/vonage/application.py | 174 -- src/vonage/client.py | 463 ----- src/vonage/errors.py | 72 - src/vonage/meetings.py | 173 -- src/vonage/messages.py | 113 -- src/vonage/ncco_builder/__init__.py | 1 - src/vonage/ncco_builder/connect_endpoints.py | 63 - src/vonage/ncco_builder/input_types.py | 26 - src/vonage/ncco_builder/ncco.py | 259 --- src/vonage/ncco_builder/pay_prompts.py | 54 - src/vonage/number_insight.py | 48 - src/vonage/number_management.py | 34 - src/vonage/proactive_connect.py | 187 -- src/vonage/redact.py | 31 - src/vonage/short_codes.py | 34 - src/vonage/sms.py | 47 - src/vonage/subaccounts.py | 163 -- src/vonage/users.py | 72 - src/vonage/ussd.py | 15 - src/vonage/verify.py | 54 - src/vonage/verify2.py | 137 -- src/vonage/video.py | 353 ---- src/vonage/voice.py | 100 -- subaccounts/BUILD | 16 + subaccounts/CHANGES.md | 14 + subaccounts/README.md | 103 ++ subaccounts/pyproject.toml | 32 + subaccounts/src/vonage_subaccounts/BUILD | 1 + .../src/vonage_subaccounts/__init__.py | 35 + .../src/vonage_subaccounts/_version.py | 1 + subaccounts/src/vonage_subaccounts/errors.py | 5 + .../src/vonage_subaccounts/requests.py | 109 ++ .../src/vonage_subaccounts/responses.py | 130 ++ .../src/vonage_subaccounts/subaccounts.py | 297 ++++ subaccounts/tests/BUILD | 1 + subaccounts/tests/data/create_subaccount.json | 11 + subaccounts/tests/data/get_subaccount.json | 10 + .../tests/data/list_balance_transfers.json | 27 + .../tests/data/list_credit_transfers.json | 27 + subaccounts/tests/data/list_subaccounts.json | 41 + subaccounts/tests/data/modify_subaccount.json | 10 + subaccounts/tests/data/transfer.json | 14 + subaccounts/tests/data/transfer_number.json | 6 + ...ansfer_number_error_suspended_account.json | 6 + subaccounts/tests/test_subaccounts.py | 255 +++ tests/conftest.py | 141 -- .../secret_management/create-validation.json | 12 - .../account/secret_management/create.json | 9 - tests/data/account/secret_management/get.json | 9 - .../secret_management/last-secret.json | 6 - .../data/account/secret_management/list.json | 20 - .../secret_management/max-secrets.json | 6 - .../account/secret_management/missing.json | 6 - .../secret_management/unauthorized.json | 6 - .../data/applications/create_application.json | 14 - tests/data/applications/get_application.json | 13 - .../data/applications/list_applications.json | 45 - .../data/applications/update_application.json | 13 - .../meetings/delete_recording_not_found.json | 5 - tests/data/meetings/delete_theme_in_use.json | 8 - tests/data/meetings/get_recording.json | 12 - .../meetings/get_recording_not_found.json | 5 - .../data/meetings/get_session_recordings.json | 18 - .../get_session_recordings_not_found.json | 5 - tests/data/meetings/list_dial_in_numbers.json | 12 - .../data/meetings/list_logo_upload_urls.json | 47 - .../list_rooms_theme_id_not_found.json | 5 - .../meetings/list_rooms_with_theme_id.json | 57 - tests/data/meetings/list_themes.json | 34 - tests/data/meetings/logo_key_error.json | 11 - tests/data/meetings/long_term_room.json | 37 - .../meetings/long_term_room_with_theme.json | 37 - tests/data/meetings/meeting_room.json | 38 - tests/data/meetings/multiple_fewer_rooms.json | 94 - tests/data/meetings/multiple_rooms.json | 205 --- tests/data/meetings/theme.json | 16 - tests/data/meetings/theme_name_in_use.json | 5 - tests/data/meetings/theme_not_found.json | 5 - tests/data/meetings/transparent_logo.png | Bin 8843 -> 0 bytes tests/data/meetings/unauthorized.json | 4 - .../meetings/update_application_theme.json | 5 - ...update_application_theme_id_not_found.json | 5 - tests/data/meetings/update_no_keys.json | 5 - tests/data/meetings/update_room.json | 37 - .../data/meetings/update_room_type_error.json | 5 - .../meetings/update_theme_already_exists.json | 5 - tests/data/meetings/updated_theme.json | 16 - tests/data/meetings/upload_to_aws_error.xml | 1 - tests/data/no_content.json | 0 .../proactive_connect/create_list_400.json | 10 - .../proactive_connect/create_list_basic.json | 16 - .../proactive_connect/create_list_manual.json | 28 - .../create_list_salesforce.json | 30 - .../data/proactive_connect/csv_to_upload.csv | 4 - .../proactive_connect/fetch_list_400.json | 6 - tests/data/proactive_connect/get_list.json | 28 - tests/data/proactive_connect/item.json | 11 - tests/data/proactive_connect/item_400.json | 9 - tests/data/proactive_connect/list_404.json | 6 - .../proactive_connect/list_all_items.json | 40 - tests/data/proactive_connect/list_events.json | 70 - tests/data/proactive_connect/list_items.csv | 4 - tests/data/proactive_connect/list_lists.json | 84 - tests/data/proactive_connect/not_found.json | 6 - tests/data/proactive_connect/update_item.json | 11 - tests/data/proactive_connect/update_list.json | 29 - .../update_list_salesforce.json | 28 - .../proactive_connect/upload_from_csv.json | 3 - tests/data/subaccounts/balance_transfer.json | 14 - tests/data/subaccounts/credit_transfer.json | 14 - tests/data/subaccounts/forbidden.json | 6 - .../data/subaccounts/insufficient_credit.json | 6 - .../data/subaccounts/invalid_credentials.json | 6 - .../subaccounts/invalid_number_transfer.json | 6 - tests/data/subaccounts/invalid_transfer.json | 6 - .../subaccounts/list_balance_transfers.json | 27 - .../subaccounts/list_credit_transfers.json | 27 - tests/data/subaccounts/list_subaccounts.json | 31 - .../data/subaccounts/modified_subaccount.json | 10 - tests/data/subaccounts/must_be_number.json | 12 - tests/data/subaccounts/not_found.json | 6 - tests/data/subaccounts/number_not_found.json | 6 - .../same_from_and_to_accounts.json | 12 - tests/data/subaccounts/subaccount.json | 11 - tests/data/subaccounts/transfer_number.json | 7 - .../transfer_validation_error.json | 12 - tests/data/subaccounts/validation_error.json | 12 - tests/data/users/invalid_content_type.json | 13 - tests/data/users/list_users_400.json | 13 - tests/data/users/list_users_404.json | 7 - tests/data/users/list_users_500.json | 7 - tests/data/users/list_users_basic.json | 43 - tests/data/users/list_users_options.json | 37 - tests/data/users/rate_limit.json | 7 - tests/data/users/user_400.json | 13 - tests/data/users/user_404.json | 7 - tests/data/users/user_basic.json | 13 - tests/data/users/user_options.json | 69 - tests/data/users/user_updated.json | 19 - tests/data/verify/blocked_with_network.json | 1 - .../blocked_with_network_and_request_id.json | 1 - .../data/verify/blocked_with_request_id.json | 1 - tests/data/verify2/already_verified.json | 6 - tests/data/verify2/check_code.json | 4 - tests/data/verify2/code_not_supported.json | 6 - tests/data/verify2/create_request.json | 3 - .../verify2/create_request_silent_auth.json | 4 - tests/data/verify2/error_conflict.json | 7 - .../verify2/fraud_check_invalid_account.json | 6 - tests/data/verify2/invalid_email.json | 12 - tests/data/verify2/invalid_sender.json | 6 - tests/data/verify2/request_not_found.json | 6 - tests/data/video/broadcast.json | 39 - tests/data/video/create_archive.json | 17 - tests/data/video/create_sip_call.json | 5 - .../video/disable_mute_multiple_streams.json | 7 - tests/data/video/get_archive.json | 18 - tests/data/video/get_stream.json | 8 - tests/data/video/list_archives.json | 28 - tests/data/video/list_broadcasts.json | 44 - tests/data/video/list_streams.json | 13 - tests/data/video/mute_multiple_streams.json | 7 - tests/data/video/mute_specific_stream.json | 7 - tests/data/video/null.json | 1 - tests/data/video/play_dtmf_invalid_error.json | 4 - tests/data/video/stop_archive.json | 15 - tests/test_account.py | 222 --- tests/test_application.py | 276 --- tests/test_client.py | 36 - tests/test_getters_setters.py | 13 - tests/test_jwt.py | 54 - tests/test_meetings.py | 783 -------- tests/test_messages_send_message.py | 42 - tests/test_messages_validate_input.py | 315 ---- .../ncco_samples/ncco_action_samples.py | 55 - .../ncco_samples/ncco_builder_samples.py | 183 -- .../test_connect_endpoints.py | 64 - tests/test_ncco_builder/test_input_types.py | 47 - tests/test_ncco_builder/test_ncco_actions.py | 332 ---- tests/test_ncco_builder/test_ncco_builder.py | 40 - tests/test_ncco_builder/test_pay_prompts.py | 71 - tests/test_number_insight.py | 50 - tests/test_number_management.py | 57 - tests/test_packages.py | 14 - tests/test_proactive_connect.py | 649 ------- tests/test_redact.py | 36 - tests/test_rest_calls.py | 139 -- tests/test_short_codes.py | 64 - tests/test_signature.py | 86 - tests/test_sms.py | 50 - tests/test_subaccounts.py | 730 -------- tests/test_users.py | 369 ---- tests/test_ussd.py | 27 - tests/test_verify.py | 204 --- tests/test_verify2.py | 647 ------- tests/test_video.py | 678 ------- tests/test_voice.py | 224 --- tests/util.py | 63 - testutils/BUILD | 3 + testutils/__init__.py | 4 + testutils/data/fake_private_key.txt | 28 + testutils/mock_auth.py | 25 + testutils/testutils.py | 55 + tox.ini | 13 - users/BUILD | 16 + users/CHANGES.md | 26 + users/README.md | 65 + users/pyproject.toml | 32 + users/src/vonage_users/BUILD | 1 + users/src/vonage_users/__init__.py | 35 + users/src/vonage_users/_version.py | 1 + users/src/vonage_users/common.py | 172 ++ users/src/vonage_users/requests.py | 23 + users/src/vonage_users/responses.py | 68 + users/src/vonage_users/users.py | 128 ++ users/tests/BUILD | 1 + users/tests/data/list_users.json | 82 + users/tests/data/list_users_options.json | 41 + users/tests/data/updated_user.json | 23 + users/tests/data/user.json | 20 + users/tests/data/user_not_found.json | 6 + users/tests/test_users.py | 258 +++ verify/BUILD | 16 + verify/CHANGES.md | 21 + verify/README.md | 54 + verify/pyproject.toml | 32 + verify/src/vonage_verify/BUILD | 1 + verify/src/vonage_verify/__init__.py | 27 + verify/src/vonage_verify/_version.py | 1 + verify/src/vonage_verify/enums.py | 25 + verify/src/vonage_verify/errors.py | 5 + verify/src/vonage_verify/requests.py | 184 ++ verify/src/vonage_verify/responses.py | 28 + verify/src/vonage_verify/verify.py | 80 + verify/tests/BUILD | 1 + verify/tests/data/check_code.json | 4 + .../tests/data/check_code_400.json | 2 +- .../tests/data/check_code_410.json | 2 +- .../data/trigger_next_workflow_error.json | 6 + verify/tests/data/verify_request.json | 4 + verify/tests/data/verify_request_error.json | 6 + verify/tests/test_models.py | 138 ++ verify/tests/test_verify.py | 189 ++ verify_legacy/BUILD | 16 + verify_legacy/CHANGES.md | 2 + verify_legacy/README.md | 63 + verify_legacy/pyproject.toml | 32 + verify_legacy/src/vonage_verify_legacy/BUILD | 1 + .../src/vonage_verify_legacy/__init__.py | 25 + .../src/vonage_verify_legacy/_version.py | 1 + .../src/vonage_verify_legacy/errors.py | 5 + .../vonage_verify_legacy/language_codes.py | 71 + .../src/vonage_verify_legacy/requests.py | 120 ++ .../src/vonage_verify_legacy/responses.py | 137 ++ .../src/vonage_verify_legacy/verify_legacy.py | 239 +++ verify_legacy/tests/BUILD | 1 + .../tests/data/cancel_verification.json | 4 + .../tests/data/cancel_verification_error.json | 4 + verify_legacy/tests/data/check_code.json | 8 + .../tests/data/check_code_error.json | 5 + verify_legacy/tests/data/network_unblock.json | 4 + .../tests/data/network_unblock_error.json | 6 + verify_legacy/tests/data/search_request.json | 32 + .../tests/data/search_request_error.json | 4 + .../tests/data/search_request_list.json | 64 + .../tests/data/trigger_next_event.json | 4 + .../tests/data/trigger_next_event_error.json | 4 + verify_legacy/tests/data/verify_request.json | 4 + .../tests/data/verify_request_error.json | 5 + .../verify_request_error_with_network.json | 6 + verify_legacy/tests/test_verify_legacy.py | 304 ++++ video/BUILD | 16 + video/CHANGES.md | 8 + video/OPENTOK_TO_VONAGE_MIGRATION.md | 112 ++ video/README.md | 311 ++++ video/pyproject.toml | 32 + video/src/vonage_video/BUILD | 1 + video/src/vonage_video/__init__.py | 4 + video/src/vonage_video/_version.py | 1 + video/src/vonage_video/errors.py | 53 + video/src/vonage_video/models/BUILD | 1 + video/src/vonage_video/models/__init__.py | 98 + video/src/vonage_video/models/archive.py | 163 ++ .../vonage_video/models/audio_connector.py | 46 + video/src/vonage_video/models/broadcast.py | 218 +++ video/src/vonage_video/models/captions.py | 41 + video/src/vonage_video/models/common.py | 91 + video/src/vonage_video/models/enums.py | 109 ++ .../models/experience_composer.py | 81 + video/src/vonage_video/models/session.py | 58 + video/src/vonage_video/models/signal.py | 13 + video/src/vonage_video/models/sip.py | 85 + video/src/vonage_video/models/stream.py | 47 + video/src/vonage_video/models/token.py | 62 + video/src/vonage_video/video.py | 757 ++++++++ video/tests/BUILD | 1 + video/tests/data/archive.json | 23 + video/tests/data/audio_connector.json | 4 + video/tests/data/broadcast.json | 33 + .../data/captions_error_already_enabled.json | 5 + video/tests/data/change_stream_layout.json | 13 + .../tests/data}/create_session.json | 8 +- video/tests/data/delete_archive_error.json | 5 + video/tests/data/get_experience_composer.json | 13 + video/tests/data/get_stream.json | 6 + video/tests/data/initiate_sip_call.json | 9 + video/tests/data/list_archives.json | 51 + video/tests/data/list_broadcasts.json | 73 + .../tests/data/list_broadcasts_next_page.json | 39 + .../tests/data/list_experience_composers.json | 42 + video/tests/data/list_streams.json | 11 + video/tests/data/nothing.json | 1 + video/tests/data/start_broadcast_error.json | 3 + video/tests/data/start_captions.json | 3 + .../tests/data/start_experience_composer.json | 12 + video/tests/data/stop_archive.json | 23 + video/tests/data/stop_archive_error.json | 5 + video/tests/data/stop_broadcast.json | 23 + .../data/stop_broadcast_timeout_error.json | 5 + video/tests/test_archive.py | 296 +++ video/tests/test_audio_connector.py | 70 + video/tests/test_broadcast.py | 292 +++ video/tests/test_captions.py | 100 ++ video/tests/test_experience_composer.py | 132 ++ video/tests/test_moderation.py | 70 + video/tests/test_session.py | 82 + video/tests/test_signal.py | 47 + video/tests/test_sip.py | 127 ++ video/tests/test_stream.py | 90 + video/tests/test_token.py | 66 + video/tests/test_video.py | 401 +++++ voice/BUILD | 16 + voice/CHANGES.md | 20 + voice/README.md | 144 ++ voice/pyproject.toml | 32 + voice/src/vonage_voice/BUILD | 1 + voice/src/vonage_voice/__init__.py | 4 + voice/src/vonage_voice/_version.py | 1 + voice/src/vonage_voice/errors.py | 9 + voice/src/vonage_voice/models/BUILD | 1 + voice/src/vonage_voice/models/__init__.py | 73 + voice/src/vonage_voice/models/common.py | 74 + .../vonage_voice/models/connect_endpoints.py | 91 + voice/src/vonage_voice/models/enums.py | 89 + voice/src/vonage_voice/models/input_types.py | 52 + voice/src/vonage_voice/models/ncco.py | 265 +++ voice/src/vonage_voice/models/requests.py | 151 ++ voice/src/vonage_voice/models/responses.py | 110 ++ voice/src/vonage_voice/voice.py | 263 +++ voice/tests/BUILD | 1 + voice/tests/data/create_call.json | 6 + voice/tests/data/get_call.json | 25 + voice/tests/data/list_calls.json | 95 + voice/tests/data/list_calls_filter.json | 51 + voice/tests/data/play_audio_into_call.json | 4 + voice/tests/data/play_dtmf_into_call.json | 4 + voice/tests/data/play_tts_into_call.json | 4 + voice/tests/data/stop_audio_stream.json | 4 + voice/tests/data/stop_tts.json | 4 + voice/tests/test_ncco_actions.py | 324 ++++ voice/tests/test_voice.py | 410 +++++ vonage/BUILD | 11 + CHANGES.md => vonage/CHANGES.md | 42 + vonage/README.md | 47 + vonage/pyproject.toml | 48 + vonage/src/vonage/BUILD | 1 + vonage/src/vonage/__init__.py | 42 + vonage/src/vonage/_version.py | 1 + vonage/src/vonage/vonage.py | 57 + vonage/tests/BUILD | 1 + vonage/tests/test_vonage.py | 15 + vonage_utils/BUILD | 11 + vonage_utils/CHANGES.md | 22 + vonage_utils/README.md | 25 + vonage_utils/pyproject.toml | 28 + vonage_utils/src/vonage_utils/BUILD | 1 + vonage_utils/src/vonage_utils/__init__.py | 5 + vonage_utils/src/vonage_utils/_version.py | 1 + vonage_utils/src/vonage_utils/errors.py | 13 + vonage_utils/src/vonage_utils/models.py | 41 + vonage_utils/src/vonage_utils/types.py | 25 + vonage_utils/src/vonage_utils/utils.py | 50 + vonage_utils/tests/BUILD | 1 + .../tests/test_format_phone_number.py | 31 + vonage_utils/tests/test_remove_none_values.py | 16 + 639 files changed, 23813 insertions(+), 12634 deletions(-) delete mode 100644 .bumpversion.cfg delete mode 100644 .editorconfig delete mode 100644 .pyup.yml create mode 100644 BUILD delete mode 100644 CONTRIBUTING.md rename LICENSE.txt => LICENSE (100%) delete mode 100644 MANIFEST.in delete mode 100644 OPENTOK_TO_VONAGE_MIGRATION.md create mode 100644 V3_TO_V4_SDK_MIGRATION_GUIDE.md create mode 100644 account/BUILD create mode 100644 account/CHANGES.md create mode 100644 account/README.md create mode 100644 account/pyproject.toml create mode 100644 account/src/vonage_account/BUILD create mode 100644 account/src/vonage_account/__init__.py create mode 100644 account/src/vonage_account/_version.py create mode 100644 account/src/vonage_account/account.py create mode 100644 account/src/vonage_account/errors.py create mode 100644 account/src/vonage_account/requests.py create mode 100644 account/src/vonage_account/responses.py create mode 100644 account/tests/BUILD create mode 100644 account/tests/data/create_secret_error_max_number.json create mode 100644 account/tests/data/get_balance.json create mode 100644 account/tests/data/get_country_pricing.json create mode 100644 account/tests/data/get_multiple_countries_pricing.json create mode 100644 account/tests/data/list_secrets.json create mode 100644 account/tests/data/revoke_secret_error.json create mode 100644 account/tests/data/secret.json create mode 100644 account/tests/data/top_up.json create mode 100644 account/tests/data/update_default_sms_webhook.json create mode 100644 account/tests/test_account.py create mode 100644 application/BUILD create mode 100644 application/CHANGES.md create mode 100644 application/README.md create mode 100644 application/pyproject.toml create mode 100644 application/src/vonage_application/BUILD create mode 100644 application/src/vonage_application/__init__.py create mode 100644 application/src/vonage_application/_version.py create mode 100644 application/src/vonage_application/application.py create mode 100644 application/src/vonage_application/common.py create mode 100644 application/src/vonage_application/enums.py create mode 100644 application/src/vonage_application/errors.py create mode 100644 application/src/vonage_application/requests.py create mode 100644 application/src/vonage_application/responses.py create mode 100644 application/tests/BUILD create mode 100644 application/tests/data/create_application_basic.json create mode 100644 application/tests/data/create_application_options.json create mode 100644 application/tests/data/get_application.json create mode 100644 application/tests/data/list_applications_basic.json create mode 100644 application/tests/data/list_applications_multiple_pages.json create mode 100644 application/tests/data/update_application.json create mode 100644 application/tests/test_application.py create mode 100644 http_client/BUILD create mode 100644 http_client/CHANGES.md create mode 100644 http_client/README.md create mode 100644 http_client/pyproject.toml create mode 100644 http_client/src/vonage_http_client/BUILD create mode 100644 http_client/src/vonage_http_client/__init__.py create mode 100644 http_client/src/vonage_http_client/_version.py create mode 100644 http_client/src/vonage_http_client/auth.py create mode 100644 http_client/src/vonage_http_client/errors.py create mode 100644 http_client/src/vonage_http_client/http_client.py create mode 100644 http_client/tests/BUILD create mode 100644 http_client/tests/data/400.json create mode 100644 http_client/tests/data/400.txt create mode 100644 http_client/tests/data/401.json create mode 100644 http_client/tests/data/403.json create mode 100644 http_client/tests/data/404.json rename tests/data/verify2/rate_limit.json => http_client/tests/data/429.json (66%) create mode 100644 http_client/tests/data/500.json rename tests/data/private_key.txt => http_client/tests/data/dummy_private_key.txt (100%) rename tests/data/public_key.txt => http_client/tests/data/dummy_public_key.txt (100%) create mode 100644 http_client/tests/data/example_get.json create mode 100644 http_client/tests/data/example_post.json create mode 100644 http_client/tests/test_auth.py create mode 100644 http_client/tests/test_http_client.py create mode 100644 jwt/BUILD create mode 100644 jwt/CHANGES.md create mode 100644 jwt/README.md create mode 100644 jwt/pyproject.toml create mode 100644 jwt/src/vonage_jwt/BUILD create mode 100644 jwt/src/vonage_jwt/__init__.py create mode 100644 jwt/src/vonage_jwt/_version.py create mode 100644 jwt/src/vonage_jwt/errors.py create mode 100644 jwt/src/vonage_jwt/jwt.py create mode 100644 jwt/src/vonage_jwt/verify_jwt.py create mode 100644 jwt/tests/BUILD create mode 100644 jwt/tests/data/private_key.txt create mode 100644 jwt/tests/data/public_key.txt create mode 100644 jwt/tests/test_jwt_generator.py create mode 100644 jwt/tests/test_verify_jwt.py create mode 100644 messages/BUILD create mode 100644 messages/CHANGES.md create mode 100644 messages/README.md create mode 100644 messages/pyproject.toml create mode 100644 messages/src/vonage_messages/BUILD create mode 100644 messages/src/vonage_messages/__init__.py create mode 100644 messages/src/vonage_messages/_version.py create mode 100644 messages/src/vonage_messages/messages.py create mode 100644 messages/src/vonage_messages/models/BUILD create mode 100644 messages/src/vonage_messages/models/__init__.py create mode 100644 messages/src/vonage_messages/models/base_message.py create mode 100644 messages/src/vonage_messages/models/enums.py create mode 100644 messages/src/vonage_messages/models/messenger.py create mode 100644 messages/src/vonage_messages/models/mms.py create mode 100644 messages/src/vonage_messages/models/rcs.py create mode 100644 messages/src/vonage_messages/models/sms.py create mode 100644 messages/src/vonage_messages/models/viber.py create mode 100644 messages/src/vonage_messages/models/whatsapp.py create mode 100644 messages/src/vonage_messages/responses.py create mode 100644 messages/tests/BUILD create mode 100644 messages/tests/data/invalid_error.json create mode 100644 messages/tests/data/low_balance_error.json create mode 100644 messages/tests/data/not_found.json create mode 100644 messages/tests/data/send_message.json create mode 100644 messages/tests/test_messages.py create mode 100644 messages/tests/test_messenger_models.py create mode 100644 messages/tests/test_mms_models.py create mode 100644 messages/tests/test_rcs_models.py create mode 100644 messages/tests/test_sms_models.py create mode 100644 messages/tests/test_viber_models.py create mode 100644 messages/tests/test_whatsapp_models.py create mode 100644 network_auth/BUILD create mode 100644 network_auth/CHANGES.md create mode 100644 network_auth/README.md create mode 100644 network_auth/pyproject.toml create mode 100644 network_auth/src/vonage_network_auth/BUILD create mode 100644 network_auth/src/vonage_network_auth/__init__.py create mode 100644 network_auth/src/vonage_network_auth/_version.py create mode 100644 network_auth/src/vonage_network_auth/network_auth.py create mode 100644 network_auth/src/vonage_network_auth/requests.py create mode 100644 network_auth/src/vonage_network_auth/responses.py create mode 100644 network_auth/tests/BUILD create mode 100644 network_auth/tests/data/oidc_request.json create mode 100644 network_auth/tests/data/oidc_request_permissions_error.json create mode 100644 network_auth/tests/data/token_request.json create mode 100644 network_auth/tests/test_network_auth.py create mode 100644 network_number_verification/BUILD create mode 100644 network_number_verification/CHANGES.md create mode 100644 network_number_verification/README.md create mode 100644 network_number_verification/pyproject.toml create mode 100644 network_number_verification/src/vonage_network_number_verification/BUILD create mode 100644 network_number_verification/src/vonage_network_number_verification/__init__.py create mode 100644 network_number_verification/src/vonage_network_number_verification/_version.py create mode 100644 network_number_verification/src/vonage_network_number_verification/errors.py create mode 100644 network_number_verification/src/vonage_network_number_verification/number_verification.py create mode 100644 network_number_verification/src/vonage_network_number_verification/requests.py create mode 100644 network_number_verification/src/vonage_network_number_verification/responses.py create mode 100644 network_number_verification/tests/BUILD create mode 100644 network_number_verification/tests/data/token_request.json create mode 100644 network_number_verification/tests/data/verify_number.json create mode 100644 network_number_verification/tests/test_number_verification.py create mode 100644 network_sim_swap/BUILD create mode 100644 network_sim_swap/CHANGES.md create mode 100644 network_sim_swap/README.md create mode 100644 network_sim_swap/pyproject.toml create mode 100644 network_sim_swap/src/vonage_network_sim_swap/BUILD create mode 100644 network_sim_swap/src/vonage_network_sim_swap/__init__.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/_version.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/requests.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/responses.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/sim_swap.py create mode 100644 network_sim_swap/tests/BUILD create mode 100644 network_sim_swap/tests/data/check_sim_swap.json create mode 100644 network_sim_swap/tests/data/get_swap_date.json create mode 100644 network_sim_swap/tests/test_sim_swap.py create mode 100644 number_insight/BUILD create mode 100644 number_insight/CHANGES.md create mode 100644 number_insight/README.md create mode 100644 number_insight/pyproject.toml create mode 100644 number_insight/src/vonage_number_insight/BUILD create mode 100644 number_insight/src/vonage_number_insight/__init__.py create mode 100644 number_insight/src/vonage_number_insight/_version.py create mode 100644 number_insight/src/vonage_number_insight/errors.py create mode 100644 number_insight/src/vonage_number_insight/number_insight.py create mode 100644 number_insight/src/vonage_number_insight/requests.py create mode 100644 number_insight/src/vonage_number_insight/responses.py create mode 100644 number_insight/tests/BUILD create mode 100644 number_insight/tests/data/advanced_async_insight.json create mode 100644 number_insight/tests/data/advanced_async_insight_error.json create mode 100644 number_insight/tests/data/advanced_async_insight_partial_error.json create mode 100644 number_insight/tests/data/advanced_sync_insight.json create mode 100644 number_insight/tests/data/basic_insight.json create mode 100644 number_insight/tests/data/basic_insight_error.json create mode 100644 number_insight/tests/data/standard_insight.json create mode 100644 number_insight/tests/test_number_insight.py create mode 100644 number_insight_v2/BUILD create mode 100644 number_insight_v2/CHANGES.md create mode 100644 number_insight_v2/README.md create mode 100644 number_insight_v2/pyproject.toml create mode 100644 number_insight_v2/src/vonage_number_insight_v2/BUILD create mode 100644 number_insight_v2/src/vonage_number_insight_v2/__init__.py create mode 100644 number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py create mode 100644 number_insight_v2/tests/BUILD create mode 100644 number_insight_v2/tests/data/default.json create mode 100644 number_insight_v2/tests/data/fraud_score.json create mode 100644 number_insight_v2/tests/data/sim_swap.json create mode 100644 number_insight_v2/tests/test_number_insight_v2.py create mode 100644 number_management/BUILD create mode 100644 number_management/CHANGES.md create mode 100644 number_management/README.md create mode 100644 number_management/pyproject.toml create mode 100644 number_management/src/vonage_numbers/BUILD create mode 100644 number_management/src/vonage_numbers/__init__.py create mode 100644 number_management/src/vonage_numbers/_version.py create mode 100644 number_management/src/vonage_numbers/enums.py create mode 100644 number_management/src/vonage_numbers/errors.py create mode 100644 number_management/src/vonage_numbers/number_management.py create mode 100644 number_management/src/vonage_numbers/requests.py create mode 100644 number_management/src/vonage_numbers/responses.py create mode 100644 number_management/tests/BUILD create mode 100644 number_management/tests/data/list_owned_numbers_basic.json create mode 100644 number_management/tests/data/list_owned_numbers_filter.json create mode 100644 number_management/tests/data/list_owned_numbers_subset.json create mode 100644 number_management/tests/data/no_number.json rename tests/data/meetings/empty_themes.json => number_management/tests/data/nothing.json (100%) create mode 100644 number_management/tests/data/number.json create mode 100644 number_management/tests/data/search_available_numbers_basic.json create mode 100644 number_management/tests/data/search_available_numbers_end_of_list.json create mode 100644 number_management/tests/data/search_available_numbers_filter.json create mode 100644 number_management/tests/test_numbers.py create mode 100644 pants.ci.toml create mode 100644 pants.toml delete mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 sms/BUILD create mode 100644 sms/CHANGES.md create mode 100644 sms/README.md create mode 100644 sms/pyproject.toml create mode 100644 sms/src/vonage_sms/BUILD create mode 100644 sms/src/vonage_sms/__init__.py create mode 100644 sms/src/vonage_sms/_version.py create mode 100644 sms/src/vonage_sms/errors.py create mode 100644 sms/src/vonage_sms/requests.py create mode 100644 sms/src/vonage_sms/responses.py create mode 100644 sms/src/vonage_sms/sms.py create mode 100644 sms/tests/BUILD create mode 100644 sms/tests/data/conversion_not_enabled.html rename tests/data/account/secret_management/delete.json => sms/tests/data/null (100%) create mode 100644 sms/tests/data/send_long_sms.json create mode 100644 sms/tests/data/send_sms.json create mode 100644 sms/tests/data/send_sms_error.json create mode 100644 sms/tests/data/send_sms_partial_error.json create mode 100644 sms/tests/test_sms.py delete mode 100644 src/vonage/__init__.py delete mode 100644 src/vonage/_internal.py delete mode 100644 src/vonage/account.py delete mode 100644 src/vonage/application.py delete mode 100644 src/vonage/client.py delete mode 100644 src/vonage/errors.py delete mode 100644 src/vonage/meetings.py delete mode 100644 src/vonage/messages.py delete mode 100644 src/vonage/ncco_builder/__init__.py delete mode 100644 src/vonage/ncco_builder/connect_endpoints.py delete mode 100644 src/vonage/ncco_builder/input_types.py delete mode 100644 src/vonage/ncco_builder/ncco.py delete mode 100644 src/vonage/ncco_builder/pay_prompts.py delete mode 100644 src/vonage/number_insight.py delete mode 100644 src/vonage/number_management.py delete mode 100644 src/vonage/proactive_connect.py delete mode 100644 src/vonage/redact.py delete mode 100644 src/vonage/short_codes.py delete mode 100644 src/vonage/sms.py delete mode 100644 src/vonage/subaccounts.py delete mode 100644 src/vonage/users.py delete mode 100644 src/vonage/ussd.py delete mode 100644 src/vonage/verify.py delete mode 100644 src/vonage/verify2.py delete mode 100644 src/vonage/video.py delete mode 100644 src/vonage/voice.py create mode 100644 subaccounts/BUILD create mode 100644 subaccounts/CHANGES.md create mode 100644 subaccounts/README.md create mode 100644 subaccounts/pyproject.toml create mode 100644 subaccounts/src/vonage_subaccounts/BUILD create mode 100644 subaccounts/src/vonage_subaccounts/__init__.py create mode 100644 subaccounts/src/vonage_subaccounts/_version.py create mode 100644 subaccounts/src/vonage_subaccounts/errors.py create mode 100644 subaccounts/src/vonage_subaccounts/requests.py create mode 100644 subaccounts/src/vonage_subaccounts/responses.py create mode 100644 subaccounts/src/vonage_subaccounts/subaccounts.py create mode 100644 subaccounts/tests/BUILD create mode 100644 subaccounts/tests/data/create_subaccount.json create mode 100644 subaccounts/tests/data/get_subaccount.json create mode 100644 subaccounts/tests/data/list_balance_transfers.json create mode 100644 subaccounts/tests/data/list_credit_transfers.json create mode 100644 subaccounts/tests/data/list_subaccounts.json create mode 100644 subaccounts/tests/data/modify_subaccount.json create mode 100644 subaccounts/tests/data/transfer.json create mode 100644 subaccounts/tests/data/transfer_number.json create mode 100644 subaccounts/tests/data/transfer_number_error_suspended_account.json create mode 100644 subaccounts/tests/test_subaccounts.py delete mode 100644 tests/conftest.py delete mode 100644 tests/data/account/secret_management/create-validation.json delete mode 100644 tests/data/account/secret_management/create.json delete mode 100644 tests/data/account/secret_management/get.json delete mode 100644 tests/data/account/secret_management/last-secret.json delete mode 100644 tests/data/account/secret_management/list.json delete mode 100644 tests/data/account/secret_management/max-secrets.json delete mode 100644 tests/data/account/secret_management/missing.json delete mode 100644 tests/data/account/secret_management/unauthorized.json delete mode 100644 tests/data/applications/create_application.json delete mode 100644 tests/data/applications/get_application.json delete mode 100644 tests/data/applications/list_applications.json delete mode 100644 tests/data/applications/update_application.json delete mode 100644 tests/data/meetings/delete_recording_not_found.json delete mode 100644 tests/data/meetings/delete_theme_in_use.json delete mode 100644 tests/data/meetings/get_recording.json delete mode 100644 tests/data/meetings/get_recording_not_found.json delete mode 100644 tests/data/meetings/get_session_recordings.json delete mode 100644 tests/data/meetings/get_session_recordings_not_found.json delete mode 100644 tests/data/meetings/list_dial_in_numbers.json delete mode 100644 tests/data/meetings/list_logo_upload_urls.json delete mode 100644 tests/data/meetings/list_rooms_theme_id_not_found.json delete mode 100644 tests/data/meetings/list_rooms_with_theme_id.json delete mode 100644 tests/data/meetings/list_themes.json delete mode 100644 tests/data/meetings/logo_key_error.json delete mode 100644 tests/data/meetings/long_term_room.json delete mode 100644 tests/data/meetings/long_term_room_with_theme.json delete mode 100644 tests/data/meetings/meeting_room.json delete mode 100644 tests/data/meetings/multiple_fewer_rooms.json delete mode 100644 tests/data/meetings/multiple_rooms.json delete mode 100644 tests/data/meetings/theme.json delete mode 100644 tests/data/meetings/theme_name_in_use.json delete mode 100644 tests/data/meetings/theme_not_found.json delete mode 100644 tests/data/meetings/transparent_logo.png delete mode 100644 tests/data/meetings/unauthorized.json delete mode 100644 tests/data/meetings/update_application_theme.json delete mode 100644 tests/data/meetings/update_application_theme_id_not_found.json delete mode 100644 tests/data/meetings/update_no_keys.json delete mode 100644 tests/data/meetings/update_room.json delete mode 100644 tests/data/meetings/update_room_type_error.json delete mode 100644 tests/data/meetings/update_theme_already_exists.json delete mode 100644 tests/data/meetings/updated_theme.json delete mode 100644 tests/data/meetings/upload_to_aws_error.xml delete mode 100644 tests/data/no_content.json delete mode 100644 tests/data/proactive_connect/create_list_400.json delete mode 100644 tests/data/proactive_connect/create_list_basic.json delete mode 100644 tests/data/proactive_connect/create_list_manual.json delete mode 100644 tests/data/proactive_connect/create_list_salesforce.json delete mode 100644 tests/data/proactive_connect/csv_to_upload.csv delete mode 100644 tests/data/proactive_connect/fetch_list_400.json delete mode 100644 tests/data/proactive_connect/get_list.json delete mode 100644 tests/data/proactive_connect/item.json delete mode 100644 tests/data/proactive_connect/item_400.json delete mode 100644 tests/data/proactive_connect/list_404.json delete mode 100644 tests/data/proactive_connect/list_all_items.json delete mode 100644 tests/data/proactive_connect/list_events.json delete mode 100644 tests/data/proactive_connect/list_items.csv delete mode 100644 tests/data/proactive_connect/list_lists.json delete mode 100644 tests/data/proactive_connect/not_found.json delete mode 100644 tests/data/proactive_connect/update_item.json delete mode 100644 tests/data/proactive_connect/update_list.json delete mode 100644 tests/data/proactive_connect/update_list_salesforce.json delete mode 100644 tests/data/proactive_connect/upload_from_csv.json delete mode 100644 tests/data/subaccounts/balance_transfer.json delete mode 100644 tests/data/subaccounts/credit_transfer.json delete mode 100644 tests/data/subaccounts/forbidden.json delete mode 100644 tests/data/subaccounts/insufficient_credit.json delete mode 100644 tests/data/subaccounts/invalid_credentials.json delete mode 100644 tests/data/subaccounts/invalid_number_transfer.json delete mode 100644 tests/data/subaccounts/invalid_transfer.json delete mode 100644 tests/data/subaccounts/list_balance_transfers.json delete mode 100644 tests/data/subaccounts/list_credit_transfers.json delete mode 100644 tests/data/subaccounts/list_subaccounts.json delete mode 100644 tests/data/subaccounts/modified_subaccount.json delete mode 100644 tests/data/subaccounts/must_be_number.json delete mode 100644 tests/data/subaccounts/not_found.json delete mode 100644 tests/data/subaccounts/number_not_found.json delete mode 100644 tests/data/subaccounts/same_from_and_to_accounts.json delete mode 100644 tests/data/subaccounts/subaccount.json delete mode 100644 tests/data/subaccounts/transfer_number.json delete mode 100644 tests/data/subaccounts/transfer_validation_error.json delete mode 100644 tests/data/subaccounts/validation_error.json delete mode 100644 tests/data/users/invalid_content_type.json delete mode 100644 tests/data/users/list_users_400.json delete mode 100644 tests/data/users/list_users_404.json delete mode 100644 tests/data/users/list_users_500.json delete mode 100644 tests/data/users/list_users_basic.json delete mode 100644 tests/data/users/list_users_options.json delete mode 100644 tests/data/users/rate_limit.json delete mode 100644 tests/data/users/user_400.json delete mode 100644 tests/data/users/user_404.json delete mode 100644 tests/data/users/user_basic.json delete mode 100644 tests/data/users/user_options.json delete mode 100644 tests/data/users/user_updated.json delete mode 100644 tests/data/verify/blocked_with_network.json delete mode 100644 tests/data/verify/blocked_with_network_and_request_id.json delete mode 100644 tests/data/verify/blocked_with_request_id.json delete mode 100644 tests/data/verify2/already_verified.json delete mode 100644 tests/data/verify2/check_code.json delete mode 100644 tests/data/verify2/code_not_supported.json delete mode 100644 tests/data/verify2/create_request.json delete mode 100644 tests/data/verify2/create_request_silent_auth.json delete mode 100644 tests/data/verify2/error_conflict.json delete mode 100644 tests/data/verify2/fraud_check_invalid_account.json delete mode 100644 tests/data/verify2/invalid_email.json delete mode 100644 tests/data/verify2/invalid_sender.json delete mode 100644 tests/data/verify2/request_not_found.json delete mode 100644 tests/data/video/broadcast.json delete mode 100644 tests/data/video/create_archive.json delete mode 100644 tests/data/video/create_sip_call.json delete mode 100644 tests/data/video/disable_mute_multiple_streams.json delete mode 100644 tests/data/video/get_archive.json delete mode 100644 tests/data/video/get_stream.json delete mode 100644 tests/data/video/list_archives.json delete mode 100644 tests/data/video/list_broadcasts.json delete mode 100644 tests/data/video/list_streams.json delete mode 100644 tests/data/video/mute_multiple_streams.json delete mode 100644 tests/data/video/mute_specific_stream.json delete mode 100644 tests/data/video/null.json delete mode 100644 tests/data/video/play_dtmf_invalid_error.json delete mode 100644 tests/data/video/stop_archive.json delete mode 100644 tests/test_account.py delete mode 100644 tests/test_application.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_getters_setters.py delete mode 100644 tests/test_jwt.py delete mode 100644 tests/test_meetings.py delete mode 100644 tests/test_messages_send_message.py delete mode 100644 tests/test_messages_validate_input.py delete mode 100644 tests/test_ncco_builder/ncco_samples/ncco_action_samples.py delete mode 100644 tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py delete mode 100644 tests/test_ncco_builder/test_connect_endpoints.py delete mode 100644 tests/test_ncco_builder/test_input_types.py delete mode 100644 tests/test_ncco_builder/test_ncco_actions.py delete mode 100644 tests/test_ncco_builder/test_ncco_builder.py delete mode 100644 tests/test_ncco_builder/test_pay_prompts.py delete mode 100644 tests/test_number_insight.py delete mode 100644 tests/test_number_management.py delete mode 100644 tests/test_packages.py delete mode 100644 tests/test_proactive_connect.py delete mode 100644 tests/test_redact.py delete mode 100644 tests/test_rest_calls.py delete mode 100644 tests/test_short_codes.py delete mode 100644 tests/test_signature.py delete mode 100644 tests/test_sms.py delete mode 100644 tests/test_subaccounts.py delete mode 100644 tests/test_users.py delete mode 100644 tests/test_ussd.py delete mode 100644 tests/test_verify.py delete mode 100644 tests/test_verify2.py delete mode 100644 tests/test_video.py delete mode 100644 tests/test_voice.py delete mode 100644 tests/util.py create mode 100644 testutils/BUILD create mode 100644 testutils/__init__.py create mode 100644 testutils/data/fake_private_key.txt create mode 100644 testutils/mock_auth.py create mode 100644 testutils/testutils.py delete mode 100644 tox.ini create mode 100644 users/BUILD create mode 100644 users/CHANGES.md create mode 100644 users/README.md create mode 100644 users/pyproject.toml create mode 100644 users/src/vonage_users/BUILD create mode 100644 users/src/vonage_users/__init__.py create mode 100644 users/src/vonage_users/_version.py create mode 100644 users/src/vonage_users/common.py create mode 100644 users/src/vonage_users/requests.py create mode 100644 users/src/vonage_users/responses.py create mode 100644 users/src/vonage_users/users.py create mode 100644 users/tests/BUILD create mode 100644 users/tests/data/list_users.json create mode 100644 users/tests/data/list_users_options.json create mode 100644 users/tests/data/updated_user.json create mode 100644 users/tests/data/user.json create mode 100644 users/tests/data/user_not_found.json create mode 100644 users/tests/test_users.py create mode 100644 verify/BUILD create mode 100644 verify/CHANGES.md create mode 100644 verify/README.md create mode 100644 verify/pyproject.toml create mode 100644 verify/src/vonage_verify/BUILD create mode 100644 verify/src/vonage_verify/__init__.py create mode 100644 verify/src/vonage_verify/_version.py create mode 100644 verify/src/vonage_verify/enums.py create mode 100644 verify/src/vonage_verify/errors.py create mode 100644 verify/src/vonage_verify/requests.py create mode 100644 verify/src/vonage_verify/responses.py create mode 100644 verify/src/vonage_verify/verify.py create mode 100644 verify/tests/BUILD create mode 100644 verify/tests/data/check_code.json rename tests/data/verify2/invalid_code.json => verify/tests/data/check_code_400.json (75%) rename tests/data/verify2/too_many_code_attempts.json => verify/tests/data/check_code_410.json (76%) create mode 100644 verify/tests/data/trigger_next_workflow_error.json create mode 100644 verify/tests/data/verify_request.json create mode 100644 verify/tests/data/verify_request_error.json create mode 100644 verify/tests/test_models.py create mode 100644 verify/tests/test_verify.py create mode 100644 verify_legacy/BUILD create mode 100644 verify_legacy/CHANGES.md create mode 100644 verify_legacy/README.md create mode 100644 verify_legacy/pyproject.toml create mode 100644 verify_legacy/src/vonage_verify_legacy/BUILD create mode 100644 verify_legacy/src/vonage_verify_legacy/__init__.py create mode 100644 verify_legacy/src/vonage_verify_legacy/_version.py create mode 100644 verify_legacy/src/vonage_verify_legacy/errors.py create mode 100644 verify_legacy/src/vonage_verify_legacy/language_codes.py create mode 100644 verify_legacy/src/vonage_verify_legacy/requests.py create mode 100644 verify_legacy/src/vonage_verify_legacy/responses.py create mode 100644 verify_legacy/src/vonage_verify_legacy/verify_legacy.py create mode 100644 verify_legacy/tests/BUILD create mode 100644 verify_legacy/tests/data/cancel_verification.json create mode 100644 verify_legacy/tests/data/cancel_verification_error.json create mode 100644 verify_legacy/tests/data/check_code.json create mode 100644 verify_legacy/tests/data/check_code_error.json create mode 100644 verify_legacy/tests/data/network_unblock.json create mode 100644 verify_legacy/tests/data/network_unblock_error.json create mode 100644 verify_legacy/tests/data/search_request.json create mode 100644 verify_legacy/tests/data/search_request_error.json create mode 100644 verify_legacy/tests/data/search_request_list.json create mode 100644 verify_legacy/tests/data/trigger_next_event.json create mode 100644 verify_legacy/tests/data/trigger_next_event_error.json create mode 100644 verify_legacy/tests/data/verify_request.json create mode 100644 verify_legacy/tests/data/verify_request_error.json create mode 100644 verify_legacy/tests/data/verify_request_error_with_network.json create mode 100644 verify_legacy/tests/test_verify_legacy.py create mode 100644 video/BUILD create mode 100644 video/CHANGES.md create mode 100644 video/OPENTOK_TO_VONAGE_MIGRATION.md create mode 100644 video/README.md create mode 100644 video/pyproject.toml create mode 100644 video/src/vonage_video/BUILD create mode 100644 video/src/vonage_video/__init__.py create mode 100644 video/src/vonage_video/_version.py create mode 100644 video/src/vonage_video/errors.py create mode 100644 video/src/vonage_video/models/BUILD create mode 100644 video/src/vonage_video/models/__init__.py create mode 100644 video/src/vonage_video/models/archive.py create mode 100644 video/src/vonage_video/models/audio_connector.py create mode 100644 video/src/vonage_video/models/broadcast.py create mode 100644 video/src/vonage_video/models/captions.py create mode 100644 video/src/vonage_video/models/common.py create mode 100644 video/src/vonage_video/models/enums.py create mode 100644 video/src/vonage_video/models/experience_composer.py create mode 100644 video/src/vonage_video/models/session.py create mode 100644 video/src/vonage_video/models/signal.py create mode 100644 video/src/vonage_video/models/sip.py create mode 100644 video/src/vonage_video/models/stream.py create mode 100644 video/src/vonage_video/models/token.py create mode 100644 video/src/vonage_video/video.py create mode 100644 video/tests/BUILD create mode 100644 video/tests/data/archive.json create mode 100644 video/tests/data/audio_connector.json create mode 100644 video/tests/data/broadcast.json create mode 100644 video/tests/data/captions_error_already_enabled.json create mode 100644 video/tests/data/change_stream_layout.json rename {tests/data/video => video/tests/data}/create_session.json (64%) create mode 100644 video/tests/data/delete_archive_error.json create mode 100644 video/tests/data/get_experience_composer.json create mode 100644 video/tests/data/get_stream.json create mode 100644 video/tests/data/initiate_sip_call.json create mode 100644 video/tests/data/list_archives.json create mode 100644 video/tests/data/list_broadcasts.json create mode 100644 video/tests/data/list_broadcasts_next_page.json create mode 100644 video/tests/data/list_experience_composers.json create mode 100644 video/tests/data/list_streams.json create mode 100644 video/tests/data/nothing.json create mode 100644 video/tests/data/start_broadcast_error.json create mode 100644 video/tests/data/start_captions.json create mode 100644 video/tests/data/start_experience_composer.json create mode 100644 video/tests/data/stop_archive.json create mode 100644 video/tests/data/stop_archive_error.json create mode 100644 video/tests/data/stop_broadcast.json create mode 100644 video/tests/data/stop_broadcast_timeout_error.json create mode 100644 video/tests/test_archive.py create mode 100644 video/tests/test_audio_connector.py create mode 100644 video/tests/test_broadcast.py create mode 100644 video/tests/test_captions.py create mode 100644 video/tests/test_experience_composer.py create mode 100644 video/tests/test_moderation.py create mode 100644 video/tests/test_session.py create mode 100644 video/tests/test_signal.py create mode 100644 video/tests/test_sip.py create mode 100644 video/tests/test_stream.py create mode 100644 video/tests/test_token.py create mode 100644 video/tests/test_video.py create mode 100644 voice/BUILD create mode 100644 voice/CHANGES.md create mode 100644 voice/README.md create mode 100644 voice/pyproject.toml create mode 100644 voice/src/vonage_voice/BUILD create mode 100644 voice/src/vonage_voice/__init__.py create mode 100644 voice/src/vonage_voice/_version.py create mode 100644 voice/src/vonage_voice/errors.py create mode 100644 voice/src/vonage_voice/models/BUILD create mode 100644 voice/src/vonage_voice/models/__init__.py create mode 100644 voice/src/vonage_voice/models/common.py create mode 100644 voice/src/vonage_voice/models/connect_endpoints.py create mode 100644 voice/src/vonage_voice/models/enums.py create mode 100644 voice/src/vonage_voice/models/input_types.py create mode 100644 voice/src/vonage_voice/models/ncco.py create mode 100644 voice/src/vonage_voice/models/requests.py create mode 100644 voice/src/vonage_voice/models/responses.py create mode 100644 voice/src/vonage_voice/voice.py create mode 100644 voice/tests/BUILD create mode 100644 voice/tests/data/create_call.json create mode 100644 voice/tests/data/get_call.json create mode 100644 voice/tests/data/list_calls.json create mode 100644 voice/tests/data/list_calls_filter.json create mode 100644 voice/tests/data/play_audio_into_call.json create mode 100644 voice/tests/data/play_dtmf_into_call.json create mode 100644 voice/tests/data/play_tts_into_call.json create mode 100644 voice/tests/data/stop_audio_stream.json create mode 100644 voice/tests/data/stop_tts.json create mode 100644 voice/tests/test_ncco_actions.py create mode 100644 voice/tests/test_voice.py create mode 100644 vonage/BUILD rename CHANGES.md => vonage/CHANGES.md (83%) create mode 100644 vonage/README.md create mode 100644 vonage/pyproject.toml create mode 100644 vonage/src/vonage/BUILD create mode 100644 vonage/src/vonage/__init__.py create mode 100644 vonage/src/vonage/_version.py create mode 100644 vonage/src/vonage/vonage.py create mode 100644 vonage/tests/BUILD create mode 100644 vonage/tests/test_vonage.py create mode 100644 vonage_utils/BUILD create mode 100644 vonage_utils/CHANGES.md create mode 100644 vonage_utils/README.md create mode 100644 vonage_utils/pyproject.toml create mode 100644 vonage_utils/src/vonage_utils/BUILD create mode 100644 vonage_utils/src/vonage_utils/__init__.py create mode 100644 vonage_utils/src/vonage_utils/_version.py create mode 100644 vonage_utils/src/vonage_utils/errors.py create mode 100644 vonage_utils/src/vonage_utils/models.py create mode 100644 vonage_utils/src/vonage_utils/types.py create mode 100644 vonage_utils/src/vonage_utils/utils.py create mode 100644 vonage_utils/tests/BUILD create mode 100644 vonage_utils/tests/test_format_phone_number.py create mode 100644 vonage_utils/tests/test_remove_none_values.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 13da7fa5..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[bumpversion] -current_version = 3.13.0 -commit = True -tag = False - -[bumpversion:file:src/vonage/__init__.py] - -[bumpversion:file:setup.py] diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index b8950ff3..00000000 --- a/.editorconfig +++ /dev/null @@ -1,24 +0,0 @@ -# EditorConfig is awesome: https://EditorConfig.org - -# top-most EditorConfig file -root = true - -# Unix-style newlines with a newline ending every file -[*] -end_of_line = lf -insert_final_newline = true - -# Python files 4 space indentation -[*.py] -charset = utf-8 -indent_style = space -indent_size = 4 - -# Makefiles tab indentation -[Makefile] -indent_style = tab - -# Yaml files 2-space indentation -[*.yml] -indent_style = space -indent_size = 2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 715918af..8fe82a53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ name: Build -on: push +on: [push, pull_request] permissions: actions: write @@ -14,6 +14,9 @@ permissions: security-events: write statuses: write +env: + PANTS_CONFIG_FILES: "pants.ci.toml" + jobs: test: name: Test @@ -21,17 +24,26 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12"] - os: ["ubuntu-latest", "macos-latest"] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] + os: ["ubuntu-latest"] steps: - - uses: actions/setup-python@v4 + - name: Clone repo + uses: actions/checkout@v4 + - name: Setup python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - - name: Clone repo - uses: actions/checkout@v3 - - name: Install dependencies - run: make install + - name: Initialize pants + uses: pantsbuild/actions/init-pants@main + with: + gha-cache-key: cache0-py${{ matrix.python }} + named-caches-hash: ${{ hashFiles('requirements.txt') }} + - name: Check BUILD files + run: | + pants tailor --check update-build-files --check :: + - name: Lint + run: | + pants lint :: - name: Run tests - run: make coverage - - name: Run codecov - uses: codecov/codecov-action@v3 + run: | + pants test --use-coverage :: diff --git a/.gitignore b/.gitignore index dd549bbf..1a72e6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,10 @@ ENV* .pytest_cache html/ .mutmut-cache +_test_scripts/ +_dev_scripts/ + +# Pants workspace files +/.pants.* +/dist/ +/.pids diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d58af5cd..37623cbb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 \ No newline at end of file diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index 6143d800..00000000 --- a/.pyup.yml +++ /dev/null @@ -1,4 +0,0 @@ -# autogenerated pyup.io config file -# see https://pyup.io/docs/configuration/ for all available options - -update: insecure diff --git a/BUILD b/BUILD new file mode 100644 index 00000000..b8519ce5 --- /dev/null +++ b/BUILD @@ -0,0 +1,3 @@ +python_requirements( + name="reqs", +) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5b653254..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,19 +0,0 @@ -# Getting Involved - -Thanks for your interest in the project, we'd love to have you involved! Check out the sections below to find out more about what to do next... - -## Documentation -Check out our [Documentaion](https://developer.nexmo.com/documentation) - -## Opening an Issue - -We always welcome issues, if you've seen something that isn't quite right or you have a suggestion for a new feature, please go ahead and open an issue in this project. Include as much information as you have, it really helps. - -## Making a Code Change - -We're always open to pull requests, but these should be small and clearly described so that we can understand what you're trying to do. Feel free to open an issue first and get some discussion going. - -When you're ready to start coding, fork this repository to your own GitHub account and make your changes in a new branch. Once you're happy, open a pull request and explain what the change is and why you think we should include it in our project. - - - diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 9f16f1e1..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,16 +0,0 @@ -include .editorconfig -include CHANGES.md -include LICENSE.txt -include README.md -include requirements.txt -include .pyup.yml -include .bumpversion.cfg -include Makefile -recursive-include docs *.bat -recursive-include docs *.py -recursive-include docs *.rst -recursive-include docs Makefile -recursive-include requirements *.txt -recursive-include tests *.py -recursive-include tests *.txt -recursive-include tests *.json \ No newline at end of file diff --git a/Makefile b/Makefile index 863d937e..18d342f4 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,13 @@ -.PHONY: clean test build coverage install requirements release - -coverage: - coverage run -m pytest -v - coverage html +.PHONY: test coverage test: - pytest -vv --disable-warnings - -clean: - rm -rf dist build + pants test :: -build: - python -m build - -release: - python -m twine upload dist/* - -install: requirements +coverage: + pants test --use-coverage :: -requirements: .requirements.txt +coverage-report: + pants test --use-coverage --open-coverage :: -.requirements.txt: requirements.txt - python -m pip install --upgrade pip setuptools - python -m pip install -r requirements.txt - python -m pip freeze > .requirements.txt +install: + pip install -r requirements.txt \ No newline at end of file diff --git a/OPENTOK_TO_VONAGE_MIGRATION.md b/OPENTOK_TO_VONAGE_MIGRATION.md deleted file mode 100644 index 87dd1938..00000000 --- a/OPENTOK_TO_VONAGE_MIGRATION.md +++ /dev/null @@ -1,92 +0,0 @@ -# Migration guide from OpenTok Python SDK to Vonage Python SDK - -## Installation - -You can now interact with Vonage's Video API using the `vonage` PyPI package rather than the `opentok` PyPI package. To do this, create a virtual environment and install the `vonage` package in your virtual environment using this command: - -```bash -python3 -m venv venv-vonage-video -. ./venv-vonage-video/bin/activate -pip install vonage -``` - -Note: not all the Video API features are yet supported in the `vonage` package. There is a full list of [Supported Features](#supported-features) later in this document. - -## Setup - -Whereas the `opentok` package used an `api_key` and `api_secret` for Authorization, the Video API implementation in the `vonage` package uses a JWT. The SDK handles JWT generation in the background for you, but will require an `application_id` and `private_key` as credentials in order to generate the token. You can obtain these by setting up a Vonage Application, which you can create via the [Developer Dashboard](https://dashboard.nexmo.com/applications). (The Vonage Application is also where you can set other settings such as callback URLs, storage preferences, etc). - -These credentials are then passed in when instantiating a `Client` object (the example below assumes you have these set as environment variables): - -```python -import vonage - -client = vonage.Client( - application_id='VONAGE_APPLICATION_ID', - private_key='VONAGE_PRIVATE_KEY_PATH', -) -``` - -You can access the Video API via the `Video` class stored at `Client.video`. To call methods related to the Video API, use this syntax: - -```python -client.video.video_api_method... -``` - -You can interact with the Vonage Video API via various methods, for example: - -- Create a Session - -```python -# Pass options for the session as a Python dictionary in SESSION_OPTIONS -session = client.video.create_session(SESSION_OPTIONS) -``` - -- Retrieve a List of Archive Recordings - -```python -archive_list = client.video.list_archives(FILTER_OPTIONS) -``` - -## Changed Methods - -There are some changes to methods between the `opentok` SDK and the Video API implementation in the `vonage` SDK. - -- Any positional parameters in method signatures have been replaced with keyword parameters in the `vonage` package. -- Methods now return the response as a Python dictionary. -- Some methods have been renamed, for clarity and/or to better reflect what the method does. These are listed below: - -| OpenTok Method Name | Vonage Video Method Name | -|---|---| -| `opentok.generate_token` | `video.generate_client_token` | -| `opentok.start_archive` | `video.create_archive` | -| `opentok.add_archive_stream` | `video.add_stream_to_archive` | -| `opentok.remove_archive_stream` | `video.remove_stream_from_archive` | -| `opentok.set_archive_layout` | `video.change_archive_layout` | -| `opentok.add_broadcast_stream` | `video.add_stream_to_broadcast` | -| `opentok.remove_broadcast_stream` | `video.remove_stream_from_broadcast` | -| `opentok.set_broadcast_layout` | `video.change_broadcast_layout` | -| `opentok.set_stream_class_lists` | `video.set_stream_layout` | -| `opentok.force_disconnect` | `video.disconnect_client` | -| `opentok.mute_all` | `video.mute_all_streams` | -| `opentok.disable_force_mute` | `video.disable_mute_all_streams`| -| `opentok.dial` | `video.create_sip_call`| - -## Supported Features - -The following is a list of Vonage Video APIs and whether the SDK provides support for them: - -| API | Supported? -|----------|:-------------:| -| Session Creation | ✅ | -| Stream Management | ✅ | -| Signaling | ✅ | -| Moderation | ✅ | -| Archiving | ✅ | -| Live Streaming Broadcasts | ✅ | -| SIP Interconnect | ✅ | -| Account Management | ❌ | -| Experience Composer | ❌ | -| Audio Connector | ❌ | -| Live Captions | ❌ | -| Custom S3/Azure buckets | ❌ | \ No newline at end of file diff --git a/README.md b/README.md index 9c5d8684..be28ad61 100644 --- a/README.md +++ b/README.md @@ -6,1195 +6,1421 @@ [![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +![Total lines](https://sloc.xyz/github/vonage/vonage-python-sdk) -This is the Python server SDK for Vonage's API. To use it you'll -need a Vonage account. Sign up [for free at vonage.com][signup]. +This is the Python server SDK to help you use Vonage APIs in your Python application. To use it you'll need a Vonage account. [Sign up for free on the Vonage site](https://ui.idp.vonage.com/ui/auth/registration). + +### Contents: - [Installation](#installation) +- [Migration Guides](#migration-guides) +- [Calling Vonage APIs](#calling-vonage-apis) - [Usage](#usage) -- [SMS API](#sms-api) +- [Account API](#account-api) +- [Application API](#application-api) +- [HTTP Client](#http-client) +- [JWT Client](#jwt-client) - [Messages API](#messages-api) -- [Voice API](#voice-api) -- [NCCO Builder](#ncco-builder) -- [Verify V2 API](#verify-v2-api) -- [Verify V1 API](#verify-v1-api) -- [Video API](#video-api) -- [Meetings API](#meetings-api) +- [Network Number Verification API](#network-number-verification-api) +- [Network Sim Swap API](#network-sim-swap-api) - [Number Insight API](#number-insight-api) -- [Proactive Connect API](#proactive-connect-api) -- [Account API](#account-api) +- [Numbers API](#numbers-api) +- [SMS API](#sms-api) - [Subaccounts API](#subaccounts-api) -- [Number Management API](#number-management-api) -- [Pricing API](#pricing-api) -- [Managing Secrets](#managing-secrets) -- [Application API](#application-api) - [Users API](#users-api) -- [Validating Webhook Signatures](#validate-webhook-signatures) -- [JWT Parameters](#jwt-parameters) -- [Overriding API Attributes](#overriding-api-attributes) +- [Verify API](#verify-api) +- [Verify API (Legacy)](#verify-api-legacy) +- [Video API](#video-api) +- [Voice API](#voice-api) +- [Vonage Utils Package](#vonage-utils-package) - [Frequently Asked Questions](#frequently-asked-questions) - [Contributing](#contributing) - [License](#license) +- [Additional Resources](#additional-resources) ## Installation -To install the Python client library using pip: +It's recommended to create a new virtual environment to install the SDK. You can do this with + +```bash +# Create the virtual environment +python3 -m venv venv + +# Activate the virtual environment in Mac/Linux +. ./venv/bin/activate - pip install vonage +# Or on Windows Command Prompt +venv\Scripts\activate +``` + +To install the Python SDK package using pip: + +```bash +pip install vonage +``` To upgrade your installed client library using pip: - pip install vonage --upgrade +```bash +pip install vonage --upgrade +``` -Alternatively, you can clone the repository via the command line: +Alternatively, you can clone the repository via the command line, or by opening it on GitHub desktop. - git clone git@github.com:Vonage/vonage-python-sdk.git +## Migration Guides -or by opening it on GitHub desktop. +### V3 to V4 -## Usage +This version of the Vonage Python SDK (4.x+) works very differently to the previous SDK. See [the v3 -> v4 migration guide](V3_TO_V4_SDK_MIGRATION_GUIDE.md) for help migrating your application code using v3 of the SDK to the new structure. + +### OpenTok to Vonage Video API + +This SDK includes support for the [Vonage Video API](https://developer.vonage.com/en/video/overview). If you have an application that uses OpenTok for video and want to migrate (which is highly recommended!) then [A migration guide is available here](video/OPENTOK_TO_VONAGE_MIGRATION.md) which will help you to migrate your applications to use Vonage Video. + +## Calling Vonage APIs + +The Vonage Python SDK is a monorepo, with separate packages for each API. When you install the Python SDK, you'll see there's a top-level package, `vonage`, and then specialised packages for every API class. + +Most methods to call Vonage APIs are accessed through the top-level `vonage` package. Many require specific custom data models accessed though the specific Vonage package corresponding to the API you're trying to use. -Begin by importing the `vonage` module: +For example, to send an SMS, you will access the SMS method from `vonage` and the `SmsMessage` object from the `vonage-sms` package. This looks something like this: ```python -import vonage +from vonage_sms import SmsMessage, SmsResponse + +message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') +response: SmsResponse = vonage_client.sms.send(message) # vonage_client is an instance of `vonage.Vonage` + +print(response.model_dump(exclude_unset=True)) ``` -Then construct a client object with your key and secret: +## Usage + +Many of the use cases require you to buy a Vonage Number, which you can [do in the Vonage Developer Dashboard](https://dashboard.nexmo.com/). ```python -client = vonage.Client(key=api_key, secret=api_secret) -``` +from vonage import Vonage, Auth, HttpClientOptions -For production, you can specify the `VONAGE_API_KEY` and `VONAGE_API_SECRET` -environment variables instead of specifying the key and secret explicitly. +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') -For newer endpoints that support JWT authentication such as the Voice API, -you can also specify the `application_id` and `private_key` arguments: +# Create HttpClientOptions instance +# (not required unless you want to change options from the defaults) +options = HttpClientOptions(api_host='api.nexmo.com', timeout=30) + +# Create a Vonage instance +vonage = Vonage(auth=auth, http_client_options=options) +``` + +The Vonage class provides access to various Vonage APIs through its properties. For example, to use methods to call the SMS API: ```python -client = vonage.Client(application_id=application_id, private_key=private_key) +from vonage_sms import SmsMessage + +message = SmsMessage(to='1234567890', from_='Vonage', text='Hello World') +response = client.sms.send(message) +print(response.model_dump_json(exclude_unset=True)) ``` -To check signatures for incoming webhook requests, you'll also need -to specify the `signature_secret` argument (or the `VONAGE_SIGNATURE_SECRET` -environment variable). +You can also access the underlying `HttpClient` instance through the `http_client` property: -To use the SDK to call Vonage APIs, pass in dicts with the required options to methods like `Sms.send_message()`. Examples of this are given below. +```python +user_agent = vonage.http_client.user_agent +``` -## Simplified structure for calling API Methods +### Convert a Pydantic Model to Dict or Json -The client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` -instantiates instances of `Account`, `Sms`, `NumberInsight` etc. These instances can now be called directly from `Client`, e.g. +Most responses to API calls in the SDK are Pydantic models. To convert a Pydantic model to a dict, use `model.model_dump`. To convert to a JSON string, use `model.model_dump_json` ```python -client = vonage.Client(key="mykey", secret="mysecret") +response = vonage.api_package.api_call(...) -print(f"Account balance is: {client.account.get_balance()}") +response_dict = response.model_dump() +response_json = response.model_dump_json() +``` + +## Account API -print("Sending an SMS") -client.sms.send_message({ - "from": "Vonage", - "to": "SOME_PHONE_NUMBER", - "text": "Hello from Vonage's SMS API" -}) +### Get Account Balance + +```python +balance = vonage_client.account.get_balance() +print(balance) ``` -This means you don't have to create a separate instance of each class to use its API methods. Instead, you can access class methods from the client instance with +### Top-Up Account + ```python -client.CLASS_NAME.CLASS_METHOD +response = vonage_client.account.top_up(trx='1234567890') +print(response) ``` -## SMS API +### Update the Default SMS Webhook -Although the Messages API adds more messaging channels, the SMS API is still supported. -### Send an SMS +This will return a Pydantic object (`SettingsResponse`) containing multiple settings for your account. ```python -# New way -client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -client.sms.send_message({ - "from": VONAGE_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Vonage SMS API", -}) +settings: SettingsResponse = vonage_client.account.update_default_sms_webhook( + mo_callback_url='https://example.com/inbound_sms_webhook', + dr_callback_url='https://example.com/delivery_receipt_webhook', +) -# Old way -from vonage import Sms -sms = Sms(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -sms.send_message({ - "from": VONAGE_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Vonage SMS API", -}) +print(settings) ``` -### Send SMS with unicode +### Get Service Pricing for a Specific Country ```python -client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -client.sms.send_message({ - 'from': VONAGE_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'こんにちは世界', - 'type': 'unicode', -}) +from vonage_account import GetCountryPricingRequest + +response = vonage_client.account.get_country_pricing( + GetCountryPricingRequest(type='sms', country_code='US') +) +print(response) ``` -### Submit SMS Conversion +### Get Service Pricing for All Countries ```python -client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_SECRET) -response = client.sms.send_message({ - 'from': VONAGE_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'Hi from Vonage' -}) -client.sms.submit_sms_conversion(response['message-id']) +response = vonage_client.account.get_all_countries_pricing(service_type='sms') +print(response) ``` -### Update the default SMS webhook URLs for callbacks/delivery reciepts +### Get Service Pricing by Dialing Prefix + ```python -client.sms.update_default_sms_webhook({ - 'moCallBackUrl': 'new.url.vonage.com', # Default inbound sms webhook url - 'drCallBackUrl': 'different.url.vonage.com' # Delivery receipt url - }}) +from vonage_account import GetPrefixPricingRequest + +response = client.account.get_prefix_pricing( + GetPrefixPricingRequest(prefix='44', type='sms') +) +print(response) ``` -The delivery receipt URL can be unset by sending an empty string. +### List Secrets Associated with the Account -## Messages API +```python +response = vonage_client.account.list_secrets() +print(response) +``` -The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. Call the API from your Python code by -passing a dict of parameters into the `client.messages.send_message()` method. +### Create a New Account Secret -It accepts JWT or API key/secret authentication. +```python +secret = vonage_client.account.create_secret('Mytestsecret12345') +print(secret) +``` -Some basic samples are below. For more detailed information and code snippets, please visit the [Vonage Developer Documentation](https://developer.vonage.com). +### Get Information About One Secret -### Send an SMS ```python -responseData = client.messages.send_message({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '447123456789', - 'from': 'Vonage', - 'text': 'Hello from Vonage' - }) +secret = vonage_client.account.get_secret(MY_SECRET_ID) +print(secret) ``` -### Send an MMS -Note: only available in the US. You will need a 10DLC number to send an MMS message. +### Revoke a Secret + +Note: it isn't possible to revoke all account secrets, there must always be one valid secret. Attempting to do so will give a 403 error. ```python -client.messages.send_message({ - 'channel': 'mms', - 'message_type': 'image', - 'to': '11112223333', - 'from': '1223345567', - 'image': {'url': 'https://example.com/image.jpg', 'caption': 'Test Image'} - }) +client.account.revoke_secret(MY_SECRET_ID) ``` -### Send an audio file via WhatsApp +## Application API + -You will need a WhatsApp Business Account to use WhatsApp messaging. WhatsApp restrictions mean that you -must send a template message to a user if they have not previously messaged you, but you can send any message -type to a user if they have messaged your business number in the last 24 hours. +### List Applications + +With no custom options specified, this method will get the first 100 applications. It returns a tuple consisting of a list of `ApplicationData` objects and an int showing the page number of the next page of results. ```python -client.messages.send_message({ - 'channel': 'whatsapp', - 'message_type': 'audio', - 'to': '447123456789', - 'from': '440123456789', - 'audio': {'url': 'https://example.com/audio.mp3'} - }) -``` +from vonage_application import ListApplicationsFilter, ApplicationData -### Send a video file via Facebook Messenger +applications, next_page = vonage_client.application.list_applications() -You will need to link your Facebook business page to your Vonage account in the Vonage developer dashboard. (Click on the sidebar -"External Accounts" option to do this.) +# With options +options = ListApplicationsFilter(page_size=3, page=2) +applications, next_page = vonage_client.application.list_applications(options) +``` + +### Create a New Application ```python -client.messages.send_message({ - 'channel': 'messenger', - 'message_type': 'video', - 'to': '594123123123123', - 'from': '1012312312312', - 'video': {'url': 'https://example.com/video.mp4'} - }) +from vonage_application import ApplicationConfig + +app_data = vonage_client.application.create_application() + +# Create with custom options (can also be done with a dict) +from vonage_application import ApplicationConfig, Keys, Voice, VoiceWebhooks +voice = Voice( + webhooks=VoiceWebhooks( + event_url=VoiceUrl( + address='https://example.com/event', + http_method='POST', + connect_timeout=500, + socket_timeout=3000, + ), + ), + signed_callbacks=True, +) +capabilities = Capabilities(voice=voice) +keys = Keys(public_key='MY_PUBLIC_KEY') +config = ApplicationConfig( + name='My Customised Application', + capabilities=capabilities, + keys=keys, +) +app_data = vonage_client.application.create_application(config) ``` -### Send a text message with Viber +### Get an Application ```python -client.messages.send_message({ - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '447123456789', - 'from': '440123456789', - 'text': 'Hello from Vonage!' -}) +app_data = client.application.get_application('MY_APP_ID') +app_data_as_dict = app.model_dump(exclude_none=True) ``` -## Voice API +### Update an Application -### Make a call +To update an application, pass config for the updated field(s) in an ApplicationConfig object ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) +from vonage_application import ApplicationConfig, Keys, Voice, VoiceWebhooks + +config = ApplicationConfig(name='My Updated Application') +app_data = vonage_client.application.update_application('MY_APP_ID', config) ``` -### Retrieve a list of calls +### Delete an Application ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -client.voice.get_calls() +vonage_client.applications.delete_application('MY_APP_ID') ``` -### Retrieve a single call +## HTTP Client + ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -client.voice.get_call(uuid) +from vonage_http_client import HttpClient, HttpClientOptions +from vonage_http_client.auth import Auth + +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') + +# Create HttpClientOptions instance +options = HttpClientOptions(api_host='api.nexmo.com', timeout=30) + +# Create a HttpClient instance +client = HttpClient(auth=auth, http_client_options=options) + +# Make a GET request +response = client.get(host='api.nexmo.com', request_path='/v1/messages') + +# Make a POST request +response = client.post(host='api.nexmo.com', request_path='/v1/messages', params={'key': 'value'}) ``` -### Update a call +### Get the Last Request and Last Response from the HTTP Client + +The `HttpClient` class exposes two properties, `last_request` and `last_response` that cache the last sent request and response. ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.update_call(response['uuid'], action='hangup') +# Get last request, has type requests.PreparedRequest +request = client.last_request + +# Get last response, has type requests.Response +response = client.last_response ``` -### Stream audio to a call +### Appending to the User-Agent Header + +The `HttpClient` class also supports appending additional information to the User-Agent header via the append_to_user_agent method: ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_audio(response['uuid'],stream_url=[stream_url]) +client.append_to_user_agent('additional_info') ``` -### Stop streaming audio to a call +### Changing the Authentication Method Used + +The `HttpClient` class automatically handles JWT and basic authentication based on the Auth instance provided. It uses JWT authentication by default, but you can specify the authentication type when making a request: ```python -client = vonage.Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_audio(response['uuid'],stream_url=[stream_url]) -client.voice.stop_audio(response['uuid']) +# Use basic authentication for this request +response = client.get(host='api.nexmo.com', request_path='/v1/messages', auth_type='basic') ``` -### Send a synthesized speech message to a call +### Catching errors + +Error objects are exposed in the package scope, so you can catch errors like this: ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_speech(response['uuid'], text='Hello from vonage') +from vonage_http_client import HttpRequestError + +try: + client.post(...) +except HttpRequestError: + ... ``` -### Stop sending a synthesized speech message to a call +## JWT Client + +This JWT Generator can be used implicitly, just by using the [Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) to make JWT-authenticated API calls. + +It can also be used as a standalone JWT generator for use with Vonage APIs, like so: + +### Import the `JwtClient` object ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_speech(response['uuid'], text='Hello from vonage') -client.voice.stop_speech(response['uuid']) +from vonage_jwt import JwtClient ``` -### Send DTMF tones to a call +### Create a `JwtClient` object ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_dtmf(response['uuid'], digits='1234') +jwt_client = JwtClient(application_id, private_key) ``` -### Get recording +### Generate a JWT using the provided application id and private key ```python -response = client.get_recording(RECORDING_URL) +jwt_client.generate_application_jwt() ``` -### Verify the Signature of a Webhook Sent by Vonage - -If signed webhooks are enabled (the default), Vonage will sign webhooks with the signature secret found in the [API Settings](https://dashboard.nexmo.com/settings) section of the Vonage Developer Dashboard. +Optional JWT claims can be provided in a python dictionary: ```python -if client.voice.verify_signature('JWT_RECEIVED_FROM_VONAGE', 'MY_VONAGE_SIGNATURE_SECRET'): - print('Signature is valid!') -else: - print('Signature is invalid!') +claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100} +jwt_client.generate_application_jwt(claims) ``` +## Verifying a JWT signature -## NCCO Builder +You can use the `verify_jwt.verify_signature` method to verify a JWT signature is valid. -The SDK contains a builder to help you create Call Control Objects (NCCOs) for use with the Vonage Voice API. +```python +from vonage_jwt import verify_signature -For more information, [check the full NCCO reference documentation on the Vonage website](https://developer.vonage.com/voice/voice-api/ncco-reference). +verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean +``` -An NCCO is a list of "Actions": steps to be followed when a call is initiated or received. +## Messages API -Use the builder to construct valid NCCO actions, which are modelled in the SDK as [Pydantic](https://docs.pydantic.dev) models, and build them into an NCCO. The NCCO actions supported by the builder are: -* Record -* Conversation -* Connect -* Talk -* Stream -* Input -* Notify +### How to Construct a Message -### Construct actions +In order to send a message, you must construct a message object of the correct type. These are all found under `vonage_messages.models`. ```python -record = Ncco.Record(eventUrl=['https://example.com']) -talk = Ncco.Talk(text='Hello from Vonage!', bargeIn=True, loop=5, premium=True) -``` +from vonage_messages.models import Sms -The Connect action has each valid endpoint type (phone, application, WebSocket, SIP and VBC) specified as a Pydantic model so these can be validated, though it is also possible to pass in a dict with the endpoint properties directly into the `Ncco.Connect` object. +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) +``` -This example shows a Connect action created with an endpoint object. +This message can now be sent with ```python -phone = ConnectEndpoints.PhoneEndpoint( - number='447000000000', - dtmfAnswer='1p2p3p#**903#', - ) -connect = Ncco.Connect(endpoint=phone, eventUrl=['https://example.com/events'], from_='447000000000') +vonage_client.messages.send(message) ``` -This example shows a different Connect action, created with a dictionary. +All possible message types from every message channel have their own message model. They are named following this rule: {Channel}{MessageType}, e.g. `Sms`, `MmsImage`, `RcsFile`, `MessengerAudio`, `WhatsappSticker`, `ViberVideo`, etc. + +The different message models are listed at the bottom of the page. + +Some message types have submodels with additional fields. In this case, import the submodels as well and use them to construct the overall options. + +e.g. ```python -connect = Ncco.Connect(endpoint={'type': 'phone', 'number': '447000000000', 'dtmfAnswer': '2p02p'}, randomFromNumber=True) +from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource + +messenger = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + messenger=MessengerOptions(category='message_tag', tag='invalid_tag'), +) ``` -### Build into an NCCO +### Send a message -Create an NCCO from the actions with the `Ncco.build_ncco` method. This will be returned as a list of dicts representing each action and can be used in calls to the Voice API. +To send a message, access the `Messages.send` method via the main Vonage object, passing in an instance of a subclass of `BaseMessage` like this: ```python -ncco = Ncco.build_ncco(record, connect, talk) +from vonage import Auth, Vonage +from vonage_messages.models import Sms + +vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': TO_NUMBER}], - 'from': {'type': 'phone', 'number': VONAGE_NUMBER}, - 'ncco': ncco -}) +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) -pprint(response) +vonage_client.messages.send(message) ``` -### Note on from_ parameter in connect action +### Mark a WhatsApp Message as Read -When using the `connect` action, use the parameter `from_` to specify the recipient (as `from` is a reserved keyword in Python!) +Note: to use this method, update the `api_host` attribute of the `vonage_http_client.HttpClientOptions` object to the API endpoint corresponding to the region where the WhatsApp number is hosted. -## Verify V2 API +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. -V2 of the Vonage Verify API lets you send verification codes via SMS, WhatsApp, Voice and Email. +```python +from vonage import Vonage, Auth, HttpClientOptions -You can also verify a user by WhatsApp Interactive Message or by Silent Authentication on their mobile device. +auth = Auth(application_id='MY-APP-ID', private_key='MY-PRIVATE-KEY') +options = HttpClientOptions(api_host='api-eu.vonage.com') -### Send a verification code +vonage_client = Vonage(auth, options) +vonage_client.messages.mark_whatsapp_message_read('MESSAGE_UUID') +``` + +### Revoke an RCS Message + +Note: as above, to use this method you need to update the `api_host` attribute of the `vonage_http_client.HttpClientOptions` object to the API endpoint corresponding to the region where the WhatsApp number is hosted. + +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. ```python -params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}] -} -verify_request = verify2.new_request(params) +from vonage import Vonage, Auth, HttpClientOptions + +auth = Auth(application_id='MY-APP-ID', private_key='MY-PRIVATE-KEY') +options = HttpClientOptions(api_host='api-eu.vonage.com') + +vonage_client = Vonage(auth, options) +vonage_client.messages.revoke_rcs_message('MESSAGE_UUID') ``` -### Use silent authentication, with email as a fallback +## Message Models + +To send a message, instantiate a message model of the correct type as described above. This is a list of message models that can be used: -```python -params = { - 'brand': 'ACME, Inc', - 'workflow': [ - {'channel': 'silent_auth', 'to': '447700900000'}, - {'channel': 'email', 'to': 'customer@example.com', 'from': 'business@example.com'} - ] -} -verify_request = verify2.new_request(params) -check_url = verify_request['check_url'] # URL to continue with the silent auth workflow ``` +Sms +MmsImage, MmsVcard, MmsAudio, MmsVideo +RcsText, RcsImage, RcsVideo, RcsFile, RcsCustom +WhatsappText, WhatsappImage, WhatsappAudio, WhatsappVideo, WhatsappFile, WhatsappTemplate, WhatsappSticker, WhatsappCustom +MessengerText, MessengerImage, MessengerAudio, MessengerVideo, MessengerFile +ViberText, ViberImage, ViberVideo, ViberFile +``` + +## Network Number Verification API -### Send a verification code with custom options, including a custom code +The Vonage Number Verification API uses Oauth2 authentication, which this SDK will also help you to do. Verifying a number has 3 stages: + +1. Get an OIDC URL for use in your front-end application +2. Use this URL in your own application to get an authorization code +3. Make a Number Verification Request using this code to verify the number + +This package contains methods to help with Steps 1 and 3. + +### Get an OIDC URL ```python -params = { - 'locale': 'en-gb', - 'channel_timeout': 120, - 'client_ref': 'my client reference', - 'code': 'asdf1234', - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': 'asdfghjklqw'}], -} -verify_request = verify2.new_request(params) +from vonage_network_number_verification import CreateOidcUrl + +url_options = CreateOidcUrl( + redirect_uri='https://example.com/redirect', + state='c9896ee6-4ff8-464c-b393-d56d6e638f88', + login_hint='+990123456', +) + +url = number_verification.get_oidc_url(url_options) +print(url) ``` -### Send a verification request to a blocked network +Get your user's device to follow this URL and a code to use for number verification will be returned in the final redirect query parameters. Note: your user must be connected to their mobile network. -This feature is only enabled if you have requested for it to be added to your account. +### Make a Number Verification Request ```python -params = { - 'brand': 'ACME, Inc', - 'fraud_check': False, - 'workflow': [{'channel': 'sms', 'to': '447700900000'}] -} -verify_request = verify2.new_request(params) +from vonage_network_number_verification import NumberVerificationRequest + +response = number_verification.verify( + NumberVerificationRequest( + code='code', + redirect_uri='https://example.com/redirect', + phone_number='+990123456', + ) +) +print(response.device_phone_number_verified) ``` -### Check a verification code +## Network Sim Swap API + +### Check if a SIM Has Been Swapped ```python -verify2.check_code(REQUEST_ID, CODE) +from vonage_network_sim_swap import SwapStatus +swap_status: SwapStatus = vonage_client.sim_swap.check(phone_number='MY_NUMBER') +print(swap_status.swapped) ``` -### Cancel an ongoing verification +### Get the Date of the Last SIM Swap ```python -verify2.cancel_verification(REQUEST_ID) +from vonage_network_sim_swap import LastSwapDate +swap_date: LastSwapDate = vonage_client.sim_swap.get_last_swap_date +print(swap_date.last_swap_date) ``` -## Verify V1 API +## Number Insight API -### Search for a Verification request +### Make a Basic Number Insight Request ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_number_insight import BasicInsightRequest -response = client.verify.search('69e2626cbc23451fbbc02f627a959677') +response = vonage_client.number_insight.basic_number_insight( + BasicInsightRequest(number='12345678900') +) -if response is not None: - print(response['status']) +print(response.model_dump(exclude_none=True)) ``` -### Send verification code +### Make a Standard Number Insight Request ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_number_insight import StandardInsightRequest -response = client.verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc') +vonage_client.number_insight.standard_number_insight( + StandardInsightRequest(number='12345678900') +) -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +# Optionally, you can get caller name information (additional charge) by setting the `cnam` parameter = True +vonage_client.number_insight.standard_number_insight( + StandardInsightRequest(number='12345678900', cnam=True) +) ``` -### Send verification code with workflow +### Make an Asynchronous Advanced Number Insight Request + +When making an asynchronous advanced number insight request, the API will return basic information about the request to you immediately and send the full data to the webhook callback URL you specify. ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_number_insight import AdvancedAsyncInsightRequest + +vonage_client.number_insight.advanced_async_number_insight( + AdvancedAsyncInsightRequest(callback='https://example.com', number='12345678900') +) +``` -response = client.verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +### Make a Synchronous Advanced Number Insight Request -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +```python +from vonage_number_insight import AdvancedSyncInsightRequest + +vonage_client.number_insight.advanced_sync_number_insight( + AdvancedSyncInsightRequest(number='12345678900') +) ``` -### Check verification code +## Numbers API + +### List Numbers You Own ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +numbers, count, next_page = vonage_client.numbers.list_owned_numbers() +print(numbers) +print(count) +print(next_page) -response = client.verify.check(REQUEST_ID, code=CODE) +# With filtering +from vonage_numbers import ListOwnedNumbersFilter +numbers, count, next_page = vonage_client.numbers.list_owned_numbers( + ListOwnedNumbersFilter(country='GB', size=3, index=2) +) -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) +numbers, count, next_page_index = vonage_client.numbers.list_owned_numbers() +print(numbers) +print(count) +print(next_page_index) ``` -### Cancel Verification Request +### Search for Available Numbers ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') - -response = client.verify.cancel(REQUEST_ID) +from vonage_numbers import SearchAvailableNumbersFilter -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) +numbers, count, next_page_index = vonage_client.numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=10, pattern='44701', search_pattern=1 + ) +) +print(numbers) +print(count) +print(next_page_index) ``` -### Trigger next verification proccess +### Buy a Number ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_numbers import NumberParams -response = client.verify.trigger_next_event(REQUEST_ID) +status = vonage_client.numbers.buy_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) +``` -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) +### Cancel a number + +```python +from vonage_numbers import NumberParams + +status = vonage_client.numbers.cancel_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) ``` -### Send payment authentication code +### Update a Number ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_numbers import UpdateNumberParams -response = client.verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +status = vonage_client.numbers.update_number( + UpdateNumberParams( + country='GB', + msisdn='447007000000', + mo_http_url='https://example.com', + mo_smpp_sytem_type='inbound', + voice_callback_type='tel', + voice_callback_value='447008000000', + voice_status_callback='https://example.com', + ) +) -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +print(status) ``` -### Send payment authentication code with workflow +## SMS API + +### Send an SMS + +Create an `SmsMessage` object, then pass into the `Sms.send` method. ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_sms import SmsMessage, SmsResponse -client.verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') +response: SmsResponse = vonage_client.sms.send(message) -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +print(response.model_dump(exclude_unset=True)) ``` -## Video API - -You can make calls to the Vonage Video API from this SDK. See the [Vonage Video API documentation](https://developer.vonage.com/en/video/overview) for detailed information and instructions on how to use the Vonage Python SDK with the Vonage Video API. Have a look at the SDK's [OpenTok to Vonage migration guide](OPENTOK_TO_VONAGE_MIGRATION.md) if you've previously used OpenTok. +## Subaccounts API -## Meetings API +### List Subaccounts -Full docs for the [Meetings API are available here](https://developer.vonage.com/en/meetings/overview). +```python +response = vonage_client.subaccounts.list_subaccounts() +print(response.model_dump) +``` -### Create a meeting room +### Create Subaccount ```python -# Instant room -params = {'display_name': 'my_test_room'} -meeting = client.meetings.create_room(params) +from vonage_subaccounts import SubaccountOptions -# Long term room -params = {'display_name': 'test_long_term_room', 'type': 'long_term', 'expires_at': '2023-01-30T00:47:04+0000'} -meeting = client.meetings.create_room(params) +response = vonage_client.subaccounts.create_subaccount( + SubaccountOptions( + name='test_subaccount', secret='1234asdfA', use_primary_account_balance=False + ) +) +print(response) ``` -### Get all meeting rooms +### Modify a Subaccount ```python -client.meetings.list_rooms() +from vonage_subaccounts import ModifySubaccountOptions + +response = vonage_client.subaccounts.modify_subaccount( + 'test_subaccount', + ModifySubaccountOptions( + suspended=True, + name='modified_test_subaccount', + ), +) +print(response) ``` -### Get a room by id +### List Balance Transfers ```python -client.meetings.get_room('MY_ROOM_ID') +from vonage_subaccounts import ListTransfersFilter + +filter = {'start_date': '2023-08-07T10:50:44Z'} +response = vonage_client.subaccounts.list_balance_transfers(ListTransfersFilter(**filter)) +for item in response: + print(item.model_dump()) ``` -### Update a long term room +### Transfer Balance Between Subaccounts ```python -params = { - 'update_details': { - "available_features": { - "is_recording_available": False, - "is_chat_available": False, - } - } -} -meeting = client.meetings.update_room('MY_ROOM_ID', params) +from vonage_subaccounts import TransferRequest + +request = TransferRequest( + from_='test_api_key', to='test_subaccount', amount=0.02, reference='A reference' +) +response = vonage_client.subaccounts.transfer_balance(request) +print(response) ``` -### Get all recordings for a session +### List Credit Transfers ```python -session = client.meetings.get_session_recordings('MY_SESSION_ID') +from vonage_subaccounts import ListTransfersFilter + +filter = {'start_date': '2023-08-07T10:50:44Z'} +response = vonage_client.subaccounts.list_credit_transfers(ListTransfersFilter(**filter)) +for item in response: + print(item.model_dump()) ``` -### Get a recording by id +### Transfer Credit Between Subaccounts ```python -recording = client.meetings.get_recording('MY_RECORDING_ID') +from vonage_subaccounts import TransferRequest + +request = TransferRequest( + from_='test_api_key', to='test_subaccount', amount=0.02, reference='A reference' +) +response = vonage_client.subaccounts.transfer_balance(request) +print(response) ``` -### Delete a recording +### Transfer a Phone Number Between Subaccounts ```python -client.meetings.delete_recording('MY_RECORDING_ID') +from vonage_subaccounts import TransferNumberRequest + +request = TransferNumberRequest( + from_='test_api_key', to='test_subaccount', number='447700900000', country='GB' +) +response = vonage_client.subaccounts.transfer_number(request) +print(response) ``` -### List dial-in numbers +## Users API + +### List Users + +With no custom options specified, this method will get the last 100 users. It returns a tuple consisting of a list of `UserSummary` objects and a string describing the cursor to the next page of results. ```python -numbers = client.meetings.list_dial_in_numbers() +from vonage_users import ListUsersRequest + +users, _ = vonage_client.users.list_users() + +# With options +params = ListUsersRequest( + page_size=10, + cursor=my_cursor, + order='desc', +) +users, next_cursor = vonage_client.users.list_users(params) ``` -### Create a theme +### Create a New User ```python -params = { - 'theme_name': 'my_theme', - 'main_color': '#12f64e', - 'brand_text': 'My Company', - 'short_company_url': 'my-company', -} -theme = client.meetings.create_theme(params) +from vonage_users import User, Channels, SmsChannel +user_options = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')]), +) +user = vonage_client.users.create_user(user_options) ``` -### Add a theme to a room +### Get a User ```python -meetings.add_theme_to_room('MY_ROOM_ID', 'MY_THEME_ID') +user = client.users.get_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') +user_as_dict = user.model_dump(exclude_none=True) ``` -### List themes - +### Update a User ```python -themes = client.meetings.list_themes() +from vonage_users import User, Channels, SmsChannel, WhatsappChannel +user_options = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')], whatsapp=[WhatsappChannel(number='9876543210')]), +) +user = vonage_client.users.update_user(id, user_options) ``` -### Get theme information +### Delete a User ```python -theme = client.meetings.get_theme('MY_THEME_ID') +vonage_client.users.delete_user(id) ``` -### Delete a theme +## Verify API + +### Make a Verify Request ```python -client.meetings.delete_theme('MY_THEME_ID') +from vonage_verify import VerifyRequest, SmsChannel +# All channels have associated models +sms_channel = SmsChannel(to='1234567890') +params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], +} +verify_request = VerifyRequest(**params) + +response = vonage_client.verify.start_verification(verify_request) ``` -### Update a theme +If using silent authentication, the response will include a `check_url` field with a url that should be accessed on the user's device to proceed with silent authentication. If used, silent auth must be the first element in the `workflow` list. ```python +silent_auth_channel = SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890') +sms_channel = SmsChannel(to='1234567890') params = { - 'update_details': { - 'theme_name': 'updated_theme', - 'main_color': '#FF0000', - 'brand_text': 'My Updated Company Name', - 'short_company_url': 'updated_company_url', - } + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], } -theme = client.meetings.update_theme('MY_THEME_ID', params) +verify_request = VerifyRequest(**params) + +response = vonage_client.verify.start_verification(verify_request) ``` -### List all rooms using a specified theme +### Check a Verification Code ```python -rooms = client.meetings.list_rooms_with_theme_id('MY_THEME_ID') +vonage_client.verify.check_code(request_id='my_request_id', code='1234') ``` -### Update the default theme for your application +### Cancel a Verification ```python -response = client.meetings.update_application_theme('MY_THEME_ID') +vonage_client.verify.cancel_verification('my_request_id') ``` -### Upload a logo to a theme +### Trigger the Next Workflow Event ```python -response = client.meetings.upload_logo_to_theme( - theme_id='MY_THEME_ID', - path_to_image='path/to/my/image.png', - logo_type='white', # 'white', 'colored' or 'favicon' - ) +vonage_client.verify.trigger_next_workflow('my_request_id') ``` -## Number Insight API +## Verify API (Legacy) -### Basic Number Insight +### Make a Verify Request ```python -client.number_insight.get_basic_number_insight(number='447700900000') +from vonage_verify_legacy import VerifyRequest +params = {'number': '1234567890', 'brand': 'Acme Inc.'} +request = VerifyRequest(**params) +response = vonage_client.verify_legacy.start_verification(request) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) - -### Standard Number Insight +### Make a PSD2 (Payment Services Directive v2) Request ```python -client.number_insight.get_standard_number_insight(number='447700900000') +from vonage_verify_legacy import Psd2Request +params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} +request = VerifyRequest(**params) +response = vonage_client.verify_legacy.start_verification(request) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) - -### Advanced Number Insight +### Check a Verification Code ```python -client.number_insight.get_advanced_number_insight(number='447700900000') +vonage_client.verify_legacy.check_code(request_id='my_request_id', code='1234') ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +### Search Verification Requests -## Proactive Connect API +```python +# Search for single request +response = vonage_client.verify_legacy.search('my_request_id') -Full documentation for the [Proactive Connect API](https://developer.vonage.com/en/proactive-connect/overview) is available here. +# Search for multiple requests +response = vonage_client.verify_legacy.search(['my_request_id_1', 'my_request_id_2']) +``` -These methods help you manage lists of contacts when using the API: +### Cancel a Verification -### Find all lists ```python -client.proactive_connect.list_all_lists() +response = vonage_client.verify_legacy.cancel_verification('my_request_id') ``` -### Create a list -Lists can be created manually or imported from Salesforce. +### Trigger the Next Workflow Event ```python -params = {'name': 'my list', 'description': 'my description', 'tags': ['vip']} -client.proactive_connect.create_list(params) +response = vonage_client.verify_legacy.trigger_next_event('my_request_id') ``` -### Get a list -```python -client.proactive_connect.get_list(LIST_ID) -``` +### Request a Network Unblock + +Note: Network Unblock is switched off by default. Contact Sales to enable the Network Unblock API for your account. -### Update a list ```python -params = {'name': 'my list', 'tags': ['sport', 'football']} -client.proactive_connect.update_list(LIST_ID, params) +response = vonage_client.verify_legacy.request_network_unblock('23410') ``` -### Delete a list +## Video API + +You will use the custom Pydantic data models to make most of the API calls in this package. They are accessed from the `vonage_video.models` package. + +### Generate a Client Token + ```python -client.proactive_connect.delete_list(LIST_ID) +from vonage_video.models import TokenOptions + +token_options = TokenOptions(session_id='your_session_id', role='publisher') +client_token = vonage_client.video.generate_client_token(token_options) ``` -### Sync a list from an external datasource +### Create a Session + ```python -params = {'name': 'my list', 'tags': ['sport', 'football']} -client.proactive_connect.sync_list_from_datasource(LIST_ID) +from vonage_video.models import SessionOptions + +session_options = SessionOptions(media_mode='routed') +video_session = vonage_client.video.create_session(session_options) ``` -These methods help you work with individual items in a list: -### Find all items in a list +### List Streams + ```python -client.proactive_connect.list_all_items(LIST_ID) +streams = vonage_client.video.list_streams(session_id='your_session_id') ``` -### Create a new list item +### Get a Stream + ```python -data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} -client.proactive_connect.create_item(LIST_ID, data) +stream_info = vonage_client.video.get_stream(session_id='your_session_id', stream_id='your_stream_id') ``` -### Get a list item +### Change Stream Layout + ```python -client.proactive_connect.get_item(LIST_ID, ITEM_ID) +from vonage_video.models import StreamLayoutOptions + +layout_options = StreamLayoutOptions(type='bestFit') +updated_streams = vonage_client.video.change_stream_layout(session_id='your_session_id', stream_layout_options=layout_options) ``` -### Update a list item +### Send a Signal + ```python -data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '447007000000'} -client.proactive_connect.update_item(LIST_ID, ITEM_ID, data) +from vonage_video.models import SignalData + +signal_data = SignalData(type='chat', data='Hello, World!') +vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) ``` -### Delete a list item +### Disconnect a Client + ```python -client.proactive_connect.delete_item(LIST_ID, ITEM_ID) +vonage_client.video.disconnect_client(session_id='your_session_id', connection_id='your_connection_id') ``` -### Download all items in a list as a .csv file +### Mute a Stream + ```python -FILE_PATH = 'path/to/the/downloaded/file/location' -client.proactive_connect.download_list_items(LIST_ID, FILE_PATH) +vonage_client.video.mute_stream(session_id='your_session_id', stream_id='your_stream_id') ``` -### Upload items from a .csv file into a list +### Mute All Streams + ```python -FILE_PATH = 'path/to/the/file/to/upload/location' -client.proactive_connect.upload_list_items(LIST_ID, FILE_PATH) +vonage_client.video.mute_all_streams(session_id='your_session_id', excluded_stream_ids=['stream_id_1', 'stream_id_2']) ``` -This method helps you work with events emitted by the Proactive Connect API when in use: +### Disable Mute All Streams -### List all events ```python -client.proactive_connect.list_events() +vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ``` -## Account API +### Start Captions -### Get your account balance ```python -client.account.get_balance() +from vonage_video.models import CaptionsOptions + +captions_options = CaptionsOptions(language='en-US') +captions_data = vonage_client.video.start_captions(captions_options) ``` -### Top up your account -This feature is only enabled when you enable auto-reload for your account in the dashboard. +### Stop Captions + ```python -# trx is the reference from when auto-reload was enabled and money was added -client.account.topup(trx=transaction_reference) +from vonage_video.models import CaptionsData + +captions_data = CaptionsData(captions_id='your_captions_id') +vonage_client.video.stop_captions(captions_data) ``` -## Subaccounts API +### Start Audio Connector -This API is used to create and configure subaccounts related to your primary account and transfer credit, balances and bought numbers between accounts. +```python +from vonage_video.models import AudioConnectorOptions -The subaccounts API is disabled by default. If you want to use subaccounts, [contact support](https://api.support.vonage.com) to have the API enabled on your account. +audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') +audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) +``` -### Get a list of all subaccounts +### Start Experience Composer ```python -client.subaccounts.list_subaccounts() +from vonage_video.models import ExperienceComposerOptions + +experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') +experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) ``` -### Create a subaccount +### List Experience Composers ```python -client.subaccounts.create_subaccount(name='my subaccount') +from vonage_video.models import ListExperienceComposersFilter -# With options -client.subaccounts.create_subaccount( - name='my subaccount', - secret='Password123', - use_primary_account_balance=False, -) +filter = ListExperienceComposersFilter(page_size=10) +experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) +print(experience_composers) ``` -### Get information about a subaccount +### Get Experience Composer ```python -client.subaccounts.get_subaccount(SUBACCOUNT_API_KEY) +experience_composer = vonage_client.video.get_experience_composer(experience_composer_id='experience_composer_id') ``` -### Modify a subaccount +### Stop Experience Composer ```python -client.subaccounts.modify_subaccount( - SUBACCOUNT_KEY, - suspended=True, - use_primary_account_balance=False, - name='my modified subaccount', -) +vonage_client.video.stop_experience_composer(experience_composer_id='experience_composer_id') ``` -### List credit transfers between accounts - -All fields are optional. If `start_date` or `end_date` are used, the dates must be specified in UTC ISO 8601 format, e.g. `1970-01-01T00:00:00Z`. Don't use milliseconds. +### List Archives ```python -client.subaccounts.list_credit_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key -) -``` +from vonage_video.models import ListArchivesFilter -### Transfer credit between accounts +filter = ListArchivesFilter(offset=2) +archives, count, next_page_offset = vonage_client.video.list_archives(filter) +print(archives) +``` -Transferring credit is only possible for postpaid accounts, i.e. accounts that can have a negative balance. For prepaid and self-serve customers, account balances can be transferred between accounts (see below). +### Start Archive ```python -client.subaccounts.transfer_credit( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - amount=0.50, - reference='test credit transfer', -) -``` +from vonage_video.models import CreateArchiveRequest -### List balance transfers between accounts +archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') +archive = vonage_client.video.start_archive(archive_options) +``` -All fields are optional. If `start_date` or `end_date` are used, the dates must be specified in UTC ISO 8601 format, e.g. `1970-01-01T00:00:00Z`. Don't use milliseconds. +### Get Archive ```python -client.subaccounts.list_balance_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key -) +archive = vonage_client.video.get_archive(archive_id='your_archive_id') +print(archive) ``` -### Transfer account balances between accounts +### Delete Archive ```python -client.subaccounts.transfer_balance( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - amount=0.50, - reference='test balance transfer', -) +vonage_client.video.delete_archive(archive_id='your_archive_id') ``` -### Transfer bought phone numbers between accounts +### Add Stream to Archive ```python -client.subaccounts.transfer_balance( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - number=NUMBER_TO_TRANSFER, - country='US', -) -``` +from vonage_video.models import AddStreamRequest -## Number Management API +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) +``` -### Get numbers associated with your account +### Remove Stream from Archive ```python -client.numbers.get_account_numbers(size=25) +vonage_client.video.remove_stream_from_archive(archive_id='your_archive_id', stream_id='your_stream_id') ``` -### Get numbers that are available to buy +### Stop Archive ```python -client.numbers.get_available_numbers('CA', size=25) +archive = vonage_client.video.stop_archive(archive_id='your_archive_id') +print(archive) ``` -### Buy an available number +### Change Archive Layout ```python -params = {'country': 'US', 'msisdn': 'number_to_buy'} -client.numbers.buy_number(params) +from vonage_video.models import ComposedLayout -# To buy a number for a subaccount -params = {'country': 'US', 'msisdn': 'number_to_buy', 'target_api_key': SUBACCOUNT_API_KEY} -client.numbers.buy_number(params) +layout = ComposedLayout(type='bestFit') +archive = vonage_client.video.change_archive_layout(archive_id='your_archive_id', layout=layout) +print(archive) ``` -### Cancel your subscription for a specific number +### List Broadcasts ```python -params = {'country': 'US', 'msisdn': 'number_to_cancel'} -client.numbers.cancel_number(params) +from vonage_video.models import ListBroadcastsFilter -# To cancel a number assigned to a subaccount -params = {'country': 'US', 'msisdn': 'number_to_buy', 'target_api_key': SUBACCOUNT_API_KEY} -client.numbers.cancel_number(params) +filter = ListBroadcastsFilter(page_size=10) +broadcasts, count, next_page_offset = vonage_client.video.list_broadcasts(filter) +print(broadcasts) ``` -### Update the behaviour of a number that you own +### Start Broadcast ```python -params = {"country": "US", "msisdn": "number_to_update", "moHttpUrl": "callback_url"} -client.numbers.update_number(params) +from vonage_video.models import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp + +broadcast_options = CreateBroadcastRequest(session_id='your_session_id', outputs=BroadcastOutputSettings( + hls=BroadcastHls(dvr=True, low_latency=False), + rtmp=[ + BroadcastRtmp( + id='test', + server_url='rtmp://a.rtmp.youtube.com/live2', + stream_name='stream-key', + ) + ], +) +) +broadcast = vonage_client.video.start_broadcast(broadcast_options) +print(broadcast) ``` -## Pricing API +### Get Broadcast -### Get pricing for a single country ```python -client.account.get_country_pricing(country_code='GB', type='sms') # Default type is sms +broadcast = vonage_client.video.get_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) ``` -### Get pricing for all countries -```python -client.account.get_all_countries_pricing(type='sms') # Default type is sms, can be voice -``` +### Stop Broadcast -### Get pricing for a specific dialling prefix ```python -client.account.get_prefix_pricing(prefix='44', type='sms') +broadcast = vonage_client.video.stop_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) ``` -## Managing Secrets - -An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. - -### List Secrets +### Change Broadcast Layout ```python -secrets = client.account.list_secrets(API_KEY) +from vonage_video.models import ComposedLayout + +layout = ComposedLayout(type='bestFit') +broadcast = vonage_client.video.change_broadcast_layout(broadcast_id='your_broadcast_id', layout=layout) +print(broadcast) ``` -### Get information about a specific secret +### Add Stream to Broadcast ```python -secrets = client.account.get_secret(API_KEY, secret_id) -``` +from vonage_video.models import AddStreamRequest -### Create A New Secret +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_broadcast(broadcast_id='your_broadcast_id', params=add_stream_request) +``` -Create a new secret (the created dates will help you know which is which): +### Remove Stream from Broadcast ```python -client.account.create_secret(API_KEY, 'awes0meNewSekret!!;'); +vonage_client.video.remove_stream_from_broadcast(broadcast_id='your_broadcast_id', stream_id='your_stream_id') ``` -### Delete A Secret - -Delete the old secret (any application still using these credentials will stop working): +### Initiate SIP Call ```python -client.account.revoke_secret(API_KEY, 'my-secret-id') -``` +from vonage_video.models import InitiateSipRequest, SipOptions, SipAuth -## Application API +sip_request_params = InitiateSipRequest( + session_id='your_session_id', + token='your_token', + sip=SipOptions( + uri=f'sip:{vonage_number}@sip.nexmo.com;transport=tls', + from_=f'test@vonage.com', + headers={'header_key': 'header_value'}, + auth=SipAuth(username='1485b9e6', password='fL8jvi4W2FmS9som'), + secure=False, + video=False, + observe_force_mute=True, + ), +) +sip_call = vonage_client.video.initiate_sip_call(sip_request_params) +print(sip_call) +``` -### Create an application +### Play DTMF into a call ```python -response = client.application.create_application({name='Example App', type='voice'}) -``` +# Play into all connections +session_id = 'your_session_id' +digits = '1234#*p' -Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) +vonage_client.video.play_dtmf(session_id=session_id, digits=digits) -### Retrieve a list of applications +# Play into one connection +session_id = 'your_session_id' +digits = '1234#*p' +connection_id = 'your_connection_id' -```python -response = client.application.list_applications() +vonage_client.video.play_dtmf(session_id=session_id, digits=digits, connection_id=connection_id) ``` -Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) +## Voice API + +### Create a Call -### Retrieve a single application +To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. ```python -response = client.application.get_application(uuid) -``` +from vonage_voice.models import CreateCallRequest, Talk -Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) +ncco = [Talk(text='Hello world', loop=3, language='en-GB')] -### Update an application +call = CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + ncco=ncco, + random_from_number=True, +) -```python -response = client.application.update_application(uuid, answer_method='POST') +response = vonage_client.voice.create_call(call) +print(response.model_dump()) ``` -Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) - -### Delete an application +### List Calls ```python -response = client.application.delete_application(uuid) -``` +# Gets the first 100 results and the record_index of the +# next page if there's more than 100 +calls, next_record_index = vonage_client.voice.list_calls() -Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) +# Specify filtering options +from vonage_voice.models import ListCallsFilter +call_filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +) -## Users API - -These API methods are part of the [Application (v2) API](https://developer.vonage.com/en/application/overview) but are a in separate module in the SDK. [See the API reference for more details](https://developer.vonage.com/en/api/application.v2#User). +calls, next_record_index = vonage_client.voice.list_calls(call_filter) +``` -### List all Users +### Get Information About a Specific Call ```python -client.users.list_users() +call = vonage_client.voice.get_call('CALL_ID') ``` -### Create a new user +### Transfer a Call to a New NCCO ```python -client.users.create_user() # Default values generated -client.users.create_user(params={...}) # Specify custom values +ncco = [Talk(text='Hello world')] +vonage_client.voice.transfer_call_ncco('UUID', ncco) ``` -### Get detailed information about a user +### Transfer a Call to a New Answer URL ```python -client.users.get_user('USER_ID') +vonage_client.voice.transfer_call_answer_url('UUID', 'ANSWER_URL') ``` -### Update user details +### Hang Up a Call + +End the call for a specified UUID, removing them from it. ```python -client.users.update_user('USER_ID', params={...}) +vonage_client.voice.hangup('UUID') ``` -### Delete a user +### Mute/Unmute a Participant ```python -client.users.delete_user('USER_ID') +vonage_client.voice.mute('UUID') +vonage_client.voice.unmute('UUID') ``` -## Validate webhook signatures +### Earmuff/Unearmuff a UUID -```python -client = vonage.Client(signature_secret='secret') +Prevent/allow a specified UUID participant to be able to hear audio. -if client.check_signature(request.query): - # valid signature -else: - # invalid signature +```python +vonage_client.voice.earmuff('UUID') +vonage_client.voice.unearmuff('UUID') ``` -Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://developer.nexmo.com/concepts/guides/signing-messages?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) +### Play Audio Into a Call -Note: you'll need to contact support@nexmo.com to enable message signing on -your account before you can validate webhook signatures. - -## JWT parameters +```python +from vonage_voice.models import AudioStreamOptions -By default, the library generates tokens for JWT authentication that have an expiry time of 15 minutes. You should set the expiry time (`exp`) to an appropriate value for your organisation's own policies and/or your use case. +# Only the `stream_url` option is required +options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 +) +response = vonage_client.voice.play_audio_into_call('UUID', options) +``` -Use the `auth` method of the client class to specify custom parameters: +### Stop Playing Audio Into a Call ```python -client.auth(nbf=nbf, exp=exp, jti=jti) -# OR -client.auth({'nbf': nbf, 'exp': exp, 'jti': jti}) +vonage_client.voice.stop_audio_stream('UUID') ``` -## Overriding API Attributes +### Play TTS Into a Call -In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client to use with all API classes. +```python +from vonage_voice.models import TtsStreamOptions -An example using setters/getters with `Object references`: +# Only the `text` field is required +options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +) +response = voice.play_tts_into_call('UUID', options) +``` + +### Stop Playing TTS Into a Call ```python -from vonage import Client +vonage_client.voice.stop_tts('UUID') +``` -# Define the client -client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # using getter for host -- value returned: rest.nexmo.com +### Play DTMF Tones Into a Call -# Change the value in client -client.host('mio.nexmo.com') # Change host to mio.nexmo.com - this change will be available for sms -client.sms.send_message(params) # Sends an SMS to the host above +```python +response = voice.play_dtmf_into_call('UUID', '1234*#') ``` -### Overriding API Host / Host Attributes - -These attributes are private in the client class and the only way to access them is using the getters/setters we provide. +## Vonage Utils Package ```python -from vonage import Client +from utils import format_phone_number, remove_none_values + +# Use format_phone_number +try: + formatted_number = format_phone_number('123-456-7890') + print(formatted_number) +except (InvalidPhoneNumberError, InvalidPhoneNumberTypeError) as e: + print(e) -client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # return rest.nexmo.com -client.host('newhost.vonage.com') # rewrites the host value to newhost.vonage.com -print(client.api_host()) # returns api.vonage.com -client.api_host('myapi.vonage.com') # rewrite the value of api_host to myapi.vonage.com +# Use remove_none_values to remove null values from a Vonage API response when converting to a dictionary with the `asdict` method +from dataclasses import asdict + +vonage_api_response = vonage.api.method() +cleaned_dict = asdict(my_dataclass, dict_factory=remove_none_values) +print(cleaned_dict) ``` ## Frequently Asked Questions @@ -1206,32 +1432,30 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | API | API Release Status | Supported? | | --------------------- | :------------------: | :--------: | | Account API | General Availability | ✅ | -| Alerts API | General Availability | ✅ | | Application API | General Availability | ✅ | | Audit API | Beta | ❌ | | Conversation API | Beta | ❌ | | Dispatch API | Beta | ❌ | | External Accounts API | Beta | ❌ | | Media API | Beta | ❌ | -| Meetings API | General Availability | ✅ | | Messages API | General Availability | ✅ | | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | -| Proactive Connect API | General Availability | ✅ (partially supported) | | Redact API | Developer Preview | ❌ | | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | | Subaccounts API | General Availability | ✅ | -| Verify API v2 | General Availability | ✅ | -| Verify API v1 (Legacy)| General Availability | ✅ | +| Verify API | General Availability | ✅ | +| Verify API (Legacy) | General Availability | ✅ | +| Video API | General Availability | ✅ | | Voice API | General Availability | ✅ | ### asyncio Support [asyncio](https://docs.python.org/3/library/asyncio.html) is a library to write **concurrent** code using the **async/await** syntax. -We don't currently support asyncio in the Python SDK but we are planning to do so in upcoming releases. +We don't currently support asyncio in the Python SDK. ## Contributing @@ -1240,13 +1464,13 @@ We :heart: contributions! But if you plan to work on something big or controvers We recommend working on `vonage-python-sdk` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: ```bash -make install +pip install -r requirements.txt ``` The tests are all written with pytest. You run them with: ```bash -make test +pytest -v ``` We use [Black](https://black.readthedocs.io/en/stable/index.html) for code formatting, with our config in the `pyproject.toml` file. To ensure a PR follows the right format, you can set up and use our pre-commit settings with @@ -1259,10 +1483,11 @@ Then when you commit code, if it's not in the right format, it will be automatic ## License -This library is released under the [Apache License][license]. +This library is released under the [Apache License](license). + +## Additional Resources -[virtualenv]: https://virtualenv.pypa.io/en/stable/ -[report-a-bug]: https://github.com/Vonage/vonage-python-sdk/issues/new -[pull-request]: https://github.com/Vonage/vonage-python-sdk/pulls -[signup]: https://dashboard.nexmo.com/sign-up?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library -[license]: LICENSE.txt +- [Vonage Video API Developer Documentation](https://developer.vonage.com/en/video/overview) +- [Link to the Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) +- [Join the Vonage Developer Community Slack](https://developer.vonage.com/en/community/slack) +- [Submit a Vonage Video API Support Request](https://api.support.vonage.com/hc/en-us) diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md new file mode 100644 index 00000000..639d3743 --- /dev/null +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -0,0 +1,255 @@ +# Vonage Python SDK v3 to v4 Migration Guide + +This is a guide to help you migrate from using v3 of the Vonage Python SDK to using the new v4 `vonage` package. It has feature parity with the v3 package and contains many enhancements and structural changes. We will only be supporting v4 from the time of its full release. + +The Vonage Python SDK (`vonage`) contains methods and data models to help you use many of Vonage's APIs. It also includes support for the new mobile network APIs announced by Vonage. + +## Contents + +- [Structural Changes and Enhancements](#structural-changes-and-enhancements) +- [Installation](#installation) +- [Configuration](#configuration) +- [Accessing API Methods](#accessing-api-methods) +- [Accessing API Data Models](#accessing-api-data-models) +- [Response Objects](#response-objects) +- [Error Handling](#error-handling) +- [General API Changes](#general-API-changes) +- [Specific API Changes](#specific-api-changes) +- [Method Name Changes](#method-name-changes) +- [Additional Resources](#additional-resources) + +## Structural Changes and Enhancements + +Here are some key changes to the SDK: + +1. v4 of the Vonage Python SDK now uses a monorepo structure, with different packages for calling different Vonage APIs all using common code. You don't need to install the different packages directly as the top-level `vonage` package pulls them in and provides a common and consistent way to access methods. +2. The v4 SDK makes heavy use of [Pydantic data models](https://docs.pydantic.dev/latest/) to make it easier to call Vonage APIs and parse the results. This also enforces correct typing and makes it easier to pass the right objects to Vonage. +3. Docstrings have been added to methods and data models across the whole SDK to increase quality-of-life developer experience and make in-IDE development easier. +4. Many new custom errors have been added for finer-grained debugging. Error objects now contain more information and error messages give more information and context. +5. Support has been added for all [Vonage Video API](https://developer.vonage.com/en/video/overview) features, bringing it to feature parity with the OpenTok package. See [the OpenTok -> Vonage Video migration guide](video/OPENTOK_TO_VONAGE_MIGRATION.md) for migration assistance. If you're using OpenTok, migration to use v4 of the Vonage Python SDK rather than the `opentok` Python package is highly recommended. +6. APIs that have been deprecated by Vonage, e.g. Meetings API, have not been implemented in v4. Objects deprecated in v3 of the SDK have also not been implemented in v4. + +## Installation + +The most common way to use the new v4 package is by installing the top-level `vonage` package, similar to how you would install v3. The difference is that the new package will install the other Vonage API packages as dependencies. + +To install the Python SDK package using pip: + +```bash +pip install vonage +``` + +To upgrade your installed client library using pip: + +```bash +pip install vonage --upgrade +``` + +You will notice that the dependent Vonage packages have been installed as well. + +## Configuration + +To get started with the v4 SDK, you'll need to initialize an instance of the `vonage.Vonage` class. This can then be used to access API methods. You need to provide authentication information and can optionally provide configuration options for the HTTP Client used to make requests to Vonage APIs. This section will break all of this down then provide an example. + +### Authentication + +Depending on the Vonage API you want to use, you'll use different forms of authentication. You'll need to provide either an API key and secret or the ID of a Vonage Application and its corresponding private key. This is done by initializing an instance of `vonage.Auth`. + +```python +from vonage import Auth + +# API key/secret authentication +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') + +# Application ID/private key authentication +auth = Auth(application_id='your_api_key', private_key='your_api_secret') +``` + +This `auth` can then be used when initializing an instance of `vonage.Vonage` (example later in this section). + +### Setting HTTP Client Options + +The HTTP client used to make requests to Vonage comes with sensible default options, but if you need to change any of these, create a `vonage.HttpClientOptions` object and pass that in to `vonage.Vonage` when you create the object. + +```python +# Create HttpClientOptions instance with some non-default settings +options = HttpClientOptions(api_host='new-api-host.example.com', timeout=100) +``` + +### Example + +Putting all this together, to set up an instance of the `vonage.Vonage` class to call Vonage APIs, do this: + +```python +from vonage import Vonage, Auth, HttpClientOptions + +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') + +# Create HttpClientOptions instance +# (not required unless you want to change options from the defaults) +options = HttpClientOptions(api_host='new-api-host.example.com', timeout=100) + +# Create a Vonage instance +vonage = Vonage(auth=auth, http_client_options=options) +``` + +## Accessing API Methods + +To access methods relating to Vonage APIs, you'll create an instance of the `vonage.Vonage` class and access them via named attributes, e.g. if you have an instance of `vonage.Vonage` called `vonage_client`, use this syntax: + +```python +vonage_client.vonage_api.api_method(...) + +# E.g. +vonage_client.video.create_session(...) +``` + +This is very similar to the v3 SDK. + +## Accessing API Data Models + +Unlike the methods to call each Vonage API, the data models and errors specific to each API are not accessed through the `vonage` package, but are instead accessed through the specific API package. + +For most APIs, data models and errors can be accessed from the top level of the API package, e.g. to send a Verify request, do this: + +```python +from vonage_verify import VerifyRequest, SmsChannel + +sms_channel = SmsChannel(to='1234567890') +verify_request = VerifyRequest(brand='Vonage', workflow=[sms_channel]) + +response = vonage_client.verify.start_verification(verify_request) +print(response) +``` + +However, some APIs with a lot of models have them located under the `.models` package, e.g. `vonage-messages`, `vonage-voice` and `vonage-video`. To access these, simply import from `.models`, e.g. to send an image via Facebook Messenger do this: + +```python +from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource + +messenger_image_model = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + messenger=MessengerOptions(category='message_tag', tag='invalid_tag'), +) + +vonage_client.messages.send(message) +``` + +## Response Objects + +In v3 of the SDK, the APIs returned Python dictionaries. In v4, almost all responses are now deserialized from the returned JSON into Pydantic models. Response data models are accessed in the same way as the other data models above and are also fully documented with useful docstrings. + +If you want to convert the Pydantic responses into dictionaries, just use the `model_dump` method on the response. You can also use the `model_dump_json` method. For example: + +```python +from vonage_account import SettingsResponse + +settings: SettingsResponse = vonage_client.account.update_default_sms_webhook( + mo_callback_url='https://example.com/sms_webhook', + dr_callback_url='https://example.com/delivery_receipt_webhook', +) + +print(settings.model_dump()) +print(settings.model_dump_json()) +``` + +Response fields are also converted into snake_case where applicable, so as to be more pythonic. This means they won't necessarily match the API one-to-one. + +## Error Handling + +In v3 of the SDK, most HTTP client errors gave a general `HttpClientError`. Errors in v4 inherit from the general `VonageError` but are more specific and finer-grained, E.g. a `RateLimitedError` when the SDK receives an HTTP 429 response. + +These errors will have a descriptive message and will also include the response object returned to the SDK, accessed by `HttpClientError.response` etc. + +Some API packages have their own errors for specific cases too. + +For older Vonage APIs that always return an HTTP 200, error handling logic has been included to give a similar experience to the newer APIs. + +## General API Changes + +In v3, you access `vonage.Client`. In v4, it's `vonage.Vonage`. + +The methods to get and set host attributes in v3 e.g. `vonage.Client.api_host` have been removed. You now get these options in v4 via the `vonage.Vonage.http_client`. Set these options in v4 by adding the options you want to the `vonage.HttpClientOptions` data model when initializing a `vonage.Vonage` object. + +## Specific API Changes + +### Video API + +Methods have been added to help you work with the Live Captions, Audio Connector and Experience Composer APIs. See the [Video API samples](video/README.md) for more information. + +### Voice API + +Methods have been added to help you moderate a voice call: + +- `voice.hangup` +- `voice.mute` +- `voice.unmute` +- `voice.earmuff` +- `voice.unearmuff` + +See the [Voice API samples](voice/README.md) for more information. + +### Network Number Verification API + +The process for verifying a number using the Network Number Verification API has been simplified. In v3 it was required to exchange a code for a token then use this token in the verify request. In v4, these steps are combined so both functions are taken care of in the `NetworkNumberVerification.verify` method. + +### Verify API Name Changes + +The functionality previously named "Verify V2" in v3 of the SDK has been renamed "Verify", along with associated methods. The old Verify product in v3 has been renamed "Verify Legacy". + +Verify v2 functionality is now accessed from `vonage_client.verify` in v4, which exposes the `vonage_verify.Verify` class. The legacy Verify v1 objects are accessed from `vonage_client.verify_legacy` in v4, in the new package `vonage-verify-legacy`. + +### SMS API + +Code for signing/verifying signatures of SMS messages that was in the `vonage.Client` class in v3 has been moved into the `vonage-http-client` package in v4. This can be accessed via the `vonage` package as we import the `vonage-http-client.Auth` class into its namespace. + +Old method -> new method +`vonage.Client.sign_params` -> `vonage.Auth.sign_params` +`vonage.Client.check_signature` -> `vonage.Auth.check_signature` + +## Method Name Changes + +Some methods from v3 have had their names changed in v4. Assuming you access all methods from the `vonage.Vonage` class in v4 with `vonage.Vonage.api_method` or the `vonage.Client` class in v3 with `vonage.Client.api_method`, this table details the changes: + +| 3.x Method Name | 4.x Method Name | +|-----------------|-----------------| +| `account.topup` | `account.top_up` | +| `messages.send_message` | `messages.send` | +| `messages.revoke_outbound_rcs_message` | `messages.revoke_rcs_message` | +| `number_insight.get_basic_number_insight` | `number_insight.basic_number_insight` | +| `number_insight.get_standard_number_insight` | `number_insight.standard_number_insight` | +| `number_insight.get_advanced_number_insight` | `number_insight.advanced_sync_number_insight` | +| `number_insight.get_async_advanced_number_insight` | `number_insight.advanced_async_number_insight` | +| `numbers.get_account_numbers` | `numbers.list_owned_numbers` | +| `numbers.get_available_numbers` | `numbers.search_available_numbers` | +| `sms.send_message` | `sms.send` | +| `verify.start_verification` | `verify_legacy.start_verification` | +| `verify.psd2` | `verify_legacy.start_psd2_verification` | +| `verify.check` | `verify_legacy.check_code` | +| `verify.search` | `verify_legacy.search` | +| `verify.cancel_verification` | `verify_legacy.cancel_verification` | +| `verify.trigger_next_event` | `verify_legacy.trigger_next_event` | +| `verify.request_network_unblock` | `verify_legacy.request_network_unblock` | +| `verify2.new_request` | `verify.start_verification` | +| `verify2.check_code` | `verify.check_code` | +| `verify2.cancel_verification` | `verify.cancel_verification` | +| `verify2.trigger_next_workflow` | `verify.trigger_next_workflow` | +| `video.set_stream_layout` | `video.change_stream_layout` | +| `video.create_archive` | `video.start_archive` | +| `video.create_sip_call` | `video.initiate_sip_call` | +| `voice.get_calls` | `voice.list_calls` | +| `voice.update_call` | `voice.transfer_call_ncco` and `voice.transfer_call_answer_url` | +| `voice.send_audio` | `voice.play_audio_into_call` | +| `voice.stop_audio` | `voice.stop_audio_stream` | +| `voice.send_speech` | `voice.play_tts_into_call` | +| `voice.stop_speech` | `voice.stop_tts` | +| `voice.send_dtmf` | `voice.play_dtmf_into_call` | + +## Additional Resources + +- [Link to the Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) +- [Join the Vonage Developer Community Slack](https://developer.vonage.com/en/community/slack) +- [Submit a Vonage API Support Request](https://api.support.vonage.com/hc/en-us) \ No newline at end of file diff --git a/account/BUILD b/account/BUILD new file mode 100644 index 00000000..8defcdfa --- /dev/null +++ b/account/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-account', + dependencies=[ + ':pyproject', + ':readme', + 'account/src/vonage_account', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/account/CHANGES.md b/account/CHANGES.md new file mode 100644 index 00000000..032649f7 --- /dev/null +++ b/account/CHANGES.md @@ -0,0 +1,12 @@ +# 1.1.0 +- Add support for the [Vonage Pricing API](https://developer.vonage.com/en/api/pricing) +- Update dependency versions + +# 1.0.2 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.1 +- Add docstrings to data models + +# 1.0.0 +- Initial upload diff --git a/account/README.md b/account/README.md new file mode 100644 index 00000000..c3dcde0d --- /dev/null +++ b/account/README.md @@ -0,0 +1,94 @@ +# Vonage Account Package + +This package contains the code to use Vonage's Account API in Python. + +It includes methods for managing Vonage accounts, managing account secrets and querying country pricing. + +## 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`. + +### Get Account Balance + +```python +balance = vonage_client.account.get_balance() +print(balance) +``` + +### Top-Up Account + +```python +response = vonage_client.account.top_up(trx='1234567890') +print(response) +``` + +### Get Service Pricing for a Specific Country + +```python +from vonage_account import GetCountryPricingRequest + +response = vonage_client.account.get_country_pricing( + GetCountryPricingRequest(type='sms', country_code='US') +) +print(response) +``` + +### Get Service Pricing for All Countries + +```python +response = vonage_client.account.get_all_countries_pricing(service_type='sms') +print(response) +``` + +### Get Service Pricing by Dialing Prefix + +```python +from vonage_account import GetPrefixPricingRequest + +response = client.account.get_prefix_pricing( + GetPrefixPricingRequest(prefix='44', type='sms') +) +print(response) +``` + +### Update the Default SMS Webhook + +This will return a Pydantic object (`SettingsResponse`) containing multiple settings for your account. + +```python +settings: SettingsResponse = vonage_client.account.update_default_sms_webhook( + mo_callback_url='https://example.com/inbound_sms_webhook', + dr_callback_url='https://example.com/delivery_receipt_webhook', +) + +print(settings) +``` + +### List Secrets Associated with the Account + +```python +response = vonage_client.account.list_secrets() +print(response) +``` + +### Create a New Account Secret + +```python +secret = vonage_client.account.create_secret('Mytestsecret12345') +print(secret) +``` + +### Get Information About One Secret + +```python +secret = vonage_client.account.get_secret(MY_SECRET_ID) +print(secret) +``` + +### Revoke a Secret + +Note: it isn't possible to revoke all account secrets, there must always be one valid secret. Attempting to do so will give a 403 error. + +```python +client.account.revoke_secret(MY_SECRET_ID) +``` diff --git a/account/pyproject.toml b/account/pyproject.toml new file mode 100644 index 00000000..dc3767b7 --- /dev/null +++ b/account/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-account' +dynamic = ["version"] +description = 'Vonage Account API package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_account._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/account/src/vonage_account/BUILD b/account/src/vonage_account/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/account/src/vonage_account/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/account/src/vonage_account/__init__.py b/account/src/vonage_account/__init__.py new file mode 100644 index 00000000..c5384afd --- /dev/null +++ b/account/src/vonage_account/__init__.py @@ -0,0 +1,27 @@ +from .account import Account +from .errors import InvalidSecretError +from .requests import GetCountryPricingRequest, GetPrefixPricingRequest, ServiceType +from .responses import ( + Balance, + GetMultiplePricingResponse, + GetPricingResponse, + NetworkPricing, + SettingsResponse, + TopUpResponse, + VonageApiSecret, +) + +__all__ = [ + 'Account', + 'InvalidSecretError', + 'GetCountryPricingRequest', + 'GetPrefixPricingRequest', + 'ServiceType', + 'Balance', + 'GetPricingResponse', + 'GetMultiplePricingResponse', + 'NetworkPricing', + 'SettingsResponse', + 'TopUpResponse', + 'VonageApiSecret', +] diff --git a/account/src/vonage_account/_version.py b/account/src/vonage_account/_version.py new file mode 100644 index 00000000..1a72d32e --- /dev/null +++ b/account/src/vonage_account/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.0' diff --git a/account/src/vonage_account/account.py b/account/src/vonage_account/account.py new file mode 100644 index 00000000..6a29df7b --- /dev/null +++ b/account/src/vonage_account/account.py @@ -0,0 +1,253 @@ +import re + +from pydantic import validate_call +from vonage_account.errors import InvalidSecretError +from vonage_account.requests import ( + GetCountryPricingRequest, + GetPrefixPricingRequest, + ServiceType, +) +from vonage_http_client.http_client import HttpClient + +from .responses import ( + Balance, + GetMultiplePricingResponse, + GetPricingResponse, + SettingsResponse, + TopUpResponse, + VonageApiSecret, +) + + +class Account: + """Class containing methods for management of a Vonage account.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'basic' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Users API. + + Returns: + HttpClient: The HTTP client used to make requests to the Users API. + """ + return self._http_client + + def get_balance(self) -> Balance: + """Get the balance of the account. + + Returns: + Balance: Object containing the account balance and whether auto-reload is + enabled for the account. + """ + + response = self._http_client.get( + self._http_client.rest_host, + '/account/get-balance', + auth_type=self._auth_type, + ) + return Balance(**response) + + @validate_call + def top_up(self, trx: str) -> TopUpResponse: + """Top-up the account balance. + + Args: + trx (str): The transaction reference of the transaction when auto-reload + was enabled on your account. + + Returns: + TopUpResponse: Object containing the top-up response. + """ + + response = self._http_client.post( + self._http_client.rest_host, + '/account/top-up', + params={'trx': trx}, + auth_type=self._auth_type, + sent_data_type='form', + ) + return TopUpResponse(**response) + + @validate_call + def update_default_sms_webhook( + self, mo_callback_url: str = None, dr_callback_url: str = None + ) -> SettingsResponse: + """Update the default SMS webhook URLs for the account. In order to unset any + default value, pass an empty string as the value. + + Args: + mo_callback_url (str, optional): The URL to which inbound SMS messages will be + sent. + dr_callback_url (str, optional): The URL to which delivery receipts will be sent. + + Returns: + SettingsResponse: Object containing the response to the settings update. + """ + + params = {} + if mo_callback_url is not None: + params['moCallbackUrl'] = mo_callback_url + if dr_callback_url is not None: + params['drCallbackUrl'] = dr_callback_url + + response = self._http_client.post( + self._http_client.rest_host, + '/account/settings', + params=params, + auth_type=self._auth_type, + sent_data_type='form', + ) + return SettingsResponse(**response) + + @validate_call + def get_country_pricing( + self, options: GetCountryPricingRequest + ) -> GetPricingResponse: + """Get the pricing for a specific country. + + Args: + options (GetCountryPricingRequest): The options for the request. + + Returns: + GetCountryPricingResponse: The response from the API. + """ + response = self._http_client.get( + self._http_client.rest_host, + f'/account/get-pricing/outbound/{options.type.value}', + params={'country': options.country_code}, + auth_type=self._auth_type, + ) + + return GetPricingResponse(**response) + + @validate_call + def get_all_countries_pricing( + self, service_type: ServiceType + ) -> GetMultiplePricingResponse: + """Get the pricing for all countries. + + Args: + service_type (ServiceType): The type of service to retrieve pricing data about. + + Returns: + GetMultiplePricingResponse: Model containing the pricing data for all countries. + """ + response = self._http_client.get( + self._http_client.rest_host, + f'/account/get-full-pricing/outbound/{service_type.value}', + auth_type=self._auth_type, + ) + + return GetMultiplePricingResponse(**response) + + @validate_call + def get_prefix_pricing( + self, options: GetPrefixPricingRequest + ) -> GetMultiplePricingResponse: + """Get the pricing for a specific prefix. + + Args: + options (GetPrefixPricingRequest): The options for the request. + + Returns: + GetMultiplePricingResponse: Model containing the pricing data for all + countries using the dialling prefix. + """ + response = self._http_client.get( + self._http_client.rest_host, + f'/account/get-prefix-pricing/outbound/{options.type.value}', + params={'prefix': options.prefix}, + auth_type=self._auth_type, + ) + + return GetMultiplePricingResponse(**response) + + def list_secrets(self) -> list[VonageApiSecret]: + """List all secrets associated with the account. + + Returns: + list[VonageApiSecret]: List of VonageApiSecret objects. + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/secrets', + auth_type=self._auth_type, + ) + secrets = [] + for element in response['_embedded']['secrets']: + secrets.append(VonageApiSecret(**element)) + + return secrets + + @validate_call + def create_secret(self, secret: str) -> VonageApiSecret: + """Create an API secret for the account. + + Args: + secret (VonageSecret): The secret to create. Must satisfy the following + conditions: + - 8-25 characters long + - At least one uppercase letter + - At least one lowercase letter + - At least one digit + + Returns: + VonageApiSecret: The created VonageApiSecret object. + """ + if not self._is_valid_secret(secret): + raise InvalidSecretError( + 'Secret must be 8-25 characters long and contain at least one uppercase ' + 'letter, one lowercase letter, and one digit.' + ) + + response = self._http_client.post( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/secrets', + params={'secret': secret}, + auth_type=self._auth_type, + ) + return VonageApiSecret(**response) + + @validate_call + def get_secret(self, secret_id: str) -> VonageApiSecret: + """Get a specific secret associated with the account. + + Args: + secret_id (str): The ID of the secret to retrieve. + + Returns: + VonageApiSecret: The VonageApiSecret object. + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/secrets/{secret_id}', + auth_type=self._auth_type, + ) + return VonageApiSecret(**response) + + @validate_call + def revoke_secret(self, secret_id: str) -> None: + """Revoke a specific secret associated with the account. + + Args: + secret_id (str): The ID of the secret to revoke. + """ + self._http_client.delete( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/secrets/{secret_id}', + auth_type=self._auth_type, + ) + + def _is_valid_secret(self, secret: str) -> bool: + if len(secret) < 8 or len(secret) > 25: + return False + if not re.search(r'[a-z]', secret): + return False + if not re.search(r'[A-Z]', secret): + return False + if not re.search(r'\d', secret): + return False + return True diff --git a/account/src/vonage_account/errors.py b/account/src/vonage_account/errors.py new file mode 100644 index 00000000..6041ca2b --- /dev/null +++ b/account/src/vonage_account/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class InvalidSecretError(VonageError): + """Indicates that the secret provided was invalid.""" diff --git a/account/src/vonage_account/requests.py b/account/src/vonage_account/requests.py new file mode 100644 index 00000000..073abe5e --- /dev/null +++ b/account/src/vonage_account/requests.py @@ -0,0 +1,44 @@ +from enum import Enum + +from pydantic import BaseModel + + +class ServiceType(str, Enum): + """The service you wish to retrieve outbound pricing data about. + + Values: + ``` + SMS: SMS + SMS_TRANSIT: SMS transit + VOICE: Voice + ``` + """ + + SMS = 'sms' + SMS_TRANSIT = 'sms-transit' + VOICE = 'voice' + + +class GetCountryPricingRequest(BaseModel): + """The options for getting the pricing for a specific country. + + Args: + country_code (str): The two-letter country code for the country to retrieve + pricing data about. + type (ServiceType, Optional): The type of service to retrieve pricing data about. + """ + + country_code: str + type: ServiceType = ServiceType.SMS + + +class GetPrefixPricingRequest(BaseModel): + """The options for getting the pricing for a specific prefix. + + Args: + prefix (str): The numerical dialing prefix to look up pricing for, e.g. "1", "44". + type (ServiceType, Optional): The type of service to retrieve pricing data about. + """ + + prefix: str + type: ServiceType = ServiceType.SMS diff --git a/account/src/vonage_account/responses.py b/account/src/vonage_account/responses.py new file mode 100644 index 00000000..b49115e1 --- /dev/null +++ b/account/src/vonage_account/responses.py @@ -0,0 +1,127 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class Balance(BaseModel): + """Model for the balance of a Vonage account. + + Args: + value (float): The balance of the account in EUR. + auto_reload (bool, Optional): Whether the account has auto-reload enabled. + """ + + value: float + auto_reload: Optional[bool] = Field(None, validation_alias='autoReload') + + +class TopUpResponse(BaseModel): + """Model for a response to a top-up request. + + Args: + error_code (str, Optional): Code describing the operation status. + error_code_label (str, Optional): Description of the operation status. + """ + + error_code: Optional[str] = Field(None, validation_alias='error-code') + error_code_label: Optional[str] = Field(None, validation_alias='error-code-label') + + +class SettingsResponse(BaseModel): + """Model for a response to a settings update request. + + Args: + mo_callback_url (str, Optional): The URL for the inbound SMS webhook. + dr_callback_url (str, Optional): The URL for the delivery receipt webhook. + max_outbound_request (int, Optional): The maximum number of outbound messages + per second. + max_inbound_request (int, Optional): The maximum number of inbound messages + per second. + max_calls_per_second (int, Optional): The maximum number of API calls per second. + """ + + mo_callback_url: Optional[str] = Field(None, validation_alias='mo-callback-url') + dr_callback_url: Optional[str] = Field(None, validation_alias='dr-callback-url') + max_outbound_request: Optional[int] = Field( + None, validation_alias='max-outbound-request' + ) + max_inbound_request: Optional[int] = Field( + None, validation_alias='max-inbound-request' + ) + max_calls_per_second: Optional[int] = Field( + None, validation_alias='max-calls-per-second' + ) + + +class NetworkPricing(BaseModel): + """Model for network pricing data. + + Args: + aliases (list[str], Optional): A list of aliases for the network. + currency (str, Optional): The currency code for the pricing data. + mcc (str, Optional): The mobile country code. + mnc (str, Optional): The mobile network code. + network_code (str, Optional): The network code. + network_name (str, Optional): The network name. + price (str, Optional): The price for the service. + type (str, Optional): The type of service. + ranges (str, Optional): Number ranges. + """ + + aliases: Optional[list[str]] = None + currency: Optional[str] = None + mcc: Optional[str] = None + mnc: Optional[str] = None + network_code: Optional[str] = Field(None, validation_alias='networkCode') + network_name: Optional[str] = Field(None, validation_alias='networkName') + price: Optional[str] = None + type: Optional[str] = None + ranges: Optional[list[int]] = None + + +class GetPricingResponse(BaseModel): + """Model for a response to a request for pricing data. + + Args: + country_code (str, Optional): The two-letter country code. + country_display_name (str, Optional): The display name of the country. + country_name (str, Optional): The name of the country. + currency (str, Optional): The currency code for the pricing data. + default_price (str, Optional): The default price for the service. + dialing_prefix (str, Optional): The dialing prefix for the country. + networks (list[NetworkPricing], Optional): A list of network pricing data. + """ + + country_code: Optional[str] = Field(None, validation_alias='countryCode') + country_display_name: Optional[str] = Field( + None, validation_alias='countryDisplayName' + ) + country_name: Optional[str] = Field(None, validation_alias='countryName') + currency: Optional[str] = None + default_price: Optional[str] = Field(None, validation_alias='defaultPrice') + dialing_prefix: Optional[str] = Field(None, validation_alias='dialingPrefix') + networks: Optional[list[NetworkPricing]] = None + + +class GetMultiplePricingResponse(BaseModel): + """Model for multiple countries' pricing data. + + Args: + count (int): The number of countries. + countries (list[GetCountryPricingResponse]): A list of country pricing data. + """ + + count: int + countries: list[GetPricingResponse] + + +class VonageApiSecret(BaseModel): + """Model for a Vonage API secret. + + Args: + id (str): The unique ID of the secret. + created_at (str): The timestamp when the secret was created. + """ + + id: str + created_at: str diff --git a/account/tests/BUILD b/account/tests/BUILD new file mode 100644 index 00000000..56b13909 --- /dev/null +++ b/account/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['account', 'testutils']) diff --git a/account/tests/data/create_secret_error_max_number.json b/account/tests/data/create_secret_error_max_number.json new file mode 100644 index 00000000..6c6947b1 --- /dev/null +++ b/account/tests/data/create_secret_error_max_number.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/secret-management#add-excess-secret", + "title": "Secret Addition Forbidden", + "detail": "Account reached maximum number [2] of allowed secrets", + "instance": "48898273-7ae1-4ce4-8125-a71058ca6069" +} \ No newline at end of file diff --git a/account/tests/data/get_balance.json b/account/tests/data/get_balance.json new file mode 100644 index 00000000..50384904 --- /dev/null +++ b/account/tests/data/get_balance.json @@ -0,0 +1,4 @@ +{ + "value": 29.18202293, + "autoReload": false +} \ No newline at end of file diff --git a/account/tests/data/get_country_pricing.json b/account/tests/data/get_country_pricing.json new file mode 100644 index 00000000..298b6949 --- /dev/null +++ b/account/tests/data/get_country_pricing.json @@ -0,0 +1,79 @@ +{ + "dialingPrefix": "260", + "defaultPrice": "0.28725000", + "currency": "EUR", + "countryDisplayName": "Zambia", + "countryCode": "ZM", + "countryName": "Zambia", + "networks": [ + { + "type": "landline_premium", + "price": "0.28725000", + "currency": "EUR", + "ranges": [ + 26090 + ], + "networkCode": "ZM-PREMIUM", + "networkName": "Zambia Premium" + }, + { + "type": "mobile", + "price": "0.28725000", + "currency": "EUR", + "ranges": [ + 2607, + 26076, + 26096 + ], + "mcc": "645", + "mnc": "02", + "networkCode": "64502", + "networkName": "MTN Zambia" + }, + { + "type": "mobile", + "price": "0.28725000", + "currency": "EUR", + "ranges": [ + 26077, + 26097 + ], + "mcc": "645", + "mnc": "01", + "networkCode": "64501", + "networkName": "Airtel" + }, + { + "type": "landline_tollfree", + "price": "0.28725000", + "currency": "EUR", + "ranges": [ + 2608 + ], + "networkCode": "ZM-TOLL-FREE", + "networkName": "Zambia Toll Free" + }, + { + "type": "landline", + "price": "0.28725000", + "currency": "EUR", + "ranges": [ + 2602 + ], + "networkCode": "ZM-FIXED", + "networkName": "Zambia Landline" + }, + { + "type": "mobile", + "price": "0.28725000", + "currency": "EUR", + "ranges": [ + 26095 + ], + "mcc": "645", + "mnc": "03", + "networkCode": "64503", + "networkName": "Zamtel" + } + ] +} \ No newline at end of file diff --git a/account/tests/data/get_multiple_countries_pricing.json b/account/tests/data/get_multiple_countries_pricing.json new file mode 100644 index 00000000..d432ba03 --- /dev/null +++ b/account/tests/data/get_multiple_countries_pricing.json @@ -0,0 +1,47 @@ +{ + "count": 2, + "countries": [ + { + "dialingPrefix": "39", + "defaultPrice": "0.08270000", + "currency": "EUR", + "countryDisplayName": "Italy", + "countryCode": "IT", + "countryName": "Italy", + "networks": [ + { + "type": "mobile", + "price": "0.08270000", + "currency": "EUR", + "mcc": "222", + "mnc": "07", + "networkCode": "22207", + "networkName": "Noverca Italia S.r.l." + }, + { + "type": "mobile", + "price": "0.08270000", + "currency": "EUR", + "mcc": "222", + "mnc": "08", + "networkCode": "22208", + "networkName": "FastWeb S.p.A." + }, + { + "type": "landline_premium", + "price": "0.08270000", + "currency": "EUR", + "networkCode": "IT-PREMIUM", + "networkName": "Italy Premium" + } + ] + }, + { + "dialingPrefix": "39", + "currency": "EUR", + "countryDisplayName": "Vatican City", + "countryCode": "VA", + "countryName": "Vatican City" + } + ] +} \ No newline at end of file diff --git a/account/tests/data/list_secrets.json b/account/tests/data/list_secrets.json new file mode 100644 index 00000000..fbd84c13 --- /dev/null +++ b/account/tests/data/list_secrets.json @@ -0,0 +1,20 @@ +{ + "_links": { + "self": { + "href": "/accounts/test_api_key/secrets" + } + }, + "_embedded": { + "secrets": [ + { + "_links": { + "self": { + "href": "/accounts/test_api_key/secrets/1b1b1b1b-1b1b-1b-1b1b-1b1b1b1b1b1b" + } + }, + "id": "1b1b1b1b-1b1b-1b-1b1b-1b1b1b1b1b1b", + "created_at": "2022-03-28T14:16:56Z" + } + ] + } +} \ No newline at end of file diff --git a/account/tests/data/revoke_secret_error.json b/account/tests/data/revoke_secret_error.json new file mode 100644 index 00000000..b1e3efb0 --- /dev/null +++ b/account/tests/data/revoke_secret_error.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret", + "title": "Secret Deletion Forbidden", + "detail": "Can not delete the last secret. The account must always have at least 1 secret active at any time", + "instance": "a845d164-5623-4cc1-b7c6-0f95b94c6e53" +} \ No newline at end of file diff --git a/account/tests/data/secret.json b/account/tests/data/secret.json new file mode 100644 index 00000000..2bcb3c2c --- /dev/null +++ b/account/tests/data/secret.json @@ -0,0 +1,9 @@ +{ + "_links": { + "self": { + "href": "/accounts/test_api_key/secrets" + } + }, + "id": "ad6dc56f-07b5-46e1-a527-85530e625800", + "created_at": "2017-03-02T16:34:49Z" +} \ No newline at end of file diff --git a/account/tests/data/top_up.json b/account/tests/data/top_up.json new file mode 100644 index 00000000..b825772e --- /dev/null +++ b/account/tests/data/top_up.json @@ -0,0 +1,4 @@ +{ + "error-code": "200", + "error-code-label": "success" +} \ No newline at end of file diff --git a/account/tests/data/update_default_sms_webhook.json b/account/tests/data/update_default_sms_webhook.json new file mode 100644 index 00000000..573da86e --- /dev/null +++ b/account/tests/data/update_default_sms_webhook.json @@ -0,0 +1,7 @@ +{ + "mo-callback-url": "https://example.com/inbound_sms_webhook", + "dr-callback-url": "https://example.com/delivery_receipt_webhook", + "max-outbound-request": 30, + "max-inbound-request": 30, + "max-calls-per-second": 30 +} \ No newline at end of file diff --git a/account/tests/test_account.py b/account/tests/test_account.py new file mode 100644 index 00000000..318d72ff --- /dev/null +++ b/account/tests/test_account.py @@ -0,0 +1,240 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_account.account import Account +from vonage_account.errors import InvalidSecretError +from vonage_account.requests import ( + GetCountryPricingRequest, + GetPrefixPricingRequest, + ServiceType, +) +from vonage_http_client.errors import ForbiddenError +from vonage_http_client.http_client import HttpClient + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +account = Account(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = account.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_get_balance(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/get-balance', + 'get_balance.json', + ) + balance = account.get_balance() + + assert balance.value == 29.18202293 + assert balance.auto_reload is False + + +@responses.activate +def test_top_up(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/account/top-up', + 'top_up.json', + ) + top_up_response = account.top_up('1234567890') + + assert top_up_response.error_code == '200' + assert top_up_response.error_code_label == 'success' + + +@responses.activate +def test_update_default_sms_webhook(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/account/settings', + 'update_default_sms_webhook.json', + ) + settings_response = account.update_default_sms_webhook( + mo_callback_url='https://example.com/inbound_sms_webhook', + dr_callback_url='https://example.com/delivery_receipt_webhook', + ) + + assert settings_response.mo_callback_url == 'https://example.com/inbound_sms_webhook' + assert ( + settings_response.dr_callback_url + == 'https://example.com/delivery_receipt_webhook' + ) + assert settings_response.max_outbound_request == 30 + assert settings_response.max_inbound_request == 30 + assert settings_response.max_calls_per_second == 30 + + +@responses.activate +def test_get_country_pricing(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/get-pricing/outbound/sms', + 'get_country_pricing.json', + ) + + response = account.get_country_pricing( + GetCountryPricingRequest(country_code='ZM', type=ServiceType.SMS) + ) + + assert response.dialing_prefix == '260' + assert response.country_name == 'Zambia' + assert response.default_price == '0.28725000' + assert response.networks[0].network_name == 'Zambia Premium' + assert response.networks[1].mcc == '645' + + +@responses.activate +def test_get_all_countries_pricing(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/get-full-pricing/outbound/sms', + 'get_multiple_countries_pricing.json', + ) + + response = account.get_all_countries_pricing(ServiceType.SMS) + + assert response.count == 2 + assert response.countries[0].country_name == 'Italy' + assert response.countries[1].country_name == 'Vatican City' + assert response.countries[0].networks[0].network_name == 'Noverca Italia S.r.l.' + assert response.countries[0].networks[0].price == '0.08270000' + + +@responses.activate +def test_get_prefix_pricing(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/get-prefix-pricing/outbound/sms', + 'get_multiple_countries_pricing.json', + ) + + response = account.get_prefix_pricing( + GetPrefixPricingRequest(prefix='39', type=ServiceType.SMS) + ) + + assert response.count == 2 + assert response.countries[0].country_name == 'Italy' + assert response.countries[0].dialing_prefix == '39' + assert response.countries[1].country_name == 'Vatican City' + assert response.countries[1].dialing_prefix == '39' + assert response.countries[0].networks[0].network_name == 'Noverca Italia S.r.l.' + assert response.countries[0].networks[0].price == '0.08270000' + + +@responses.activate +def test_list_secrets(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/accounts/test_api_key/secrets', + 'list_secrets.json', + ) + secrets = account.list_secrets() + + assert len(secrets) == 1 + assert secrets[0].id == '1b1b1b1b-1b1b-1b-1b1b-1b1b1b1b1b1b' + assert secrets[0].created_at == '2022-03-28T14:16:56Z' + + +@responses.activate +def test_create_secret(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/secrets', + 'secret.json', + 201, + ) + secret = account.create_secret('Mytestsecret1234') + + assert account.http_client.last_response.status_code == 201 + assert secret.id == 'ad6dc56f-07b5-46e1-a527-85530e625800' + assert secret.created_at == '2017-03-02T16:34:49Z' + + +def test_create_secret_invalid_secret(): + with raises(InvalidSecretError) as e: + account.create_secret('secret') + + with raises(InvalidSecretError) as e: + account.create_secret('MYTESTSECRET1234') + + with raises(InvalidSecretError) as e: + account.create_secret('mytestsecret1234') + + with raises(InvalidSecretError) as e: + account.create_secret('Mytestsecret') + + assert e.match( + 'Secret must be 8-25 characters long and contain at least one uppercase letter, one lowercase letter, and one digit.' + ) + + +@responses.activate +def test_create_secret_error_max_number(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/secrets', + 'create_secret_error_max_number.json', + 403, + ) + + with raises(ForbiddenError) as e: + account.create_secret('Mytestsecret23456') + assert 'Account reached maximum number [2] of allowed secrets' in e.exconly() + + +@responses.activate +def test_get_secret(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/accounts/test_api_key/secrets/secret_id', + 'secret.json', + ) + secret = account.get_secret('secret_id') + + assert secret.id == 'ad6dc56f-07b5-46e1-a527-85530e625800' + assert secret.created_at == '2017-03-02T16:34:49Z' + + +@responses.activate +def test_revoke_api_secret(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/accounts/test_api_key/secrets/secret_id', + status=204, + ) + account.revoke_secret('secret_id') + assert account.http_client.last_response.status_code == 204 + + +@responses.activate +def test_revoke_api_secret_error_last_secret(): + build_response( + path, + 'DELETE', + 'https://api.nexmo.com/accounts/test_api_key/secrets/secret_id', + 'revoke_secret_error.json', + 403, + ) + + with raises(ForbiddenError) as e: + account.revoke_secret('secret_id') + + assert 'Can not delete the last secret.' in e.exconly() diff --git a/application/BUILD b/application/BUILD new file mode 100644 index 00000000..f65f6345 --- /dev/null +++ b/application/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-application', + dependencies=[ + ':pyproject', + ':readme', + 'application/src/vonage_application', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/application/CHANGES.md b/application/CHANGES.md new file mode 100644 index 00000000..37a93036 --- /dev/null +++ b/application/CHANGES.md @@ -0,0 +1,15 @@ +# 2.0.0 +- Rename `params` -> `config` in method arguments +- Update dependency versions + +# 1.0.3 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.2 +- Add docstrings to data models + +# 1.0.1 +- Update project metadata + +# 1.0.0 +- Initial upload diff --git a/application/README.md b/application/README.md new file mode 100644 index 00000000..99d27cb8 --- /dev/null +++ b/application/README.md @@ -0,0 +1,77 @@ +# Vonage Application API Package + +This package contains the code to use Vonage's Application API in Python. + +It includes methods for managing applications. + +## 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`. + +### List Applications + +With no custom options specified, this method will get the first 100 applications. It returns a tuple consisting of a list of `ApplicationData` objects and an int showing the page number of the next page of results. + +```python +from vonage_application import ListApplicationsFilter, ApplicationData + +applications, next_page = vonage_client.application.list_applications() + +# With options +options = ListApplicationsFilter(page_size=3, page=2) +applications, next_page = vonage_client.application.list_applications(options) +``` + +### Create a New Application + +```python +from vonage_application import ApplicationConfig + +app_data = vonage_client.application.create_application() + +# Create with custom options (can also be done with a dict) +from vonage_application import ApplicationConfig, Keys, Voice, VoiceWebhooks +voice = Voice( + webhooks=VoiceWebhooks( + event_url=VoiceUrl( + address='https://example.com/event', + http_method='POST', + connect_timeout=500, + socket_timeout=3000, + ), + ), + signed_callbacks=True, +) +capabilities = Capabilities(voice=voice) +keys = Keys(public_key='MY_PUBLIC_KEY') +config = ApplicationConfig( + name='My Customised Application', + capabilities=capabilities, + keys=keys, +) +app_data = vonage_client.application.create_application(config) +``` + +### Get an Application + +```python +app_data = client.application.get_application('MY_APP_ID') +app_data_as_dict = app.model_dump(exclude_none=True) +``` + +### Update an Application + +To update an application, pass config for the updated field(s) in an ApplicationConfig object + +```python +from vonage_application import ApplicationConfig, Keys, Voice, VoiceWebhooks + +config = ApplicationConfig(name='My Updated Application') +app_data = vonage_client.application.update_application('MY_APP_ID', config) +``` + +### Delete an Application + +```python +vonage_client.applications.delete_application('MY_APP_ID') +``` \ No newline at end of file diff --git a/application/pyproject.toml b/application/pyproject.toml new file mode 100644 index 00000000..5ce02df5 --- /dev/null +++ b/application/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-application' +dynamic = ["version"] +description = 'Vonage Application API package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_application._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/application/src/vonage_application/BUILD b/application/src/vonage_application/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/application/src/vonage_application/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/application/src/vonage_application/__init__.py b/application/src/vonage_application/__init__.py new file mode 100644 index 00000000..f4c43be8 --- /dev/null +++ b/application/src/vonage_application/__init__.py @@ -0,0 +1,45 @@ +from . import errors +from .application import Application +from .common import ( + ApplicationUrl, + Capabilities, + Keys, + Messages, + MessagesWebhooks, + Privacy, + Rtc, + RtcWebhooks, + Vbc, + Verify, + VerifyWebhooks, + Voice, + VoiceUrl, + VoiceWebhooks, +) +from .enums import Region +from .requests import ApplicationConfig, ListApplicationsFilter +from .responses import ApplicationData, ListApplicationsResponse + +__all__ = [ + 'Application', + 'ApplicationConfig', + 'ApplicationData', + 'ApplicationUrl', + 'Capabilities', + 'Keys', + 'ListApplicationsFilter', + 'ListApplicationsResponse', + 'Messages', + 'MessagesWebhooks', + 'Privacy', + 'Region', + 'Rtc', + 'RtcWebhooks', + 'Vbc', + 'Verify', + 'VerifyWebhooks', + 'Voice', + 'VoiceUrl', + 'VoiceWebhooks', + 'errors', +] diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py new file mode 100644 index 00000000..afced147 --- /dev/null +++ b/application/src/vonage_application/_version.py @@ -0,0 +1 @@ +__version__ = '2.0.0' diff --git a/application/src/vonage_application/application.py b/application/src/vonage_application/application.py new file mode 100644 index 00000000..0f41b579 --- /dev/null +++ b/application/src/vonage_application/application.py @@ -0,0 +1,125 @@ +from typing import Optional + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .requests import ApplicationConfig, ListApplicationsFilter +from .responses import ApplicationData, ListApplicationsResponse + + +class Application: + """Class containing methods for Vonage Application management.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'basic' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Users API. + + Returns: + HttpClient: The HTTP client used to make requests to the Users API. + """ + return self._http_client + + @validate_call + def list_applications( + self, filter: ListApplicationsFilter = ListApplicationsFilter() + ) -> tuple[list[ApplicationData], Optional[str]]: + """List applications. + + By default, returns the first 100 applications and the page index of + the next page of results, if there are more than 100 applications. + + Args: + filter (ListApplicationsFilter): The filter object. + + Returns: + tuple[list[ApplicationData], Optional[str]]: A tuple containing a + list of applications and the next page index. + """ + response = self._http_client.get( + self._http_client.api_host, + '/v2/applications', + filter.model_dump(exclude_none=True), + self._auth_type, + ) + + applications_response = ListApplicationsResponse(**response) + + if applications_response.page == applications_response.total_pages: + return applications_response.embedded.applications, None + + next_page = applications_response.page + 1 + return applications_response.embedded.applications, next_page + + @validate_call + def create_application( + self, config: Optional[ApplicationConfig] = None + ) -> ApplicationData: + """Create a new application. + + Args: + config (Optional[ApplicationConfig]): Configuration options describing the + application to create. + + Returns: + ApplicationData: The created application object. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v2/applications', + config.model_dump(exclude_none=True) if config is not None else None, + self._auth_type, + ) + return ApplicationData(**response) + + @validate_call + def get_application(self, id: str) -> ApplicationData: + """Get application info by ID. + + Args: + id (str): The ID of the application to retrieve. + + Returns: + ApplicationData: The created application object. + """ + response = self._http_client.get( + self._http_client.api_host, f'/v2/applications/{id}', None, self._auth_type + ) + return ApplicationData(**response) + + @validate_call + def update_application(self, id: str, config: ApplicationConfig) -> ApplicationData: + """Update an application. + + Args: + id (str): The ID of the application to update. + config (ApplicationConfig): Configuration options describing the application + to update. + + Returns: + ApplicationData: The updated application object. + """ + response = self._http_client.put( + self._http_client.api_host, + f'/v2/applications/{id}', + config.model_dump(exclude_none=True), + self._auth_type, + ) + return ApplicationData(**response) + + @validate_call + def delete_application(self, id: str) -> None: + """Delete an application. + + Args: + id (str): The ID of the application to delete. + + Returns: + None + """ + self._http_client.delete( + self._http_client.api_host, f'/v2/applications/{id}', None, self._auth_type + ) diff --git a/application/src/vonage_application/common.py b/application/src/vonage_application/common.py new file mode 100644 index 00000000..5c58a7d5 --- /dev/null +++ b/application/src/vonage_application/common.py @@ -0,0 +1,230 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .enums import Region +from .errors import ApplicationError + + +class ApplicationUrl(BaseModel): + """URL for an application webhook. + + Args: + address (str): The URL address. + http_method (str, Optional): The HTTP method. Must be 'GET' or 'POST'. + """ + + address: str + http_method: Optional[Literal['GET', 'POST']] = None + + +class VoiceUrl(ApplicationUrl): + """Model with options to set URLs for a voice application webhook. + + Args: + address (str): The URL address. + http_method (str, Optional): The HTTP method. Must be 'GET' or 'POST'. + connect_timeout (int, Optional): If Vonage can't connect to the webhook URL + for this specified amount of time, then Vonage makes one additional attempt + to connect to the webhook endpoint. This is an integer value specified in + milliseconds. + socket_timeout (int, Optional): If a response from the webhook URL can't be read + for this specified amount of time, then Vonage makes one additional attempt + to read the webhook endpoint. This is an integer value specified in + milliseconds. + """ + + connect_timeout: Optional[int] = Field(None, ge=300, le=1000) + socket_timeout: Optional[int] = Field(None, ge=1000, le=10000) + + +class VoiceWebhooks(BaseModel): + """Voice application webhook URLs. + + Args: + answer_url (VoiceUrl, Optional): The URL to which Vonage makes a request when a call + is placed/received. This URL is used to provide the Nexmo Call Control Object + (NCCO) that governs the call. + fallback_answer_url (VoiceUrl, Optional): The URL to which Vonage makes a request when + an error occurs in retrieving or executing the NCCO provided by the `answer_url`. + event_url (VoiceUrl, Optional): The URL to which Vonage makes a request when a call + event occurs. + """ + + answer_url: Optional[VoiceUrl] = None + fallback_answer_url: Optional[VoiceUrl] = None + event_url: Optional[VoiceUrl] = None + + +class Voice(BaseModel): + """Voice application capabilities. + + Args: + webhooks (VoiceWebhooks, Optional): Voice application webhook URLs. + signed_callbacks (bool, Optional): Whether to sign the webhook callbacks. + conversations_ttl (int, Optional): The length of time named conversations will + remain active for after creation, in hours. + leg_persistence_time (int, Optional):The persistence duration for legs, in days. + region (Region, Optional): The region in which the application is hosted. + """ + + webhooks: Optional[VoiceWebhooks] = None + signed_callbacks: Optional[bool] = None + conversations_ttl: Optional[int] = Field(None, ge=1, le=9000) + leg_persistence_time: Optional[int] = Field(None, ge=1, le=31) + region: Optional[Region] = None + + +class RtcWebhooks(BaseModel): + """Real-Time Communications application webhook URLs. + + Args: + event_url (ApplicationUrl, Optional): The URL to which Vonage makes a request when + an event occurs. + """ + + event_url: Optional[ApplicationUrl] = None + + +class Rtc(BaseModel): + """Real-Time Communications application capabilities. + + Args: + webhooks (RtcWebhooks, Optional): Real-Time Communications application webhook URLs. + signed_callbacks (bool, Optional): Whether to sign the webhook callbacks. + """ + + webhooks: Optional[RtcWebhooks] = None + signed_callbacks: Optional[bool] = None + + +class MessagesWebhooks(BaseModel): + """Messages application webhook URLs. + + Args: + inbound_url (ApplicationUrl, Optional): The URL Vonage forwards inbound messages + to when they are received. + status_url (ApplicationUrl, Optional): The URL where Vonage sends events related to + your messages. + """ + + inbound_url: Optional[ApplicationUrl] = None + status_url: Optional[ApplicationUrl] = None + + @field_validator('inbound_url', 'status_url') + @classmethod + def check_http_method(cls, v: ApplicationUrl): + if v.http_method is not None and v.http_method != 'POST': + raise ApplicationError('HTTP method must be POST') + return v + + +class Messages(BaseModel): + """Messages application capabilities. + + Args: + webhooks (MessagesWebhooks, Optional): Messages application webhook URLs. + version (str, Optional): The version of the Messages API to use. + authenticate_inbound_media (bool, Optional): Whether to authenticate inbound media. + """ + + webhooks: Optional[MessagesWebhooks] = None + version: Optional[str] = None + authenticate_inbound_media: Optional[bool] = None + + +class Vbc(BaseModel): + """VBC capabilities. + + This object should be empty when creating or updating an application. + """ + + +class VerifyWebhooks(BaseModel): + """Verify application webhook URLs. + + Args: + status_url (ApplicationUrl, Optional): The URL to which Vonage makes a request when + a verification event occurs. + """ + + status_url: Optional[ApplicationUrl] = None + + @field_validator('status_url') + @classmethod + def check_http_method(cls, v: ApplicationUrl): + if v.http_method is not None and v.http_method != 'POST': + raise ApplicationError('HTTP method must be POST') + return v + + +class Verify(BaseModel): + """Verify application capabilities. + + Don't set the `version` field when creating or updating an application. + + Args: + webhooks (VerifyWebhooks, Optional): Verify application webhook URLs. + """ + + webhooks: Optional[VerifyWebhooks] = None + version: Optional[str] = None + + +class Privacy(BaseModel): + """Privacy settings for an application. + + Args: + improve_ai (bool, Optional): If set to true, Vonage may store and use your + content and data for the improvement of Vonage's AI based services and + technologies. + """ + + improve_ai: Optional[bool] = None + + +class Capabilities(BaseModel): + """Application capabilities. + + Args: + voice (Voice, Optional): Voice application capabilities. + rtc (Rtc, Optional): Real-Time Communications application capabilities. + messages (Messages, Optional): Messages application capabilities. + vbc (Vbc, Optional): VBC capabilities. + verify (Verify, Optional): Verify application capabilities. + """ + + voice: Optional[Voice] = None + rtc: Optional[Rtc] = None + messages: Optional[Messages] = None + vbc: Optional[Vbc] = None + verify: Optional[Verify] = None + + +class Keys(BaseModel): + """Application keys. + + Args: + public_key (str, Optional): The public key. + """ + + model_config = ConfigDict(extra='allow') + + public_key: Optional[str] = None + + +class ApplicationBase(BaseModel): + """Base application object used in requests and responses when communicating with the + Vonage Application API. + + Args: + name (str): The name of the application. + capabilities (Capabilities, Optional): The capabilities of the application. + privacy (Privacy, Optional): The privacy settings for the application. + keys (Keys, Optional): The application keys. + """ + + name: str + capabilities: Optional[Capabilities] = None + privacy: Optional[Privacy] = None + keys: Optional[Keys] = None diff --git a/application/src/vonage_application/enums.py b/application/src/vonage_application/enums.py new file mode 100644 index 00000000..66aaf0fa --- /dev/null +++ b/application/src/vonage_application/enums.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class Region(str, Enum): + """All inbound, programmable SIP and SIP connect voice calls will be sent to the + selected region unless the call itself is sent to a regional endpoint. + + If the call is using a regional endpoint, this will override the application setting. + """ + + NA_EAST = 'na-east' + NA_WEST = 'na-west' + EU_EAST = 'eu-east' + EU_WEST = 'eu-west' + APAC_SNG = 'apac-sng' + APAC_AUSTRALIA = 'apac-australia' diff --git a/application/src/vonage_application/errors.py b/application/src/vonage_application/errors.py new file mode 100644 index 00000000..ae667886 --- /dev/null +++ b/application/src/vonage_application/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class ApplicationError(VonageError): + """Indicates an error with the Application package.""" diff --git a/application/src/vonage_application/requests.py b/application/src/vonage_application/requests.py new file mode 100644 index 00000000..756a2737 --- /dev/null +++ b/application/src/vonage_application/requests.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import BaseModel + +from .common import ApplicationBase + + +class ListApplicationsFilter(BaseModel): + """Request object for filtering applications. + + Args: + page_size (int, Optional): The number of applications to return per page. + page (int, Optional): The page number to return. + """ + + page_size: Optional[int] = 100 + page: int = None + + +class ApplicationConfig(ApplicationBase): + """Application object used in requests when communicating with the Vonage Application + API. + + Args: + name (str): The name of the application. + capabilities (Capabilities, Optional): The capabilities of the application. + privacy (Privacy, Optional): The privacy settings for the application. + keys (Keys, Optional): The application keys. + """ diff --git a/application/src/vonage_application/responses.py b/application/src/vonage_application/responses.py new file mode 100644 index 00000000..5fbb9728 --- /dev/null +++ b/application/src/vonage_application/responses.py @@ -0,0 +1,64 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import HalLinks, ResourceLink + +from .common import ApplicationBase, Keys + + +class ApplicationData(ApplicationBase): + """Application object used to structure responses received from the Vonage Application + API. + + Args: + name (str): The name of the application. + capabilities (Capabilities, Optional): The capabilities of the application. + privacy (Privacy, Optional): The privacy settings for the application. + keys (Keys, Optional): The application keys. + id (str): The unique application ID. + keys (Keys, Optional): The application keys. + links (ResourceLink, Optional): Links to the application. + link (str, Optional): The self link of the application. + """ + + id: str + keys: Optional[Keys] = None + links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True) + link: Optional[str] = None + + @model_validator(mode='after') + def get_link(self): + if self.links is not None: + self.link = self.links.self.href + return self + + +class Embedded(BaseModel): + """Model for embedded application data. This is used in the response model. + + Args: + applications (list[ApplicationData]): A list of application data objects. + """ + + applications: list[ApplicationData] = [] + + +class ListApplicationsResponse(BaseModel): + """Response object for listing applications. This is used when providing lists of the + applications associated with a Vonage account. + + Args: + page_size (int, Optional): The number of applications to return per page. + page (int): The page number to return. + total_items (int, Optional): The total number of applications. + total_pages (int, Optional): The total number of pages. + embedded (Embedded): Embedded application data. + links (HalLinks): Links to the pages, used for pagination/cursoring. + """ + + page_size: Optional[int] = None + page: int = Field(None, ge=1) + total_items: Optional[int] = None + total_pages: Optional[int] = None + embedded: Embedded = Field(..., validation_alias='_embedded') + links: HalLinks = Field(..., validation_alias='_links') diff --git a/application/tests/BUILD b/application/tests/BUILD new file mode 100644 index 00000000..6cb97097 --- /dev/null +++ b/application/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['application', 'testutils']) diff --git a/application/tests/data/create_application_basic.json b/application/tests/data/create_application_basic.json new file mode 100644 index 00000000..96250b8c --- /dev/null +++ b/application/tests/data/create_application_basic.json @@ -0,0 +1,17 @@ +{ + "id": "ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7", + "name": "My Application", + "keys": { + "private_key": "-----BEGIN PRIVATE KEY-----\nprivate_key_info_goes_here\n-----END PRIVATE KEY-----\n", + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": false + }, + "capabilities": {}, + "_links": { + "self": { + "href": "/v2/applications/ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7" + } + } +} \ No newline at end of file diff --git a/application/tests/data/create_application_options.json b/application/tests/data/create_application_options.json new file mode 100644 index 00000000..139e0354 --- /dev/null +++ b/application/tests/data/create_application_options.json @@ -0,0 +1,75 @@ +{ + "id": "33e3329f-d1cc-48f3-9105-55e5a6e475c1", + "name": "My Customised Application", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": false + }, + "capabilities": { + "voice": { + "webhooks": { + "event_url": { + "address": "https://example.com/event", + "http_method": "POST", + "socket_timeout": 3000, + "connect_timeout": 500 + }, + "answer_url": { + "address": "https://example.com/answer", + "http_method": "POST", + "socket_timeout": 3000, + "connect_timeout": 500 + }, + "fallback_answer_url": { + "address": "https://example.com/fallback", + "http_method": "POST", + "socket_timeout": 3000, + "connect_timeout": 500 + } + }, + "signed_callbacks": true, + "conversations_ttl": 8000, + "leg_persistence_time": 14, + "region": "na-east" + }, + "rtc": { + "webhooks": { + "event_url": { + "address": "https://example.com/event", + "http_method": "POST" + } + }, + "signed_callbacks": true + }, + "messages": { + "webhooks": { + "inbound_url": { + "address": "https://example.com/inbound", + "http_method": "POST" + }, + "status_url": { + "address": "https://example.com/status", + "http_method": "POST" + } + }, + "version": "v1", + "authenticate_inbound_media": true + }, + "verify": { + "webhooks": { + "status_url": { + "address": "https://example.com/status", + "http_method": "POST" + } + } + }, + "vbc": {} + }, + "_links": { + "self": { + "href": "/v2/applications/33e3329f-d1cc-48f3-9105-55e5a6e475c1" + } + } +} \ No newline at end of file diff --git a/application/tests/data/get_application.json b/application/tests/data/get_application.json new file mode 100644 index 00000000..d3239e36 --- /dev/null +++ b/application/tests/data/get_application.json @@ -0,0 +1,36 @@ +{ + "id": "1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b", + "name": "My Server Demo", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": false + }, + "capabilities": { + "voice": { + "webhooks": { + "event_url": { + "address": "http://example.ngrok.app/webhooks/events", + "http_method": "POST", + "socket_timeout": 10000, + "connect_timeout": 1000 + }, + "answer_url": { + "address": "http://example.ngrok.app/webhooks/answer", + "http_method": "GET", + "socket_timeout": 5000, + "connect_timeout": 1000 + } + }, + "signed_callbacks": true, + "conversations_ttl": 48, + "leg_persistence_time": 7 + } + }, + "_links": { + "self": { + "href": "/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b" + } + } +} \ No newline at end of file diff --git a/application/tests/data/list_applications_basic.json b/application/tests/data/list_applications_basic.json new file mode 100644 index 00000000..f6ffc06e --- /dev/null +++ b/application/tests/data/list_applications_basic.json @@ -0,0 +1,48 @@ +{ + "page_size": 100, + "page": 1, + "total_items": 1, + "total_pages": 1, + "_embedded": { + "applications": [ + { + "id": "1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b", + "name": "dev-application", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": true + }, + "capabilities": { + "voice": { + "webhooks": { + "event_url": { + "address": "http://example.com", + "http_method": "POST" + }, + "answer_url": { + "address": "http://example.com", + "http_method": "GET" + } + }, + "signed_callbacks": true, + "conversations_ttl": 9000, + "leg_persistence_time": 7 + } + } + } + ] + }, + "_links": { + "self": { + "href": "/v2/applications?page_size=100&page=1" + }, + "first": { + "href": "/v2/applications?page_size=100" + }, + "last": { + "href": "/v2/applications?page_size=100&page=1" + } + } +} \ No newline at end of file diff --git a/application/tests/data/list_applications_multiple_pages.json b/application/tests/data/list_applications_multiple_pages.json new file mode 100644 index 00000000..123a0e53 --- /dev/null +++ b/application/tests/data/list_applications_multiple_pages.json @@ -0,0 +1,100 @@ +{ + "page_size": 3, + "page": 1, + "total_items": 10, + "total_pages": 4, + "_embedded": { + "applications": [ + { + "id": "1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b", + "name": "dev-application", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": true + }, + "capabilities": { + "voice": { + "webhooks": { + "event_url": { + "address": "http://example.com", + "http_method": "POST" + }, + "answer_url": { + "address": "http://example.com", + "http_method": "GET" + } + }, + "signed_callbacks": true, + "conversations_ttl": 9000, + "leg_persistence_time": 7 + } + } + }, + { + "id": "2b2b2b2b-2b2b-2b2b-2b2b-2b2b2b2b2b2b", + "name": "My Test Server Application", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": false + }, + "capabilities": { + "voice": { + "webhooks": { + "event_url": { + "address": "http://9ff8266be1ed.ngrok.app/webhooks/events", + "http_method": "POST", + "socket_timeout": 10000, + "connect_timeout": 1000 + }, + "answer_url": { + "address": "http://9ff8266be1ed.ngrok.app/webhooks/answer", + "http_method": "GET", + "socket_timeout": 5000, + "connect_timeout": 1000 + } + }, + "signed_callbacks": true, + "conversations_ttl": 48, + "leg_persistence_time": 7 + } + } + }, + { + "id": "3b3b3b3b-3b3b-3b3b-3b3b-3b3b3b3b3b3b", + "name": "test-application", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n" + }, + "privacy": { + "improve_ai": false + }, + "capabilities": { + "voice": { + "webhooks": {}, + "signed_callbacks": true, + "conversations_ttl": 9000, + "leg_persistence_time": 7 + } + } + } + ] + }, + "_links": { + "self": { + "href": "/v2/applications?page_size=3&page=1" + }, + "first": { + "href": "/v2/applications?page_size=3" + }, + "last": { + "href": "/v2/applications?page_size=3&page=4" + }, + "next": { + "href": "/v2/applications?page_size=3&page=2" + } + } +} \ No newline at end of file diff --git a/application/tests/data/update_application.json b/application/tests/data/update_application.json new file mode 100644 index 00000000..335ea708 --- /dev/null +++ b/application/tests/data/update_application.json @@ -0,0 +1,13 @@ +{ + "id": "1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b", + "name": "My Updated Application", + "keys": { + "public_key": "-----BEGIN PUBLIC KEY-----\nupdated_public_key_info\n-----END PUBLIC KEY-----\n" + }, + "capabilities": {}, + "_links": { + "self": { + "href": "/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b" + } + } +} \ No newline at end of file diff --git a/application/tests/test_application.py b/application/tests/test_application.py new file mode 100644 index 00000000..6c14a4c6 --- /dev/null +++ b/application/tests/test_application.py @@ -0,0 +1,357 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_application.application import Application +from vonage_application.common import ( + ApplicationUrl, + Capabilities, + Keys, + Messages, + MessagesWebhooks, + Privacy, + Rtc, + RtcWebhooks, + Vbc, + Verify, + VerifyWebhooks, + Voice, + VoiceUrl, + VoiceWebhooks, +) +from vonage_application.enums import Region +from vonage_application.errors import ApplicationError +from vonage_application.requests import ApplicationConfig, ListApplicationsFilter +from vonage_http_client.http_client import HttpClient + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +application = Application(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = application.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_create_application_basic(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/applications', + 'create_application_basic.json', + ) + app = application.create_application(ApplicationConfig(name='My Application')) + + assert app.id == 'ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7' + assert app.name == 'My Application' + assert ( + app.keys.public_key + == '-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n' + ) + assert app.link == '/v2/applications/ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7' + + +def test_create_application_options_model_from_dict(): + capabilities = { + 'voice': { + 'webhooks': { + 'answer_url': { + 'address': 'https://example.com/answer', + 'http_method': 'POST', + 'connect_timeout': 500, + 'socket_timeout': 3000, + }, + 'fallback_answer_url': { + 'address': 'https://example.com/fallback', + 'http_method': 'POST', + 'connect_timeout': 500, + 'socket_timeout': 3000, + }, + 'event_url': { + 'address': 'https://example.com/event', + 'http_method': 'POST', + 'connect_timeout': 500, + 'socket_timeout': 3000, + }, + }, + 'signed_callbacks': True, + 'conversations_ttl': 8000, + 'leg_persistence_time': 14, + 'region': 'na-east', + }, + 'rtc': { + 'webhooks': { + 'event_url': { + 'address': 'https://example.com/event', + 'http_method': 'POST', + } + }, + 'signed_callbacks': True, + }, + 'messages': { + 'version': 'v1', + 'webhooks': { + 'inbound_url': { + 'address': 'https://example.com/inbound', + 'http_method': 'POST', + }, + 'status_url': { + 'address': 'https://example.com/status', + 'http_method': 'POST', + }, + }, + 'authenticate_inbound_media': True, + }, + 'verify': { + 'webhooks': { + 'status_url': { + 'address': 'https://example.com/status', + 'http_method': 'POST', + } + } + }, + 'vbc': {}, + } + + privacy = {'improve_ai': False} + + public_key = '-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n' + keys = {'public_key': public_key} + + params = { + 'name': 'My Application Created from a Dict', + 'capabilities': capabilities, + 'privacy': privacy, + 'keys': keys, + } + application_options_dict = params + application_options_model = ApplicationConfig(**application_options_dict) + assert ( + application_options_model.model_dump(exclude_unset=True) + == application_options_dict + ) + + +@responses.activate +def test_create_application_options_with_models(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/applications', + 'create_application_options.json', + ) + + voice = Voice( + webhooks=VoiceWebhooks( + answer_url=VoiceUrl( + address='https://example.com/answer', + http_method='POST', + connect_timeout=500, + socket_timeout=3000, + ), + fallback_answer_url=VoiceUrl( + address='https://example.com/fallback', + http_method='POST', + connect_timeout=500, + socket_timeout=3000, + ), + event_url=VoiceUrl( + address='https://example.com/event', + http_method='POST', + connect_timeout=500, + socket_timeout=3000, + ), + ), + signed_callbacks=True, + conversations_ttl=8000, + leg_persistence_time=14, + region=Region.NA_EAST, + ) + + rtc = Rtc( + webhooks=RtcWebhooks( + event_url=ApplicationUrl( + address='https://example.com/event', http_method='POST' + ), + ), + signed_callbacks=True, + ) + + messages = Messages( + version='v1', + webhooks=MessagesWebhooks( + inbound_url=ApplicationUrl( + address='https://example.com/inbound', http_method='POST' + ), + status_url=ApplicationUrl( + address='https://example.com/status', http_method='POST' + ), + ), + authenticate_inbound_media=True, + ) + + verify = Verify( + webhooks=VerifyWebhooks( + status_url=ApplicationUrl( + address='https://example.com/status', http_method='POST' + ) + ), + ) + + capabilities = Capabilities( + voice=voice, rtc=rtc, messages=messages, verify=verify, vbc=Vbc() + ) + + privacy = Privacy(improve_ai=False) + + public_key = '-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n' + keys = Keys(public_key=public_key) + + params = ApplicationConfig( + name='My Customised Application', + capabilities=capabilities, + privacy=privacy, + keys=keys, + ) + app = application.create_application(params) + + assert app.id == '33e3329f-d1cc-48f3-9105-55e5a6e475c1' + assert app.name == 'My Customised Application' + assert app.keys.public_key == public_key + assert app.link == '/v2/applications/33e3329f-d1cc-48f3-9105-55e5a6e475c1' + assert app.privacy.improve_ai is False + assert ( + app.capabilities.voice.webhooks.event_url.address == 'https://example.com/event' + ) + assert app.capabilities.voice.webhooks.answer_url.socket_timeout == 3000 + assert app.capabilities.voice.webhooks.fallback_answer_url.connect_timeout == 500 + assert app.capabilities.voice.signed_callbacks is True + assert app.capabilities.rtc.signed_callbacks is True + assert app.capabilities.messages.version == 'v1' + assert app.capabilities.messages.authenticate_inbound_media is True + assert ( + app.capabilities.verify.webhooks.status_url.address + == 'https://example.com/status' + ) + assert app.capabilities.vbc.model_dump() == {} + + +def test_create_application_invalid_request_method(): + with raises(ApplicationError) as err: + VerifyWebhooks( + status_url=ApplicationUrl( + address='https://example.com/status', http_method='GET' + ) + ) + assert err.match('HTTP method must be POST') + + with raises(ApplicationError) as err: + MessagesWebhooks( + inbound_url=ApplicationUrl( + address='https://example.com/inbound', http_method='GET' + ) + ) + assert err.match('HTTP method must be POST') + + +@responses.activate +def test_list_applications_basic(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v2/applications', + 'list_applications_basic.json', + ) + applications, next_page = application.list_applications() + + assert len(applications) == 1 + assert applications[0].id == '1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b' + assert applications[0].name == 'dev-application' + assert ( + applications[0].keys.public_key + == '-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n' + ) + + assert next_page is None + + +@responses.activate +def test_list_applications_multiple_pages(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v2/applications', + 'list_applications_multiple_pages.json', + ) + options = ListApplicationsFilter(page_size=3, page=1) + applications, next_page = application.list_applications(options) + + assert len(applications) == 3 + assert applications[0].id == '1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b' + assert applications[0].name == 'dev-application' + assert ( + applications[0].keys.public_key + == '-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n' + ) + assert applications[1].id == '2b2b2b2b-2b2b-2b2b-2b2b-2b2b2b2b2b2b' + assert ( + applications[1].capabilities.voice.webhooks.event_url.address + == 'http://9ff8266be1ed.ngrok.app/webhooks/events' + ) + assert applications[2].id == '3b3b3b3b-3b3b-3b3b-3b3b-3b3b3b3b3b3b' + assert next_page == 2 + + +@responses.activate +def test_get_application(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b', + 'get_application.json', + ) + app = application.get_application('1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b') + + assert app.id == '1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b' + assert app.link == '/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b' + + +@responses.activate +def test_update_application(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b', + 'update_application.json', + ) + + public_key = ( + '-----BEGIN PUBLIC KEY-----\nupdated_public_key_info\n-----END PUBLIC KEY-----\n' + ) + keys = Keys(public_key=public_key) + params = ApplicationConfig(name='My Updated Application', keys=keys) + application_data = application.update_application( + '1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b', params + ) + + assert application_data.name == 'My Updated Application' + assert application_data.keys.public_key == public_key + assert ( + application_data.link == '/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b' + ) + + +@responses.activate +def test_delete_application(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/applications/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b', + status=204, + ) + + application.delete_application('1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b') + assert application.http_client.last_response.status_code == 204 diff --git a/http_client/BUILD b/http_client/BUILD new file mode 100644 index 00000000..f07bfef7 --- /dev/null +++ b/http_client/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-http-client', + dependencies=[ + ':pyproject', + ':readme', + 'http_client/src/vonage_http_client:http_client', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md new file mode 100644 index 00000000..b01231cc --- /dev/null +++ b/http_client/CHANGES.md @@ -0,0 +1,34 @@ +# 1.4.3 +- Update JWT dependency version + +# 1.4.2 +- Support for Python 3.13, drop support for 3.8 + +# 1.4.1 +- Add docstrings to data models + +# 1.4.0 +- Add new `oauth2` logic for calling APIs that require Oauth + +# 1.3.1 +- Update minimum dependency version + +# 1.3.0 +- Add new PUT method + +# 1.2.1 +- Expose classes and errors at the package level + +# 1.2.0 +- Add `last_request` and `last_response` properties +- Add new `Forbidden` error + +# 1.1.1 +- Add new Patch method +- New input fields for different ways to pass data in a request + +# 1.1.0 +- Add support for signature authentication + +# 1.0.0 +- Initial upload diff --git a/http_client/README.md b/http_client/README.md new file mode 100644 index 00000000..3f436da9 --- /dev/null +++ b/http_client/README.md @@ -0,0 +1,85 @@ +# Vonage HTTP Client Package + +This Python package provides a synchronous HTTP client for sending authenticated requests to Vonage APIs. + +This package (`vonage-http-client`) is used by the `vonage` Python package and SDK so doesn't require manual installation or config unless you're using this package independently of a SDK. + +The `HttpClient` class is initialized with an instance of the `Auth` class for credentials, an optional class of HTTP client options, and an optional SDK version (this is provided automatically when using this module via an SDK). + +The `HttpClientOptions` class defines the options for the HTTP client, including the API and REST hosts, timeout, pool connections, pool max size, and max retries. + +This package also includes an `Auth` class that allows you to manage API key- and secret-based authentication as well as JSON Web Token (JWT) authentication. + +For full API documentation refer to the [Vonage Developer documentation](https://developer.vonage.com). + +## Installation (if not using via an SDK) + +You can install the package using pip: + +```bash +pip install vonage-http-client +``` + +## Usage + +```python +from vonage_http_client import HttpClient, HttpClientOptions +from vonage_http_client.auth import Auth + +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') + +# Create HttpClientOptions instance +options = HttpClientOptions(api_host='api.nexmo.com', timeout=30) + +# Create a HttpClient instance +client = HttpClient(auth=auth, http_client_options=options) + +# Make a GET request +response = client.get(host='api.nexmo.com', request_path='/v1/messages') + +# Make a POST request +response = client.post(host='api.nexmo.com', request_path='/v1/messages', params={'key': 'value'}) +``` + +### Get the Last Request and Last Response from the HTTP Client + +The `HttpClient` class exposes two properties, `last_request` and `last_response` that cache the last sent request and response. + +```python +# Get last request, has type requests.PreparedRequest +request = client.last_request + +# Get last response, has type requests.Response +response = client.last_response +``` + +### Appending to the User-Agent Header + +The `HttpClient` class also supports appending additional information to the User-Agent header via the append_to_user_agent method: + +```python +client.append_to_user_agent('additional_info') +``` + +### Changing the Authentication Method Used + +The `HttpClient` class automatically handles JWT and basic authentication based on the Auth instance provided. It uses JWT authentication by default, but you can specify the authentication type when making a request: + +```python +# Use basic authentication for this request +response = client.get(host='api.nexmo.com', request_path='/v1/messages', auth_type='basic') +``` + +### Catching errors + +Error objects are exposed in the package scope, so you can catch errors like this: + +```python +from vonage_http_client import HttpRequestError + +try: + client.post(...) +except HttpRequestError: + ... +``` \ No newline at end of file diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml new file mode 100644 index 00000000..e068f809 --- /dev/null +++ b/http_client/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "vonage-http-client" +dynamic = ["version"] +description = "An HTTP client for making requests to Vonage APIs." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-utils>=1.1.4", + "vonage-jwt>=1.1.4", + "requests>=2.27.0", + "typing-extensions>=4.9.0", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_http_client._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/http_client/src/vonage_http_client/BUILD b/http_client/src/vonage_http_client/BUILD new file mode 100644 index 00000000..7b9f5b6c --- /dev/null +++ b/http_client/src/vonage_http_client/BUILD @@ -0,0 +1 @@ +python_sources(name='http_client') diff --git a/http_client/src/vonage_http_client/__init__.py b/http_client/src/vonage_http_client/__init__.py new file mode 100644 index 00000000..88e8a9b7 --- /dev/null +++ b/http_client/src/vonage_http_client/__init__.py @@ -0,0 +1,28 @@ +from .auth import Auth +from .errors import ( + AuthenticationError, + ForbiddenError, + HttpRequestError, + InvalidAuthError, + InvalidHttpClientOptionsError, + JWTGenerationError, + NotFoundError, + RateLimitedError, + ServerError, +) +from .http_client import HttpClient, HttpClientOptions + +__all__ = [ + 'Auth', + 'AuthenticationError', + 'ForbiddenError', + 'HttpRequestError', + 'InvalidAuthError', + 'InvalidHttpClientOptionsError', + 'JWTGenerationError', + 'NotFoundError', + 'RateLimitedError', + 'ServerError', + 'HttpClient', + 'HttpClientOptions', +] diff --git a/http_client/src/vonage_http_client/_version.py b/http_client/src/vonage_http_client/_version.py new file mode 100644 index 00000000..4e7c72a5 --- /dev/null +++ b/http_client/src/vonage_http_client/_version.py @@ -0,0 +1 @@ +__version__ = '1.4.3' diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py new file mode 100644 index 00000000..556c85ae --- /dev/null +++ b/http_client/src/vonage_http_client/auth.py @@ -0,0 +1,161 @@ +import hashlib +import hmac +from base64 import b64encode +from time import time +from typing import Literal, Optional + +from pydantic import validate_call +from vonage_jwt.jwt import JwtClient + +from .errors import InvalidAuthError, JWTGenerationError + + +class Auth: + """Deals with Vonage API authentication. + + Some Vonage APIs require an API key and secret for authentication. Others require an application ID and JWT. + It is also possible to use a message signature with the SMS API. + + Args: + - api_key (str): The API key for authentication. + - api_secret (str): The API secret for authentication. + - application_id (str): The application ID for JWT authentication. + - private_key (str): The private key for JWT authentication. + - signature_secret (str): The signature secret for authentication. + - signature_method (str): The signature method for authentication. + This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. If you want to use a simple MD5 hash, leave this as `None`. + + Note: + To use JWT authentication, provide values for both `application_id` and `private_key`. + """ + + @validate_call + def __init__( + self, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + application_id: Optional[str] = None, + private_key: Optional[str] = None, + signature_secret: Optional[str] = None, + signature_method: Optional[Literal['md5', 'sha1', 'sha256', 'sha512']] = 'md5', + ) -> None: + self._validate_input_combinations( + api_key, api_secret, application_id, private_key, signature_secret + ) + + self._api_key = api_key + self._api_secret = api_secret + self._application_id = application_id + + if application_id is not None and private_key is not None: + self._jwt_client = JwtClient(application_id, private_key) + + self._signature_secret = signature_secret + self._signature_method = getattr(hashlib, signature_method) + + @property + def api_key(self): + return self._api_key + + @property + def api_secret(self): + return self._api_secret + + @property + def application_id(self): + return self._application_id + + def create_jwt_auth_string(self): + """Creates a JWT authentication string for use in the Authorization header by + generating a JWT token.""" + return b'Bearer ' + self.generate_application_jwt() + + def generate_application_jwt(self, claims: dict = None) -> bytes: + """Generates a JWT. + + Args: + claims (dict): The claims to include in the JWT. + + Returns: + bytes: The JWT token. + """ + if claims is None: + claims = {} + try: + token = self._jwt_client.generate_application_jwt(claims) + return token + except AttributeError as err: + raise JWTGenerationError( + 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' + ) from err + + def create_basic_auth_string(self): + """Creates a basic authentication string for use in the Authorization header.""" + + hash = b64encode(f'{self.api_key}:{self.api_secret}'.encode('utf-8')).decode( + 'ascii' + ) + return f'Basic {hash}' + + def sign_params(self, params: dict) -> str: + """Signs the provided message parameters using the signature secret provided to + the `Auth` class. If no signature secret is provided, the message parameters are + signed using a simple MD5 hash. + + Args: + params (dict): The message parameters to be signed. + + Returns: + str: A hexadecimal digest of the signed message parameters. + """ + + hasher = hmac.new( + self._signature_secret.encode(), + digestmod=self._signature_method, + ) + + if not params.get('timestamp'): + params['timestamp'] = int(time()) + + for key in sorted(params): + value = params[key] + + if isinstance(value, str): + value = value.replace('&', '_').replace('=', '_') + + hasher.update(f'&{key}={value}'.encode('utf-8')) + + return hasher.hexdigest() + + @validate_call + def check_signature(self, params: dict) -> bool: + """Checks the signature hash of the given parameters. + + Args: + params (dict): The parameters to check the signature for. + This should include the `sig` parameter which contains the + signature hash of the other parameters. + + Returns: + bool: True if the signature is valid, False otherwise. + """ + signature = params.pop('sig', '').lower() + return hmac.compare_digest(signature, self.sign_params(params)) + + def _validate_input_combinations( + self, api_key, api_secret, application_id, private_key, signature_secret + ): + if (api_secret or signature_secret) and not api_key: + raise InvalidAuthError( + '`api_key` must be set when `api_secret` or `signature_secret` is set.' + ) + + if api_key and not (api_secret or signature_secret): + raise InvalidAuthError( + 'At least one of `api_secret` and `signature_secret` must be set if `api_key` is set.' + ) + + if (application_id and not private_key) or (not application_id and private_key): + raise InvalidAuthError( + 'Both `application_id` and `private_key` must be set or both must be None.' + ) diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py new file mode 100644 index 00000000..c262f5a3 --- /dev/null +++ b/http_client/src/vonage_http_client/errors.py @@ -0,0 +1,141 @@ +from json import JSONDecodeError, dumps + +from requests import Response +from vonage_utils.errors import VonageError + + +class JWTGenerationError(VonageError): + """Indicates an error with generating a JWT.""" + + +class InvalidAuthError(VonageError): + """Indicates an error with the authentication credentials provided.""" + + +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 = 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: \n{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 ForbiddenError(HttpRequestError): + """Exception indicating a forbidden request in a Vonage SDK request. + + This error is raised when the HTTP response status code is 403 (Forbidden). + + 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 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. + + 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) diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py new file mode 100644 index 00000000..539b1e99 --- /dev/null +++ b/http_client/src/vonage_http_client/http_client.py @@ -0,0 +1,286 @@ +from json import JSONDecodeError +from logging import getLogger +from platform import python_version +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, Field, ValidationError, validate_call +from requests import PreparedRequest, Response +from requests.adapters import HTTPAdapter +from requests.sessions import Session +from vonage_http_client.auth import Auth +from vonage_http_client.errors import ( + AuthenticationError, + ForbiddenError, + HttpRequestError, + InvalidHttpClientOptionsError, + NotFoundError, + RateLimitedError, + ServerError, +) + +logger = getLogger('vonage') + + +class HttpClientOptions(BaseModel): + """Options for customizing the HTTP Client. + + Args: + api_host (str, optional): The API host to use for HTTP requests. + rest_host (str, optional): The REST host to use for HTTP requests. + video_host (str, optional): The Video host to use for HTTP requests. + timeout (int, optional): The timeout for HTTP requests in seconds. + pool_connections (int, optional): The number of pool connections. + pool_maxsize (int, optional): The maximum size of the connection pool. + max_retries (int, optional): The maximum number of retries for HTTP requests. + """ + + api_host: str = 'api.nexmo.com' + rest_host: Optional[str] = 'rest.nexmo.com' + video_host: Optional[str] = 'video.api.vonage.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 + + +class HttpClient: + """A synchronous HTTP client used to send authenticated requests to Vonage APIs. + + Args: + auth (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. + sdk_version (str, optional): The SDK version used. + + 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'. + video_host (str, optional): The Video host to use for HTTP requests. Defaults to 'video.api.vonage.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, + http_client_options: HttpClientOptions = None, + sdk_version: str = None, + ): + self._auth = auth + 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._video_host = self._http_client_options.video_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._user_agent = f'vonage-python-sdk/{sdk_version} python/{python_version()}' + self._headers = {'User-Agent': self._user_agent, 'Accept': 'application/json'} + + self._last_request = None + self._last_response = None + + @property + def auth(self): + return self._auth + + @property + 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 + + @property + def video_host(self): + return self._video_host + + @property + def user_agent(self): + return self._user_agent + + @property + def last_request(self) -> Optional[PreparedRequest]: + """The last request sent to the server. + + Returns: + Optional[PreparedRequest]: The exact bytes of the request sent to the server, + or None if no request has been sent. + """ + return self._last_response.request + + @property + def last_response(self) -> Optional[Response]: + """The last response received from the server. + + Returns: + Optional[Response]: The response object received from the server, + or None if no response has been received. + """ + return self._last_response + + def post( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'body', 'signature', 'oauth2'] = 'jwt', + sent_data_type: Literal['json', 'form', 'query-params'] = 'json', + token: Optional[str] = None, + ) -> Union[dict, None]: + return self.make_request( + 'POST', host, request_path, params, auth_type, sent_data_type, token + ) + + def get( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', + sent_data_type: Literal['json', 'form', 'query_params'] = 'query_params', + ) -> Union[dict, None]: + return self.make_request( + 'GET', host, request_path, params, auth_type, sent_data_type + ) + + def patch( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', + ) -> Union[dict, None]: + return self.make_request( + 'PATCH', host, request_path, params, auth_type, sent_data_type + ) + + def put( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', + ) -> Union[dict, None]: + return self.make_request( + 'PUT', host, request_path, params, auth_type, sent_data_type + ) + + def delete( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', + ) -> Union[dict, None]: + return self.make_request( + 'DELETE', host, request_path, params, auth_type, sent_data_type + ) + + @validate_call + def make_request( + self, + request_type: Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], + host: str, + request_path: str = '', + params: Optional[dict] = None, + auth_type: Literal['jwt', 'basic', 'body', 'signature', 'oauth2'] = 'jwt', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', + token: Optional[str] = None, + ): + url = f'https://{host}{request_path}' + logger.debug( + f'{request_type} request to {url}, with data: {params}; headers: {self._headers}' + ) + if auth_type == 'jwt': + 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 == 'oauth2': + self._headers['Authorization'] = f'Bearer {token}' + elif auth_type == 'signature': + params['api_key'] = self._auth.api_key + params['sig'] = self._auth.sign_params(params) + + request_params = { + 'method': request_type, + 'url': url, + 'headers': self._headers, + 'timeout': self._timeout, + } + + if sent_data_type == 'json': + self._headers['Content-Type'] = 'application/json' + request_params['json'] = params + elif sent_data_type == 'query_params': + request_params['params'] = params + elif sent_data_type == 'form': + request_params['data'] = params + + with self._session.request(**request_params) as response: + return self._parse_response(response) + + def append_to_user_agent(self, string: str): + """Append a string to the User-Agent header. + + Args: + string (str): The string to append to the User-Agent header. + """ + self._user_agent += f' {string}' + + def _parse_response(self, response: Response) -> Union[dict, None]: + logger.debug( + f'Response received from {response.url} with status code: {response.status_code}; headers: {response.headers}' + ) + self._last_response = response + if 200 <= response.status_code < 300: + try: + return response.json() + except JSONDecodeError: + return None + if response.status_code >= 400: + content_type = response.headers['Content-Type'].split(';', 1)[0] + 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) + if response.status_code == 403: + raise ForbiddenError(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: + raise ServerError(response, content_type) + raise HttpRequestError(response, content_type) diff --git a/http_client/tests/BUILD b/http_client/tests/BUILD new file mode 100644 index 00000000..55e58f97 --- /dev/null +++ b/http_client/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['http_client']) diff --git a/http_client/tests/data/400.json b/http_client/tests/data/400.json new file mode 100644 index 00000000..f9c25904 --- /dev/null +++ b/http_client/tests/data/400.json @@ -0,0 +1 @@ +{"Error": "Bad Request"} \ No newline at end of file diff --git a/http_client/tests/data/400.txt b/http_client/tests/data/400.txt new file mode 100644 index 00000000..4ff18741 --- /dev/null +++ b/http_client/tests/data/400.txt @@ -0,0 +1 @@ +Error: Bad Request \ No newline at end of file diff --git a/http_client/tests/data/401.json b/http_client/tests/data/401.json new file mode 100644 index 00000000..c068aec7 --- /dev/null +++ b/http_client/tests/data/401.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#unauthorized", + "title": "Unauthorized", + "detail": "You did not provide correct credentials.", + "instance": "a813c536-43f6-4568-acbf-f36ef2db955a" +} \ No newline at end of file diff --git a/http_client/tests/data/403.json b/http_client/tests/data/403.json new file mode 100644 index 00000000..c08ab5ef --- /dev/null +++ b/http_client/tests/data/403.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/api-errors#forbidden", + "title": "Forbidden", + "detail": "Your account does not have permission to perform this action.", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" +} \ No newline at end of file diff --git a/http_client/tests/data/404.json b/http_client/tests/data/404.json new file mode 100644 index 00000000..868b5561 --- /dev/null +++ b/http_client/tests/data/404.json @@ -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" +} \ No newline at end of file diff --git a/tests/data/verify2/rate_limit.json b/http_client/tests/data/429.json similarity index 66% rename from tests/data/verify2/rate_limit.json rename to http_client/tests/data/429.json index ddafeb6f..cc29b165 100644 --- a/tests/data/verify2/rate_limit.json +++ b/http_client/tests/data/429.json @@ -1,6 +1,6 @@ { "title": "Rate Limit Hit", - "type": "https://www.developer.vonage.com/api-errors#throttled", + "type": "https://developer.vonage.com/api-errors#rate-limit", "detail": "Please wait, then retry your request", "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" } \ No newline at end of file diff --git a/http_client/tests/data/500.json b/http_client/tests/data/500.json new file mode 100644 index 00000000..82af672d --- /dev/null +++ b/http_client/tests/data/500.json @@ -0,0 +1,5 @@ +{ + "type": "https://developer.vonage.com/api-errors", + "title": "Internal Server Error", + "instance": "272c5fa3-c02a-4451-b33c-d01e8de74023" +} \ No newline at end of file diff --git a/tests/data/private_key.txt b/http_client/tests/data/dummy_private_key.txt similarity index 100% rename from tests/data/private_key.txt rename to http_client/tests/data/dummy_private_key.txt diff --git a/tests/data/public_key.txt b/http_client/tests/data/dummy_public_key.txt similarity index 100% rename from tests/data/public_key.txt rename to http_client/tests/data/dummy_public_key.txt diff --git a/http_client/tests/data/example_get.json b/http_client/tests/data/example_get.json new file mode 100644 index 00000000..d02fab03 --- /dev/null +++ b/http_client/tests/data/example_get.json @@ -0,0 +1,3 @@ +{ + "hello": "world" +} \ No newline at end of file diff --git a/http_client/tests/data/example_post.json b/http_client/tests/data/example_post.json new file mode 100644 index 00000000..1c802729 --- /dev/null +++ b/http_client/tests/data/example_post.json @@ -0,0 +1,3 @@ +{ + "hello": "world!" +} \ No newline at end of file diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py new file mode 100644 index 00000000..3ff48e91 --- /dev/null +++ b/http_client/tests/test_auth.py @@ -0,0 +1,183 @@ +import hashlib +from os.path import dirname, join +from unittest.mock import patch + +from pydantic import ValidationError +from pytest import raises +from vonage_http_client.auth import Auth +from vonage_http_client.errors import InvalidAuthError, JWTGenerationError +from vonage_jwt.jwt import JwtClient + + +def read_file(path): + with open(join(dirname(__file__), path)) as input_file: + return input_file.read() + + +api_key = 'qwerasdf' +api_secret = '1234qwerasdfzxcv' +application_id = 'asdfzxcv' +private_key = read_file('data/dummy_private_key.txt') +signature_secret = 'signature_secret' +signature_method = 'sha256' + + +def test_create_auth_class_and_get_objects(): + auth = Auth( + api_key=api_key, + api_secret=api_secret, + application_id=application_id, + private_key=private_key, + signature_secret=signature_secret, + signature_method=signature_method, + ) + + assert auth.api_key == api_key + assert auth.api_secret == api_secret + assert type(auth._jwt_client) == JwtClient + assert auth._signature_secret == signature_secret + assert auth._signature_method == hashlib.sha256 + + +def test_create_new_auth_invalid_type(): + with raises(ValidationError): + Auth(api_key=1234) + + +def test_auth_init_missing_combinations(): + with raises(InvalidAuthError): + Auth(api_key=api_key) + with raises(InvalidAuthError): + Auth(api_secret=api_secret) + with raises(InvalidAuthError): + Auth(application_id=application_id) + with raises(InvalidAuthError): + Auth(private_key=private_key) + + +def test_auth_init_with_invalid_combinations(): + with raises(InvalidAuthError): + Auth(api_key=api_key, application_id=application_id) + with raises(InvalidAuthError): + Auth(api_key=api_key, private_key=private_key) + with raises(InvalidAuthError): + Auth(api_secret=api_secret, application_id=application_id) + with raises(InvalidAuthError): + Auth(api_secret=api_secret, private_key=private_key) + with raises(InvalidAuthError): + Auth(application_id=application_id, signature_secret=signature_secret) + with raises(InvalidAuthError): + Auth(private_key=private_key, signature_secret=signature_secret) + + +def test_auth_init_with_valid_api_key_and_api_secret(): + auth = Auth(api_key=api_key, api_secret=api_secret) + assert auth._api_key == api_key + assert auth._api_secret == api_secret + + +def test_auth_init_with_valid_application_id_and_private_key(): + auth = Auth(application_id=application_id, private_key=private_key) + assert auth._api_key is None + assert auth._api_secret is None + assert isinstance(auth._jwt_client, JwtClient) + + +test_jwt = b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBsaWNhdGlvbl9pZCI6ImFzZGYxMjM0IiwiaWF0IjoxNjg1NzMxMzkxLCJqdGkiOiIwYzE1MDJhZS05YmI5LTQ4YzQtYmQyZC0yOGFhNWUxYjZkMTkiLCJleHAiOjE2ODU3MzIyOTF9.mAkGeVgWOb7Mrzka7DSj32vSM8RaFpYse_2E7jCQ4DuH8i32wq9FxXGgfwdBQDHzgku3RYIjLM1xlVrGjNM3MsnZgR7ymQ6S4bdTTOmSK0dKbk91SrN7ZAC9k2a6JpCC2ZYgXpZ5BzpDTdy9BYu6msHKmkL79_aabFAhrH36Nk26pLvoI0-KiGImEex-aRR4iiaXhOebXBeqiQTRPKoKizREq4-8zBQv_j6yy4AiEYvBatQ8L_sjHsLj9jjITreX8WRvEW-G4TPpPLMaHACHTDMpJSOZAnegAkzTV2frVRmk6DyVXnemm4L0RQD1XZDaH7JPsKk24Hd2WZQyIgHOqQ' + + +def vonage_jwt_mock(self): + return test_jwt + + +def test_generate_application_jwt(): + auth = Auth(application_id=application_id, private_key=private_key) + with patch('vonage_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('vonage_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 + + +def test_create_jwt_error_no_application_id_or_private_key(): + auth = Auth() + with raises(JWTGenerationError): + auth.generate_application_jwt() + + +def test_create_basic_auth_string(): + auth = Auth(api_key=api_key, api_secret=api_secret) + assert auth.create_basic_auth_string() == 'Basic cXdlcmFzZGY6MTIzNHF3ZXJhc2Rmenhjdg==' + + +def test_sign_params(): + auth = Auth( + api_key=api_key, + signature_secret=signature_secret, + signature_method=signature_method, + ) + + params = {'param1': 'value1', 'param2': 'value2', 'timestamp': 1234567890} + + signed_params_hash = auth.sign_params(params) + + assert ( + signed_params_hash + == '280c4320703dbc98bfa22db676655ed2acfbfe8792b062ff7622e67f1183c287' + ) + + +def test_sign_params_default_sig_method(): + auth = Auth(api_key=api_key, signature_secret=signature_secret) + + params = {'param1': 'value1', 'param2': 'value2', 'timestamp': 1234567890} + + signed_params_hash = auth.sign_params(params) + + assert signed_params_hash == '724c2bf6ca423c36e20631b11d1c5753' + + +def test_sign_params_with_special_characters(): + auth = Auth(api_key=api_key, signature_secret=signature_secret) + + params = {'param1': 'value&1', 'param2': 'value=2', 'timestamp': 1234567890} + + signed_params = auth.sign_params(params) + + assert signed_params == '2bbf0abafb2c55e5af6231513896a2ac' + + +@patch('vonage_http_client.auth.time', return_value=12345) +def test_sign_params_with_dynamic_timestamp(mock_time): + auth = Auth(api_key=api_key, signature_secret=signature_secret) + + params = {'param1': 'value1', 'param2': 'value2'} + + signed_params = auth.sign_params(params) + + assert signed_params == 'bc7e95bb4e341090b3a202a2885903a5' + + +def test_check_signature_valid_signature(): + auth = Auth(api_key=api_key, signature_secret=signature_secret) + params = { + 'param': 'value', + 'timestamp': 1234567890, + 'sig': '655a4d0b7f064dff438defc52b012cf5', + } + assert auth.check_signature(params) == True + + +def test_check_signature_invalid_signature(): + auth = Auth(api_key=api_key, signature_secret=signature_secret) + params = { + 'param': 'value', + 'timestamp': 1234567890, + 'sig': 'invalid_signature', + } + assert auth.check_signature(params) == False diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py new file mode 100644 index 00000000..f871fa8c --- /dev/null +++ b/http_client/tests/test_http_client.py @@ -0,0 +1,252 @@ +from json import loads +from os.path import abspath, dirname, join + +import responses +from pytest import raises +from requests import PreparedRequest, Response +from responses import matchers +from vonage_http_client.auth import Auth +from vonage_http_client.errors import ( + AuthenticationError, + ForbiddenError, + HttpRequestError, + InvalidHttpClientOptionsError, + RateLimitedError, + ServerError, +) +from vonage_http_client.http_client import HttpClient + +from testutils import build_response + +path = abspath(__file__) + + +def read_file(path): + with open(join(dirname(__file__), path)) as input_file: + return input_file.read() + + +application_id = 'asdfzxcv' +private_key = read_file('data/dummy_private_key.txt') + + +def test_create_http_client(): + client = HttpClient(Auth()) + assert type(client) == HttpClient + assert client.api_host == 'api.nexmo.com' + assert client.rest_host == 'rest.nexmo.com' + + +def test_create_http_client_options(): + client_options = { + 'api_host': 'api.nexmo.com', + 'rest_host': 'rest.nexmo.com', + 'video_host': 'video.api.vonage.com', + 'timeout': 30, + 'pool_connections': 5, + 'pool_maxsize': 12, + 'max_retries': 5, + } + client = HttpClient(Auth(), client_options) + assert client.http_client_options.model_dump() == client_options + + +def test_create_http_client_invalid_options_error(): + with raises(InvalidHttpClientOptionsError): + HttpClient(Auth(), []) + + +@responses.activate +def test_make_get_request_and_last_request_and_response(): + build_response( + path, 'GET', 'https://example.com/get_json?key=value', 'example_get.json' + ) + client = HttpClient( + Auth(application_id=application_id, private_key=private_key), + http_client_options={'api_host': 'example.com'}, + ) + res = client.get( + host='example.com', request_path='/get_json', params={'key': 'value'} + ) + + assert res['hello'] == 'world' + assert responses.calls[0].request.headers['User-Agent'] == client._user_agent + + assert type(client.last_request) == PreparedRequest + assert client.last_request.method == 'GET' + assert client.last_request.url == 'https://example.com/get_json?key=value' + assert client.last_request.body == None + + assert type(client.last_response) == Response + assert client.last_response.status_code == 200 + assert client.last_response.json() == res + assert client.last_response.headers == {'Content-Type': 'application/json'} + + +@responses.activate +def test_make_get_request_no_content(): + build_response(path, 'GET', 'https://example.com/get_json', status_code=204) + client = HttpClient( + Auth('asdfqwer', 'asdfqwer1234'), + http_client_options={'api_host': 'example.com'}, + ) + res = client.get(host='example.com', request_path='/get_json', auth_type='basic') + assert res == None + + +@responses.activate +def test_make_post_request(): + build_response(path, 'POST', 'https://example.com/post_json', 'example_post.json') + client = HttpClient( + Auth(application_id=application_id, private_key=private_key), + http_client_options={'api_host': 'example.com'}, + ) + params = { + 'test': 'post request', + 'testing': 'http client', + } + + res = client.post(host='example.com', request_path='/post_json', params=params) + assert res['hello'] == 'world!' + + assert loads(responses.calls[0].request.body) == params + + +@responses.activate +def test_make_post_request_with_signature(): + params = { + 'test': 'post request', + 'testing': 'http client', + 'timestamp': '1234567890', + } + + build_response( + path, + 'POST', + 'https://example.com/post_signed_params', + 'example_post.json', + match=[ + matchers.urlencoded_params_matcher( + { + **params, + 'api_key': 'asdfzxcv', + 'sig': '237b06fd1f994a9ec2f3283a4a0239f35b56d64639d6485b45cffedcb385b033', + } + ) + ], + ) + client = HttpClient( + Auth( + api_key='asdfzxcv', signature_secret='qwerasdfzxcv', signature_method='sha256' + ), + http_client_options={'api_host': 'example.com'}, + ) + + res = client.post( + host='example.com', + request_path='/post_signed_params', + params=params, + auth_type='signature', + sent_data_type='form', + ) + assert res['hello'] == 'world!' + + +@responses.activate +def test_http_response_general_error(): + build_response(path, 'GET', 'https://example.com/get_json', '400.json', 400) + + client = HttpClient(Auth()) + try: + client.get(host='example.com', request_path='/get_json', auth_type='basic') + except HttpRequestError as err: + assert err.response.json()['Error'] == 'Bad Request' + assert '400 response from https://example.com/get_json.' in err.message + + +@responses.activate +def test_http_response_general_text_error(): + build_response(path, 'GET', 'https://example.com/get', '400.txt', 400, 'text/plain') + + client = HttpClient(Auth()) + try: + client.get(host='example.com', request_path='/get', auth_type='basic') + except HttpRequestError as err: + assert err.response.text == 'Error: Bad Request' + assert '400 response from https://example.com/get.' in err.message + + +@responses.activate +def test_authentication_error(): + build_response(path, 'GET', 'https://example.com/get_json', '401.json', 401) + + client = HttpClient(Auth(application_id=application_id, private_key=private_key)) + try: + client.get(host='example.com', request_path='/get_json') + except AuthenticationError as err: + assert err.response.json()['title'] == 'Unauthorized' + + +@responses.activate +def test_authentication_error_no_content(): + build_response(path, 'GET', 'https://example.com/get_json', status_code=401) + + client = HttpClient(Auth()) + try: + client.get(host='example.com', request_path='/get_json', auth_type='basic') + except AuthenticationError as err: + assert type(err.response) == Response + + +@responses.activate +def test_forbidden_error(): + build_response(path, 'GET', 'https://example.com/get_json', '403.json', 403) + + client = HttpClient(Auth()) + try: + client.get(host='example.com', request_path='/get_json', auth_type='basic') + except ForbiddenError as err: + assert err.response.json()['title'] == 'Forbidden' + assert ( + err.response.json()['detail'] + == 'Your account does not have permission to perform this action.' + ) + + +@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) + + client = HttpClient(Auth()) + try: + client.get(host='example.com', request_path='/get_json', auth_type='basic') + except RateLimitedError as err: + assert err.response.json()['title'] == 'Rate Limit Hit' + + +@responses.activate +def test_server_error(): + build_response(path, 'GET', 'https://example.com/get_json', '500.json', 500) + + client = HttpClient(Auth(application_id=application_id, private_key=private_key)) + try: + client.get(host='example.com', request_path='/get_json') + except ServerError as err: + assert err.response.json()['title'] == 'Internal Server Error' + + +def test_append_to_user_agent(): + client = HttpClient(Auth()) + client.append_to_user_agent('TestAgent') + assert 'TestAgent' in client.user_agent diff --git a/jwt/BUILD b/jwt/BUILD new file mode 100644 index 00000000..c49d3ee3 --- /dev/null +++ b/jwt/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-jwt', + dependencies=[ + ':pyproject', + ':readme', + 'jwt/src/vonage_jwt', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/jwt/CHANGES.md b/jwt/CHANGES.md new file mode 100644 index 00000000..f4d4b74e --- /dev/null +++ b/jwt/CHANGES.md @@ -0,0 +1,18 @@ +# 1.1.4 +- Fix a bug with generating non-default JWTs + +# 1.1.3 +- Support for Python 3.13, drop support for 3.8 + +# 1.1.2 +- Dynamically specify package version + +# 1.1.1 +- Exceptions inherit from `VonageError` +- Moving the package into the Vonage Python SDK monorepo + +# 1.1.0 +- Add new module with method to verify JWT signatures, `verify_jwt.verify_signature` + +# 1.0.0 +- First stable release \ No newline at end of file diff --git a/jwt/README.md b/jwt/README.md new file mode 100644 index 00000000..d577c8ff --- /dev/null +++ b/jwt/README.md @@ -0,0 +1,60 @@ +# Vonage JWT Generator for Python + +This package (`vonage-jwt`) provides functionality to generate a JWT in Python code. + +It is used by the [Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk), specifically by the `vonage-http-client` package, to generate JWTs for authentication. Thus, it doesn't require manual installation or configuration unless you're using this package independently of an SDK. + +For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). + +- [Installation](#installation) +- [Generating JWTs](#generating-jwts) +- [Verifying a JWT signature](#verifying-a-jwt-signature) + +## Installation + +Install from the Python Package Index with pip: + +```bash +pip install vonage-jwt +``` + +## Generating JWTs + +This JWT Generator can be used implicitly, just by using the [Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) to make JWT-authenticated API calls. + +It can also be used as a standalone JWT generator for use with Vonage APIs, like so: + +### Import the `JwtClient` object + +```python +from vonage_jwt import JwtClient +``` + +### Create a `JwtClient` object + +```python +jwt_client = JwtClient(application_id, private_key) +``` + +### Generate a JWT using the provided application id and private key + +```python +jwt_client.generate_application_jwt() +``` + +Optional JWT claims can be provided in a python dictionary: + +```python +claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100} +jwt_client.generate_application_jwt(claims) +``` + +## Verifying a JWT signature + +You can use the `verify_jwt.verify_signature` method to verify a JWT signature is valid. + +```python +from vonage_jwt import verify_signature + +verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean +``` diff --git a/jwt/pyproject.toml b/jwt/pyproject.toml new file mode 100644 index 00000000..d5f4ee23 --- /dev/null +++ b/jwt/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "vonage-jwt" +dynamic = ["version"] +description = "Tooling for working with JWTs for Vonage APIs in Python." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = ["vonage-utils>=1.1.4", "pyjwt[crypto]>=1.6.4"] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_jwt._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/jwt/src/vonage_jwt/BUILD b/jwt/src/vonage_jwt/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/jwt/src/vonage_jwt/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/jwt/src/vonage_jwt/__init__.py b/jwt/src/vonage_jwt/__init__.py new file mode 100644 index 00000000..4419f04f --- /dev/null +++ b/jwt/src/vonage_jwt/__init__.py @@ -0,0 +1,5 @@ +from .errors import VonageJwtError, VonageVerifyJwtError +from .jwt import JwtClient +from .verify_jwt import verify_signature + +__all__ = ['JwtClient', 'VonageJwtError', 'VonageVerifyJwtError', 'verify_signature'] diff --git a/jwt/src/vonage_jwt/_version.py b/jwt/src/vonage_jwt/_version.py new file mode 100644 index 00000000..bc50bee6 --- /dev/null +++ b/jwt/src/vonage_jwt/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.4' diff --git a/jwt/src/vonage_jwt/errors.py b/jwt/src/vonage_jwt/errors.py new file mode 100644 index 00000000..80177182 --- /dev/null +++ b/jwt/src/vonage_jwt/errors.py @@ -0,0 +1,9 @@ +from vonage_utils import VonageError + + +class VonageJwtError(VonageError): + """An error relating to the Vonage JWT Generator.""" + + +class VonageVerifyJwtError(VonageError): + """The signature could not be verified.""" diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py new file mode 100644 index 00000000..3ea36937 --- /dev/null +++ b/jwt/src/vonage_jwt/jwt.py @@ -0,0 +1,66 @@ +import re +from copy import deepcopy +from time import time +from typing import Union +from uuid import uuid4 + +from jwt import encode + +from .errors import VonageJwtError + + +class JwtClient: + """Object used to pass in an application ID and private key to generate JWT + methods.""" + + def __init__(self, application_id: str, private_key: str): + self._application_id = application_id + + try: + self._set_private_key(private_key) + except Exception as err: + raise VonageJwtError(err) + + if self._application_id is None or self._private_key is None: + raise VonageJwtError( + 'Both of "application_id" and "private_key" are required.' + ) + + def generate_application_jwt(self, jwt_options: dict = None) -> bytes: + """Generates a JWT for the specified Vonage application. + + You can override values for application_id and private_key on the JWTClient object by + specifying them in the `jwt_options` dict if required. + + Args: + jwt_options (dict): The options to include in the JWT. + + Returns: + bytes: The generated JWT. + """ + if jwt_options is None: + jwt_options = {} + + iat = int(time()) + + payload = deepcopy(jwt_options) + payload["application_id"] = self._application_id + payload['iat'] = payload.get("iat", iat) + payload["jti"] = payload.get("jti", str(uuid4())) + payload["exp"] = payload.get("exp", payload["iat"] + (15 * 60)) + + headers = {'alg': 'RS256', 'typ': 'JWT'} + + token = encode(payload, self._private_key, algorithm='RS256', headers=headers) + return bytes(token, 'utf-8') + + def _set_private_key(self, key: Union[str, bytes]) -> None: + if isinstance(key, (str, bytes)) and re.search("[.][a-zA-Z0-9_]+$", key): + with open(key, "rb") as key_file: + self._private_key = key_file.read() + elif isinstance(key, str) and '-----BEGIN PRIVATE KEY-----' not in key: + raise VonageJwtError( + "If passing the private key directly as a string, it must be formatted correctly with newlines." + ) + else: + self._private_key = key diff --git a/jwt/src/vonage_jwt/verify_jwt.py b/jwt/src/vonage_jwt/verify_jwt.py new file mode 100644 index 00000000..31de3302 --- /dev/null +++ b/jwt/src/vonage_jwt/verify_jwt.py @@ -0,0 +1,15 @@ +from jwt import InvalidSignatureError, decode + +from .errors import VonageVerifyJwtError + + +def verify_signature(token: str, signature_secret: str = None) -> bool: + """Method to verify that an incoming JWT was sent by Vonage.""" + + try: + decode(token, signature_secret, algorithms='HS256') + return True + except InvalidSignatureError: + return False + except Exception as e: + raise VonageVerifyJwtError(repr(e)) diff --git a/jwt/tests/BUILD b/jwt/tests/BUILD new file mode 100644 index 00000000..dec8ad99 --- /dev/null +++ b/jwt/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['jwt', 'testutils']) diff --git a/jwt/tests/data/private_key.txt b/jwt/tests/data/private_key.txt new file mode 100644 index 00000000..163ff367 --- /dev/null +++ b/jwt/tests/data/private_key.txt @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQdAHqJHs/a+Ra +2ubvSd1vz/aWlJ9BqnMUtB7guTlyggdENAbleIkzep6mUHepDJdQh8Qv6zS3lpUe +K0UkDfr1/FvsvxurGw/YYPagUEhP/HxMbs2rnQTiAdWOT+Ux9vPABoyNYvZB90xN +IVhBDRWgkz1HPQBRNjFcm3NOol83h5Uwp5YroGTWx+rpmIiRhQj3mv6luk102d95 +4ulpPpzcYWKIpJNdclJrEkBZaghDZTOpbv79qd+ds9AVp1j8i9cG/owBJpsJWxfw +StMDpNeEZqopeQWmA121sSEsxpAbKJ5DA7F/lmckx74sulKHX1fDWT76cRhloaEQ +VmETdj0VAgMBAAECggEAZ+SBtchz8vKbsBqtAbM/XcR5Iqi1TR2eWMHDJ/65HpSm ++XuyujjerN0e6EZvtT4Uxmq8QaPJNP0kmhI31hXvsB0UVcUUDa4hshb1pIYO3Gq7 +Kr8I29EZB2mhndm9Ii9yYhEBiVA66zrNeR225kkWr97iqjhBibhoVr8Vc6oiqcIP +nFy5zSFtQSkhucaPge6rW00JSOD3wg2GM+rgS6r22t8YmqTzAwvwfil5pQfUngal +oywqLOf6CUYXPBleJc1KgaIIP/cSvqh6b/t25o2VXnI4rpRhtleORvYBbH6K6xLa +OWgg6B58T+0/QEqtZIAn4miYtVCkYLB78Ormc7Q9ewKBgQDuSytuYqxdZh/L/RDU +CErFcNO5I1e9fkLAs5dQEBvvdQC74+oA1MsDEVv0xehFa1JwPKSepmvB2UznZg9L +CtR7QKMDZWvS5xx4j0E/b+PiNQ/tlcFZB2UZ0JwviSxdd7omOTscq9c3RIhFHar1 +Y38Fixkfm44Ij/K3JqIi2v2QMwKBgQDf8TYOOmAr9UuipUDxMsRSqTGVIY8B+aEJ +W+2aLrqJVkLGTRfrbjzXWYo3+n7kNJjFgNkltDq6HYtufHMYRs/0PPtNR0w0cDPS +Xr7m2LNHTDcBalC/AS4yKZJLNLm+kXA84vkw4qiTjc0LSFxJkouTQzkea0l8EWHt +zRMv/qYVlwKBgBaJOWRJJK/4lo0+M7c5yYh+sSdTNlsPc9Sxp1/FBj9RO26JkXne +pgx2OdIeXWcjTTqcIZ13c71zhZhkyJF6RroZVNFfaCEcBk9IjQ0o0c504jq/7Pc0 +gdU9K2g7etykFBDFXNfLUKFDc/fFZIOskzi8/PVGStp4cqXrm23cdBqNAoGBAKtf +A2bP9ViuVjsZCyGJIAPBxlfBXpa8WSe4WZNrvwPqJx9pT6yyp4yE0OkVoJUyStaZ +S5M24NocUd8zDUC+r9TP9d+leAOI+Z87MgumOUuOX2mN2kzQsnFgrrsulhXnZmSx +rNBkI20HTqobrcP/iSAgiU1l/M4c3zwDe3N3A9HxAoGBAM2hYu0Ij6htSNgo/WWr +IEYYXuwf8hPkiuwzlaiWhD3eocgd4S8SsBu/bTCY19hQ2QbBPaYyFlNem+ynQyXx +IOacrgIHCrYnRCxjPfFF/MxgUHJb8ZoiexprP/FME5p0PoRQIEFYa+jVht3hT5wC +9aedWufq4JJb+akO6MVUjTvs +-----END PRIVATE KEY----- diff --git a/jwt/tests/data/public_key.txt b/jwt/tests/data/public_key.txt new file mode 100644 index 00000000..a1715089 --- /dev/null +++ b/jwt/tests/data/public_key.txt @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0HQB6iR7P2vkWtrm70nd +b8/2lpSfQapzFLQe4Lk5coIHRDQG5XiJM3qeplB3qQyXUIfEL+s0t5aVHitFJA36 +9fxb7L8bqxsP2GD2oFBIT/x8TG7Nq50E4gHVjk/lMfbzwAaMjWL2QfdMTSFYQQ0V +oJM9Rz0AUTYxXJtzTqJfN4eVMKeWK6Bk1sfq6ZiIkYUI95r+pbpNdNnfeeLpaT6c +3GFiiKSTXXJSaxJAWWoIQ2UzqW7+/anfnbPQFadY/IvXBv6MASabCVsX8ErTA6TX +hGaqKXkFpgNdtbEhLMaQGyieQwOxf5ZnJMe+LLpSh19Xw1k++nEYZaGhEFZhE3Y9 +FQIDAQAB +-----END PUBLIC KEY----- diff --git a/jwt/tests/test_jwt_generator.py b/jwt/tests/test_jwt_generator.py new file mode 100644 index 00000000..f32f0225 --- /dev/null +++ b/jwt/tests/test_jwt_generator.py @@ -0,0 +1,68 @@ +from os import environ +from os.path import dirname, join +from time import time + +from jwt.exceptions import ImmatureSignatureError +from pytest import raises +from vonage_jwt.jwt import JwtClient, VonageJwtError + +from jwt import decode + +# Ensure the client isn't being configured with real values +environ.clear() + + +def read_file(path): + with open(join(dirname(__file__), path)) as input_file: + return input_file.read() + + +application_id = 'asdf1234' +private_key_string = read_file('data/private_key.txt') +private_key_file_path = 'jwt/tests/data/private_key.txt' +jwt_client = JwtClient(application_id, private_key_file_path) + +public_key = read_file('data/public_key.txt') + + +def test_create_jwt_client_key_string(): + jwt_client = JwtClient(application_id, private_key_string) + assert jwt_client._application_id == application_id + assert jwt_client._private_key == private_key_string + + +def test_create_jwt_client_key_file(): + jwt_client = JwtClient(application_id, private_key_file_path) + assert jwt_client._application_id == application_id + assert jwt_client._private_key == bytes(private_key_string, 'utf-8') + + +def test_create_jwt_client_error_incomplete(): + with raises(VonageJwtError) as err: + JwtClient(application_id, None) + assert str(err.value) == 'Both of "application_id" and "private_key" are required.' + + +def test_create_jwt_client_error_invalid_key(): + with raises(VonageJwtError) as err: + JwtClient(application_id, 'invalid-private-key-string') + assert ( + str(err.value) + == 'If passing the private key directly as a string, it must be formatted correctly with newlines.' + ) + + +def test_generate_application_jwt_basic(): + jwt = jwt_client.generate_application_jwt() + decoded_jwt = decode(jwt, key=public_key, algorithms='RS256') + assert decoded_jwt['application_id'] == 'asdf1234' + assert decoded_jwt['exp'] - decoded_jwt['iat'] == 15 * 60 + + +def test_generate_application_jwt_custom_claims(): + now = int(time()) + claims = {'jti': 'qwerasdfzxcv1234', 'nbf': now + 100} + jwt = jwt_client.generate_application_jwt(claims) + with raises(ImmatureSignatureError) as err: + decode(jwt, key=public_key, algorithms='RS256') + assert str(err.value) == 'The token is not yet valid (nbf)' diff --git a/jwt/tests/test_verify_jwt.py b/jwt/tests/test_verify_jwt.py new file mode 100644 index 00000000..6e7e7286 --- /dev/null +++ b/jwt/tests/test_verify_jwt.py @@ -0,0 +1,21 @@ +import pytest +from vonage_jwt.errors import VonageVerifyJwtError +from vonage_jwt.verify_jwt import verify_signature + +token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM' +valid_signature = "qwertyuiopasdfghjklzxcvbnm123456" +invalid_signature = 'asdf' + + +def test_verify_signature_valid(): + assert verify_signature(token, valid_signature) is True + + +def test_verify_signature_invalid(): + assert verify_signature(token, invalid_signature) is False + + +def test_verify_signature_error(): + with pytest.raises(VonageVerifyJwtError) as e: + verify_signature('asdf', valid_signature) + assert 'DecodeError' in str(e.value) diff --git a/messages/BUILD b/messages/BUILD new file mode 100644 index 00000000..1089da87 --- /dev/null +++ b/messages/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-messages', + dependencies=[ + ':pyproject', + ':readme', + 'messages/src/vonage_messages', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/messages/CHANGES.md b/messages/CHANGES.md new file mode 100644 index 00000000..2019adce --- /dev/null +++ b/messages/CHANGES.md @@ -0,0 +1,21 @@ +# 1.2.3 +- Update dependency versions + +# 1.2.2 +- Support for Python 3.13, drop support for 3.8 + +# 1.2.1 +- Add docstrings to data models + +# 1.2.0 +- Add RCS channel support +- Add methods to revoke an RCS message and mark a WhatsApp message as read + +# 1.1.1 +- Update minimum dependency version + +# 1.1.0 +- Add `http_client` property + +# 1.0.0 +- Initial upload diff --git a/messages/README.md b/messages/README.md new file mode 100644 index 00000000..37f7f957 --- /dev/null +++ b/messages/README.md @@ -0,0 +1,110 @@ +# Vonage Messages Package + +This package contains the code to use [Vonage's Messages API](https://developer.vonage.com/en/messages/overview) in Python. + +## 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`. + +### How to Construct a Message + +In order to send a message, you must construct a message object of the correct type. These are all found under `vonage_messages.models`. + +```python +from vonage_messages.models import Sms + +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) +``` + +This message can now be sent with + +```python +vonage_client.messages.send(message) +``` + +All possible message types from every message channel have their own message model. They are named following this rule: {Channel}{MessageType}, e.g. `Sms`, `MmsImage`, `RcsFile`, `MessengerAudio`, `WhatsappSticker`, `ViberVideo`, etc. + +The different message models are listed at the bottom of the page. + +Some message types have submodels with additional fields. In this case, import the submodels as well and use them to construct the overall options. + +e.g. + +```python +from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource + +messenger = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + messenger=MessengerOptions(category='message_tag', tag='invalid_tag'), +) +``` + +### Send a message + +To send a message, access the `Messages.send` method via the main Vonage object, passing in an instance of a subclass of `BaseMessage` like this: + +```python +from vonage import Auth, Vonage +from vonage_messages.models import Sms + +vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) + +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) + +vonage_client.messages.send(message) +``` + +### Mark a WhatsApp Message as Read + +Note: to use this method, update the `api_host` attribute of the `vonage_http_client.HttpClientOptions` object to the API endpoint corresponding to the region where the WhatsApp number is hosted. + +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. + +```python +from vonage import Vonage, Auth, HttpClientOptions + +auth = Auth(application_id='MY-APP-ID', private_key='MY-PRIVATE-KEY') +options = HttpClientOptions(api_host='api-eu.vonage.com') + +vonage_client = Vonage(auth, options) +vonage_client.messages.mark_whatsapp_message_read('MESSAGE_UUID') +``` + +### Revoke an RCS Message + +Note: as above, to use this method you need to update the `api_host` attribute of the `vonage_http_client.HttpClientOptions` object to the API endpoint corresponding to the region where the WhatsApp number is hosted. + +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. + +```python +from vonage import Vonage, Auth, HttpClientOptions + +auth = Auth(application_id='MY-APP-ID', private_key='MY-PRIVATE-KEY') +options = HttpClientOptions(api_host='api-eu.vonage.com') + +vonage_client = Vonage(auth, options) +vonage_client.messages.revoke_rcs_message('MESSAGE_UUID') +``` + +## Message Models + +To send a message, instantiate a message model of the correct type as described above. This is a list of message models that can be used: + +``` +Sms +MmsImage, MmsVcard, MmsAudio, MmsVideo +RcsText, RcsImage, RcsVideo, RcsFile, RcsCustom +WhatsappText, WhatsappImage, WhatsappAudio, WhatsappVideo, WhatsappFile, WhatsappTemplate, WhatsappSticker, WhatsappCustom +MessengerText, MessengerImage, MessengerAudio, MessengerVideo, MessengerFile +ViberText, ViberImage, ViberVideo, ViberFile +``` diff --git a/messages/pyproject.toml b/messages/pyproject.toml new file mode 100644 index 00000000..1f0bb113 --- /dev/null +++ b/messages/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-messages' +dynamic = ["version"] +description = 'Vonage messages package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +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" + +[tool.setuptools.dynamic] +version = { attr = "vonage_messages._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/messages/src/vonage_messages/BUILD b/messages/src/vonage_messages/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/messages/src/vonage_messages/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/messages/src/vonage_messages/__init__.py b/messages/src/vonage_messages/__init__.py new file mode 100644 index 00000000..11000717 --- /dev/null +++ b/messages/src/vonage_messages/__init__.py @@ -0,0 +1,5 @@ +from . import models +from .messages import Messages +from .responses import SendMessageResponse + +__all__ = ['models', 'Messages', 'SendMessageResponse'] diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py new file mode 100644 index 00000000..5a5df3be --- /dev/null +++ b/messages/src/vonage_messages/_version.py @@ -0,0 +1 @@ +__version__ = '1.2.3' diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py new file mode 100644 index 00000000..c39af991 --- /dev/null +++ b/messages/src/vonage_messages/messages.py @@ -0,0 +1,86 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .models import BaseMessage +from .responses import SendMessageResponse + + +class Messages: + """Calls Vonage's Messages API. + + This class provides methods to interact with Vonage's Messages API, allowing you to send messages. + + Args: + http_client (HttpClient): An instance of the HttpClient class used to make HTTP requests. + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Messages API. + + Returns: + HttpClient: The HTTP client used to make requests to the Messages API. + """ + return self._http_client + + @validate_call + def send(self, message: BaseMessage) -> SendMessageResponse: + """Send a message using Vonage's Messages API. + + Args: + message (BaseMessage): The message to be sent as a Pydantic model. + Use the provided models (in `vonage_messages.models`) to create messages and pass them in to this method. + + Returns: + SendMessageResponse: Response model containing the unique identifier of the sent message. + Access the identifier with the `message_uuid` attribute. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v1/messages', + message.model_dump(by_alias=True, exclude_none=True) or message, + ) + return SendMessageResponse(**response) + + @validate_call + def mark_whatsapp_message_read(self, message_uuid: str) -> None: + """Mark a WhatsApp message as read. + + Note: to use this method, update the `api_host` attribute of the + `vonage_http_client.HttpClientOptions` object to the API endpoint + corresponding to the region where the WhatsApp number is hosted. + + For example, to use the EU API endpoint, set the `api_host` + attribute to 'api-eu.vonage.com'. + + Args: + message_uuid (str): The unique identifier of the WhatsApp message to mark as read. + """ + self._http_client.patch( + self._http_client.api_host, + f'/v1/messages/{message_uuid}', + {'status': 'read'}, + ) + + @validate_call + def revoke_rcs_message(self, message_uuid: str) -> None: + """Revoke an RCS message. + + Note: to use this method, update the `api_host` attribute of the + `vonage_http_client.HttpClientOptions` object to the API endpoint + corresponding to the region where the RCS number is hosted. + + For example, to use the EU API endpoint, set the `api_host` + attribute to 'api-eu.vonage.com'. + + Args: + message_uuid (str): The unique identifier of the RCS message to revoke. + """ + self._http_client.patch( + self._http_client.api_host, + f'/v1/messages/{message_uuid}', + {'status': 'revoked'}, + ) diff --git a/messages/src/vonage_messages/models/BUILD b/messages/src/vonage_messages/models/BUILD new file mode 100644 index 00000000..62f5decc --- /dev/null +++ b/messages/src/vonage_messages/models/BUILD @@ -0,0 +1 @@ +python_sources(name='models') diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py new file mode 100644 index 00000000..bbd6d65d --- /dev/null +++ b/messages/src/vonage_messages/models/__init__.py @@ -0,0 +1,104 @@ +from .base_message import BaseMessage +from .enums import ChannelType, EncodingType, MessageType, WebhookVersion +from .messenger import ( + MessengerAudio, + MessengerFile, + MessengerImage, + MessengerOptions, + MessengerResource, + MessengerText, + MessengerVideo, +) +from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo +from .sms import Sms, SmsOptions +from .viber import ( + ViberAction, + ViberFile, + ViberFileOptions, + ViberFileResource, + ViberImage, + ViberImageOptions, + ViberImageResource, + ViberText, + ViberTextOptions, + ViberVideo, + ViberVideoOptions, + ViberVideoResource, +) +from .whatsapp import ( + WhatsappAudio, + WhatsappAudioResource, + WhatsappContext, + WhatsappCustom, + WhatsappFile, + WhatsappFileResource, + WhatsappImage, + WhatsappImageResource, + WhatsappSticker, + WhatsappStickerId, + WhatsappStickerUrl, + WhatsappTemplate, + WhatsappTemplateResource, + WhatsappTemplateSettings, + WhatsappText, + WhatsappVideo, + WhatsappVideoResource, +) + +__all__ = [ + 'BaseMessage', + 'ChannelType', + 'EncodingType', + 'MessageType', + 'MessengerAudio', + 'MessengerFile', + 'MessengerImage', + 'MessengerOptions', + 'MessengerResource', + 'MessengerText', + 'MessengerVideo', + 'MmsAudio', + 'MmsImage', + 'MmsResource', + 'MmsVcard', + 'MmsVideo', + 'RcsCustom', + 'RcsFile', + 'RcsImage', + 'RcsResource', + 'RcsText', + 'RcsVideo', + 'Sms', + 'SmsOptions', + 'ViberAction', + 'ViberFile', + 'ViberFileOptions', + 'ViberFileResource', + 'ViberImage', + 'ViberImageOptions', + 'ViberImageResource', + 'ViberText', + 'ViberTextOptions', + 'ViberVideo', + 'ViberVideoOptions', + 'ViberVideoResource', + 'WebhookVersion', + 'WhatsappAudio', + 'WhatsappAudioResource', + 'WhatsappContext', + 'WhatsappCustom', + 'WhatsappFile', + 'WhatsappFileResource', + 'WhatsappImage', + 'WhatsappImageResource', + 'WhatsappSticker', + 'WhatsappStickerId', + 'WhatsappStickerUrl', + 'WhatsappTemplate', + 'WhatsappTemplateResource', + 'WhatsappTemplateSettings', + 'WhatsappText', + 'WhatsappVideo', + 'WhatsappVideoResource', +] diff --git a/messages/src/vonage_messages/models/base_message.py b/messages/src/vonage_messages/models/base_message.py new file mode 100644 index 00000000..25959d77 --- /dev/null +++ b/messages/src/vonage_messages/models/base_message.py @@ -0,0 +1,22 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + +from .enums import WebhookVersion + + +class BaseMessage(BaseModel): + """Model with base properties for a message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + to: PhoneNumber + client_ref: Optional[str] = Field(None, max_length=100) + webhook_url: Optional[str] = None + webhook_version: Optional[WebhookVersion] = None diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py new file mode 100644 index 00000000..f0bb501a --- /dev/null +++ b/messages/src/vonage_messages/models/enums.py @@ -0,0 +1,39 @@ +from enum import Enum + + +class MessageType(str, Enum): + """The type of message.""" + + TEXT = 'text' + IMAGE = 'image' + AUDIO = 'audio' + VIDEO = 'video' + FILE = 'file' + TEMPLATE = 'template' + STICKER = 'sticker' + CUSTOM = 'custom' + VCARD = 'vcard' + + +class ChannelType(str, Enum): + """The channel used to send a message.""" + + SMS = 'sms' + MMS = 'mms' + RCS = 'rcs' + WHATSAPP = 'whatsapp' + MESSENGER = 'messenger' + VIBER = 'viber_service' + + +class WebhookVersion(str, Enum): + """Which version of the Messages API will be used to send Status Webhook messages.""" + + V0_1 = 'v0.1' + V1 = 'v1' + + +class EncodingType(str, Enum): + TEXT = 'text' + UNICODE = 'unicode' + AUTO = 'auto' diff --git a/messages/src/vonage_messages/models/messenger.py b/messages/src/vonage_messages/models/messenger.py new file mode 100644 index 00000000..0a9afccf --- /dev/null +++ b/messages/src/vonage_messages/models/messenger.py @@ -0,0 +1,137 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator + +from .base_message import BaseMessage +from .enums import ChannelType, MessageType + + +class MessengerResource(BaseModel): + """Model for a resource in a Messenger message. + + Args: + url (str): The URL of the resource. + """ + + url: str + + +class MessengerOptions(BaseModel): + """Model for Messenger options. + + Args: + category (str, Optional): The category of the message. The use of different category tags enables the business to send messages for different use cases. + tag (str, Optional): A tag describing the type and relevance of the 1:1 communication between your app and the end user. + """ + + category: Optional[Literal['response', 'update', 'message_tag']] = None + tag: Optional[str] = None + + @model_validator(mode='after') + def check_tag_if_category_message_tag(self): + if self.category == 'message_tag' and not self.tag: + raise ValueError('"tag" is required when "category" == "message_tag"') + return self + + +class BaseMessenger(BaseMessage): + """Model for a base Messenger message. + + Args: + to (str): The ID of the message recipient. + from_ (str): The ID of the message sender. + messenger (MessengerOptions, Optional): Messenger options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + to: str = Field(..., min_length=1, max_length=50) + from_: str = Field(..., min_length=1, max_length=50, serialization_alias='from') + messenger: Optional[MessengerOptions] = None + channel: ChannelType = ChannelType.MESSENGER + + +class MessengerText(BaseMessenger): + """Model for a Messenger text message. + + Args: + text (str): The text of the message. + to (str): The ID of the message recipient. + from_ (str): The ID of the message sender. + messenger (MessengerOptions, Optional): Messenger options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + text: str = Field(..., max_length=640) + message_type: MessageType = MessageType.TEXT + + +class MessengerImage(BaseMessenger): + """Model for a Messenger image message. + + Args: + image (MessengerResource): The image resource. + to (str): The ID of the message recipient. + from_ (str): The ID of the message sender. + messenger (MessengerOptions, Optional): Messenger options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + image: MessengerResource + message_type: MessageType = MessageType.IMAGE + + +class MessengerAudio(BaseMessenger): + """Model for a Messenger audio message. + + Args: + audio (MessengerResource): The audio resource. + to (str): The ID of the message recipient. + from_ (str): The ID of the message sender. + messenger (MessengerOptions, Optional): Messenger options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + audio: MessengerResource + message_type: MessageType = MessageType.AUDIO + + +class MessengerVideo(BaseMessenger): + """Model for a Messenger video message. + + Args: + video (MessengerResource): The video resource. + to (str): The ID of the message recipient. + from_ (str): The ID of the message sender. + messenger (MessengerOptions, Optional): Messenger options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + video: MessengerResource + message_type: MessageType = MessageType.VIDEO + + +class MessengerFile(BaseMessenger): + """Model for a Messenger file message. + + Args: + file (MessengerResource): The file resource. + to (str): The ID of the message recipient. + from_ (str): The ID of the message sender. + messenger (MessengerOptions, Optional): Messenger options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + file: MessengerResource + message_type: MessageType = MessageType.FILE diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py new file mode 100644 index 00000000..6220ed11 --- /dev/null +++ b/messages/src/vonage_messages/models/mms.py @@ -0,0 +1,105 @@ +from typing import Optional, Union + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + +from .base_message import BaseMessage +from .enums import ChannelType, MessageType + + +class MmsResource(BaseModel): + """Model for a resource in an MMS message. + + Args: + url (str): The URL of the resource. + caption (str, Optional): Additional text to accompany the resource. + """ + + url: str + caption: Optional[str] = Field(None, min_length=1, max_length=2000) + + +class BaseMms(BaseMessage): + """Model for a base MMS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + to: PhoneNumber + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + ttl: Optional[int] = Field(None, ge=300, le=259200) + channel: ChannelType = ChannelType.MMS + + +class MmsImage(BaseMms): + """Model for an MMS image message. + + Args: + image (MmsResource): The image resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + image: MmsResource + message_type: MessageType = MessageType.IMAGE + + +class MmsVcard(BaseMms): + """Model for an MMS vCard message. + + Args: + vcard (MmsResource): The vCard resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + vcard: MmsResource + message_type: MessageType = MessageType.VCARD + + +class MmsAudio(BaseMms): + """Model for an MMS audio message. + + Args: + audio (MmsResource): The audio resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + audio: MmsResource + message_type: MessageType = MessageType.AUDIO + + +class MmsVideo(BaseMms): + """Model for an MMS video message. + + Args: + video (MmsResource): The video resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + video: MmsResource + message_type: MessageType = MessageType.VIDEO diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py new file mode 100644 index 00000000..ef856061 --- /dev/null +++ b/messages/src/vonage_messages/models/rcs.py @@ -0,0 +1,120 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + +from .base_message import BaseMessage +from .enums import ChannelType, MessageType + + +class RcsResource(BaseModel): + """Model for a resource in an RCS message. + + Args: + url (str): The URL of the resource. + """ + + url: str + + +class BaseRcs(BaseMessage): + """Model for a base RCS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + to: PhoneNumber + from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9]+$') + ttl: Optional[int] = Field(None, ge=300, le=259200) + channel: ChannelType = ChannelType.RCS + + +class RcsText(BaseRcs): + """Model for an RCS text message. + + Args: + text (str): The text of the message. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + text: str = Field(..., min_length=1, max_length=3072) + message_type: MessageType = MessageType.TEXT + + +class RcsImage(BaseRcs): + """Model for an RCS image message. + + Args: + image (RcsResource): The image resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + image: RcsResource + message_type: MessageType = MessageType.IMAGE + + +class RcsVideo(BaseRcs): + """Model for an RCS video message. + + Args: + video (RcsResource): The video resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + video: RcsResource + message_type: MessageType = MessageType.VIDEO + + +class RcsFile(BaseRcs): + """Model for an RCS file message. + + Args: + file (RcsResource): The file resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + file: RcsResource + message_type: MessageType = MessageType.FILE + + +class RcsCustom(BaseRcs): + """Model for an RCS custom message. + + Args: + custom (dict): The custom message data. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + custom: dict + message_type: MessageType = MessageType.CUSTOM diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py new file mode 100644 index 00000000..dd52541f --- /dev/null +++ b/messages/src/vonage_messages/models/sms.py @@ -0,0 +1,52 @@ +from typing import Optional, Union + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + +from .base_message import BaseMessage +from .enums import ChannelType, EncodingType, MessageType + + +class SmsOptions(BaseModel): + """Model for SMS options. + + Args: + encoding_type (EncodingType, Optional): The encoding type to use for the message. + If set to either text or unicode the specified type will be used. + If set to auto (the default), the Messages API will automatically set + the type based on the content. + content_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. Not needed unless + sending SMS in a country that requires a specific content ID. + entity_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. Not needed unless + sending SMS in a country that requires a specific entity ID. + """ + + encoding_type: Optional[EncodingType] = None + content_id: Optional[str] = None + entity_id: Optional[str] = None + + +class Sms(BaseMessage): + """Model for an SMS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. + Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + text (str): The text of the message. + ttl (int, Optional): The duration in seconds for which the message is valid. + sms (SmsOptions, Optional): SMS options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + text: str = Field(..., max_length=1000) + ttl: Optional[int] = None + sms: Optional[SmsOptions] = None + channel: ChannelType = ChannelType.SMS + message_type: MessageType = MessageType.TEXT diff --git a/messages/src/vonage_messages/models/viber.py b/messages/src/vonage_messages/models/viber.py new file mode 100644 index 00000000..71ea8b35 --- /dev/null +++ b/messages/src/vonage_messages/models/viber.py @@ -0,0 +1,270 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field, field_validator + +from .base_message import BaseMessage +from .enums import ChannelType, MessageType + + +class ViberAction(BaseModel): + """Model for an action button in a Viber message. + + Args: + url (str): A URL which is requested when the action button is clicked. + text (str): Text which is rendered on the action button. + """ + + url: str + text: str = Field(..., max_length=30) + + +class ViberOptions(BaseModel): + """Model for Viber message options. + + Args: + category (Literal['transaction', 'promotion'], Optional): The use of different + category tags enables the business to send messages for different use cases. + For Viber Business Messages the first message sent from a business to a user + must be personal, informative and a targeted message - not promotional. + ttl (int, Optional): The duration in seconds for which the message is valid. + type (Literal['string', 'template'], Optional): The type of the message. To use + "template", please contact your Vonage Account Manager to setup your templates. + """ + + category: Literal['transaction', 'promotion'] = None + ttl: Optional[int] = Field(None, ge=30, le=259200) + type: Optional[Literal['string', 'template']] = None + + +class BaseViber(BaseMessage): + """Model for a base Viber message. + + Args: + to (str): The recipient's phone number in E.164 format. Don't use a leading + plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading + plus sign. + viber_service (ViberOptions, Optional): Viber message options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + from_: str = Field(..., min_length=1, max_length=50, serialization_alias='from') + viber_service: Optional[ViberOptions] = None + channel: ChannelType = ChannelType.VIBER + + +class ViberTextOptions(ViberOptions): + """Model for Viber text message options. + + Args: + action (ViberAction, Optional): An action button to include in the message. + category (Literal['transaction', 'promotion'], Optional): The use of different + category tags enables the business to send messages for different use cases. + For Viber Business Messages the first message sent from a business to a user + must be personal, informative and a targeted message - not promotional. + ttl (int, Optional): The duration in seconds for which the message is valid. + type (Literal['string', 'template'], Optional): The type of the message. To use + "template", please contact your Vonage Account Manager to setup your templates. + """ + + action: Optional[ViberAction] = None + + +class ViberText(BaseViber): + """Model for a Viber text message. + + Args: + text (str): The text of the message. + to (str): The recipient's phone number in E.164 format. Don't use a leading + plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading + plus sign. + viber_service (ViberTextOptions, Optional): Viber text message options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + text: str = Field(..., max_length=1000) + viber_service: Optional[ViberTextOptions] = None + message_type: MessageType = MessageType.TEXT + + +class ViberImageResource(BaseModel): + """Model for an image resource in a Viber message. + + Args: + url (str): The URL of the image. + caption (str, Optional): Additional text to accompany the image. + """ + + url: str + caption: Optional[str] = None + + +class ViberImageOptions(ViberOptions): + """Model for Viber image message options. + + Args: + action (ViberAction, Optional): An action button to include in the message. + category (Literal['transaction', 'promotion'], Optional): The use of different + category tags enables the business to send messages for different use cases. + For Viber Business Messages the first message sent from a business to a user + must be personal, informative and a targeted message - not promotional. + ttl (int, Optional): The duration in seconds for which the message is valid. + type (Literal['string', 'template'], Optional): The type of the message. To use + "template", please contact your Vonage Account Manager to setup your templates. + """ + + action: Optional[ViberAction] = None + + +class ViberImage(BaseViber): + """Model for a Viber image message. + + Args: + image (ViberImageResource): The image resource. + to (str): The recipient's phone number in E.164 format. Don't use a leading + plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading + plus sign. + viber_service (ViberImageOptions, Optional): Viber image message options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + image: ViberImageResource + viber_service: Optional[ViberImageOptions] = None + message_type: MessageType = MessageType.IMAGE + + +class ViberVideoResource(BaseModel): + """Model for a video resource in a Viber message. + + Args: + url (str): The URL of the video. + thumb_url (str): The URL of a thumbnail image to display before the video is + played. + caption (str, Optional): Additional text to accompany the video. + """ + + url: str + thumb_url: str = Field(..., max_length=1000) + caption: Optional[str] = Field(None, max_length=1000) + + +class ViberVideoOptions(ViberOptions): + """Model for Viber video message options. + + Args: + duration (str): The duration of the video in seconds. + file_size (str): The size of the video file in MB. + action (ViberAction, Optional): An action button to include in the message. + category (Literal['transaction', 'promotion'], Optional): The use of different + category tags enables the business to send messages for different use cases. + For Viber Business Messages the first message sent from a business to a user + must be personal, informative and a targeted message - not promotional. + ttl (int, Optional): The duration in seconds for which the message is valid. + type (Literal['string', 'template'], Optional): The type of the message. To use + "template", please contact your Vonage Account Manager to setup your templates. + """ + + duration: str + file_size: str + + @field_validator('duration') + @classmethod + def validate_duration(cls, value): + value_int = int(value) + if not 1 <= value_int <= 600: + raise ValueError('"Duration" must be a number between 1 and 600.') + return value + + @field_validator('file_size') + @classmethod + def validate_file_size(cls, value): + value_int = int(value) + if not 1 <= value_int <= 200: + raise ValueError('"File size" must be a number between 1 and 200.') + return value + + +class ViberVideo(BaseViber): + """Model for a Viber video message. + + Args: + video (ViberVideoResource): The video resource. + to (str): The recipient's phone number in E.164 format. Don't use a leading + plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading + plus sign. + viber_service (ViberVideoOptions, Optional): Viber video message options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + video: ViberVideoResource + viber_service: ViberVideoOptions + message_type: MessageType = MessageType.VIDEO + + +class ViberFileResource(BaseModel): + """Model for a file resource in a Viber message. + + Args: + url (str): The URL for the file attachment or the path for the location of the + file attachment. If name is included, can just be the path. If `name` is not + included, must include the filename and extension. + name (str, Optional): The name and extension of the file. + """ + + url: str + name: Optional[str] = Field(None, max_length=25) + + +class ViberFileOptions(ViberOptions): + """Model for Viber file message options. + + Args: + category (Literal['transaction', 'promotion'], Optional): The use of different + category tags enables the business to send messages for different use cases. + For Viber Business Messages the first message sent from a business to a user + must be personal, informative and a targeted message - not promotional. + ttl (int, Optional): The duration in seconds for which the message is valid. + type (Literal['string', 'template'], Optional): The type of the message. To use + "template", please contact your Vonage Account Manager to setup your templates. + """ + + +class ViberFile(BaseViber): + """Model for a Viber file message. + + Args: + file (ViberFileResource): The file resource. + to (str): The recipient's phone number in E.164 format. Don't use a leading + plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading + plus sign. + viber_service (ViberFileOptions, Optional): Viber file message options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + file: ViberFileResource + viber_service: Optional[ViberFileOptions] = None + message_type: MessageType = MessageType.FILE diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py new file mode 100644 index 00000000..d4f4582e --- /dev/null +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -0,0 +1,363 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from vonage_utils.types import PhoneNumber + +from .base_message import BaseMessage +from .enums import ChannelType, MessageType + + +class WhatsappContext(BaseModel): + """Model for the context of a WhatsApp message. This is used for quoting/replying. + + /reacting to a specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble that + displays the quoted/replied to message's content. When used for reacting, the WhatsApp + UI will display the reaction emoji below the reacted to message. + + Args: + message_uuid (str): The UUID of the message to quote/reply/react to. + """ + + message_uuid: str + + +class BaseWhatsapp(BaseMessage): + """Model for a base WhatsApp message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + context: Optional[WhatsappContext] = None + channel: ChannelType = ChannelType.WHATSAPP + + +class WhatsappText(BaseWhatsapp): + """Model for a WhatsApp text message. + + Args: + text (str): The text of the message. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + text: str = Field(..., max_length=4096) + message_type: MessageType = MessageType.TEXT + + +class WhatsappImageResource(BaseModel): + """Model for an image attachment in a WhatsApp message. + + Args: + url (str): The publicly accessible URL of the image attachment. + caption (Optional[str]): Additional text to accompany the image. + """ + + url: str + caption: Optional[str] = Field(None, min_length=1, max_length=3000) + + +class WhatsappImage(BaseWhatsapp): + """Model for a WhatsApp image message. + + Args: + image (WhatsappImageResource): The image attachment. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + image: WhatsappImageResource + message_type: MessageType = MessageType.IMAGE + + +class WhatsappAudioResource(BaseModel): + """Model for an audio attachment in a WhatsApp message. + + Args: + url (str): The publicly accessible URL of the audio attachment. + """ + + url: str = Field(..., min_length=10, max_length=2000) + + +class WhatsappAudio(BaseWhatsapp): + """Model for a WhatsApp audio message. + + Args: + audio (WhatsappAudioResource): The audio attachment. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + audio: WhatsappAudioResource + message_type: MessageType = MessageType.AUDIO + + +class WhatsappVideoResource(BaseModel): + """Model for a video attachment in a WhatsApp message. + + Args: + url (str): The publicly accessible URL of the video attachment. + caption (Optional[str]): Additional text to accompany the video. + """ + + url: str + caption: Optional[str] = None + + +class WhatsappVideo(BaseWhatsapp): + """Model for a WhatsApp video message. + + Args: + video (WhatsappVideoResource): The video attachment. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + video: WhatsappVideoResource + message_type: MessageType = MessageType.VIDEO + + +class WhatsappFileResource(BaseModel): + """Model for a file attachment in a WhatsApp message. + + Args: + url (str): The publicly accessible URL of the file attachment. + caption (Optional[str]): Additional text to accompany the file. + name (Optional[str]): Optional parameter that specifies the name of the file + being sent. If not included, the value for `caption` will be used as the + file name. If neither `name` or `caption` are included, the file name will be + parsed from the url. + """ + + url: str + caption: Optional[str] = None + name: Optional[str] = None + + +class WhatsappFile(BaseWhatsapp): + """Model for a WhatsApp file message. + + Args: + file (WhatsappFileResource): The file attachment. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + file: WhatsappFileResource + message_type: MessageType = MessageType.FILE + + +class WhatsappTemplateResource(BaseModel): + """Model for a WhatsApp template message. + + Args: + name (str): The name of the template. For WhatsApp use your WhatsApp namespace + (available via Facebook Business Manager), followed by a colon : and the + name of the template to use. + parameters (Optional[list[str]]): The parameters to be used in the template. + An array of strings, with the first string being used for 1 in the template, + the second being 2, etc. Only required if the template specified by name + contains parameters. + """ + + name: str + parameters: Optional[list[str]] = None + + model_config = ConfigDict(extra='allow') + + +class WhatsappTemplateSettings(BaseModel): + """Model for WhatsApp template settings. + + Args: + locale (Optional[str]): The BCP 47 language of the template. + policy (Optional[Literal['deterministic']]): Policy for resolving what language + template to use. As of now, the only valid choice is deterministic. + """ + + locale: Optional[str] = 'en_US' + policy: Optional[Literal['deterministic']] = None + + +class WhatsappTemplate(BaseWhatsapp): + """Model for a WhatsApp template message. + + Args: + template (WhatsappTemplateResource): The template to use. + whatsapp (WhatsappTemplateSettings): WhatsApp template settings. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + template: WhatsappTemplateResource + whatsapp: WhatsappTemplateSettings = WhatsappTemplateSettings() + message_type: MessageType = MessageType.TEMPLATE + + +class WhatsappStickerUrl(BaseModel): + """Model for a sticker attachment in a WhatsApp message. + + Args: + url (str): The publicly accessible URL of the sticker attachment. + """ + + url: str + + +class WhatsappStickerId(BaseModel): + """Model for a sticker attachment in a WhatsApp message. + + Args: + id (str): The id of the sticker in relation to a specific WhatsApp deployment. + """ + + id: str + + +class WhatsappSticker(BaseWhatsapp): + """Model for a WhatsApp sticker message. + + Args: + sticker (Union[WhatsappStickerUrl, WhatsappStickerId]): The sticker attachment. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + sticker: Union[WhatsappStickerUrl, WhatsappStickerId] + message_type: MessageType = MessageType.STICKER + + +class WhatsappCustom(BaseWhatsapp): + """Model for a WhatsApp custom message. + + Args: + custom (dict): A custom payload, which is passed directly to WhatsApp for certain + features such as templates and interactive messages. The schema of a custom + object can vary widely. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + + custom: Optional[dict] = None + message_type: MessageType = MessageType.CUSTOM diff --git a/messages/src/vonage_messages/responses.py b/messages/src/vonage_messages/responses.py new file mode 100644 index 00000000..59a84b50 --- /dev/null +++ b/messages/src/vonage_messages/responses.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class SendMessageResponse(BaseModel): + """Response from Vonage's Messages API. + + Attributes: + message_uuid (str): The UUID of the sent message. + """ + + message_uuid: str diff --git a/messages/tests/BUILD b/messages/tests/BUILD new file mode 100644 index 00000000..72fce921 --- /dev/null +++ b/messages/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['messages', 'testutils']) diff --git a/messages/tests/data/invalid_error.json b/messages/tests/data/invalid_error.json new file mode 100644 index 00000000..a59ca83e --- /dev/null +++ b/messages/tests/data/invalid_error.json @@ -0,0 +1,12 @@ +{ + "type": "https://developer.vonage.com/api-errors/messages#1150", + "title": "Invalid params", + "detail": "The value of one or more parameters is invalid.", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf", + "invalid_parameters": [ + { + "name": "messenger.tag", + "reason": "invalid value" + } + ] +} \ No newline at end of file diff --git a/messages/tests/data/low_balance_error.json b/messages/tests/data/low_balance_error.json new file mode 100644 index 00000000..fb1fbde3 --- /dev/null +++ b/messages/tests/data/low_balance_error.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/#low-balance", + "title": "Low balance", + "detail": "This request could not be performed due to your account balance being low.", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" +} \ No newline at end of file diff --git a/messages/tests/data/not_found.json b/messages/tests/data/not_found.json new file mode 100644 index 00000000..a1f4624a --- /dev/null +++ b/messages/tests/data/not_found.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/api-errors#not-found", + "title": "Not Found", + "detail": "Message with ID asdf not found", + "instance": "617431f2-06b7-4798-af36-1b8151df8359" +} \ No newline at end of file diff --git a/messages/tests/data/send_message.json b/messages/tests/data/send_message.json new file mode 100644 index 00000000..b584d2db --- /dev/null +++ b/messages/tests/data/send_message.json @@ -0,0 +1,3 @@ +{ + "message_uuid": "d8f86df1-dec6-442f-870a-2241be27d721" +} \ No newline at end of file diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py new file mode 100644 index 00000000..beba2945 --- /dev/null +++ b/messages/tests/test_messages.py @@ -0,0 +1,147 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient, HttpClientOptions +from vonage_messages.messages import Messages +from vonage_messages.models import Sms +from vonage_messages.models.messenger import ( + MessengerImage, + MessengerOptions, + MessengerResource, +) +from vonage_messages.responses import SendMessageResponse + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +messages = Messages(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_send_message(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/messages', 'send_message.json', 202 + ) + sms = Sms( + from_='Vonage APIs', + to='1234567890', + text='Hello, World!', + ) + response = messages.send(sms) + assert type(response) == SendMessageResponse + assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + + +@responses.activate +def test_send_message_low_balance_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v1/messages', + 'low_balance_error.json', + 402, + ) + + with raises(HttpRequestError) as e: + messages.send(Sms(from_='Vonage APIs', to='1234567890', text='Hello, World!')) + + assert e.value.response.status_code == 402 + assert e.value.response.json()['title'] == 'Low balance' + + +@responses.activate +def test_send_message_invalid_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v1/messages', + 'invalid_error.json', + 422, + ) + + messenger = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + messenger=MessengerOptions(category='message_tag', tag='invalid_tag'), + ) + + with raises(HttpRequestError) as e: + messages.send(messenger) + + assert e.value.response.status_code == 422 + assert e.value.response.json()['title'] == 'Invalid params' + + +def test_http_client_property(): + http_client = HttpClient(get_mock_jwt_auth()) + messages = Messages(http_client) + assert messages.http_client == http_client + + +@responses.activate +def test_mark_whatsapp_message_read(): + responses.add( + responses.PATCH, + 'https://api-eu.vonage.com/v1/messages/asdf', + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + messages.http_client.http_client_options.api_host = 'api-eu.vonage.com' + messages.mark_whatsapp_message_read('asdf') + + +@responses.activate +def test_mark_whatsapp_message_read_not_found(): + build_response( + path, + 'PATCH', + 'https://api-eu.vonage.com/v1/messages/asdf', + 'not_found.json', + 404, + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + with raises(HttpRequestError) as e: + messages.mark_whatsapp_message_read('asdf') + + assert e.value.response.status_code == 404 + assert e.value.response.json()['title'] == 'Not Found' + + +@responses.activate +def test_revoke_rcs_message(): + responses.add( + responses.PATCH, + 'https://api-eu.vonage.com/v1/messages/asdf', + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + messages.http_client.http_client_options.api_host = 'api-eu.vonage.com' + messages.revoke_rcs_message('asdf') + + +@responses.activate +def test_revoke_rcs_message_not_found(): + build_response( + path, + 'PATCH', + 'https://api-eu.vonage.com/v1/messages/asdf', + 'not_found.json', + 404, + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + with raises(HttpRequestError) as e: + messages.revoke_rcs_message('asdf') + + assert e.value.response.status_code == 404 + assert e.value.response.json()['title'] == 'Not Found' diff --git a/messages/tests/test_messenger_models.py b/messages/tests/test_messenger_models.py new file mode 100644 index 00000000..f34ef099 --- /dev/null +++ b/messages/tests/test_messenger_models.py @@ -0,0 +1,224 @@ +from pytest import raises +from vonage_messages.models import ( + MessengerAudio, + MessengerFile, + MessengerImage, + MessengerOptions, + MessengerResource, + MessengerText, + MessengerVideo, +) +from vonage_messages.models.enums import WebhookVersion + + +def test_messenger_options_validator(): + with raises(ValueError): + MessengerOptions(category='message_tag') + + +def test_create_messenger_text(): + messenger_model = MessengerText( + to='1234567890', from_='1234567890', text='Hello, World!' + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'channel': 'messenger', + 'message_type': 'text', + } + + assert messenger_model.model_dump(by_alias=True, exclude_none=True) == messenger_dict + + +def test_create_messenger_text_all_fields(): + messenger_model = MessengerText( + to='1234567890', + from_='1234567890', + text='Hello, World!', + messenger=MessengerOptions(category='message_tag', tag='tag'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'messenger': {'category': 'message_tag', 'tag': 'tag'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'messenger', + 'message_type': 'text', + } + + assert messenger_model.model_dump(by_alias=True) == messenger_dict + + +def test_create_messenger_image(): + messenger_model = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': {'url': 'https://example.com/image.jpg'}, + 'channel': 'messenger', + 'message_type': 'image', + } + + assert messenger_model.model_dump(by_alias=True, exclude_none=True) == messenger_dict + + +def test_create_messenger_image_all_fields(): + messenger_model = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + messenger=MessengerOptions(category='message_tag', tag='tag'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': {'url': 'https://example.com/image.jpg'}, + 'messenger': {'category': 'message_tag', 'tag': 'tag'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'messenger', + 'message_type': 'image', + } + + assert messenger_model.model_dump(by_alias=True) == messenger_dict + + +def test_create_messenger_audio(): + messenger_model = MessengerAudio( + to='1234567890', + from_='1234567890', + audio=MessengerResource(url='https://example.com/audio.mp3'), + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': {'url': 'https://example.com/audio.mp3'}, + 'channel': 'messenger', + 'message_type': 'audio', + } + + assert messenger_model.model_dump(by_alias=True, exclude_none=True) == messenger_dict + + +def test_create_messenger_audio_all_fields(): + messenger_model = MessengerAudio( + to='1234567890', + from_='1234567890', + audio=MessengerResource(url='https://example.com/audio.mp3'), + messenger=MessengerOptions(category='message_tag', tag='tag'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': {'url': 'https://example.com/audio.mp3'}, + 'messenger': {'category': 'message_tag', 'tag': 'tag'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'messenger', + 'message_type': 'audio', + } + + assert messenger_model.model_dump(by_alias=True) == messenger_dict + + +def test_create_messenger_video(): + messenger_model = MessengerVideo( + to='1234567890', + from_='1234567890', + video=MessengerResource(url='https://example.com/video.mp4'), + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': {'url': 'https://example.com/video.mp4'}, + 'channel': 'messenger', + 'message_type': 'video', + } + + assert messenger_model.model_dump(by_alias=True, exclude_none=True) == messenger_dict + + +def test_create_messenger_video_all_fields(): + messenger_model = MessengerVideo( + to='1234567890', + from_='1234567890', + video=MessengerResource(url='https://example.com/video.mp4'), + messenger=MessengerOptions(category='message_tag', tag='tag'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': {'url': 'https://example.com/video.mp4'}, + 'messenger': {'category': 'message_tag', 'tag': 'tag'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'messenger', + 'message_type': 'video', + } + + assert messenger_model.model_dump(by_alias=True) == messenger_dict + + +def test_create_messenger_file(): + messenger_model = MessengerFile( + to='1234567890', + from_='1234567890', + file=MessengerResource(url='https://example.com/file.pdf'), + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': {'url': 'https://example.com/file.pdf'}, + 'channel': 'messenger', + 'message_type': 'file', + } + + assert messenger_model.model_dump(by_alias=True, exclude_none=True) == messenger_dict + + +def test_create_messenger_file_all_fields(): + messenger_model = MessengerFile( + to='1234567890', + from_='1234567890', + file=MessengerResource(url='https://example.com/file.pdf'), + messenger=MessengerOptions(category='message_tag', tag='tag'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + messenger_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': {'url': 'https://example.com/file.pdf'}, + 'messenger': {'category': 'message_tag', 'tag': 'tag'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'messenger', + 'message_type': 'file', + } + + assert messenger_model.model_dump(by_alias=True) == messenger_dict diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py new file mode 100644 index 00000000..c74d6994 --- /dev/null +++ b/messages/tests/test_mms_models.py @@ -0,0 +1,210 @@ +from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from vonage_messages.models.enums import WebhookVersion + + +def test_create_mms_image(): + mms_model = MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource( + url='https://example.com/image.jpg', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': { + 'url': 'https://example.com/image.jpg', + }, + 'channel': 'mms', + 'message_type': 'image', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_image_all_fields(): + mms_model = MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource( + url='https://example.com/image.jpg', + caption='Image caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': { + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'image', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_vcard(): + mms_model = MmsVcard( + to='1234567890', + from_='1234567890', + vcard=MmsResource( + url='https://example.com/vcard.vcf', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'vcard': { + 'url': 'https://example.com/vcard.vcf', + }, + 'channel': 'mms', + 'message_type': 'vcard', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_vcard_all_fields(): + mms_model = MmsVcard( + to='1234567890', + from_='1234567890', + vcard=MmsResource( + url='https://example.com/vcard.vcf', + caption='Vcard caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'vcard': { + 'url': 'https://example.com/vcard.vcf', + 'caption': 'Vcard caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'vcard', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_audio(): + mms_model = MmsAudio( + to='1234567890', + from_='1234567890', + audio=MmsResource( + url='https://example.com/audio.mp3', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': { + 'url': 'https://example.com/audio.mp3', + }, + 'channel': 'mms', + 'message_type': 'audio', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_audio_all_fields(): + mms_model = MmsAudio( + to='1234567890', + from_='1234567890', + audio=MmsResource( + url='https://example.com/audio.mp3', + caption='Audio caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': { + 'url': 'https://example.com/audio.mp3', + 'caption': 'Audio caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'audio', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_video(): + mms_model = MmsVideo( + to='1234567890', + from_='1234567890', + video=MmsResource( + url='https://example.com/video.mp4', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + }, + 'channel': 'mms', + 'message_type': 'video', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_video_all_fields(): + mms_model = MmsVideo( + to='1234567890', + from_='1234567890', + video=MmsResource( + url='https://example.com/video.mp4', + caption='Video caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'video', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py new file mode 100644 index 00000000..4723fa47 --- /dev/null +++ b/messages/tests/test_rcs_models.py @@ -0,0 +1,128 @@ +from vonage_messages.models import ( + RcsCustom, + RcsFile, + RcsImage, + RcsResource, + RcsText, + RcsVideo, +) + + +def test_create_rcs_text(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_all_fields(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + client_ref='client-ref', + webhook_url='https://example.com', + ttl=600, + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'ttl': 600, + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_image(): + rcs_model = RcsImage( + to='1234567890', + from_='asdf1234', + image=RcsResource( + url='https://example.com/image.jpg', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'image': { + 'url': 'https://example.com/image.jpg', + }, + 'channel': 'rcs', + 'message_type': 'image', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_video(): + rcs_model = RcsVideo( + to='1234567890', + from_='asdf1234', + video=RcsResource( + url='https://example.com/video.mp4', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'video': { + 'url': 'https://example.com/video.mp4', + }, + 'channel': 'rcs', + 'message_type': 'video', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_file(): + rcs_model = RcsFile( + to='1234567890', + from_='asdf1234', + file=RcsResource( + url='https://example.com/file.pdf', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'file': { + 'url': 'https://example.com/file.pdf', + }, + 'channel': 'rcs', + 'message_type': 'file', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_custom(): + rcs_model = RcsCustom( + to='1234567890', + from_='asdf1234', + custom={'key': 'value'}, + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'custom': {'key': 'value'}, + 'channel': 'rcs', + 'message_type': 'custom', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py new file mode 100644 index 00000000..49b19771 --- /dev/null +++ b/messages/tests/test_sms_models.py @@ -0,0 +1,54 @@ +from vonage_messages.models import Sms, SmsOptions +from vonage_messages.models.enums import EncodingType, WebhookVersion + + +def test_create_sms(): + sms_model = Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ) + sms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'channel': 'sms', + 'message_type': 'text', + } + + assert sms_model.model_dump(by_alias=True, exclude_none=True) == sms_dict + + +def test_create_sms_all_fields(): + sms_model = Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + sms=SmsOptions( + encoding_type=EncodingType.TEXT, + content_id='content-id', + entity_id='entity-id', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + sms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'sms': { + 'encoding_type': 'text', + 'content_id': 'content-id', + 'entity_id': 'entity-id', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'sms', + 'message_type': 'text', + } + + assert sms_model.model_dump(by_alias=True) == sms_dict diff --git a/messages/tests/test_viber_models.py b/messages/tests/test_viber_models.py new file mode 100644 index 00000000..21373261 --- /dev/null +++ b/messages/tests/test_viber_models.py @@ -0,0 +1,243 @@ +from pytest import raises +from vonage_messages.models import ( + ViberAction, + ViberFile, + ViberFileOptions, + ViberFileResource, + ViberImage, + ViberImageOptions, + ViberImageResource, + ViberText, + ViberTextOptions, + ViberVideo, + ViberVideoOptions, + ViberVideoResource, +) +from vonage_messages.models.enums import WebhookVersion + + +def test_viber_video_options_validator(): + with raises(ValueError): + ViberVideoOptions(duration='601', file_size='10') + + with raises(ValueError): + ViberVideoOptions(duration='100', file_size='201') + + +def test_create_viber_text(): + viber_model = ViberText(to='1234567890', from_='1234567890', text='Hello, World!') + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'channel': 'viber_service', + 'message_type': 'text', + } + + assert viber_model.model_dump(by_alias=True, exclude_none=True) == viber_dict + + +def test_create_viber_text_all_fields(): + viber_model = ViberText( + to='1234567890', + from_='1234567890', + text='Hello, World!', + viber_service=ViberTextOptions( + category='transaction', + ttl=30, + type='string', + action=ViberAction(url='https://example.com', text='text'), + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'viber_service': { + 'category': 'transaction', + 'ttl': 30, + 'type': 'string', + 'action': {'url': 'https://example.com', 'text': 'text'}, + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'viber_service', + 'message_type': 'text', + } + + assert viber_model.model_dump(by_alias=True) == viber_dict + + +def test_create_viber_image(): + viber_model = ViberImage( + to='1234567890', + from_='1234567890', + image=ViberImageResource(url='https://example.com/image.jpg'), + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': {'url': 'https://example.com/image.jpg'}, + 'channel': 'viber_service', + 'message_type': 'image', + } + + assert viber_model.model_dump(by_alias=True, exclude_none=True) == viber_dict + + +def test_create_viber_image_all_fields(): + viber_model = ViberImage( + to='1234567890', + from_='1234567890', + image=ViberImageResource(url='https://example.com/image.jpg', caption='caption'), + viber_service=ViberImageOptions( + category='transaction', + ttl=30, + type='string', + action=ViberAction(url='https://example.com', text='text'), + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': {'url': 'https://example.com/image.jpg', 'caption': 'caption'}, + 'viber_service': { + 'category': 'transaction', + 'ttl': 30, + 'type': 'string', + 'action': {'url': 'https://example.com', 'text': 'text'}, + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'viber_service', + 'message_type': 'image', + } + + assert viber_model.model_dump(by_alias=True) == viber_dict + + +def test_create_viber_video(): + viber_model = ViberVideo( + to='1234567890', + from_='1234567890', + video=ViberVideoResource( + url='https://example.com/video.mp4', thumb_url='https://example.com/thumb.jpg' + ), + viber_service=ViberVideoOptions(duration='100', file_size='10'), + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + 'thumb_url': 'https://example.com/thumb.jpg', + }, + 'viber_service': {'duration': '100', 'file_size': '10'}, + 'channel': 'viber_service', + 'message_type': 'video', + } + + assert viber_model.model_dump(by_alias=True, exclude_none=True) == viber_dict + + +def test_create_viber_video_all_fields(): + viber_model = ViberVideo( + to='1234567890', + from_='1234567890', + video=ViberVideoResource( + url='https://example.com/video.mp4', + thumb_url='https://example.com/thumb.jpg', + caption='caption', + ), + viber_service=ViberVideoOptions( + duration='100', + file_size='10', + category='transaction', + ttl=30, + type='string', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + 'thumb_url': 'https://example.com/thumb.jpg', + 'caption': 'caption', + }, + 'viber_service': { + 'duration': '100', + 'file_size': '10', + 'category': 'transaction', + 'ttl': 30, + 'type': 'string', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'viber_service', + 'message_type': 'video', + } + + assert viber_model.model_dump(by_alias=True) == viber_dict + + +def test_create_viber_file(): + viber_model = ViberFile( + to='1234567890', + from_='1234567890', + file=ViberFileResource(url='https://example.com/file.pdf'), + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': {'url': 'https://example.com/file.pdf'}, + 'channel': 'viber_service', + 'message_type': 'file', + } + + assert viber_model.model_dump(by_alias=True, exclude_none=True) == viber_dict + + +def test_create_viber_file_all_fields(): + viber_model = ViberFile( + to='1234567890', + from_='1234567890', + file=ViberFileResource(url='https://example.com/file.pdf', name='file.pdf'), + viber_service=ViberFileOptions( + category='transaction', + ttl=30, + type='string', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + viber_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': {'url': 'https://example.com/file.pdf', 'name': 'file.pdf'}, + 'viber_service': { + 'category': 'transaction', + 'ttl': 30, + 'type': 'string', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'viber_service', + 'message_type': 'file', + } + + assert viber_model.model_dump(by_alias=True) == viber_dict diff --git a/messages/tests/test_whatsapp_models.py b/messages/tests/test_whatsapp_models.py new file mode 100644 index 00000000..6967d9dc --- /dev/null +++ b/messages/tests/test_whatsapp_models.py @@ -0,0 +1,384 @@ +from copy import deepcopy + +from vonage_messages.models import ( + WhatsappAudio, + WhatsappAudioResource, + WhatsappContext, + WhatsappCustom, + WhatsappFile, + WhatsappFileResource, + WhatsappImage, + WhatsappImageResource, + WhatsappSticker, + WhatsappStickerId, + WhatsappStickerUrl, + WhatsappTemplate, + WhatsappTemplateResource, + WhatsappTemplateSettings, + WhatsappText, + WhatsappVideo, + WhatsappVideoResource, +) +from vonage_messages.models.enums import WebhookVersion + + +def test_whatsapp_text(): + whatsapp_model = WhatsappText( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'channel': 'whatsapp', + 'message_type': 'text', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_text_all_fields(): + whatsapp_model = WhatsappText( + to='1234567890', + from_='1234567890', + text='Hello, World!', + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'text', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + whatsapp_pre_dict = deepcopy(whatsapp_dict) + whatsapp_pre_dict['from_'] = '1234567890' + whatsapp_model_from_dict = WhatsappText(**whatsapp_pre_dict) + assert whatsapp_model_from_dict.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_image(): + whatsapp_model = WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource(url='https://example.com/image.jpg'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': {'url': 'https://example.com/image.jpg'}, + 'channel': 'whatsapp', + 'message_type': 'image', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_image_all_fields(): + whatsapp_model = WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource( + url='https://example.com/image.jpg', + caption='Image caption', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': { + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'image', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_audio(): + whatsapp_model = WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='https://example.com/audio.mp3'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': {'url': 'https://example.com/audio.mp3'}, + 'channel': 'whatsapp', + 'message_type': 'audio', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_audio_all_fields(): + whatsapp_model = WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource( + url='https://example.com/audio.mp3', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': {'url': 'https://example.com/audio.mp3'}, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'audio', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_video(): + whatsapp_model = WhatsappVideo( + to='1234567890', + from_='1234567890', + video=WhatsappVideoResource(url='https://example.com/video.mp4'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': {'url': 'https://example.com/video.mp4'}, + 'channel': 'whatsapp', + 'message_type': 'video', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_video_all_fields(): + whatsapp_model = WhatsappVideo( + to='1234567890', + from_='1234567890', + video=WhatsappVideoResource( + url='https://example.com/video.mp4', + caption='Video caption', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'video', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_file(): + whatsapp_model = WhatsappFile( + to='1234567890', + from_='1234567890', + file=WhatsappFileResource(url='https://example.com/file.pdf'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': {'url': 'https://example.com/file.pdf'}, + 'channel': 'whatsapp', + 'message_type': 'file', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_file_all_fields(): + whatsapp_model = WhatsappFile( + to='1234567890', + from_='1234567890', + file=WhatsappFileResource( + url='https://example.com/file.pdf', + caption='File caption', + name='file.pdf', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + 'name': 'file.pdf', + }, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'file', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_template(): + whatsapp_model = WhatsappTemplate( + to='1234567890', + from_='1234567890', + template=WhatsappTemplateResource(name='template'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'template': {'name': 'template'}, + 'whatsapp': {'locale': 'en_US'}, + 'channel': 'whatsapp', + 'message_type': 'template', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_template_all_fields(): + whatsapp_model = WhatsappTemplate( + to='1234567890', + from_='1234567890', + template=WhatsappTemplateResource( + name='template', parameters=['param1', 'param2'] + ), + whatsapp=WhatsappTemplateSettings(locale='es_ES', policy='deterministic'), + context=WhatsappContext(message_uuid='uuid'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'template': {'name': 'template', 'parameters': ['param1', 'param2']}, + 'whatsapp': {'locale': 'es_ES', 'policy': 'deterministic'}, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'template', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_sticker_url(): + whatsapp_model = WhatsappSticker( + to='1234567890', + from_='1234567890', + sticker=WhatsappStickerUrl(url='https://example.com/sticker.webp'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'sticker': {'url': 'https://example.com/sticker.webp'}, + 'channel': 'whatsapp', + 'message_type': 'sticker', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_sticker_id(): + whatsapp_model = WhatsappSticker( + to='1234567890', from_='1234567890', sticker=WhatsappStickerId(id='sticker-id') + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'sticker': {'id': 'sticker-id'}, + 'channel': 'whatsapp', + 'message_type': 'sticker', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_custom(): + whatsapp_model = WhatsappCustom(to='1234567890', from_='1234567890') + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'channel': 'whatsapp', + 'message_type': 'custom', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_custom_all_fields(): + whatsapp_model = WhatsappCustom( + to='1234567890', + from_='1234567890', + custom={'key': 'value'}, + context=WhatsappContext(message_uuid='uuid'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'custom': {'key': 'value'}, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'custom', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict diff --git a/network_auth/BUILD b/network_auth/BUILD new file mode 100644 index 00000000..8487b434 --- /dev/null +++ b/network_auth/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-network-auth', + dependencies=[ + ':pyproject', + ':readme', + 'network_auth/src/vonage_network_auth', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/network_auth/CHANGES.md b/network_auth/CHANGES.md new file mode 100644 index 00000000..72a7fc85 --- /dev/null +++ b/network_auth/CHANGES.md @@ -0,0 +1,12 @@ +# 1.0.1 +- Update dependency versions + +# 1.0.0 +- Add methods to work with the Vonage Number Verification API +- Internal refactoring + +# 0.1.1b0 +- Add docstrings to data models + +# 0.1.0b0 +- Initial upload \ No newline at end of file diff --git a/network_auth/README.md b/network_auth/README.md new file mode 100644 index 00000000..71bfbdcd --- /dev/null +++ b/network_auth/README.md @@ -0,0 +1,27 @@ +# Vonage Network API Authentication Client + +This package (`vonage-network-auth`) provides a client for authenticating Network APIs that require Oauth2 authentication. Using it, it is possible to generate authenticated JWTs for use with Vonage Network APIs, e.g. Sim Swap, Number Verification. + +This package is intended to be used as part of the `vonage` SDK package, accessing required methods through the SDK instead of directly. Thus, it doesn't require manual installation or configuration unless you're using this package independently of an SDK. + +For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). + +## Installation + +Install from the Python Package Index with pip: + +```bash +pip install vonage-network-auth +``` + +## Usage + +### Create a `NetworkAuth` Object + +```python +from vonage_network_auth import NetworkAuth +from vonage_http_client import HttpClient, Auth + +network_auth = NetworkAuth(HttpClient(Auth(application_id='application-id', private_key='private-key'))) +``` + diff --git a/network_auth/pyproject.toml b/network_auth/pyproject.toml new file mode 100644 index 00000000..c983dd96 --- /dev/null +++ b/network_auth/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "vonage-network-auth" +dynamic = ["version"] +description = "Package for working with Network APIs that require Oauth2 in Python." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_network_auth._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/network_auth/src/vonage_network_auth/BUILD b/network_auth/src/vonage_network_auth/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/network_auth/src/vonage_network_auth/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/network_auth/src/vonage_network_auth/__init__.py b/network_auth/src/vonage_network_auth/__init__.py new file mode 100644 index 00000000..ca379c9e --- /dev/null +++ b/network_auth/src/vonage_network_auth/__init__.py @@ -0,0 +1,10 @@ +from .network_auth import NetworkAuth +from .requests import CreateOidcUrl +from .responses import OidcResponse, TokenResponse + +__all__ = [ + 'NetworkAuth', + 'CreateOidcUrl', + 'OidcResponse', + 'TokenResponse', +] diff --git a/network_auth/src/vonage_network_auth/_version.py b/network_auth/src/vonage_network_auth/_version.py new file mode 100644 index 00000000..cd7ca498 --- /dev/null +++ b/network_auth/src/vonage_network_auth/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.1' diff --git a/network_auth/src/vonage_network_auth/network_auth.py b/network_auth/src/vonage_network_auth/network_auth.py new file mode 100644 index 00000000..62e5440e --- /dev/null +++ b/network_auth/src/vonage_network_auth/network_auth.py @@ -0,0 +1,165 @@ +from urllib.parse import urlencode, urlunparse + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_network_auth.requests import CreateOidcUrl + +from .responses import OidcResponse, TokenResponse + + +class NetworkAuth: + """Class containing methods for authenticating Network APIs following CAMARA + standards.""" + + def __init__(self, http_client: HttpClient): + self._http_client = http_client + self._host = 'api-eu.vonage.com' + self._auth_type = 'jwt' + self._sent_data_type = 'form' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Network Auth API. + + Returns: + HttpClient: The HTTP client used to make requests to the Network Auth API. + """ + return self._http_client + + @validate_call + def get_oidc_url(self, url_settings: CreateOidcUrl) -> str: + """Get the URL to use for authentication in a front-end application. + + Args: + url_settings (CreateOidcUrl): The settings to use for the URL. Settings include: + - redirect_uri (str): The URI to redirect to after authentication. + - state (str): A unique identifier for the request. Can be any string. + - login_hint (str): The phone number to use for the request. + + Returns: + str: The URL to use to make an OIDC request in a front-end application. + """ + base_url = 'https://oidc.idp.vonage.com/oauth2/auth' + + params = { + 'client_id': self._http_client.auth.application_id, + 'redirect_uri': url_settings.redirect_uri, + 'response_type': 'code', + 'scope': url_settings.scope, + 'state': url_settings.state, + 'login_hint': self._ensure_plus_prefix(url_settings.login_hint), + } + + full_url = urlunparse(('', '', base_url, '', urlencode(params), '')) + return full_url + + @validate_call + def get_number_verification_camara_token(self, code: str, redirect_uri: str) -> str: + """Exchange an OIDC authorization code for a CAMARA access token. + + Args: + code (str): The authorization code to use. + redirect_uri (str): The URI to redirect to after authentication. + + Returns: + str: The access token to use for further requests. + """ + params = { + 'code': code, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code', + } + return self._request_access_token(params).access_token + + @validate_call + def get_sim_swap_camara_token(self, number: str, scope: str) -> str: + """Get an OAuth2 user token for a given number and scope, to do a sim swap check. + A CAMARA token is requested using the number and scope, and the token is returned. + + Args: + number (str): The phone number to authenticate. + scope (str): The scope of the token. + + Returns: + str: The OAuth2 user token. + """ + oidc_response = self.make_oidc_auth_id_request(number, scope) + token_response = self.request_sim_swap_access_token(oidc_response.auth_req_id) + return token_response.access_token + + @validate_call + def make_oidc_auth_id_request(self, number: str, scope: str) -> OidcResponse: + """Make an OIDC request for an authentication ID. The auth ID is then used to + request a JWT. Returns a response containing the authentication request ID that + can be used to generate an authorised JWT. Follows the Camara standard. + + Args: + number (str): The phone number to authenticate. + scope (str): The scope of the token. + + Returns: + OidcResponse: A response containing the authentication request ID. + """ + number = self._ensure_plus_prefix(number) + params = {'login_hint': number, 'scope': scope} + + response = self._http_client.post( + self._host, + '/oauth2/bc-authorize', + params, + self._auth_type, + self._sent_data_type, + ) + return OidcResponse(**response) + + @validate_call + def request_sim_swap_access_token( + self, auth_req_id: str, grant_type: str = 'urn:openid:params:grant-type:ciba' + ) -> TokenResponse: + """Request a Camara access token for a SIM Swap check using an authentication + request ID given as a response to an OIDC request. + + Args: + auth_req_id (str): The authentication request ID. + grant_type (str, optional): The grant type. + + Returns: + TokenResponse: A response containing the access token. + """ + params = {'auth_req_id': auth_req_id, 'grant_type': grant_type} + + return self._request_access_token(params) + + @validate_call + def _request_access_token(self, params: dict) -> TokenResponse: + """Request a Camara access token using an authentication request ID given as a + response to an OIDC request. + + Args: + auth_req_id (str): The authentication request ID. + grant_type (str, optional): The grant type. + + Returns: + TokenResponse: A response containing the access token. + """ + response = self._http_client.post( + self._host, + '/oauth2/token', + params, + self._auth_type, + self._sent_data_type, + ) + return TokenResponse(**response) + + def _ensure_plus_prefix(self, number: str) -> str: + """Ensure that the number has a plus prefix. + + Args: + number (str): The phone number to check. + + Returns: + str: The phone number with a plus prefix. + """ + if number.startswith('+'): + return number + return f'+{number}' diff --git a/network_auth/src/vonage_network_auth/requests.py b/network_auth/src/vonage_network_auth/requests.py new file mode 100644 index 00000000..e7452a11 --- /dev/null +++ b/network_auth/src/vonage_network_auth/requests.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import BaseModel + + +class CreateOidcUrl(BaseModel): + """Model to craft a URL for OIDC authentication. + + Args: + redirect_uri (str): The URI to redirect to after authentication. + state (str): A unique identifier for the request. Can be any string. + login_hint (str): The phone number to use for the request. + """ + + redirect_uri: str + state: str + login_hint: str + scope: Optional[ + str + ] = 'openid dpv:FraudPreventionAndDetection#number-verification-verify-read' diff --git a/network_auth/src/vonage_network_auth/responses.py b/network_auth/src/vonage_network_auth/responses.py new file mode 100644 index 00000000..edcc6b62 --- /dev/null +++ b/network_auth/src/vonage_network_auth/responses.py @@ -0,0 +1,33 @@ +from typing import Optional + +from pydantic import BaseModel + + +class OidcResponse(BaseModel): + """Model for an OpenID Connect response. + + Args: + auth_req_id (str): The authentication request ID. + expires_in (int): The time in seconds until the authentication code expires. + interval (int, Optional): The time in seconds until the next request can be made. + """ + + auth_req_id: str + expires_in: int + interval: Optional[int] = None + + +class TokenResponse(BaseModel): + """Model for a token response. + + Args: + access_token (str): The access token. + token_type (str, Optional): The token type. + refresh_token (str, Optional): The refresh token. + expires_in (int, Optional): The time until the token expires. + """ + + access_token: str + token_type: Optional[str] = None + refresh_token: Optional[str] = None + expires_in: Optional[int] = None diff --git a/network_auth/tests/BUILD b/network_auth/tests/BUILD new file mode 100644 index 00000000..0f917372 --- /dev/null +++ b/network_auth/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['network_auth', 'testutils']) diff --git a/network_auth/tests/data/oidc_request.json b/network_auth/tests/data/oidc_request.json new file mode 100644 index 00000000..1ad71776 --- /dev/null +++ b/network_auth/tests/data/oidc_request.json @@ -0,0 +1,5 @@ +{ + "auth_req_id": "arid/8b0d35f3-4627-487c-a776-aegtdsf4rsd2", + "expires_in": 300, + "interval": 0 +} \ No newline at end of file diff --git a/network_auth/tests/data/oidc_request_permissions_error.json b/network_auth/tests/data/oidc_request_permissions_error.json new file mode 100644 index 00000000..873e6c0a --- /dev/null +++ b/network_auth/tests/data/oidc_request_permissions_error.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/api-errors#invalid-param", + "title": "Bad Request", + "detail": "No Network Application associated with Vonage Application: 29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "instance": "b45ae630-7621-42b0-8ff0-6c1ad98e6e32" +} \ No newline at end of file diff --git a/network_auth/tests/data/token_request.json b/network_auth/tests/data/token_request.json new file mode 100644 index 00000000..cd10a01f --- /dev/null +++ b/network_auth/tests/data/token_request.json @@ -0,0 +1,5 @@ +{ + "access_token": "eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg", + "token_type": "bearer", + "expires_in": 29 +} \ No newline at end of file diff --git a/network_auth/tests/test_network_auth.py b/network_auth/tests/test_network_auth.py new file mode 100644 index 00000000..c25e6cb8 --- /dev/null +++ b/network_auth/tests/test_network_auth.py @@ -0,0 +1,143 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_network_auth import NetworkAuth +from vonage_network_auth.responses import OidcResponse + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +network_auth = NetworkAuth(HttpClient(get_mock_jwt_auth())) + + +def test_http_client_property(): + http_client = network_auth.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_oidc_request(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/bc-authorize', + 'oidc_request.json', + ) + + response = network_auth.make_oidc_auth_id_request( + number='447700900000', + scope='dpv:FraudPreventionAndDetection#check-sim-swap', + ) + + assert response.auth_req_id == 'arid/8b0d35f3-4627-487c-a776-aegtdsf4rsd2' + assert response.expires_in == 300 + assert response.interval == 0 + + +@responses.activate +def test_sim_swap_token(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/token', + 'token_request.json', + ) + + oidc_response_dict = { + 'auth_req_id': '0dadaeb4-7c79-4d39-b4b0-5a6cc08bf537', + 'expires_in': '120', + 'interval': '2', + } + oidc_response = OidcResponse(**oidc_response_dict) + response = network_auth.request_sim_swap_access_token(oidc_response.auth_req_id) + + assert ( + response.access_token + == 'eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg' + ) + assert response.token_type == 'bearer' + assert response.expires_in == 29 + + +@responses.activate +def test_whole_oauth2_flow(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/bc-authorize', + 'oidc_request.json', + ) + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/token', + 'token_request.json', + ) + + access_token = network_auth.get_sim_swap_camara_token( + number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap' + ) + assert ( + access_token + == 'eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg' + ) + + +def test_number_plus_prefixes(): + assert network_auth._ensure_plus_prefix('447700900000') == '+447700900000' + assert network_auth._ensure_plus_prefix('+447700900000') == '+447700900000' + + +@responses.activate +def test_oidc_request_permissions_error(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/bc-authorize', + 'oidc_request_permissions_error.json', + status_code=400, + ) + + with raises(HttpRequestError) as err: + network_auth.make_oidc_auth_id_request( + number='447700900000', + scope='dpv:FraudPreventionAndDetection#check-sim-swap', + ) + assert err.match('"title": "Bad Request"') + + +def test_get_oidc_url(): + url_options = { + 'redirect_uri': 'https://example.com/callback', + 'state': 'state_id', + 'login_hint': '447700900000', + } + response = network_auth.get_oidc_url(url_options) + + assert ( + response + == 'https://oidc.idp.vonage.com/oauth2/auth?client_id=test_application_id&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=openid+dpv%3AFraudPreventionAndDetection%23number-verification-verify-read&state=state_id&login_hint=%2B447700900000' + ) + + +@responses.activate +def test_get_number_verification_camara_token(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/token', + 'token_request.json', + ) + token = network_auth.get_number_verification_camara_token( + 'code', 'https://example.com/redirect' + ) + + assert ( + token + == 'eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg' + ) diff --git a/network_number_verification/BUILD b/network_number_verification/BUILD new file mode 100644 index 00000000..d2c51e5e --- /dev/null +++ b/network_number_verification/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-network-number-verification', + dependencies=[ + ':pyproject', + ':readme', + 'network_number_verification/src/vonage_network_number_verification', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/network_number_verification/CHANGES.md b/network_number_verification/CHANGES.md new file mode 100644 index 00000000..38ac7bab --- /dev/null +++ b/network_number_verification/CHANGES.md @@ -0,0 +1,5 @@ +# 1.0.1 +- Update dependency versions + +# 1.0.0 +- Initial upload \ No newline at end of file diff --git a/network_number_verification/README.md b/network_number_verification/README.md new file mode 100644 index 00000000..d170d32e --- /dev/null +++ b/network_number_verification/README.md @@ -0,0 +1,63 @@ +# Vonage Number Verification Network API Client + +This package (`vonage-network-number-verification`) allows you to verify a mobile device. It verifies the phone number linked to the SIM card in a device which is connected to a mobile data network, without any user input. + +This package is not intended to be used directly, instead being accessed from an enclosing SDK package. Thus, it doesn't require manual installation or configuration unless you're using this package independently of an SDK. + +For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). + +## Registering to Use the Network Number Verification API + +To use this API, you must first create and register a business profile with the Vonage Network Registry. [This documentation page](https://developer.vonage.com/en/getting-started-network/registration) explains how this can be done. You need to obtain approval for each network and region you want to use the APIs in. + +## Installation + +Install from the Python Package Index with pip: + +```bash +pip install vonage-network-number-verification +``` + +## Usage + +It is recommended to use this as part of the `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. + +The Vonage Number Verification API uses Oauth2 authentication, which this SDK will also help you to do. Verifying a number has 3 stages: + +1. Get an OIDC URL for use in your front-end application +2. Use this URL in your own application to get an authorization code +3. Make a Number Verification Request using this code to verify the number + +This package contains methods to help with Steps 1 and 3. + +### Get an OIDC URL + +```python +from vonage_network_number_verification import CreateOidcUrl + +url_options = CreateOidcUrl( + redirect_uri='https://example.com/redirect', + state='c9896ee6-4ff8-464c-b393-d56d6e638f88', + login_hint='+990123456', +) + +url = number_verification.get_oidc_url(url_options) +print(url) +``` + +Get your user's device to follow this URL and a code to use for number verification will be returned in the final redirect query parameters. Note: your user must be connected to their mobile network. + +### Make a Number Verification Request + +```python +from vonage_network_number_verification import NumberVerificationRequest + +response = number_verification.verify( + NumberVerificationRequest( + code='code', + redirect_uri='https://example.com/redirect', + phone_number='+990123456', + ) +) +print(response.device_phone_number_verified) +``` \ No newline at end of file diff --git a/network_number_verification/pyproject.toml b/network_number_verification/pyproject.toml new file mode 100644 index 00000000..f270235f --- /dev/null +++ b/network_number_verification/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "vonage-network-number-verification" +dynamic = ["version"] +description = "Package for working with the Vonage Number Verification Network API." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-network-auth>=1.0.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_network_number_verification._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/network_number_verification/src/vonage_network_number_verification/BUILD b/network_number_verification/src/vonage_network_number_verification/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/network_number_verification/src/vonage_network_number_verification/__init__.py b/network_number_verification/src/vonage_network_number_verification/__init__.py new file mode 100644 index 00000000..ec46a53c --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/__init__.py @@ -0,0 +1,12 @@ +from .errors import NetworkNumberVerificationError +from .number_verification import CreateOidcUrl, NetworkNumberVerification +from .requests import NumberVerificationRequest +from .responses import NumberVerificationResponse + +__all__ = [ + 'NetworkNumberVerification', + 'CreateOidcUrl', + 'NumberVerificationRequest', + 'NumberVerificationResponse', + 'NetworkNumberVerificationError', +] diff --git a/network_number_verification/src/vonage_network_number_verification/_version.py b/network_number_verification/src/vonage_network_number_verification/_version.py new file mode 100644 index 00000000..cd7ca498 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.1' diff --git a/network_number_verification/src/vonage_network_number_verification/errors.py b/network_number_verification/src/vonage_network_number_verification/errors.py new file mode 100644 index 00000000..c24f7872 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/errors.py @@ -0,0 +1,5 @@ +from vonage_utils import VonageError + + +class NetworkNumberVerificationError(VonageError): + """Base class for Vonage Network Number Verification errors.""" diff --git a/network_number_verification/src/vonage_network_number_verification/number_verification.py b/network_number_verification/src/vonage_network_number_verification/number_verification.py new file mode 100644 index 00000000..67820e04 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/number_verification.py @@ -0,0 +1,83 @@ +from pydantic import validate_call +from vonage_http_client import HttpClient +from vonage_network_auth import NetworkAuth +from vonage_network_auth.requests import CreateOidcUrl +from vonage_network_number_verification.requests import NumberVerificationRequest +from vonage_network_number_verification.responses import NumberVerificationResponse + + +class NetworkNumberVerification: + """Class containing methods for working with the Vonage Number Verification Network + API.""" + + def __init__(self, http_client: HttpClient): + self._http_client = http_client + self._host = 'api-eu.vonage.com' + + self._auth_type = 'oauth2' + self._network_auth = NetworkAuth(self._http_client) + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Network Sim Swap API. + + Returns: + HttpClient: The HTTP client used to make requests to the Network Sim Swap API. + """ + return self._http_client + + @validate_call + def get_oidc_url(self, url_settings: CreateOidcUrl) -> str: + """Get the URL to use for authentication in a front-end application. + + Args: + url_settings (CreateOidcUrl): The settings to use for the URL. Settings include: + - redirect_uri (str): The URI to redirect to after authentication. + - state (str, optional): A unique identifier for the request. Can be any string. + - login_hint (str, optional): The phone number to use for the request. + + Returns: + str: The URL to use to make an OIDC request in a front-end application. + """ + return self._network_auth.get_oidc_url(url_settings) + + @validate_call + def verify( + self, number_verification_params: NumberVerificationRequest + ) -> NumberVerificationResponse: + """Verify if the specified phone number matches the one that the user is currently + using. + + Args: + number_verification_params (NumberVerificationRequest): The parameters to use for + the verification. Parameters include: + - code (str): The code returned from the OIDC redirect. + - redirect_uri (str): The URI to redirect to after authentication. + - phone_number (str, optional): The phone number to verify. Use the E.164 format with + or without a leading +. + - hashed_phone_number (str, optional): The hashed phone number to verify. + + Returns: + NumberVerificationResponse: The Number Verification response containing the + device verification information. + """ + + access_token = self._network_auth.get_number_verification_camara_token( + number_verification_params.code, number_verification_params.redirect_uri + ) + + params = {} + if number_verification_params.phone_number is not None: + params = {'phoneNumber': number_verification_params.phone_number} + else: + params = {'hashedPhoneNumber': number_verification_params.hashed_phone_number} + + response = self._http_client.post( + self._host, + '/camara/number-verification/v031/verify', + params=params, + auth_type=self._auth_type, + token=access_token, + ) + + return NumberVerificationResponse(**response) diff --git a/network_number_verification/src/vonage_network_number_verification/requests.py b/network_number_verification/src/vonage_network_number_verification/requests.py new file mode 100644 index 00000000..a5a2cde2 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/requests.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, Field, model_validator +from vonage_network_number_verification.errors import NetworkNumberVerificationError + + +class NumberVerificationRequest(BaseModel): + """Model for the request to verify a phone number. + + Args: + code (str): The code returned from the OIDC redirect. + redirect_uri (str): The URI to redirect to after authentication. + phone_number (str): The phone number to verify. Use the E.164 format with + or without a leading +. + hashed_phone_number (str): The hashed phone number to verify. + """ + + code: str + redirect_uri: str + phone_number: str = Field(None, serialization_alias='phoneNumber') + hashed_phone_number: str = Field(None, serialization_alias='hashedPhoneNumber') + + @model_validator(mode='after') + def check_only_one_phone_number(self): + """Check that only one of `phone_number` and `hashed_phone_number` is set.""" + + if self.phone_number is not None and self.hashed_phone_number is not None: + raise NetworkNumberVerificationError( + 'Only one of `phone_number` and `hashed_phone_number` can be set.' + ) + + if self.phone_number is None and self.hashed_phone_number is None: + raise NetworkNumberVerificationError( + 'One of `phone_number` and `hashed_phone_number` must be set.' + ) + + return self diff --git a/network_number_verification/src/vonage_network_number_verification/responses.py b/network_number_verification/src/vonage_network_number_verification/responses.py new file mode 100644 index 00000000..de1e9722 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/responses.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, Field + + +class NumberVerificationResponse(BaseModel): + """Model for the response from the Number Verification API. + + Args: + device_phone_number_verified (bool): Whether the phone number has been + successfully verified. + """ + + device_phone_number_verified: bool = Field( + ..., validation_alias='devicePhoneNumberVerified' + ) diff --git a/network_number_verification/tests/BUILD b/network_number_verification/tests/BUILD new file mode 100644 index 00000000..c7d95e9b --- /dev/null +++ b/network_number_verification/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['network_number_verification', 'testutils']) diff --git a/network_number_verification/tests/data/token_request.json b/network_number_verification/tests/data/token_request.json new file mode 100644 index 00000000..cd10a01f --- /dev/null +++ b/network_number_verification/tests/data/token_request.json @@ -0,0 +1,5 @@ +{ + "access_token": "eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYW51YmlzLWNlcnRzLWMxLWV1dzEucHJvZC52MS52b25hZ2VuZXR3b3Jrcy5uZXQvandrcyIsImtpZCI6IkNOPVZvbmFnZSAxdmFwaWd3IEludGVybmFsIENBOjoxOTUxODQ2ODA3NDg1NTYwNjYzODY3MTM0NjE2MjU2MTU5MjU2NDkiLCJ0eXAiOiJKV1QiLCJ4NXUiOiJodHRwczovL2FudWJpcy1jZXJ0cy1jMS1ldXcxLnByb2QudjEudm9uYWdlbmV0d29ya3MubmV0L3YxL2NlcnRzLzA4NjliNDMyZTEzZmIyMzcwZTk2ZGI4YmUxMDc4MjJkIn0.eyJwcmluY2lwYWwiOnsiYXBpS2V5IjoiNGI1MmMwMGUiLCJhcHBsaWNhdGlvbklkIjoiMmJlZTViZWQtNmZlZS00ZjM2LTkxNmQtNWUzYjRjZDI1MjQzIiwibWFzdGVyQWNjb3VudElkIjoiNGI1MmMwMGUiLCJjYXBhYmlsaXRpZXMiOlsibmV0d29yay1hcGktZmVhdHVyZXMiXSwiZXh0cmFDb25maWciOnsiY2FtYXJhU3RhdGUiOiJmb0ZyQndnOFNmeGMydnd2S1o5Y3UrMlgrT0s1K2FvOWhJTTVGUGZMQ1dOeUlMTHR3WmY1dFRKbDdUc1p4QnY4QWx3aHM2bFNWcGVvVkhoWngvM3hUenFRWVkwcHpIZE5XL085ZEdRN1RKOE9sU1lDdTFYYXFEcnNFbEF4WEJVcUpGdnZTTkp5a1A5ZDBYWVN4ajZFd0F6UUFsNGluQjE1c3VMRFNsKy82U1FDa29Udnpld0tvcFRZb0F5MVg2dDJVWXdEVWFDNjZuOS9kVWxIemN3V0NGK3QwOGNReGxZVUxKZyt3T0hwV2xvWGx1MGc3REx0SCtHd0pvRGJoYnMyT2hVY3BobGZqajBpeHQ1OTRsSG5sQ1NYNkZrMmhvWEhKUW01S3JtOVBKSmttK0xTRjVsRTd3NUxtWTRvYTFXSGpkY0dwV1VsQlNQY000YnprOGU0bVE9PSJ9fSwiZmVkZXJhdGVkQXNzZXJ0aW9ucyI6e30sImF1ZCI6ImFwaS1ldS52b25hZ2UuY29tIiwiZXhwIjoxNzE3MDkyODY4LCJqdGkiOiJmNDZhYTViOC1hODA2LTRjMzctODQyMS02OGYwMzJjNDlhMWYiLCJpYXQiOjE3MTcwOTE5NzAsImlzcyI6IlZJQU0tSUFQIiwibmJmIjoxNzE3MDkxOTU1fQ.iLUbyDPR1HGLKh29fy6fqK65Q1O7mjWOletAEPJD4eu7gb0E85EL4M9R7ckJq5lIvgedQt3vBheTaON9_u-VYjMqo8ulPoEoGUDHbOzNbs4MmCW0_CRdDPGyxnUhvcbuJhPgnEHxmfHjJBljncUnk-Z7XCgyNajBNXeQQnHkRF_6NMngxJ-qjjhqbYL0VsF_JS7-TXxixNL0KAFl0SeN2DjkfwRBCclP-69CTExDjyOvouAcchqi-6ZYj_tXPCrTADuzUrQrW8C5nHp2-XjWJSFKzyvi48n8V1U6KseV-eYzBzvy7bJf0tRMX7G6gctTYq3DxdC_eXvXlnp1zx16mg", + "token_type": "bearer", + "expires_in": 29 +} \ No newline at end of file diff --git a/network_number_verification/tests/data/verify_number.json b/network_number_verification/tests/data/verify_number.json new file mode 100644 index 00000000..0eaf6b46 --- /dev/null +++ b/network_number_verification/tests/data/verify_number.json @@ -0,0 +1,3 @@ +{ + "devicePhoneNumberVerified": true +} \ No newline at end of file diff --git a/network_number_verification/tests/test_number_verification.py b/network_number_verification/tests/test_number_verification.py new file mode 100644 index 00000000..e70a408c --- /dev/null +++ b/network_number_verification/tests/test_number_verification.py @@ -0,0 +1,114 @@ +from os.path import abspath +from unittest.mock import MagicMock, patch + +import responses +from pytest import raises +from vonage_http_client.http_client import HttpClient +from vonage_network_auth.requests import CreateOidcUrl +from vonage_network_number_verification.errors import NetworkNumberVerificationError +from vonage_network_number_verification.number_verification import ( + NetworkNumberVerification, +) +from vonage_network_number_verification.requests import NumberVerificationRequest + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +number_verification = NetworkNumberVerification(HttpClient(get_mock_jwt_auth())) + + +def test_http_client_property(): + http_client = number_verification.http_client + assert isinstance(http_client, HttpClient) + + +def test_get_oidc_url(): + url_options = CreateOidcUrl( + redirect_uri='https://example.com/callback', + state='state_id', + login_hint='447700900000', + ) + response = number_verification.get_oidc_url(url_options) + + assert ( + response + == 'https://oidc.idp.vonage.com/oauth2/auth?client_id=test_application_id&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=openid+dpv%3AFraudPreventionAndDetection%23number-verification-verify-read&state=state_id&login_hint=%2B447700900000' + ) + + +@patch('vonage_network_auth.NetworkAuth.get_number_verification_camara_token') +@responses.activate +def test_verify_number(mock_get_number_verification_camara_token: MagicMock): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/token', + 'token_request.json', + ) + + mock_get_number_verification_camara_token.return_value = 'token' + + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/camara/number-verification/v031/verify', + 'verify_number.json', + ) + + number_verification_params = NumberVerificationRequest( + code='token', + redirect_uri='https://example.com/callback', + phone_number='447700900000', + ) + response = number_verification.verify(number_verification_params) + + assert response.device_phone_number_verified == True + + +@patch('vonage_network_auth.NetworkAuth.get_number_verification_camara_token') +@responses.activate +def test_verify_hashed_number(mock_get_number_verification_camara_token: MagicMock): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/oauth2/token', + 'token_request.json', + ) + + mock_get_number_verification_camara_token.return_value = 'token' + + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/camara/number-verification/v031/verify', + 'verify_number.json', + ) + + number_verification_params = NumberVerificationRequest( + code='token', + redirect_uri='https://example.com/callback', + hashed_phone_number='d867b6540ac8db72d860d67d3d612a1621adcf3277573e9299be1153b6d0de15', + ) + response = number_verification.verify(number_verification_params) + + assert response.device_phone_number_verified == True + + +def test_verify_number_model_errors(): + with raises(NetworkNumberVerificationError): + number_verification.verify( + NumberVerificationRequest( + code='code', redirect_uri='https://example.com/callback' + ) + ) + + with raises(NetworkNumberVerificationError): + number_verification.verify( + NumberVerificationRequest( + code='code', + redirect_uri='https://example.com/callback', + phone_number='447700900000', + hashed_phone_number='hash', + ) + ) diff --git a/network_sim_swap/BUILD b/network_sim_swap/BUILD new file mode 100644 index 00000000..11bc974e --- /dev/null +++ b/network_sim_swap/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-network-sim-swap', + dependencies=[ + ':pyproject', + ':readme', + 'network_sim_swap/src/vonage_network_sim_swap', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/network_sim_swap/CHANGES.md b/network_sim_swap/CHANGES.md new file mode 100644 index 00000000..8d7cd951 --- /dev/null +++ b/network_sim_swap/CHANGES.md @@ -0,0 +1,14 @@ +# 1.1.1 +- Update dependency versions + +# 1.1.0 +- Add new model `SimSwapCheckRequest` to replace arguments in the `SimSwap.check` method + +# 1.0.0 +- Support for Python 3.13, drop support for 3.8 + +# 0.1.1b0 +- Add docstrings to data models + +# 0.1.0b0 +- Initial upload \ No newline at end of file diff --git a/network_sim_swap/README.md b/network_sim_swap/README.md new file mode 100644 index 00000000..2f764842 --- /dev/null +++ b/network_sim_swap/README.md @@ -0,0 +1,39 @@ +# Vonage Sim Swap Network API Client + +This package (`vonage-network-sim-swap`) allows you to check whether a SIM card has been swapped, and the last swap date. + +This package is not intended to be used directly, instead being accessed from an enclosing SDK package. Thus, it doesn't require manual installation or configuration unless you're using this package independently of an SDK. + +For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). + +## Registering to Use the Sim Swap API + +To use this API, you must first create and register your business profile with the Vonage Network Registry. [This documentation page](https://developer.vonage.com/en/getting-started-network/registration) explains how this can be done. You need to obtain approval for each network and region you want to use the APIs in. + +## Installation + +Install from the Python Package Index with pip: + +```bash +pip install vonage-network-sim-swap +``` + +## Usage + +It is recommended to use this as part of the `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. + +### Check if a SIM Has Been Swapped + +```python +from vonage_network_sim_swap import SwapStatus +swap_status: SwapStatus = vonage_client.sim_swap.check(phone_number='MY_NUMBER') +print(swap_status.swapped) +``` + +### Get the Date of the Last SIM Swap + +```python +from vonage_network_sim_swap import LastSwapDate +swap_date: LastSwapDate = vonage_client.sim_swap.get_last_swap_date +print(swap_date.last_swap_date) +``` \ No newline at end of file diff --git a/network_sim_swap/pyproject.toml b/network_sim_swap/pyproject.toml new file mode 100644 index 00000000..d14bd321 --- /dev/null +++ b/network_sim_swap/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "vonage-network-sim-swap" +dynamic = ["version"] +description = "Package for working with the Vonage Sim Swap Network API." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-network-auth>=1.0.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_network_sim_swap._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/network_sim_swap/src/vonage_network_sim_swap/BUILD b/network_sim_swap/src/vonage_network_sim_swap/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/network_sim_swap/src/vonage_network_sim_swap/__init__.py b/network_sim_swap/src/vonage_network_sim_swap/__init__.py new file mode 100644 index 00000000..2e6bd8cb --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/__init__.py @@ -0,0 +1,5 @@ +from .requests import SimSwapCheckRequest +from .responses import LastSwapDate, SwapStatus +from .sim_swap import NetworkSimSwap + +__all__ = ['NetworkSimSwap', 'LastSwapDate', 'SimSwapCheckRequest', 'SwapStatus'] diff --git a/network_sim_swap/src/vonage_network_sim_swap/_version.py b/network_sim_swap/src/vonage_network_sim_swap/_version.py new file mode 100644 index 00000000..b3ddbc41 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.1' diff --git a/network_sim_swap/src/vonage_network_sim_swap/requests.py b/network_sim_swap/src/vonage_network_sim_swap/requests.py new file mode 100644 index 00000000..717d2865 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/requests.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class SimSwapCheckRequest(BaseModel): + """Request model to check if a SIM has been swapped using the Vonage Sim Swap Network + API. + + Args: + phone_number (str): The phone number to check. Use the E.164 format with + or without a leading +. + max_age (int, optional): Period in hours to be checked for SIM swap. + """ + + phone_number: str = Field(..., serialization_alias='phoneNumber') + max_age: Optional[int] = Field(None, serialization_alias='maxAge') diff --git a/network_sim_swap/src/vonage_network_sim_swap/responses.py b/network_sim_swap/src/vonage_network_sim_swap/responses.py new file mode 100644 index 00000000..20924cb4 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/responses.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, Field + + +class SwapStatus(BaseModel): + """Model for the status of a SIM swap. + + Args: + swapped (str): Indicates whether the SIM card has been swapped during the period + within the `max_age` provided in the request. + """ + + swapped: str + + +class LastSwapDate(BaseModel): + """Model for the last SIM swap date information. + + Args: + last_swap_date (str): The timestamp of the latest SIM swap performed. + """ + + last_swap_date: str = Field(..., validation_alias='latestSimChange') diff --git a/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py new file mode 100644 index 00000000..c9c5cf5b --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py @@ -0,0 +1,73 @@ +from pydantic import validate_call +from vonage_http_client import HttpClient +from vonage_network_auth import NetworkAuth +from vonage_network_sim_swap.requests import SimSwapCheckRequest + +from .responses import LastSwapDate, SwapStatus + + +class NetworkSimSwap: + """Class containing methods for working with the Vonage SIM Swap Network API.""" + + def __init__(self, http_client: HttpClient): + self._http_client = http_client + self._host = 'api-eu.vonage.com' + + self._auth_type = 'oauth2' + self._network_auth = NetworkAuth(self._http_client) + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Network Sim Swap API. + + Returns: + HttpClient: The HTTP client used to make requests to the Network Sim Swap API. + """ + return self._http_client + + @validate_call + def check(self, sim_swap_request: SimSwapCheckRequest) -> SwapStatus: + """Check if a SIM swap has been performed in a given time frame. + + Args: + sim_swap_request (SimSwapCheckRequest): The request model to check if a SIM + has been swapped. + + Returns: + SwapStatus: Class containing the Swap Status response. + """ + token = self._network_auth.get_sim_swap_camara_token( + number=sim_swap_request.phone_number, + scope='dpv:FraudPreventionAndDetection#check-sim-swap', + ) + + return self._http_client.post( + self._host, + '/camara/sim-swap/v040/check', + params=sim_swap_request.model_dump(by_alias=True, exclude_none=True), + auth_type=self._auth_type, + token=token, + ) + + @validate_call + def get_last_swap_date(self, phone_number: str) -> LastSwapDate: + """Get the last SIM swap date for a phone number. + + Args: + phone_number (str): The phone number to check. Use the E.164 format with + or without a leading +. + + Returns: + LastSwapDate: Class containing the Last Swap Date response. + """ + token = self._network_auth.get_sim_swap_camara_token( + number=phone_number, + scope='dpv:FraudPreventionAndDetection#retrieve-sim-swap-date', + ) + return self._http_client.post( + self._host, + '/camara/sim-swap/v040/retrieve-date', + params={'phoneNumber': phone_number}, + auth_type=self._auth_type, + token=token, + ) diff --git a/network_sim_swap/tests/BUILD b/network_sim_swap/tests/BUILD new file mode 100644 index 00000000..b77e3c32 --- /dev/null +++ b/network_sim_swap/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['network_sim_swap', 'testutils']) diff --git a/network_sim_swap/tests/data/check_sim_swap.json b/network_sim_swap/tests/data/check_sim_swap.json new file mode 100644 index 00000000..8d90e1b6 --- /dev/null +++ b/network_sim_swap/tests/data/check_sim_swap.json @@ -0,0 +1,3 @@ +{ + "swapped": true +} \ No newline at end of file diff --git a/network_sim_swap/tests/data/get_swap_date.json b/network_sim_swap/tests/data/get_swap_date.json new file mode 100644 index 00000000..13d48322 --- /dev/null +++ b/network_sim_swap/tests/data/get_swap_date.json @@ -0,0 +1,3 @@ +{ + "latestSimChange": "2023-12-22T04:00:44.000Z" +} \ No newline at end of file diff --git a/network_sim_swap/tests/test_sim_swap.py b/network_sim_swap/tests/test_sim_swap.py new file mode 100644 index 00000000..e4ab50eb --- /dev/null +++ b/network_sim_swap/tests/test_sim_swap.py @@ -0,0 +1,52 @@ +from os.path import abspath +from unittest.mock import MagicMock, patch + +import responses +from vonage_http_client.http_client import HttpClient +from vonage_network_sim_swap import NetworkSimSwap +from vonage_network_sim_swap.requests import SimSwapCheckRequest + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +sim_swap = NetworkSimSwap(HttpClient(get_mock_jwt_auth())) + + +def test_http_client_property(): + http_client = sim_swap.http_client + assert isinstance(http_client, HttpClient) + + +@patch('vonage_network_auth.NetworkAuth.get_sim_swap_camara_token') +@responses.activate +def test_check_sim_swap(mock_get_oauth2_user_token: MagicMock): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/camara/sim-swap/v040/check', + 'check_sim_swap.json', + ) + mock_get_oauth2_user_token.return_value = 'token' + + response = sim_swap.check( + SimSwapCheckRequest(phone_number='447700900000', max_age=24) + ) + + assert response['swapped'] == True + + +@patch('vonage_network_auth.NetworkAuth.get_sim_swap_camara_token') +@responses.activate +def test_get_last_swap_date(mock_get_oauth2_user_token: MagicMock): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/camara/sim-swap/v040/retrieve-date', + 'get_swap_date.json', + ) + mock_get_oauth2_user_token.return_value = 'token' + + response = sim_swap.get_last_swap_date('447700900000') + + assert response['latestSimChange'] == '2023-12-22T04:00:44.000Z' diff --git a/number_insight/BUILD b/number_insight/BUILD new file mode 100644 index 00000000..b3212a5f --- /dev/null +++ b/number_insight/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-number-insight', + dependencies=[ + ':pyproject', + ':readme', + 'number_insight/src/vonage_number_insight', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md new file mode 100644 index 00000000..c1abf577 --- /dev/null +++ b/number_insight/CHANGES.md @@ -0,0 +1,14 @@ +# 1.0.4 +- Update dependency versions + +# 1.0.3 +- Rename `basic_number_insight` -> `get_basic_info`, `standard_number_insight` -> `get_standard_info`, `advanced_async_number_insight` -> `get_advanced_info_async`, `advanced_sync_number_insight` -> `get_advanced_info_sync` + +# 1.0.2 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.1 +- Add docstrings to data models + +# 1.0.0 +- Initial upload diff --git a/number_insight/README.md b/number_insight/README.md new file mode 100644 index 00000000..5a128b3d --- /dev/null +++ b/number_insight/README.md @@ -0,0 +1,58 @@ +# Vonage Number Insight Package + +This package contains the code to use [Vonage's Number Insight API](https://developer.vonage.com/en/number-insight/overview) in Python. This package includes methods to get information about phone numbers. It has 3 levels of insight: basic, standard, and advanced. + +The advanced insight can be obtained synchronously or asynchronously. An async approach is recommended to avoid timeouts. Optionally, you can get caller name information (additional charge) by passing the `cnam` parameter to a standard or advanced insight request. + +## 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 Basic Number Insight Request + +```python +from vonage_number_insight import BasicInsightRequest + +response = vonage_client.number_insight.basic_number_insight( + BasicInsightRequest(number='12345678900') +) + +print(response.model_dump(exclude_none=True)) +``` + +### Make a Standard Number Insight Request + +```python +from vonage_number_insight import StandardInsightRequest + +vonage_client.number_insight.standard_number_insight( + StandardInsightRequest(number='12345678900') +) + +# Optionally, you can get caller name information (additional charge) by setting the `cnam` parameter = True +vonage_client.number_insight.standard_number_insight( + StandardInsightRequest(number='12345678900', cnam=True) +) +``` + +### Make an Asynchronous Advanced Number Insight Request + +When making an asynchronous advanced number insight request, the API will return basic information about the request to you immediately and send the full data to the webhook callback URL you specify. + +```python +from vonage_number_insight import AdvancedAsyncInsightRequest + +vonage_client.number_insight.advanced_async_number_insight( + AdvancedAsyncInsightRequest(callback='https://example.com', number='12345678900') +) +``` + +### Make a Synchronous Advanced Number Insight Request + +```python +from vonage_number_insight import AdvancedSyncInsightRequest + +vonage_client.number_insight.advanced_sync_number_insight( + AdvancedSyncInsightRequest(number='12345678900') +) +``` \ No newline at end of file diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml new file mode 100644 index 00000000..8dbd7558 --- /dev/null +++ b/number_insight/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-number-insight' +dynamic = ["version"] +description = 'Vonage Number Insight package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_number_insight._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/number_insight/src/vonage_number_insight/BUILD b/number_insight/src/vonage_number_insight/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/number_insight/src/vonage_number_insight/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/number_insight/src/vonage_number_insight/__init__.py b/number_insight/src/vonage_number_insight/__init__.py new file mode 100644 index 00000000..b10cfa05 --- /dev/null +++ b/number_insight/src/vonage_number_insight/__init__.py @@ -0,0 +1,33 @@ +from . import errors +from .number_insight import NumberInsight +from .requests import ( + AdvancedAsyncInsightRequest, + AdvancedSyncInsightRequest, + BasicInsightRequest, + StandardInsightRequest, +) +from .responses import ( + AdvancedAsyncInsightResponse, + AdvancedSyncInsightResponse, + BasicInsightResponse, + CallerIdentity, + Carrier, + RoamingStatus, + StandardInsightResponse, +) + +__all__ = [ + 'NumberInsight', + 'BasicInsightRequest', + 'StandardInsightRequest', + 'AdvancedAsyncInsightRequest', + 'AdvancedSyncInsightRequest', + 'BasicInsightResponse', + 'CallerIdentity', + 'Carrier', + 'RoamingStatus', + 'StandardInsightResponse', + 'AdvancedSyncInsightResponse', + 'AdvancedAsyncInsightResponse', + 'errors', +] diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py new file mode 100644 index 00000000..8a81504c --- /dev/null +++ b/number_insight/src/vonage_number_insight/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.4' diff --git a/number_insight/src/vonage_number_insight/errors.py b/number_insight/src/vonage_number_insight/errors.py new file mode 100644 index 00000000..be22357e --- /dev/null +++ b/number_insight/src/vonage_number_insight/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class NumberInsightError(VonageError): + """Indicates an error when using the Vonage Number Insight API.""" diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py new file mode 100644 index 00000000..d71790b1 --- /dev/null +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -0,0 +1,153 @@ +from logging import getLogger + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import NumberInsightError +from .requests import ( + AdvancedAsyncInsightRequest, + AdvancedSyncInsightRequest, + BasicInsightRequest, + StandardInsightRequest, +) +from .responses import ( + AdvancedAsyncInsightResponse, + AdvancedSyncInsightResponse, + BasicInsightResponse, + StandardInsightResponse, +) + +logger = getLogger('vonage_number_insight') + + +class NumberInsight: + """Calls Vonage's Number Insight API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'body' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Vonage Number Insight API. + + Returns: + HttpClient: The HTTP client used to make requests to the Number Insight API. + """ + return self._http_client + + @validate_call + def get_basic_info(self, options: BasicInsightRequest) -> BasicInsightResponse: + """Get basic number insight information about a phone number. + + Args: + Options (BasicInsightRequest): The options for the request. The `number` paramerter + is required, and the `country_code` parameter is optional. + + Returns: + BasicInsightResponse: The response object containing the basic number insight + information about the phone number. + """ + response = self._http_client.get( + self._http_client.api_host, + '/ni/basic/json', + params=options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return BasicInsightResponse(**response) + + @validate_call + def standard_number_insight( + self, options: StandardInsightRequest + ) -> StandardInsightResponse: + """Get standard number insight information about a phone number. + + Args: + Options (StandardInsightRequest): The options for the request. The `number` paramerter + is required, and the `country_code` and `cnam` parameters are optional. + + Returns: + StandardInsightResponse: The response object containing the standard number insight + information about the phone number. + """ + response = self._http_client.get( + self._http_client.api_host, + '/ni/standard/json', + params=options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return StandardInsightResponse(**response) + + @validate_call + def get_advanced_info_async( + self, options: AdvancedAsyncInsightRequest + ) -> AdvancedAsyncInsightResponse: + """Get advanced number insight information about a phone number asynchronously. + + Args: + Options (AdvancedAsyncInsightRequest): The options for the request. You must provide values + for the `callback` and `number` parameters. The `country_code` and `cnam` parameters + are optional. + + Returns: + AdvancedAsyncInsightResponse: The response object containing the advanced number insight + information about the phone number. + """ + response = self._http_client.get( + self._http_client.api_host, + '/ni/advanced/async/json', + params=options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return AdvancedAsyncInsightResponse(**response) + + @validate_call + def get_advanced_info_sync( + self, options: AdvancedSyncInsightRequest + ) -> AdvancedSyncInsightResponse: + """Get advanced number insight information about a phone number synchronously. + + Args: + Options (AdvancedSyncInsightRequest): The options for the request. The `number` parameter + is required, and the `country_code` and `cnam` parameters are optional. + + Returns: + AdvancedSyncInsightResponse: The response object containing the advanced number insight + information about the phone number. + """ + response = self._http_client.get( + self._http_client.api_host, + '/ni/advanced/json', + params=options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return AdvancedSyncInsightResponse(**response) + + def _check_for_error(self, response: dict) -> None: + """Check for an error in the response from the Number Insight API. + + Args: + response (dict): The response from the Number Insight API. + + Raises: + NumberInsightError: If the response contains an error. + """ + if response['status'] != 0: + if response['status'] in {43, 44, 45}: + logger.warning( + 'Live mobile lookup not returned. Not all parameters are available.' + ) + return + logger.warning( + f'Error using the Number Insight API. Response received: {response}' + ) + error_message = f'Error with the following details: {response}' + raise NumberInsightError(error_message) diff --git a/number_insight/src/vonage_number_insight/requests.py b/number_insight/src/vonage_number_insight/requests.py new file mode 100644 index 00000000..2e57674e --- /dev/null +++ b/number_insight/src/vonage_number_insight/requests.py @@ -0,0 +1,51 @@ +from typing import Optional + +from pydantic import BaseModel +from vonage_utils.types import PhoneNumber + + +class BasicInsightRequest(BaseModel): + """Model for a basic number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + """ + + number: PhoneNumber + country: Optional[str] = None + + +class StandardInsightRequest(BasicInsightRequest): + """Model for a standard number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. + """ + + cnam: Optional[bool] = None + + +class AdvancedAsyncInsightRequest(StandardInsightRequest): + """Model for an advanced asynchronous number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. + callback (str): The URL to send the asynchronous response to. + """ + + callback: str + + +class AdvancedSyncInsightRequest(StandardInsightRequest): + """Model for an advanced synchronous number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. + """ diff --git a/number_insight/src/vonage_number_insight/responses.py b/number_insight/src/vonage_number_insight/responses.py new file mode 100644 index 00000000..d50c77a5 --- /dev/null +++ b/number_insight/src/vonage_number_insight/responses.py @@ -0,0 +1,212 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel + + +class BasicInsightResponse(BaseModel): + """Model for a basic number insight response. + + Args: + status (int, Optional): The status code of the request. + status_message (str, Optional): The status message of the request. + request_id (str, Optional): The unique identifier for the request. + international_format_number (str, Optional): The international format of the phone + number in your request. + national_format_number (str, Optional): The national format of the phone number + in your request. + country_code (str, Optional): The 2-character country code of the phone number in + your request. This is in ISO 3166-1 alpha-2 format. + country_code_iso3 (str, Optional): The 3-character country code of the phone number + in your request. This is in ISO 3166-1 alpha-3 format. + country_name (str, Optional): The name of the country that the phone number is + registered in. + country_prefix (str, Optional): The numeric prefix for the country that the phone + number is registered in. + """ + + status: int = None + status_message: str = None + request_id: Optional[str] = None + international_format_number: Optional[str] = None + national_format_number: Optional[str] = None + country_code: Optional[str] = None + country_code_iso3: Optional[str] = None + country_name: Optional[str] = None + country_prefix: Optional[str] = None + + +class Carrier(BaseModel): + """Model for the carrier information of a phone number. While in some cases and + regions it may return information for non-mobile numbers, this field is supported only + for mobile numbers. + + Args: + network_code (str, Optional): The Mobile Country Code for the carrier the number + is associated with. Unreal numbers are marked as null and the request is + rejected altogether if the number is impossible according to the E.164 guidelines. + name (str, Optional): The full name of the carrier. + country (str, Optional): The country that the carrier is registered in. + This is in ISO 3166-1 alpha-2 format. + network_type (str, Optional): The type of network the number is associated with. + """ + + network_code: Optional[str] = None + name: Optional[str] = None + country: Optional[str] = None + network_type: Optional[str] = None + + +class CallerIdentity(BaseModel): + """Model for the caller identity information of a phone number. Only included if + `cnam=True` in the request. + + Args: + caller_type (str, Optional): The type of caller. Possible values are "business" + or "consumer". + caller_name (str, Optional): Full name of the person or business who owns the + phone number. + first_name (str, Optional): The first name of the caller if an individual. + last_name (str, Optional): The last name of the caller if an individual. + subscription_type (str, Optional): The type of subscription the caller has. + """ + + caller_type: Optional[str] = None + caller_name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + subscription_type: Optional[str] = None + + +class StandardInsightResponse(BasicInsightResponse): + """Model for a standard number insight response. + + Args: + request_price (str, Optional): The price in EUR charged for the request. + refund_price (str, Optional): The price in EUR that will be refunded to your + account in case the request is not successful. + remaining_balance (str, Optional): The remaining balance in your account in EUR. + current_carrier (Carrier, Optional): Information about the network `number` is + currently connected to. While in some cases and regions it may return + information for non-mobile numbers, this field is supported only for mobile + numbers. + original_carrier (Carrier, Optional): Information about the network `number` was + initially connected to. + ported (str, Optional): If the user has changed carrier for `number`. The assumed + status means that the information supplier has replied to the request but has + not said explicitly that the number is ported. + caller_identity (CallerIdentity, Optional): Information about the caller. Only + included if `cnam=True` in the request. + status (int, Optional): The status code of the request. + status_message (str, Optional): The status message of the request. + request_id (str, Optional): The unique identifier for the request. + international_format_number (str, Optional): The international format of the phone + number in your request. + national_format_number (str, Optional): The national format of the phone number + in your request. + country_code (str, Optional): The 2-character country code of the phone number in + your request. This is in ISO 3166-1 alpha-2 format. + country_code_iso3 (str, Optional): The 3-character country code of the phone number + in your request. This is in ISO 3166-1 alpha-3 format. + country_name (str, Optional): The name of the country that the phone number is + registered in. + country_prefix (str, Optional): The numeric prefix for the country that the phone + number is registered in. + """ + + request_price: Optional[str] = None + refund_price: Optional[str] = None + remaining_balance: Optional[str] = None + current_carrier: Optional[Carrier] = None + original_carrier: Optional[Carrier] = None + ported: Optional[str] = None + caller_identity: Optional[CallerIdentity] = None + + +class RoamingStatus(BaseModel): + """Model for the roaming status of a phone number. + + Args: + status (str, Optional): The roaming status of the phone number. + roaming_country_code (str, Optional): If the number is roaming, this is the country + code of the country the number is roaming in. + roaming_network_code (str, Optional): If the number is roaming, this is the ID of + the carrier network the number is roaming with. + roaming_network_name (str, Optional): If roaming, this is the name of the carrier + network the number is roaming in. + """ + + status: Optional[str] = None + roaming_country_code: Optional[str] = None + roaming_network_code: Optional[str] = None + roaming_network_name: Optional[str] = None + + +class AdvancedSyncInsightResponse(StandardInsightResponse): + """Model for an advanced synchronous number insight response. + + Args: + roaming (RoamingStatus, Optional): Information about the roaming status of the phone + number. + lookup_outcome (int, Optional): Shows if all information about the number + has been returned. + lookup_outcome_message (str, Optional): Status message about the lookup outcome. + valid_number (str, Optional): The validity of the phone number. + reachable (str, Optional): The reachability of the phone number. Only applies + to mobile numbers. + request_price (str, Optional): The price in EUR charged for the request. + refund_price (str, Optional): The price in EUR that will be refunded to your + account in case the request is not successful. + remaining_balance (str, Optional): The remaining balance in your account in EUR. + current_carrier (Carrier, Optional): Information about the network `number` is + currently connected to. While in some cases and regions it may return + information for non-mobile numbers, this field is supported only for mobile + numbers. + original_carrier (Carrier, Optional): Information about the network `number` was + initially connected to. + ported (str, Optional): If the user has changed carrier for `number`. The assumed + status means that the information supplier has replied to the request but has + not said explicitly that the number is ported. + caller_identity (CallerIdentity, Optional): Information about the caller. Only + included if `cnam=True` in the request. + status (int, Optional): The status code of the request. + status_message (str, Optional): The status message of the request. + request_id (str, Optional): The unique identifier for the request. + international_format_number (str, Optional): The international format of the phone + number in your request. + national_format_number (str, Optional): The national format of the phone number + in your request. + country_code (str, Optional): The 2-character country code of the phone number in + your request. This is in ISO 3166-1 alpha-2 format. + country_code_iso3 (str, Optional): The 3-character country code of the phone number + in your request. This is in ISO 3166-1 alpha-3 format. + country_name (str, Optional): The name of the country that the phone number is + registered in. + country_prefix (str, Optional): The numeric prefix for the country that the phone + number is registered in. + """ + + roaming: Optional[Union[RoamingStatus, Literal['unknown']]] = None + lookup_outcome: Optional[int] = None + lookup_outcome_message: Optional[str] = None + valid_number: Optional[str] = None + reachable: Optional[str] = None + + +class AdvancedAsyncInsightResponse(BaseModel): + """Model for an advanced asynchronous number insight response. + + Args: + request_id (str, Optional): The unique identifier for the request. + number (str, Optional): The phone number to get insight information for. + remaining_balance (str, Optional): The remaining balance in your account in EUR. + request_price (str, Optional): The price in EUR charged for the request. + status (int, Optional): The status code of the request. + error_text (str, Optional): The status description of the request. + """ + + request_id: Optional[str] = None + number: Optional[str] = None + remaining_balance: Optional[str] = None + request_price: Optional[str] = None + status: Optional[int] = None + error_text: Optional[str] = None diff --git a/number_insight/tests/BUILD b/number_insight/tests/BUILD new file mode 100644 index 00000000..63769138 --- /dev/null +++ b/number_insight/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['number_insight', 'testutils']) diff --git a/number_insight/tests/data/advanced_async_insight.json b/number_insight/tests/data/advanced_async_insight.json new file mode 100644 index 00000000..5033a11a --- /dev/null +++ b/number_insight/tests/data/advanced_async_insight.json @@ -0,0 +1,7 @@ +{ + "number": "447700900000", + "remaining_balance": "32.92665294", + "request_id": "434205b5-90ec-4ee2-a337-7b40d9683420", + "request_price": "0.04000000", + "status": 0 +} \ No newline at end of file diff --git a/number_insight/tests/data/advanced_async_insight_error.json b/number_insight/tests/data/advanced_async_insight_error.json new file mode 100644 index 00000000..d95a1f76 --- /dev/null +++ b/number_insight/tests/data/advanced_async_insight_error.json @@ -0,0 +1,4 @@ +{ + "error_text": "Invalid credentials", + "status": 4 +} \ No newline at end of file diff --git a/number_insight/tests/data/advanced_async_insight_partial_error.json b/number_insight/tests/data/advanced_async_insight_partial_error.json new file mode 100644 index 00000000..180b3154 --- /dev/null +++ b/number_insight/tests/data/advanced_async_insight_partial_error.json @@ -0,0 +1,8 @@ +{ + "error_text": "Live mobile lookup not returned", + "status": 43, + "number": "447700900000", + "remaining_balance": "32.92665294", + "request_id": "434205b5-90ec-4ee2-a337-7b40d9683420", + "request_price": "0.04000000" +} \ No newline at end of file diff --git a/number_insight/tests/data/advanced_sync_insight.json b/number_insight/tests/data/advanced_sync_insight.json new file mode 100644 index 00000000..63e266e5 --- /dev/null +++ b/number_insight/tests/data/advanced_sync_insight.json @@ -0,0 +1,44 @@ +{ + "caller_identity": { + "caller_name": "John Smith", + "caller_type": "consumer", + "first_name": "John", + "last_name": "Smith", + "subscription_type": "postpaid" + }, + "caller_name": "John Smith", + "caller_type": "consumer", + "country_code": "US", + "country_code_iso3": "USA", + "country_name": "United States of America", + "country_prefix": "1", + "current_carrier": { + "country": "US", + "name": "AT&T Mobility", + "network_code": "310090", + "network_type": "mobile" + }, + "first_name": "John", + "international_format_number": "12345678900", + "ip_warnings": "unknown", + "last_name": "Smith", + "lookup_outcome": 1, + "lookup_outcome_message": "Partial success - some fields populated", + "national_format_number": "(234) 567-8900", + "original_carrier": { + "country": "US", + "name": "AT&T Mobility", + "network_code": "310090", + "network_type": "mobile" + }, + "ported": "not_ported", + "reachable": "unknown", + "refund_price": "0.01025000", + "remaining_balance": "32.68590294", + "request_id": "97e973e7-2e27-4fd3-9e1a-972ea14dd992", + "request_price": "0.05025000", + "roaming": "unknown", + "status": 44, + "status_message": "Lookup Handler unable to handle request", + "valid_number": "valid" +} \ No newline at end of file diff --git a/number_insight/tests/data/basic_insight.json b/number_insight/tests/data/basic_insight.json new file mode 100644 index 00000000..b376f1d3 --- /dev/null +++ b/number_insight/tests/data/basic_insight.json @@ -0,0 +1,11 @@ +{ + "status": 0, + "status_message": "Success", + "request_id": "7f4a8a16-aa89-4078-b0ae-7743da34aca5", + "international_format_number": "12345678900", + "national_format_number": "(234) 567-8900", + "country_code": "US", + "country_code_iso3": "USA", + "country_name": "United States of America", + "country_prefix": "1" +} \ No newline at end of file diff --git a/number_insight/tests/data/basic_insight_error.json b/number_insight/tests/data/basic_insight_error.json new file mode 100644 index 00000000..142fe9b9 --- /dev/null +++ b/number_insight/tests/data/basic_insight_error.json @@ -0,0 +1,4 @@ +{ + "status": 3, + "status_message": "Invalid request :: Not valid number format detected [ 145645562 ]" +} \ No newline at end of file diff --git a/number_insight/tests/data/standard_insight.json b/number_insight/tests/data/standard_insight.json new file mode 100644 index 00000000..7017b1fa --- /dev/null +++ b/number_insight/tests/data/standard_insight.json @@ -0,0 +1,36 @@ +{ + "status": 0, + "status_message": "Success", + "request_id": "1d56406b-9d52-497a-a023-b3f40b62f9b3", + "international_format_number": "447700900000", + "national_format_number": "07700 900000", + "country_code": "GB", + "country_code_iso3": "GBR", + "country_name": "United Kingdom", + "country_prefix": "44", + "request_price": "0.00500000", + "remaining_balance": "32.98665294", + "current_carrier": { + "network_code": "23415", + "name": "Vodafone Limited", + "country": "GB", + "network_type": "mobile" + }, + "original_carrier": { + "network_code": "23420", + "name": "Hutchison 3G Ltd", + "country": "GB", + "network_type": "mobile" + }, + "ported": "ported", + "caller_identity": { + "caller_type": "consumer", + "caller_name": "John Smith", + "first_name": "John", + "last_name": "Smith" + }, + "caller_name": "John Smith", + "last_name": "Smith", + "first_name": "John", + "caller_type": "consumer" +} \ No newline at end of file diff --git a/number_insight/tests/test_number_insight.py b/number_insight/tests/test_number_insight.py new file mode 100644 index 00000000..02b7ca63 --- /dev/null +++ b/number_insight/tests/test_number_insight.py @@ -0,0 +1,159 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.http_client import HttpClient +from vonage_number_insight.errors import NumberInsightError +from vonage_number_insight.number_insight import NumberInsight +from vonage_number_insight.requests import ( + AdvancedAsyncInsightRequest, + AdvancedSyncInsightRequest, + BasicInsightRequest, + StandardInsightRequest, +) + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + + +number_insight = NumberInsight(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = number_insight.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_basic_insight(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/basic/json', + 'basic_insight.json', + ) + options = BasicInsightRequest(number='12345678900', country_code='US') + response = number_insight.get_basic_info(options) + assert response.status == 0 + assert response.status_message == 'Success' + + +@responses.activate +def test_basic_insight_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/basic/json', + 'basic_insight_error.json', + ) + + with raises(NumberInsightError) as e: + options = BasicInsightRequest(number='1234567890', country_code='US') + number_insight.get_basic_info(options) + assert e.match('Invalid request :: Not valid number format detected') + + +@responses.activate +def test_standard_insight(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/standard/json', + 'standard_insight.json', + ) + options = StandardInsightRequest(number='12345678900', country_code='US', cnam=True) + response = number_insight.standard_number_insight(options) + assert response.status == 0 + assert response.status_message == 'Success' + assert response.current_carrier.network_code == '23415' + assert response.original_carrier.network_type == 'mobile' + assert response.caller_identity.caller_name == 'John Smith' + + +@responses.activate +def test_advanced_async_insight(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/advanced/async/json', + 'advanced_async_insight.json', + ) + options = AdvancedAsyncInsightRequest( + callback='https://example.com/callback', + number='447700900000', + country_code='GB', + cnam=True, + ) + response = number_insight.get_advanced_info_async(options) + assert response.status == 0 + assert response.request_id == '434205b5-90ec-4ee2-a337-7b40d9683420' + assert response.number == '447700900000' + assert response.remaining_balance == '32.92665294' + + +@responses.activate +def test_advanced_async_insight_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/advanced/async/json', + 'advanced_async_insight_error.json', + ) + + options = AdvancedAsyncInsightRequest( + callback='https://example.com/callback', + number='447700900000', + country_code='GB', + cnam=True, + ) + with raises(NumberInsightError) as e: + number_insight.get_advanced_info_async(options) + assert e.match('Invalid credentials') + + +@responses.activate +def test_advanced_async_insight_partial_error(caplog): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/advanced/async/json', + 'advanced_async_insight_partial_error.json', + ) + + options = AdvancedAsyncInsightRequest( + callback='https://example.com/callback', + number='447700900000', + country_code='GB', + cnam=True, + ) + response = number_insight.get_advanced_info_async(options) + assert 'Not all parameters are available' in caplog.text + assert response.status == 43 + + +@responses.activate +def test_advanced_sync_insight(caplog): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/advanced/json', + 'advanced_sync_insight.json', + ) + options = AdvancedSyncInsightRequest( + number='12345678900', country_code='US', cnam=True + ) + response = number_insight.get_advanced_info_sync(options) + + assert 'Not all parameters are available' in caplog.text + assert response.status == 44 + assert response.request_id == '97e973e7-2e27-4fd3-9e1a-972ea14dd992' + assert response.current_carrier.network_code == '310090' + assert response.caller_identity.first_name == 'John' + assert response.caller_identity.last_name == 'Smith' + assert response.caller_identity.subscription_type == 'postpaid' + assert response.lookup_outcome == 1 + assert response.lookup_outcome_message == 'Partial success - some fields populated' + assert response.roaming == 'unknown' + assert response.status_message == 'Lookup Handler unable to handle request' + assert response.valid_number == 'valid' diff --git a/number_insight_v2/BUILD b/number_insight_v2/BUILD new file mode 100644 index 00000000..6fa1a4f5 --- /dev/null +++ b/number_insight_v2/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-number-insight-v2', + dependencies=[ + ':pyproject', + ':readme', + 'number_insight_v2/src/vonage_number_insight_v2', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/number_insight_v2/CHANGES.md b/number_insight_v2/CHANGES.md new file mode 100644 index 00000000..36feaaeb --- /dev/null +++ b/number_insight_v2/CHANGES.md @@ -0,0 +1,5 @@ +# 0.1.1b0 +- Update minimum dependency version + +# 0.1.0b0 +- Beta release \ No newline at end of file diff --git a/number_insight_v2/README.md b/number_insight_v2/README.md new file mode 100644 index 00000000..43b9ac1c --- /dev/null +++ b/number_insight_v2/README.md @@ -0,0 +1,23 @@ +# Vonage Number Insight Python SDK package + +This package contains the code to use v2 of Vonage's Number Insight API (currently in beta) in Python. + +It includes classes for making fraud check requests and handling the responses. + +## Usage +First, import the necessary classes and create an instance of the `NumberInsightV2` class: + +```python +from vonage_http_client.http_client import HttpClient, Auth +from number_insight_v2 import NumberInsightV2, FraudCheckRequest + +http_client = HttpClient(Auth(api_key='your_api_key', api_secret='your_api_secret')) +number_insight = NumberInsightV2(http_client) +``` + +You can then create a `FraudCheckRequest` object and use the `fraud_check` method to initiate a fraud check request: + +```python +request = FraudCheckRequest(phone='1234567890') +response = number_insight.fraud_check(request) +``` \ No newline at end of file diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml new file mode 100644 index 00000000..442aeb98 --- /dev/null +++ b/number_insight_v2/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-number-insight-v2' +version = '0.1.1b0' +description = 'Vonage Number Insight v2 package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.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/number_insight_v2/src/vonage_number_insight_v2/BUILD b/number_insight_v2/src/vonage_number_insight_v2/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/number_insight_v2/src/vonage_number_insight_v2/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/number_insight_v2/src/vonage_number_insight_v2/__init__.py b/number_insight_v2/src/vonage_number_insight_v2/__init__.py new file mode 100644 index 00000000..8998fbc0 --- /dev/null +++ b/number_insight_v2/src/vonage_number_insight_v2/__init__.py @@ -0,0 +1,7 @@ +from .number_insight_v2 import FraudCheckRequest, FraudCheckResponse, NumberInsightV2 + +__all__ = [ + 'NumberInsightV2', + 'FraudCheckRequest', + 'FraudCheckResponse', +] diff --git a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py new file mode 100644 index 00000000..ad44ce10 --- /dev/null +++ b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py @@ -0,0 +1,93 @@ +from copy import deepcopy +from dataclasses import dataclass +from typing import Literal, Optional, Union + +from pydantic import BaseModel, field_validator, validate_call +from vonage_http_client.http_client import HttpClient + +from vonage_utils import format_phone_number + + +class FraudCheckRequest(BaseModel): + phone: Union[str, int] + insights: Union[ + Literal['fraud_score', 'sim_swap'], list[Literal['fraud_score', 'sim_swap']] + ] = ['fraud_score', 'sim_swap'] + type: Literal['phone'] = 'phone' + + @field_validator('phone') + @classmethod + def format_phone_number(cls, value): + return format_phone_number(value) + + +@dataclass +class Phone: + phone: str + carrier: Optional[str] = None + type: Optional[str] = None + + +@dataclass +class FraudScore: + risk_score: str + risk_recommendation: str + label: str + status: str + + +@dataclass +class SimSwap: + status: str + swapped: Optional[bool] = None + reason: Optional[str] = None + + +@dataclass +class FraudCheckResponse: + request_id: str + type: str + phone: Phone + fraud_score: Optional[FraudScore] + sim_swap: Optional[SimSwap] + + +class NumberInsightV2: + """Number Insight API V2.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = deepcopy(http_client) + self._auth_type = 'basic' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Number Insight V2 API. + + Returns: + HttpClient: The HTTP client used to make requests to the Number Insight V2 API. + """ + return self._http_client + + @validate_call + def fraud_check(self, request: FraudCheckRequest) -> FraudCheckResponse: + """Initiate a fraud check request.""" + response = self._http_client.post( + self._http_client.api_host, + '/v2/ni', + request.model_dump(), + self._auth_type, + ) + + phone = Phone(**response['phone']) + fraud_score = ( + FraudScore(**response['fraud_score']) if 'fraud_score' in response else None + ) + sim_swap = SimSwap(**response['sim_swap']) if 'sim_swap' in response else None + + return FraudCheckResponse( + request_id=response['request_id'], + type=response['type'], + phone=phone, + fraud_score=fraud_score, + sim_swap=sim_swap, + ) diff --git a/number_insight_v2/tests/BUILD b/number_insight_v2/tests/BUILD new file mode 100644 index 00000000..0b73afe7 --- /dev/null +++ b/number_insight_v2/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['number_insight_v2', 'testutils']) diff --git a/number_insight_v2/tests/data/default.json b/number_insight_v2/tests/data/default.json new file mode 100644 index 00000000..cb808dd7 --- /dev/null +++ b/number_insight_v2/tests/data/default.json @@ -0,0 +1,19 @@ +{ + "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", + "type": "phone", + "phone": { + "phone": "1234567890", + "carrier": "Verizon Wireless", + "type": "MOBILE" + }, + "fraud_score": { + "risk_score": "0", + "risk_recommendation": "allow", + "label": "low", + "status": "completed" + }, + "sim_swap": { + "status": "completed", + "swapped": false + } +} \ No newline at end of file diff --git a/number_insight_v2/tests/data/fraud_score.json b/number_insight_v2/tests/data/fraud_score.json new file mode 100644 index 00000000..be7cc267 --- /dev/null +++ b/number_insight_v2/tests/data/fraud_score.json @@ -0,0 +1,15 @@ +{ + "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", + "type": "phone", + "phone": { + "phone": "1234567890", + "carrier": "Verizon Wireless", + "type": "MOBILE" + }, + "fraud_score": { + "risk_score": "0", + "risk_recommendation": "allow", + "label": "low", + "status": "completed" + } +} \ No newline at end of file diff --git a/number_insight_v2/tests/data/sim_swap.json b/number_insight_v2/tests/data/sim_swap.json new file mode 100644 index 00000000..594028c8 --- /dev/null +++ b/number_insight_v2/tests/data/sim_swap.json @@ -0,0 +1,11 @@ +{ + "request_id": "db5282b6-8046-4217-9c0e-d9c55d8696e9", + "type": "phone", + "phone": { + "phone": "1234567890" + }, + "sim_swap": { + "status": "completed", + "swapped": false + } +} \ No newline at end of file diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py new file mode 100644 index 00000000..31a89181 --- /dev/null +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -0,0 +1,98 @@ +from dataclasses import asdict +from os.path import abspath + +import responses +from pydantic import ValidationError +from pytest import raises +from vonage_http_client.http_client import HttpClient +from vonage_number_insight_v2.number_insight_v2 import ( + FraudCheckRequest, + FraudCheckResponse, + NumberInsightV2, +) +from vonage_utils.errors import InvalidPhoneNumberError +from vonage_utils.utils import remove_none_values + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +ni2 = NumberInsightV2(HttpClient(get_mock_api_key_auth())) + + +def test_fraud_check_request_defaults(): + request = FraudCheckRequest(phone='1234567890') + assert request.type == 'phone' + assert request.phone == '1234567890' + assert request.insights == ['fraud_score', 'sim_swap'] + + +def test_fraud_check_request_custom_insights(): + request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) + assert request.type == 'phone' + assert request.phone == '1234567890' + assert request.insights == ['fraud_score'] + + +def test_fraud_check_request_invalid_phone(): + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='invalid_phone') + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='123') + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='12345678901234567890') + + +def test_fraud_check_request_invalid_insights(): + with raises(ValidationError): + FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) + + +@responses.activate +def test_ni2_defaults(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') + request = FraudCheckRequest(phone='1234567890') + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' + assert response.phone.carrier == 'Verizon Wireless' + assert response.fraud_score.risk_score == '0' + assert response.sim_swap.status == 'completed' + + +@responses.activate +def test_ni2_fraud_score_only(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') + request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' + assert response.phone.carrier == 'Verizon Wireless' + assert response.fraud_score.risk_score == '0' + assert response.sim_swap is None + + clear_response = asdict(response, dict_factory=remove_none_values) + assert 'fraud_score' in clear_response + assert 'sim_swap' not in clear_response + + +@responses.activate +def test_ni2_sim_swap_only(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') + request = FraudCheckRequest(phone='1234567890', insights='sim_swap') + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' + assert response.phone.phone == '1234567890' + assert response.fraud_score is None + assert response.sim_swap.status == 'completed' + assert response.sim_swap.swapped is False + + clear_response = asdict(response, dict_factory=remove_none_values) + assert 'fraud_score' not in clear_response + assert 'sim_swap' in clear_response + assert 'reason' not in clear_response['sim_swap'] + + +def test_number_insight_v2_http_client(): + assert type(ni2.http_client) == HttpClient diff --git a/number_management/BUILD b/number_management/BUILD new file mode 100644 index 00000000..57536cb4 --- /dev/null +++ b/number_management/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-numbers', + dependencies=[ + ':pyproject', + ':readme', + 'number_management/src/vonage_numbers', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/number_management/CHANGES.md b/number_management/CHANGES.md new file mode 100644 index 00000000..724aa699 --- /dev/null +++ b/number_management/CHANGES.md @@ -0,0 +1,11 @@ +# 1.0.3 +- Update dependency versions + +# 1.0.2 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.1 +- Add docstrings for data models + +# 1.0.0 +- Initial upload diff --git a/number_management/README.md b/number_management/README.md new file mode 100644 index 00000000..5d82e459 --- /dev/null +++ b/number_management/README.md @@ -0,0 +1,82 @@ +# Vonage Numbers Package + +This package contains the code to use Vonage's Numbers API in Python. + +It includes methods for managing and buying numbers. + +## 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`. + +### List Numbers You Own + +```python +numbers, count, next_page = vonage_client.numbers.list_owned_numbers() +print(numbers) +print(count) +print(next_page) + +# With filtering +from vonage_numbers import ListOwnedNumbersFilter +numbers, count, next_page = vonage_client.numbers.list_owned_numbers( + ListOwnedNumbersFilter(country='GB', size=3, index=2) +) + +numbers, count, next_page_index = vonage_client.numbers.list_owned_numbers() +print(numbers) +print(count) +print(next_page_index) +``` + +### Search for Available Numbers + +```python +from vonage_numbers import SearchAvailableNumbersFilter + +numbers, count, next_page_index = vonage_client.numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=10, pattern='44701', search_pattern=1 + ) +) +print(numbers) +print(count) +print(next_page_index) +``` + +### Buy a Number + +```python +from vonage_numbers import NumberParams + +status = vonage_client.numbers.buy_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) +``` + +### Cancel a number + +```python +from vonage_numbers import NumberParams + +status = vonage_client.numbers.cancel_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) +``` + +### Update a Number + +```python +from vonage_numbers import UpdateNumberParams + +status = vonage_client.numbers.update_number( + UpdateNumberParams( + country='GB', + msisdn='447007000000', + mo_http_url='https://example.com', + mo_smpp_sytem_type='inbound', + voice_callback_type='tel', + voice_callback_value='447008000000', + voice_status_callback='https://example.com', + ) +) + +print(status) +``` \ No newline at end of file diff --git a/number_management/pyproject.toml b/number_management/pyproject.toml new file mode 100644 index 00000000..848bf636 --- /dev/null +++ b/number_management/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-numbers' +dynamic = ["version"] +description = 'Vonage Numbers package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_numbers._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/number_management/src/vonage_numbers/BUILD b/number_management/src/vonage_numbers/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/number_management/src/vonage_numbers/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/number_management/src/vonage_numbers/__init__.py b/number_management/src/vonage_numbers/__init__.py new file mode 100644 index 00000000..c6d68d2a --- /dev/null +++ b/number_management/src/vonage_numbers/__init__.py @@ -0,0 +1,25 @@ +from .enums import NumberFeatures, NumberType, VoiceCallbackType +from .errors import NumbersError +from .number_management import Numbers +from .requests import ( + ListOwnedNumbersFilter, + NumberParams, + SearchAvailableNumbersFilter, + UpdateNumberParams, +) +from .responses import AvailableNumber, NumbersStatus, OwnedNumber + +__all__ = [ + 'NumberFeatures', + 'NumberType', + 'VoiceCallbackType', + 'NumbersError', + 'Numbers', + 'ListOwnedNumbersFilter', + 'NumberParams', + 'SearchAvailableNumbersFilter', + 'UpdateNumberParams', + 'AvailableNumber', + 'NumbersStatus', + 'OwnedNumber', +] diff --git a/number_management/src/vonage_numbers/_version.py b/number_management/src/vonage_numbers/_version.py new file mode 100644 index 00000000..3f6fab60 --- /dev/null +++ b/number_management/src/vonage_numbers/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.3' diff --git a/number_management/src/vonage_numbers/enums.py b/number_management/src/vonage_numbers/enums.py new file mode 100644 index 00000000..5d000c8e --- /dev/null +++ b/number_management/src/vonage_numbers/enums.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class NumberType(str, Enum): + LANDLINE = 'landline' + MOBILE_LVN = 'mobile-lvn' + LANDLINE_TOLL_FREE = 'landline-toll-free' + + +class NumberFeatures(str, Enum): + SMS = 'SMS' + VOICE = 'VOICE' + MMS = 'MMS' + SMS_VOICE = 'SMS,VOICE' + SMS_MMS = 'SMS,MMS' + VOICE_MMS = 'VOICE,MMS' + SMS_VOICE_MMS = 'SMS,VOICE,MMS' + + +class VoiceCallbackType(str, Enum): + SIP = 'sip' + TEL = 'tel' diff --git a/number_management/src/vonage_numbers/errors.py b/number_management/src/vonage_numbers/errors.py new file mode 100644 index 00000000..4197cf64 --- /dev/null +++ b/number_management/src/vonage_numbers/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class NumbersError(VonageError): + """Indicates an error with the Numbers API package.""" diff --git a/number_management/src/vonage_numbers/number_management.py b/number_management/src/vonage_numbers/number_management.py new file mode 100644 index 00000000..7343d07a --- /dev/null +++ b/number_management/src/vonage_numbers/number_management.py @@ -0,0 +1,182 @@ +from typing import Optional + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_numbers.errors import NumbersError + +from .requests import ( + ListOwnedNumbersFilter, + NumberParams, + SearchAvailableNumbersFilter, + UpdateNumberParams, +) +from .responses import AvailableNumber, NumbersStatus, OwnedNumber + + +class Numbers: + """Class containing methods for Vonage Application management.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'basic' + self._sent_data_type = 'form' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Numbers API. + + Returns: + HttpClient: The HTTP client used to make requests to the Numbers API. + """ + return self._http_client + + @validate_call + def list_owned_numbers( + self, filter: ListOwnedNumbersFilter = ListOwnedNumbersFilter() + ) -> tuple[list[OwnedNumber], int, Optional[int]]: + """List numbers you own. + + By default, returns the first 100 numbers and the page index of + the next page of results, if there are more than 100 numbers. + + Args: + filter (ListOwnedNumbersFilter): The filter object. + + Returns: + tuple[list[OwnedNumber], int, Optional[int]]: A tuple containing a + list of owned numbers, the total count of owned phone numbers + and the next page index, if applicable. + i.e. + number_list: list[OwnedNumber], count: int, next_page_index: Optional[int]) + """ + response = self._http_client.get( + self._http_client.rest_host, + '/account/numbers', + filter.model_dump(exclude_none=True), + self._auth_type, + ) + + index = filter.index or 1 + page_size = filter.size + + numbers = [] + try: + for number in response['numbers']: + numbers.append(OwnedNumber(**number)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * index: + return numbers, count, index + 1 + return numbers, count, None + + @validate_call + def search_available_numbers( + self, filter: SearchAvailableNumbersFilter + ) -> tuple[list[AvailableNumber], int, Optional[int]]: + """Search for available numbers to buy. + + By default, returns the first 100 numbers and the page index of + the next page of results, if there are more than 100 numbers. + + Args: + filter (SearchAvailableNumbersFilter): The filter object. + + Returns: + tuple[list[AvailableNumber], int, Optional[int]]: A tuple containing a + list of available numbers, the total count of available phone numbers + and the next page index, if applicable. + i.e. + number_list: list[AvailableNumber], count: int, next_page_index: Optional[int]) + """ + response = self._http_client.get( + self._http_client.rest_host, + '/number/search', + filter.model_dump(exclude_none=True), + self._auth_type, + ) + + index = filter.index or 1 + page_size = filter.size + + numbers = [] + try: + for number in response['numbers']: + numbers.append(AvailableNumber(**number)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * index: + return numbers, count, index + 1 + return numbers, count, None + + @validate_call + def buy_number(self, params: NumberParams) -> NumbersStatus: + """Buy a number. + + Args: + params (NumberParams): The number parameters. + + Returns: + NumbersStatus: The status of the number purchase. + """ + response = self._http_client.post( + self._http_client.rest_host, + '/number/buy', + params.model_dump(exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + + self._check_for_error(response) + return NumbersStatus(**response) + + @validate_call + def cancel_number(self, params: NumberParams) -> NumbersStatus: + """Cancel a number. + + Args: + params (NumberParams): The number parameters. + + Returns: + NumbersStatus: The status of the number cancellation. + """ + response = self._http_client.post( + self._http_client.rest_host, + '/number/cancel', + params.model_dump(exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + + self._check_for_error(response) + return NumbersStatus(**response) + + @validate_call + def update_number(self, params: UpdateNumberParams) -> NumbersStatus: + """Update a number. + + Args: + params (UpdateNumberParams): The number parameters. + + Returns: + NumbersStatus: The status of the number update. + """ + response = self._http_client.post( + self._http_client.rest_host, + '/number/update', + params.model_dump(exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + + self._check_for_error(response) + return NumbersStatus(**response) + + def _check_for_error(self, response_data): + if response_data['error-code'] != '200': + raise NumbersError( + f'Numbers API operation failed: {response_data["error-code"]} {response_data["error-code-label"]}' + ) diff --git a/number_management/src/vonage_numbers/requests.py b/number_management/src/vonage_numbers/requests.py new file mode 100644 index 00000000..1189ff61 --- /dev/null +++ b/number_management/src/vonage_numbers/requests.py @@ -0,0 +1,155 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_numbers.enums import NumberFeatures, NumberType, VoiceCallbackType +from vonage_utils.types import PhoneNumber + +from .errors import NumbersError + + +class ListNumbersFilter(BaseModel): + """Model with filters for listing numbers. + + Args: + pattern (str, Optional): The number pattern you want to search for. Use in + conjunction with `search_pattern`. + search_pattern (int, Optional): The strategy to use when searching for numbers. + - 0: Search for numbers that start with `pattern` (Note: all numbers are in + E.164 format, so the starting pattern includes the country code, such + as 1 for USA). + - 1: Search for numbers that contain `pattern`. + - 2: Search for numbers that end with `pattern`. + size (int, Optional): The number of results to return per page. + index (int, Optional): The page number to return. + """ + + pattern: Optional[str] = None + search_pattern: Optional[int] = Field(None, ge=0, le=2) + size: Optional[int] = Field(100, le=100) + index: Optional[int] = Field(None, ge=1) + + @model_validator(mode='after') + def check_search_pattern_if_pattern(self): + if (self.pattern is None) != (self.search_pattern is None): + raise NumbersError( + '"search_pattern" is required when "pattern"" is provided and vice versa.' + ) + return self + + +class ListOwnedNumbersFilter(ListNumbersFilter): + """Model with filters for listing numbers you own. + + Args: + country (str, Optional): The two-letter country code (in ISO 3166-1 alpha-2 format). + application_id (str, Optional): The Vonage application ID. + has_application (bool, Optional): Whether the number has an application associated + with it. Set this optional field to `True` to restrict your results to numbers + associated with an Application (any Application). Set to `false` to find all + numbers not associated with any Application. Omit the field to avoid filtering + on whether or not the number is assigned to an Application. + pattern (str, Optional): The number pattern you want to search for. Use in + conjunction with `search_pattern`. + search_pattern (int, Optional): The strategy to use when searching for numbers. + - 0: Search for numbers that start with `pattern` (Note: all numbers are in + E.164 format, so the starting pattern includes the country code, such + as 1 for USA). + - 1: Search for numbers that contain `pattern`. + - 2: Search for numbers that end with `pattern`. + size (int, Optional): The number of results to return per page. + index (int, Optional): The page number to return. + """ + + country: Optional[str] = Field(None, min_length=2, max_length=2) + application_id: Optional[str] = None + has_application: Optional[bool] = None + + +class SearchAvailableNumbersFilter(ListNumbersFilter): + """Model with filters for searching available numbers. + + Args: + country (str): The two-letter country code (in ISO 3166-1 alpha-2 format). + type (NumberType, Optional): The type of number you are searching for. + features (NumberFeatures, Optional): The features you want the number to have. + pattern (str, Optional): The number pattern you want to search for. Use in + conjunction with `search_pattern`. + search_pattern (int, Optional): The strategy to use when searching for numbers. + - 0: Search for numbers that start with `pattern` (Note: all numbers are in + E.164 format, so the starting pattern includes the country code, such + as 1 for USA). + - 1: Search for numbers that contain `pattern`. + - 2: Search for numbers that end with `pattern`. + size (int, Optional): The number of results to return per page. + index (int, Optional): The page number to return. + """ + + country: str = Field(..., min_length=2, max_length=2) + type: Optional[NumberType] = None + features: Optional[NumberFeatures] = None + + +class NumberParams(BaseModel): + """Model for buying/cancelling a number. + + If you'd like to perform an action on a subaccount, provide the api_key of that + account in the `target_api_key` field. If you'd like to perform an action on your own + account, you do not need to provide this field. + + Args: + country (str): The two-letter country code (in ISO 3166-1 alpha-2 format). + msisdn (PhoneNumber): The phone number in E.164 format. + target_api_key (str, Optional): The API key of the subaccount you want to + perform the action on. If you want to perform the action on your own account, + you do not need to provide this field. + """ + + country: str = Field(..., min_length=2, max_length=2) + msisdn: PhoneNumber + target_api_key: Optional[str] = None + + +class UpdateNumberParams(BaseModel): + """Model for updating a number. + + Args: + country (str): The two-letter country code (in ISO 3166-1 alpha-2 format). + msisdn (PhoneNumber): The phone number in E.164 format. + app_id (str, Optional): The Vonage application that will handle inbound traffic + to this number. + mo_http_url (str, Optional): The URL to which Vonage sends a webhook when a + message is received. Set to an empty string to remove the webhook. + mo_smpp_system_type (str, Optional): The associated system type for your SMPP + client. + voice_callback_type (VoiceCallbackType, Optional): Specify whether inbound voice + calls on your number are forwarded to a SIP or a telephone number. This must + be used with the `voice_callback_value parameter. If set, `sip` or `tel` are + prioritised over the Voice capability set in your Application. + voice_callback_value (str, Optional): A SIP URI or telephone number. Must be used + with the `voice_callback_type` parameter. + voice_status_callback (str, Optional): A webhook URI for Vonage sends a request + to when a call ends. + """ + + country: str = Field(..., min_length=2, max_length=2) + msisdn: str + app_id: Optional[str] = None + mo_http_url: Optional[str] = Field(None, serialization_alias='moHttpUrl') + mo_smpp_sytem_type: Optional[str] = Field(None, serialization_alias='moSmppSysType') + voice_callback_type: Optional[VoiceCallbackType] = Field( + None, serialization_alias='voiceCallbackType' + ) + voice_callback_value: Optional[str] = Field( + None, serialization_alias='voiceCallbackValue' + ) + voice_status_callback: Optional[str] = Field( + None, serialization_alias='voiceStatusCallback' + ) + + @model_validator(mode='after') + def check_voice_callbacks(self): + if (self.voice_callback_type is None) != (self.voice_callback_value is None): + raise NumbersError( + '"voice_callback_value" is required when "voice_callback_type" is provided, and vice versa.' + ) + return self diff --git a/number_management/src/vonage_numbers/responses.py b/number_management/src/vonage_numbers/responses.py new file mode 100644 index 00000000..6491b9b1 --- /dev/null +++ b/number_management/src/vonage_numbers/responses.py @@ -0,0 +1,71 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class OwnedNumber(BaseModel): + """Model for an owned number. + + Args: + country (str): The two-letter country code (in ISO 3166-1 alpha-2 format). + msisdn (PhoneNumber): The phone number in E.164 format. + mo_http_url (str, Optional): The URL of the webhook endpoint that handles inbound + messages. + type (str, Optional): The type of number. + features (list[str], Optional): The capabilities of the number. + messages_callback_type (str, Optional): The type of webhook for messages. + This is always `app`. + messages_callback_value (str, Optional): A Vonage application ID. + voice_callback_type (str, Optional): The type of webhook for voice. + voice_callback_value (str, Optional): A SIP URI, telephone number or Vonage + application ID. + app_id (str, Optional): ID of the Vonage application linked to this number. + """ + + country: Optional[str] = Field(None, min_length=2, max_length=2) + msisdn: Optional[str] = None + mo_http_url: Optional[str] = Field(None, validation_alias='moHttpUrl') + type: Optional[str] = None + features: Optional[list[str]] = None + messages_callback_type: Optional[str] = Field( + None, validation_alias='messagesCallbackType' + ) + messages_callback_value: Optional[str] = Field( + None, validation_alias='messagesCallbackValue' + ) + voice_callback_type: Optional[str] = Field(None, validation_alias='voiceCallbackType') + voice_callback_value: Optional[str] = Field( + None, validation_alias='voiceCallbackValue' + ) + app_id: Optional[str] = None + + +class AvailableNumber(BaseModel): + """Model for an available number. + + Args: + country (str, Optional): The two-letter country code (in ISO 3166-1 alpha-2 format). + msisdn (str, Optional): The phone number in E.164 format. + type (str, Optional): The type of number. + cost (str, Optional): The monthly rental cost for this number, in Euros. + features (list[str], Optional): The capabilities of the number. + """ + + country: Optional[str] = Field(None, min_length=2, max_length=2) + msisdn: Optional[str] = None + type: Optional[str] = None + cost: Optional[str] = None + features: Optional[list[str]] = None + + +class NumbersStatus(BaseModel): + """Model for the status of a number. + + Args: + error_code (str, Optional): The status code of the response. 200 indicates a + successful request. + error_code_label (str, Optional): A human-readable description of the error code. + """ + + error_code: str = Field(None, validation_alias='error-code') + error_code_label: str = Field(None, validation_alias='error-code-label') diff --git a/number_management/tests/BUILD b/number_management/tests/BUILD new file mode 100644 index 00000000..0830b596 --- /dev/null +++ b/number_management/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['number_management', 'testutils']) diff --git a/number_management/tests/data/list_owned_numbers_basic.json b/number_management/tests/data/list_owned_numbers_basic.json new file mode 100644 index 00000000..c18d7e32 --- /dev/null +++ b/number_management/tests/data/list_owned_numbers_basic.json @@ -0,0 +1,25 @@ +{ + "count": 2, + "numbers": [ + { + "country": "ES", + "msisdn": "3400000000", + "type": "mobile-lvn", + "features": [ + "SMS" + ] + }, + { + "country": "GB", + "msisdn": "447007000000", + "type": "mobile-lvn", + "features": [ + "VOICE", + "SMS" + ], + "voiceCallbackType": "app", + "voiceCallbackValue": "29f769u7-7ce1-46c9-ade3-f2dedee4fr4t", + "app_id": "29f769u7-7ce1-46c9-ade3-f2dedee4fr4t" + } + ] +} \ No newline at end of file diff --git a/number_management/tests/data/list_owned_numbers_filter.json b/number_management/tests/data/list_owned_numbers_filter.json new file mode 100644 index 00000000..cc19907e --- /dev/null +++ b/number_management/tests/data/list_owned_numbers_filter.json @@ -0,0 +1,17 @@ +{ + "count": 1, + "numbers": [ + { + "country": "GB", + "msisdn": "447007000000", + "type": "mobile-lvn", + "features": [ + "VOICE", + "SMS" + ], + "voiceCallbackType": "app", + "voiceCallbackValue": "29f769u7-7ce1-46c9-ade3-f2dedee4fr4t", + "app_id": "29f769u7-7ce1-46c9-ade3-f2dedee4fr4t" + } + ] +} \ No newline at end of file diff --git a/number_management/tests/data/list_owned_numbers_subset.json b/number_management/tests/data/list_owned_numbers_subset.json new file mode 100644 index 00000000..610fd273 --- /dev/null +++ b/number_management/tests/data/list_owned_numbers_subset.json @@ -0,0 +1,13 @@ +{ + "count": 2, + "numbers": [ + { + "country": "ES", + "msisdn": "3400000000", + "type": "mobile-lvn", + "features": [ + "SMS" + ] + } + ] +} \ No newline at end of file diff --git a/number_management/tests/data/no_number.json b/number_management/tests/data/no_number.json new file mode 100644 index 00000000..57a1af6d --- /dev/null +++ b/number_management/tests/data/no_number.json @@ -0,0 +1,4 @@ +{ + "error-code": "420", + "error-code-label": "method failed" +} \ No newline at end of file diff --git a/tests/data/meetings/empty_themes.json b/number_management/tests/data/nothing.json similarity index 100% rename from tests/data/meetings/empty_themes.json rename to number_management/tests/data/nothing.json diff --git a/number_management/tests/data/number.json b/number_management/tests/data/number.json new file mode 100644 index 00000000..b825772e --- /dev/null +++ b/number_management/tests/data/number.json @@ -0,0 +1,4 @@ +{ + "error-code": "200", + "error-code-label": "success" +} \ No newline at end of file diff --git a/number_management/tests/data/search_available_numbers_basic.json b/number_management/tests/data/search_available_numbers_basic.json new file mode 100644 index 00000000..6189643a --- /dev/null +++ b/number_management/tests/data/search_available_numbers_basic.json @@ -0,0 +1,32 @@ +{ + "count": 8353, + "numbers": [ + { + "country": "GB", + "msisdn": "442039050911", + "cost": "1.00", + "type": "landline", + "features": [ + "VOICE" + ] + }, + { + "country": "GB", + "msisdn": "442039051911", + "cost": "1.00", + "type": "landline", + "features": [ + "VOICE" + ] + }, + { + "country": "GB", + "msisdn": "442039052911", + "cost": "1.00", + "type": "landline", + "features": [ + "VOICE" + ] + } + ] +} \ No newline at end of file diff --git a/number_management/tests/data/search_available_numbers_end_of_list.json b/number_management/tests/data/search_available_numbers_end_of_list.json new file mode 100644 index 00000000..f3c45228 --- /dev/null +++ b/number_management/tests/data/search_available_numbers_end_of_list.json @@ -0,0 +1,15 @@ +{ + "count": 1, + "numbers": [ + { + "country": "GB", + "msisdn": "442039055555", + "cost": "0.80", + "type": "mobile-lvn", + "features": [ + "VOICE", + "SMS" + ] + } + ] +} \ No newline at end of file diff --git a/number_management/tests/data/search_available_numbers_filter.json b/number_management/tests/data/search_available_numbers_filter.json new file mode 100644 index 00000000..3bbbb729 --- /dev/null +++ b/number_management/tests/data/search_available_numbers_filter.json @@ -0,0 +1,14 @@ +{ + "count": 2, + "numbers": [ + { + "country": "GB", + "msisdn": "442039055555", + "cost": "1.00", + "type": "landline", + "features": [ + "VOICE" + ] + } + ] +} \ No newline at end of file diff --git a/number_management/tests/test_numbers.py b/number_management/tests/test_numbers.py new file mode 100644 index 00000000..1b31ea0b --- /dev/null +++ b/number_management/tests/test_numbers.py @@ -0,0 +1,272 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.http_client import HttpClient +from vonage_numbers.errors import NumbersError +from vonage_numbers.number_management import Numbers +from vonage_numbers.requests import ( + ListOwnedNumbersFilter, + NumberParams, + SearchAvailableNumbersFilter, + UpdateNumberParams, +) + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +numbers = Numbers(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = numbers.http_client + assert isinstance(http_client, HttpClient) + + +def test_filter_properties(): + with raises(NumbersError) as err: + ListOwnedNumbersFilter(pattern='123') + assert err.match( + '"search_pattern" is required when "pattern"" is provided and vice versa.' + ) + + +@responses.activate +def test_list_owned_numbers_basic(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'list_owned_numbers_basic.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers() + + assert len(numbers_list) == 2 + assert numbers_list[0].msisdn == '3400000000' + assert numbers_list[0].country == 'ES' + assert numbers_list[1].msisdn == '447007000000' + assert numbers_list[1].country == 'GB' + assert numbers_list[1].features == ['VOICE', 'SMS'] + assert numbers_list[1].type == 'mobile-lvn' + assert count == 2 + assert next_page is None + + +@responses.activate +def test_list_owned_numbers_with_filter(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'list_owned_numbers_filter.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers( + ListOwnedNumbersFilter(application_id='29f769u7-7ce1-46c9-ade3-f2dedee4fr4t') + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '447007000000' + assert numbers_list[0].voice_callback_type == 'app' + assert numbers_list[0].voice_callback_value == '29f769u7-7ce1-46c9-ade3-f2dedee4fr4t' + assert numbers_list[0].app_id == '29f769u7-7ce1-46c9-ade3-f2dedee4fr4t' + assert count == 1 + assert next_page is None + + +@responses.activate +def test_list_owned_numbers_subset(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'list_owned_numbers_subset.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers( + ListOwnedNumbersFilter(size=1) + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '3400000000' + assert count == 2 + assert next_page == 2 + + +@responses.activate +def test_search_available_numbers_basic(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'search_available_numbers_basic.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter(country='GB', size=3) + ) + + assert len(numbers_list) == 3 + assert numbers_list[0].msisdn == '442039050911' + assert count == 8353 + assert next_page is 2 + + +@responses.activate +def test_search_available_numbers_with_filter(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'search_available_numbers_filter.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', + size=1, + index=1, + pattern='44203905', + search_pattern=1, + type='landline', + features='VOICE', + ) + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '442039055555' + assert numbers_list[0].country == 'GB' + assert numbers_list[0].features == ['VOICE'] + assert numbers_list[0].type == 'landline' + assert count == 2 + assert next_page == 2 + + +@responses.activate +def test_search_available_numbers_end_of_list(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'search_available_numbers_end_of_list.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=3, pattern='44203905', search_pattern=0 + ) + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '442039055555' + assert count == 1 + assert next_page is None + + +@responses.activate +def test_empty_response(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'nothing.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers( + ListOwnedNumbersFilter(pattern='12345612345', search_pattern=1) + ) + + assert len(numbers_list) == 0 + assert count == 0 + assert next_page is None + + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'nothing.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=3, pattern='12345612345', search_pattern=1 + ) + ) + + assert len(numbers_list) == 0 + assert count == 0 + assert next_page is None + + +@responses.activate +def test_buy_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/buy', + 'number.json', + ) + response = numbers.buy_number(NumberParams(country='GB', msisdn='447000000000')) + + assert response.error_code == '200' + assert response.error_code_label == 'success' + + +@responses.activate +def test_cancel_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/cancel', + 'number.json', + ) + response = numbers.cancel_number(NumberParams(country='GB', msisdn='447000000000')) + + assert response.error_code == '200' + assert response.error_code_label == 'success' + + +@responses.activate +def test_cancel_number_error_no_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/cancel', + 'no_number.json', + ) + with raises(NumbersError) as e: + numbers.cancel_number(NumberParams(country='GB', msisdn='447000000000')) + + assert e.match('method failed') + + +@responses.activate +def test_update_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/update', + 'number.json', + ) + response = numbers.update_number( + UpdateNumberParams( + country='GB', + msisdn='447009000000', + app_id='29f769u7-7ce1-46c9-ade3-f2dedee4fr4t', + mo_http_url='https://example.com', + mo_smpp_sytem_type='inbound', + voice_callback_type='tel', + voice_callback_value='447009000000', + voice_status_callback='https://example.com', + ) + ) + + assert response.error_code == '200' + assert response.error_code_label == 'success' + + +def test_update_number_options_error(): + with raises(NumbersError) as e: + UpdateNumberParams( + country='GB', + msisdn='447009000000', + voice_callback_value='447009000000', + ) + + assert e.match( + '"voice_callback_value" is required when "voice_callback_type" is provided, and vice versa.' + ) diff --git a/pants.ci.toml b/pants.ci.toml new file mode 100644 index 00000000..a0749d00 --- /dev/null +++ b/pants.ci.toml @@ -0,0 +1,5 @@ +[GLOBAL] +colors = true + +[python] +interpreter_constraints = ['>=3.8'] diff --git a/pants.toml b/pants.toml new file mode 100644 index 00000000..16761ca5 --- /dev/null +++ b/pants.toml @@ -0,0 +1,70 @@ +[GLOBAL] +pants_version = '2.23.0rc1' + +backend_packages = [ + 'pants.backend.python', + 'pants.backend.python.lint.autoflake', + 'pants.backend.build_files.fmt.black', + 'pants.backend.python.lint.isort', + 'pants.backend.python.lint.black', + 'pants.backend.python.lint.docformatter', + 'pants.backend.tools.taplo', + "pants.backend.experimental.python", +] + +pants_ignore.add = ['!_test_scripts/', '!_dev_scripts/'] + +[anonymous-telemetry] +enabled = false + +[source] +root_patterns = ['/', 'src/', 'tests/'] + +[python] +interpreter_constraints = ['==3.12.*'] + +[pytest] +args = ['-vv', '--no-header'] + +[coverage-py] +interpreter_constraints = ['>=3.8'] +report = ['html', 'console'] +filter = [ + 'vonage/src', + 'http_client/src', + 'account/src', + 'application/src', + 'jwt/src', + 'messages/src', + 'network_auth/src', + 'network_number_verification/src', + 'network_sim_swap/src', + 'number_insight/src', + 'number_insight_v2/src', + 'number_management/src', + 'sms/src', + 'subaccounts/src', + 'users/src', + 'utils/src', + 'testutils', + 'verify/src', + 'verify_legacy/src', + 'video/src', + 'voice/src', + 'vonage_utils/src', +] + +[black] +args = ['--line-length=90', '--skip-string-normalization'] +interpreter_constraints = ['>=3.8'] + +[isort] +args = ['--profile=black', '--line-length=90'] +interpreter_constraints = ['>=3.8'] + +[docformatter] +args = ['--wrap-summaries=90', '--wrap-descriptions=90'] +interpreter_constraints = ['>=3.8'] + +[autoflake] +interpreter_constraints = ['>=3.8'] diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index e0286062..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[tool.black] -color = true -line-length = 100 -target-version = ['py311'] -skip-string-normalization = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6b622a6e..06803d26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,28 @@ --e . -pytest==7.4.2 -responses==0.22.0 -coverage -pydantic==2.5.2 +pytest>=8.0.0 +requests>=2.31.0 +responses>=0.24.1 +pydantic>=2.7.1 +typing-extensions>=4.9.0 +pyjwt[crypto]>=1.6.4 +toml>=0.10.2 -bump2version -build -twine -pre-commit +-e jwt +-e http_client +-e account +-e application +-e messages +-e network_auth +-e network_number_verification +-e network_sim_swap +-e number_insight +-e number_insight_v2 +-e number_management +-e sms +-e subaccounts +-e users +-e verify +-e verify_legacy +-e video +-e voice +-e vonage_utils +-e vonage diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a8c354a5..00000000 --- a/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[tool:pytest] -testpaths=tests -addopts=--tb=short -p no:doctest -norecursedirs = bin dist docs htmlcov .* {args} - -[pycodestyle] -max-line-length=100 - -[coverage:run] -# TODO: Change this to True: -branch=False -source=src - -[coverage:paths] -source = - .tox/*/site-packages - -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 5b75759a..00000000 --- a/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -import io -import os - -from setuptools import setup, find_packages - - -with io.open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="vonage", - version="3.13.0", - description="Vonage Server SDK for Python", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Vonage/vonage-python-sdk", - author="Vonage", - author_email="devrel@vonage.com", - license="Apache", - packages=find_packages(where="src"), - package_dir={"": "src"}, - platforms=["any"], - install_requires=[ - "vonage-jwt>=1.1.0", - "requests>=2.4.2", - "pytz>=2018.5", - "Deprecated", - "pydantic>=2.5.2", - ], - python_requires=">=3.8", - tests_require=["cryptography>=2.3.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", - ], -) diff --git a/sms/BUILD b/sms/BUILD new file mode 100644 index 00000000..0f896959 --- /dev/null +++ b/sms/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-sms', + dependencies=[ + ':pyproject', + ':readme', + 'sms/src/vonage_sms', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/sms/CHANGES.md b/sms/CHANGES.md new file mode 100644 index 00000000..0937faee --- /dev/null +++ b/sms/CHANGES.md @@ -0,0 +1,23 @@ +# 1.1.4 +- Update dependency versions + +# 1.1.3 +- Support for Python 3.13, drop support for 3.8 + +# 1.1.2 +- Add docstrings to data models + +# 1.1.1 +- Update minimum dependency version + +# 1.1.0 +- Add `http_client` property + +# 1.0.2 +- Internal refactoring + +# 1.0.1 +- Internal refactoring + +# 1.0.0 +- Initial upload diff --git a/sms/README.md b/sms/README.md new file mode 100644 index 00000000..66ac2662 --- /dev/null +++ b/sms/README.md @@ -0,0 +1,24 @@ +# Vonage SMS Package + +This package contains the code to use Vonage's SMS API in Python. + +It includes a method for sending SMS messages and returns an `SmsResponse` class to handle the response. + +## 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`. + +### Send an SMS + +Create an `SmsMessage` object, then pass into the `Sms.send` method. + +```python +from vonage_sms import SmsMessage, SmsResponse + +message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') +response: SmsResponse = vonage_client.sms.send(message) + +print(response.model_dump(exclude_unset=True)) +``` + + diff --git a/sms/pyproject.toml b/sms/pyproject.toml new file mode 100644 index 00000000..1c21cec2 --- /dev/null +++ b/sms/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-sms' +dynamic = ["version"] +description = 'Vonage SMS package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_sms._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/sms/src/vonage_sms/BUILD b/sms/src/vonage_sms/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/sms/src/vonage_sms/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/sms/src/vonage_sms/__init__.py b/sms/src/vonage_sms/__init__.py new file mode 100644 index 00000000..b38bc93d --- /dev/null +++ b/sms/src/vonage_sms/__init__.py @@ -0,0 +1,13 @@ +from .errors import PartialFailureError, SmsError +from .requests import SmsMessage +from .responses import MessageResponse, SmsResponse +from .sms import Sms + +__all__ = [ + 'Sms', + 'SmsMessage', + 'SmsResponse', + 'MessageResponse', + 'SmsError', + 'PartialFailureError', +] diff --git a/sms/src/vonage_sms/_version.py b/sms/src/vonage_sms/_version.py new file mode 100644 index 00000000..bc50bee6 --- /dev/null +++ b/sms/src/vonage_sms/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.4' diff --git a/sms/src/vonage_sms/errors.py b/sms/src/vonage_sms/errors.py new file mode 100644 index 00000000..fa399c14 --- /dev/null +++ b/sms/src/vonage_sms/errors.py @@ -0,0 +1,17 @@ +from requests import Response +from vonage_utils.errors import VonageError + + +class SmsError(VonageError): + """Indicates an error with the Vonage SMS 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/sms/src/vonage_sms/requests.py b/sms/src/vonage_sms/requests.py new file mode 100644 index 00000000..b06c832a --- /dev/null +++ b/sms/src/vonage_sms/requests.py @@ -0,0 +1,85 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator + + +class SmsMessage(BaseModel): + """Message object containing the data and options for an SMS message. + + Args: + to (str): The recipient's phone number in E.164 format. + from_ (str): The name or number the message should be sent from. If a number, it + must be specified in E.164 format, without a leading `+` or `00`. If using + an alphanumeric sender IDs, spaces will be ignored. Sender IDs are not + supported in all countries. + text (str): The message body. If your message contains characters that can be + encoded according to the GSM Standard and Extended tables then you can set + `type` to `text`. If your message contains characters outside this range, + you will need to set `type` to `unicode`. + sig (str, Optional): The hash of the request parameters in alphabetical order, a + timestamp and the signature secret. + client_ref (str, Optional): A client reference string you can optionally include. + type (str, Optional): The format of the message body. Can be 'text', 'binary', or + 'unicode'. + ttl (int, Optional): The duration in milliseconds the delivery of an SMS will be + attempted. + status_report_req (bool, Optional): Boolean indicating if you like to receive a + delivery receipt. + callback (str, Optional): The webhook endpoint the delivery receipt for this SMS + is sent to. This parameter overrides the webhook endpoint you set in the + Vonage Developer Dashboard. + message_class (int, Optional): The Data Coding Scheme value of the message. + body (str, Optional): Hex-encoded binary data. Depends on `type` having the value + `binary`. + udh (str, Optional): The hex-encoded user data header for binary messages. + protocol_id (int, Optional): The protocol identifier for binary messages. Ensure + that the value is aligned with `udh`. + account_ref (str, Optional): An optional string used to identify separate + accounts using the SMS endpoint for billing purposes. To use this feature, + please email support. + entity_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. + content_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. + """ + + 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 diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py new file mode 100644 index 00000000..04282796 --- /dev/null +++ b/sms/src/vonage_sms/responses.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class MessageResponse(BaseModel): + """Individual message response model. + + Args: + to (str): The recipient's phone number in E.164 format. + message_id (str): The message ID. + status (str): The status of the message. + remaining_balance (str): The estimated remaining balance. + message_price (str): The estimated message cost. + network (str): The estimated ID of the network of the recipient + client_ref (str, Optional): If a `client_ref` was included when sending the SMS, + this field will be included and hold the value that was sent. + account_ref (str, Optional): An optional string used to identify separate + accounts using the SMS endpoint for billing purposes. To use this feature, + please email support. + """ + + 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): + """Response recieved after sending an SMS. + + Args: + message_count (str): The number of messages sent. + messages (list[MessageResponse]): A list of individual message responses. See + `MessageResponse` for more information. + """ + + message_count: str = Field(..., validation_alias='message-count') + messages: list[MessageResponse] diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py new file mode 100644 index 00000000..b9e4b6ba --- /dev/null +++ b/sms/src/vonage_sms/sms.py @@ -0,0 +1,124 @@ +from datetime import datetime, timezone + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import PartialFailureError, SmsError +from .requests import SmsMessage +from .responses import SmsResponse + + +class Sms: + """Calls Vonage's SMS API. + + Args: + http_client (HttpClient): The HTTP client used to make requests to the SMS API. + + Raises: + PartialFailureError: Raised when not all messages were sent successfully. + SmsError: Raised when the SMS API returns an error. + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._sent_data_type = 'form' + if self._http_client.auth._signature_secret: + self._auth_type = 'signature' + else: + self._auth_type = 'basic' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the SMS API. + + Returns: + HttpClient: The HTTP client used to make requests to the SMS API. + """ + return self._http_client + + @validate_call + def send(self, message: SmsMessage) -> SmsResponse: + """Send an SMS message. + + Args: + message (SmsMessage): The message to send. + + Returns: + SmsResponse: The response from the API. + + Raises: + PartialFailureError: Raised when not all messages were sent successfully. + SmsError: Raised when the SMS API returns an error. + + Example: + >>> sms = Sms(http_client) + >>> message = SmsMessage( + ... to='1234567890', + ... from_='9876543210', + ... text='Hello, World!', + ... ) + >>> response = sms.send(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 + ) -> 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/sms/tests/BUILD b/sms/tests/BUILD new file mode 100644 index 00000000..b6ae47d7 --- /dev/null +++ b/sms/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['sms', 'testutils']) diff --git a/sms/tests/data/conversion_not_enabled.html b/sms/tests/data/conversion_not_enabled.html new file mode 100644 index 00000000..a47e5f23 --- /dev/null +++ b/sms/tests/data/conversion_not_enabled.html @@ -0,0 +1,12 @@ + + + +Error 402 + + +

HTTP ERROR: 402

+

Problem accessing /conversions/sms. Reason: +

    Bad Account Credentials

+
Powered by Jetty:// + + \ No newline at end of file diff --git a/tests/data/account/secret_management/delete.json b/sms/tests/data/null similarity index 100% rename from tests/data/account/secret_management/delete.json rename to sms/tests/data/null diff --git a/sms/tests/data/send_long_sms.json b/sms/tests/data/send_long_sms.json new file mode 100644 index 00000000..42dc3737 --- /dev/null +++ b/sms/tests/data/send_long_sms.json @@ -0,0 +1,23 @@ +{ + "message-count": "2", + "messages": [ + { + "to": "1234567890", + "message-id": "62dfdf68-6c7c-479a-a190-5c52f798a787", + "status": "0", + "remaining-balance": "37.43563628", + "message-price": "0.04120000", + "network": "23420", + "client-ref": "ref123" + }, + { + "to": "1234567890", + "message-id": "72ff9536-62d6-455a-9f0b-65f3c265b423", + "status": "0", + "remaining-balance": "37.43563628", + "message-price": "0.04120000", + "network": "23420", + "client-ref": "ref123" + } + ] +} \ No newline at end of file diff --git a/sms/tests/data/send_sms.json b/sms/tests/data/send_sms.json new file mode 100644 index 00000000..dce30b3e --- /dev/null +++ b/sms/tests/data/send_sms.json @@ -0,0 +1,13 @@ +{ + "message-count": "1", + "messages": [ + { + "to": "1234567890", + "message-id": "3295d748-4e14-4681-af78-166dca3c5aab", + "status": "0", + "remaining-balance": "38.07243628", + "message-price": "0.04120000", + "network": "23420" + } + ] +} diff --git a/sms/tests/data/send_sms_error.json b/sms/tests/data/send_sms_error.json new file mode 100644 index 00000000..bb918f8b --- /dev/null +++ b/sms/tests/data/send_sms_error.json @@ -0,0 +1,9 @@ +{ + "message-count": "1", + "messages": [ + { + "status": "7", + "error-text": "Number barred." + } + ] +} \ No newline at end of file diff --git a/sms/tests/data/send_sms_partial_error.json b/sms/tests/data/send_sms_partial_error.json new file mode 100644 index 00000000..e05ea0bc --- /dev/null +++ b/sms/tests/data/send_sms_partial_error.json @@ -0,0 +1,17 @@ +{ + "message-count": "2", + "messages": [ + { + "to": "1234567890", + "message-id": "3295d748-4e14-4681-af78-166dca3c5aab", + "status": "0", + "remaining-balance": "38.07243628", + "message-price": "0.04120000", + "network": "23420" + }, + { + "status": "1", + "error-text": "Throttled" + } + ] +} \ No newline at end of file diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py new file mode 100644 index 00000000..520d5c15 --- /dev/null +++ b/sms/tests/test_sms.py @@ -0,0 +1,178 @@ +from os.path import abspath + +import responses +from pydantic import ValidationError +from pytest import raises +from vonage_http_client.auth import Auth +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_sms import Sms +from vonage_sms.errors import PartialFailureError, SmsError +from vonage_sms.requests import SmsMessage + +from testutils import build_response + +path = abspath(__file__) + +api_key = 'qwerasdf' +api_secret = '1234qwerasdfzxcv' +signature_secret = 'signature_secret' +signature_method = 'sha256' + +sms = Sms(HttpClient(Auth(api_key=api_key, api_secret=api_secret))) + + +def test_create_valid_SmsMessage(): + valid_message = { + 'to': '1234567890', + 'from_': 'Acme Inc.', + 'text': 'Hello, World!', + } + SmsMessage(**valid_message) + + valid_message = { + 'to': '1234567890', + 'from_': 'Acme Inc.', + 'text': 'Hello, World!', + 'sig': 'asdfqwerzxcv12345678', + 'client_ref': 'ref123', + 'type': 'binary', + 'ttl': 3000000, + 'status_report_req': True, + 'callback': 'https://example.com/callback', + 'message_class': 0, + 'body': 'some binary data', + 'udh': 'udh123', + 'protocol_id': 127, + 'account_ref': 'account123', + 'entity_id': 'entity123', + 'content_id': 'content123', + } + SmsMessage(**valid_message) + + +def test_create_invalid_SmsMessage(): + # Missing required fields + invalid_message = {'to': '1234567890', 'text': 'Hello, World!'} + with raises(ValidationError): + SmsMessage(**invalid_message) + + # Invalid body for non-binary type + invalid_message = { + 'to': '1234567890', + 'from_': 'Acme Inc.', + 'text': 'Hello, World!', + 'type': 'text', + 'body': 'binary data', + } + with raises(ValidationError): + SmsMessage(**invalid_message) + + # Missing body and udh for binary type + invalid_message = { + 'to': '1234567890', + 'from_': 'Acme Inc.', + 'text': 'Hello, World!', + 'type': 'binary', + } + with raises(ValidationError): + SmsMessage(**invalid_message) + + +@responses.activate +def test_send_message(): + build_response(path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_sms.json') + message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') + response = sms.send(message) + assert response.message_count == '1' + assert response.messages[0].to == '1234567890' + assert response.messages[0].message_id == '3295d748-4e14-4681-af78-166dca3c5aab' + assert response.messages[0].status == '0' + assert response.messages[0].remaining_balance == '38.07243628' + assert response.messages[0].message_price == '0.04120000' + assert response.messages[0].network == '23420' + + +@responses.activate +def test_send_long_message(): + build_response(path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_long_sms.json') + message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') + response = sms.send(message) + assert response.message_count == '2' + assert response.messages[0].message_id == '62dfdf68-6c7c-479a-a190-5c52f798a787' + assert response.messages[1].message_id == '72ff9536-62d6-455a-9f0b-65f3c265b423' + + +@responses.activate +def test_send_message_with_signature(): + sms = Sms( + HttpClient( + Auth( + api_key=api_key, + signature_secret=signature_secret, + signature_method=signature_method, + ) + ) + ) + build_response(path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_sms.json') + message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') + response = sms.send(message) + assert response.message_count == '1' + assert response.messages[0].status == '0' + + +@responses.activate +def test_send_message_partial_failure(): + build_response( + path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_sms_partial_error.json' + ) + message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') + try: + sms.send(message) + except PartialFailureError as err: + assert err.response['message-count'] == '2' + assert err.response['messages'][1]['error-text'] == 'Throttled' + + +@responses.activate +def test_send_message_error(): + build_response(path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_sms_error.json') + message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') + try: + sms.send(message) + except SmsError as err: + assert ( + str(err) == 'Sms.send_message method failed with error code 7: Number barred.' + ) + + +@responses.activate +def test_submit_sms_conversion(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/conversions/sms', + 'null', + ) + response = sms.submit_sms_conversion('3295d748-4e14-4681-af78-166dca3c5aab') + assert response is None + + +@responses.activate +def test_submit_sms_conversion_402(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/conversions/sms', + 'conversion_not_enabled.html', + status_code=402, + ) + try: + sms.submit_sms_conversion('3295d748-4e14-4681-af78-166dca3c5aab') + except HttpRequestError as err: + assert err.message == '402 response from https://api.nexmo.com/conversions/sms.' + + +def test_http_client_property(): + sms = Sms(HttpClient(Auth(api_key='qwerasdf', api_secret='1234qwerasdfzxcv'))) + assert isinstance(sms.http_client, HttpClient) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py deleted file mode 100644 index 50e2d8dd..00000000 --- a/src/vonage/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .client import * -from .ncco_builder.ncco import * - -__version__ = "3.13.0" diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py deleted file mode 100644 index 5d6d2d74..00000000 --- a/src/vonage/_internal.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from vonage import Client - - -def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): - """ - Utility function to convert datetime values to strings. - - If the value is already a str, or is not in the dict, no change is made. - - :param params: A `dict` of params that may contain a `datetime` value. - :param key: The datetime value to be converted to a `str` - :param format: The `strftime` format to be used to format the date. The default value is '%Y-%m-%d %H:%M:%S' - """ - if key in params: - param = params[key] - if hasattr(param, "strftime"): - params[key] = param.strftime(format) - - -def set_auth_type(client: Client) -> str: - """Sets the authentication type used. If a JWT Client has been created, - it will create a JWT and use JWT authentication.""" - - if hasattr(client, '_jwt_client'): - return 'jwt' - else: - return 'header' diff --git a/src/vonage/account.py b/src/vonage/account.py deleted file mode 100644 index 32968dcc..00000000 --- a/src/vonage/account.py +++ /dev/null @@ -1,116 +0,0 @@ -from .errors import PricingTypeError - -from deprecated import deprecated - - -class Account: - account_auth_type = 'params' - pricing_auth_type = 'params' - secrets_auth_type = 'header' - - allowed_pricing_types = {'sms', 'sms-transit', 'voice'} - - def __init__(self, client): - self._client = client - - def get_balance(self): - return self._client.get( - self._client.host(), "/account/get-balance", auth_type=Account.account_auth_type - ) - - def topup(self, params=None, **kwargs): - return self._client.post( - self._client.host(), - "/account/top-up", - params or kwargs, - auth_type=Account.account_auth_type, - body_is_json=False, - ) - - def get_country_pricing(self, country_code: str, type: str = 'sms'): - self._check_allowed_pricing_type(type) - return self._client.get( - self._client.host(), - f"/account/get-pricing/outbound/{type}", - {"country": country_code}, - auth_type=Account.pricing_auth_type, - ) - - def get_all_countries_pricing(self, type: str = 'sms'): - self._check_allowed_pricing_type(type) - return self._client.get( - self._client.host(), - f"/account/get-full-pricing/outbound/{type}", - auth_type=Account.pricing_auth_type, - ) - - def get_prefix_pricing(self, prefix: str, type: str = 'sms'): - self._check_allowed_pricing_type(type) - return self._client.get( - self._client.host(), - f"/account/get-prefix-pricing/outbound/{type}", - {"prefix": prefix}, - auth_type=Account.pricing_auth_type, - ) - - @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') - def get_sms_pricing(self, number: str): - return self._client.get( - self._client.host(), - "/account/get-phone-pricing/outbound/sms", - {"phone": number}, - auth_type=Account.pricing_auth_type, - ) - - @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') - def get_voice_pricing(self, number: str): - return self._client.get( - self._client.host(), - "/account/get-phone-pricing/outbound/voice", - {"phone": number}, - auth_type=Account.pricing_auth_type, - ) - - def update_default_sms_webhook(self, params=None, **kwargs): - return self._client.post( - self._client.host(), - "/account/settings", - params or kwargs, - auth_type=Account.account_auth_type, - body_is_json=False, - ) - - def list_secrets(self, api_key): - return self._client.get( - self._client.api_host(), - f"/accounts/{api_key}/secrets", - auth_type=Account.secrets_auth_type, - ) - - def get_secret(self, api_key, secret_id): - return self._client.get( - self._client.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - auth_type=Account.secrets_auth_type, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._client.post( - self._client.api_host(), - f"/accounts/{api_key}/secrets", - body, - auth_type=Account.secrets_auth_type, - body_is_json=False, - ) - - def revoke_secret(self, api_key, secret_id): - return self._client.delete( - self._client.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - auth_type=Account.secrets_auth_type, - ) - - def _check_allowed_pricing_type(self, type): - if type not in Account.allowed_pricing_types: - raise PricingTypeError('Invalid pricing type specified.') diff --git a/src/vonage/application.py b/src/vonage/application.py deleted file mode 100644 index c5398a76..00000000 --- a/src/vonage/application.py +++ /dev/null @@ -1,174 +0,0 @@ -from deprecated import deprecated - - -@deprecated( - version='3.0.0', - reason='Renamed to Application as V1 is out of support and this new \ - naming is in line with other APIs. Please use Application instead.', -) -class ApplicationV2: - auth_type = 'header' - - def __init__(self, client): - self._client = client - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication - """ - return self._client.post( - self._client.api_host(), - "/v2/applications", - application_data, - auth_type=ApplicationV2.auth_type, - ) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._client.get( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=ApplicationV2.auth_type, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._client.put( - self._client.api_host(), - f"/v2/applications/{application_id}", - params, - auth_type=ApplicationV2.auth_type, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._client.delete( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=ApplicationV2.auth_type, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._client.get( - self._client.api_host(), - "/v2/applications", - params=params, - auth_type=ApplicationV2.auth_type, - ) - - -class Application: - auth_type = 'header' - - def __init__(self, client): - self._client = client - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication - """ - return self._client.post( - self._client.api_host(), - "/v2/applications", - application_data, - auth_type=Application.auth_type, - ) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._client.get( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=Application.auth_type, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._client.put( - self._client.api_host(), - f"/v2/applications/{application_id}", - params, - auth_type=Application.auth_type, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._client.delete( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=Application.auth_type, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._client.get( - self._client.api_host(), - "/v2/applications", - params=params, - auth_type=Application.auth_type, - ) - - -def _filter_none_values(d): - return {k: v for k, v in d.items() if v is not None} diff --git a/src/vonage/client.py b/src/vonage/client.py deleted file mode 100644 index af97036d..00000000 --- a/src/vonage/client.py +++ /dev/null @@ -1,463 +0,0 @@ -import vonage -from vonage_jwt.jwt import JwtClient - -from .account import Account -from .application import ApplicationV2, Application -from .errors import * -from .meetings import Meetings -from .messages import Messages -from .number_insight import NumberInsight -from .number_management import Numbers -from .proactive_connect import ProactiveConnect -from .redact import Redact -from .short_codes import ShortCodes -from .sms import Sms -from .subaccounts import Subaccounts -from .users import Users -from .ussd import Ussd -from .video import Video -from .voice import Voice -from .verify import Verify -from .verify2 import Verify2 - -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import os -import time - -from requests import Response -from requests.adapters import HTTPAdapter -from requests.sessions import Session - -string_types = (str, bytes) - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("vonage") - - -class Client: - """ - Create a Client object to start making calls to Vonage/Nexmo APIs. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Vonage API docs ` - for details of the authentication used by the APIs you wish to use, and instantiate your - client with the appropriate credentials. - - :param str key: Your Vonage API key - :param str secret: Your Vonage API secret. - :param str signature_secret: Your Vonage API signature secret. - You may need to have this enabled by Vonage support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Vonage Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key, for calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used to track your app statistics. - :param timeout: (optional) How many seconds to wait for the server to send data - before giving up, as a float, or a (connect timeout, read - timeout) tuple. If set this timeout is used for every call to the Vonage enpoints - :type timeout: float or tuple - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - timeout=None, - pool_connections=10, - pool_maxsize=10, - max_retries=3, - ): - self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) - - self.application_id = application_id - - self.signature_secret = signature_secret or os.environ.get("VONAGE_SIGNATURE_SECRET", None) - self.signature_method = signature_method or os.environ.get("VONAGE_SIGNATURE_METHOD", None) - - if self.signature_method in { - "md5", - "sha1", - "sha256", - "sha512", - }: - self.signature_method = getattr(hashlib, signature_method) - - if private_key is not None and application_id is not None: - self._jwt_client = JwtClient(application_id, private_key) - - self._jwt_claims = {} - self._host = "rest.nexmo.com" - self._api_host = "api.nexmo.com" - self._video_host = "video.api.vonage.com" - self._meetings_api_host = "api-eu.vonage.com/v1/meetings" - self._proactive_connect_host = "api-eu.vonage.com" - - user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" - - if app_name and app_version: - user_agent += f" {app_name}/{app_version}" - - self.headers = { - "User-Agent": user_agent, - "Accept": "application/json", - } - - self.account = Account(self) - self.application = Application(self) - self.meetings = Meetings(self) - self.messages = Messages(self) - self.number_insight = NumberInsight(self) - self.numbers = Numbers(self) - self.proactive_connect = ProactiveConnect(self) - self.short_codes = ShortCodes(self) - self.sms = Sms(self) - self.subaccounts = Subaccounts(self) - self.users = Users(self) - self.ussd = Ussd(self) - self.verify = Verify(self) - self.verify2 = Verify2(self) - self.video = Video(self) - self.voice = Voice(self) - - self.timeout = timeout - self.session = Session() - self.adapter = HTTPAdapter( - pool_connections=pool_connections, - pool_maxsize=pool_maxsize, - max_retries=max_retries, - ) - self.session.mount("https://", self.adapter) - - # Gets and sets _host attribute - def host(self, value=None): - if value is None: - return self._host - else: - self._host = value - - # Gets and sets _api_host attribute - def api_host(self, value=None): - if value is None: - return self._api_host - else: - self._api_host = value - - def video_host(self, value=None): - if value is None: - return self._video_host - else: - self._video_host = value - - # Gets and sets _meetings_api_host attribute - def meetings_api_host(self, value=None): - if value is None: - return self._meetings_api_host - else: - self._meetings_api_host = value - - def proactive_connect_host(self, value=None): - if value is None: - return self._proactive_connect_host - else: - self._proactive_connect_host = value - - def auth(self, params=None, **kwargs): - self._jwt_claims = params or kwargs - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), - digestmod=self.signature_method, - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update(f"&{key}={value}".encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'params': - params = dict( - params or {}, - api_key=self.api_key, - api_secret=self.api_secret, - ) - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug( - f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - return self.parse( - host, - self.session.get( - uri, - params=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - - def post( - self, - host, - request_uri, - params, - auth_type=None, - body_is_json=True, - supports_signature_auth=False, - ): - """ - Low-level method to make a post request to an API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, - then signature authentication will be used. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided - when initializing this client. - """ - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'params': - params = dict( - params, - api_key=self.api_key, - api_secret=self.api_secret, - ) - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug( - f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - if body_is_json: - return self.parse( - host, - self.session.post( - uri, - json=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - else: - return self.parse( - host, - self.session.post( - uri, - data=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - - def put(self, host, request_uri, params, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug( - f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - # All APIs that currently use put methods require a json-formatted body so don't need to check this - return self.parse( - host, - self.session.put( - uri, - json=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - - def patch(self, host, request_uri, params, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError(f"""Invalid authentication type.""") - - logger.debug( - f"PATCH to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - # Only newer APIs (that expect json-bodies) currently use this method, so we will always send a json-formatted body - return self.parse( - host, - self.session.patch( - uri, - json=params, - headers=self._request_headers, - ), - ) - - def delete(self, host, request_uri, params=None, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") - if params is not None: - logger.debug(f"DELETE call has params {repr(params)}") - return self.parse( - host, - self.session.delete( - uri, - headers=self._request_headers, - timeout=self.timeout, - params=params, - ), - ) - - def parse(self, host, response: Response): - logger.debug(f"Response headers {repr(response.headers)}") - if response.status_code == 401: - raise AuthenticationError("Authentication failed.") - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - # Strip off any encoding from the content-type header: - try: - content_mime = response.headers.get("content-type").split(";", 1)[0] - except AttributeError: - if response.json() is None: - return None - if content_mime == "application/json": - try: - return response.json() - except JSONDecodeError: - pass - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning(f"Client error: {response.status_code} {repr(response.content)}") - message = f"{response.status_code} response from {host}" - - # Test for standard error format: - try: - error_data = response.json() - if "type" in error_data and "title" in error_data and "detail" in error_data: - title = error_data["title"] - detail = error_data["detail"] - type = error_data["type"] - message = f"{title}: {detail} ({type}){self._add_individual_errors(error_data)}" - elif 'status' in error_data and 'message' in error_data and 'name' in error_data: - message = ( - f'Status Code {error_data["status"]}: {error_data["name"]}: {error_data["message"]}' - f'{self._add_individual_errors(error_data)}' - ) - else: - message = error_data - except JSONDecodeError: - pass - raise ClientError(message) - - elif 500 <= response.status_code < 600: - logger.warning(f"Server error: {response.status_code} {repr(response.content)}") - message = f"{response.status_code} response from {host}" - raise ServerError(message) - - def _add_individual_errors(self, error_data): - message = '' - if 'errors' in error_data: - for error in error_data["errors"]: - message += f"\nError: {error}" - return message - - def _create_jwt_auth_string(self): - 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): - raise ClientError( - 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' - ) - else: - raise err - - def _create_header_auth_string(self): - hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") - return f"Basic {hash}" diff --git a/src/vonage/errors.py b/src/vonage/errors.py deleted file mode 100644 index 27e4f8dd..00000000 --- a/src/vonage/errors.py +++ /dev/null @@ -1,72 +0,0 @@ -class Error(Exception): - pass - - -class ClientError(Error): - pass - - -class ServerError(Error): - pass - - -class AuthenticationError(ClientError): - pass - - -class CallbackRequiredError(Error): - """Indicates a callback is required but was not present.""" - - -class MessagesError(Error): - """ - Indicates an error related to the Messages class which calls the Vonage Messages API. - """ - - -class PricingTypeError(Error): - """A pricing type was specified that is not allowed.""" - - -class RedactError(Error): - """Error related to the Redact class or Redact API.""" - - -class InvalidAuthenticationTypeError(Error): - """An authentication method was specified that is not allowed""" - - -class MeetingsError(ClientError): - """An error related to the Meetings class which calls the Vonage Meetings API.""" - - -class Verify2Error(ClientError): - """An error relating to the Verify (V2) API.""" - - -class SubaccountsError(ClientError): - """An error relating to the Subaccounts API.""" - - -class ProactiveConnectError(ClientError): - """An error relating to the Proactive Connect API.""" - - -class VideoError(ClientError): - """An error relating to the Video API.""" - - -class UsersError(ClientError): - """An error relating to the Users API.""" - - -class InvalidRoleError(ClientError): - """The specified role was invalid.""" - - -class TokenExpiryError(ClientError): - """The specified token expiry time was invalid.""" - - -class SipError(ClientError): - """Error related to usage of SIP calls.""" diff --git a/src/vonage/meetings.py b/src/vonage/meetings.py deleted file mode 100644 index a506bddc..00000000 --- a/src/vonage/meetings.py +++ /dev/null @@ -1,173 +0,0 @@ -from .errors import MeetingsError - -from typing_extensions import Literal -import logging -import requests - - -logger = logging.getLogger("vonage") - - -class Meetings: - """Class containing methods used to create and manage meetings using the Meetings API.""" - - _auth_type = 'jwt' - - def __init__(self, client): - self._client = client - self._meetings_api_host = client.meetings_api_host() - - def list_rooms(self, page_size: str = 20, start_id: str = None, end_id: str = None): - params = Meetings.set_start_and_end_params(start_id, end_id) - params['page_size'] = page_size - return self._client.get( - self._meetings_api_host, '/rooms', params, auth_type=Meetings._auth_type - ) - - def create_room(self, params: dict = {}): - if 'display_name' not in params: - raise MeetingsError( - 'You must include a value for display_name as a field in the params dict when creating a meeting room.' - ) - if 'type' not in params or 'type' in params and params['type'] != 'long_term': - if 'expires_at' in params: - raise MeetingsError('Cannot set "expires_at" for an instant room.') - elif params['type'] == 'long_term' and 'expires_at' not in params: - raise MeetingsError('You must set a value for "expires_at" for a long-term room.') - - return self._client.post( - self._meetings_api_host, '/rooms', params, auth_type=Meetings._auth_type - ) - - def get_room(self, room_id: str): - return self._client.get( - self._meetings_api_host, f'/rooms/{room_id}', auth_type=Meetings._auth_type - ) - - def update_room(self, room_id: str, params: dict): - return self._client.patch( - self._meetings_api_host, f'/rooms/{room_id}', params, auth_type=Meetings._auth_type - ) - - def add_theme_to_room(self, room_id: str, theme_id: str): - params = {'update_details': {'theme_id': theme_id}} - return self._client.patch( - self._meetings_api_host, f'/rooms/{room_id}', params, auth_type=Meetings._auth_type - ) - - def get_recording(self, recording_id: str): - return self._client.get( - self._meetings_api_host, f'/recordings/{recording_id}', auth_type=Meetings._auth_type - ) - - def delete_recording(self, recording_id: str): - return self._client.delete( - self._meetings_api_host, f'/recordings/{recording_id}', auth_type=Meetings._auth_type - ) - - def get_session_recordings(self, session_id: str): - return self._client.get( - self._meetings_api_host, - f'/sessions/{session_id}/recordings', - auth_type=Meetings._auth_type, - ) - - def list_dial_in_numbers(self): - return self._client.get( - self._meetings_api_host, '/dial-in-numbers', auth_type=Meetings._auth_type - ) - - def list_themes(self): - return self._client.get(self._meetings_api_host, '/themes', auth_type=Meetings._auth_type) - - def create_theme(self, params: dict): - if 'main_color' not in params or 'brand_text' not in params: - raise MeetingsError('Values for "main_color" and "brand_text" must be specified') - - return self._client.post( - self._meetings_api_host, '/themes', params, auth_type=Meetings._auth_type - ) - - def get_theme(self, theme_id: str): - return self._client.get( - self._meetings_api_host, f'/themes/{theme_id}', auth_type=Meetings._auth_type - ) - - def delete_theme(self, theme_id: str, force: bool = False): - params = {'force': force} - return self._client.delete( - self._meetings_api_host, - f'/themes/{theme_id}', - params=params, - auth_type=Meetings._auth_type, - ) - - def update_theme(self, theme_id: str, params: dict): - return self._client.patch( - self._meetings_api_host, f'/themes/{theme_id}', params, auth_type=Meetings._auth_type - ) - - def list_rooms_with_theme_id( - self, theme_id: str, page_size: int = 20, start_id: str = None, end_id: str = None - ): - params = Meetings.set_start_and_end_params(start_id, end_id) - params['page_size'] = page_size - - return self._client.get( - self._meetings_api_host, - f'/themes/{theme_id}/rooms', - params, - auth_type=Meetings._auth_type, - ) - - def update_application_theme(self, theme_id: str): - params = {'update_details': {'default_theme_id': theme_id}} - return self._client.patch( - self._meetings_api_host, '/applications', params, auth_type=Meetings._auth_type - ) - - def upload_logo_to_theme( - self, theme_id: str, path_to_image: str, logo_type: Literal['white', 'colored', 'favicon'] - ): - params = self._get_logo_upload_url(logo_type) - self._upload_to_aws(params, path_to_image) - self._add_logo_to_theme(theme_id, params['fields']['key']) - return f'Logo upload to theme: {theme_id} was successful.' - - def _get_logo_upload_url(self, logo_type): - upload_urls = self._client.get( - self._meetings_api_host, '/themes/logos-upload-urls', auth_type=Meetings._auth_type - ) - for url_object in upload_urls: - if url_object['fields']['logoType'] == logo_type: - return url_object - raise MeetingsError('Cannot find the upload URL for the specified logo type.') - - def _upload_to_aws(self, params, path_to_image): - form = {**params['fields'], 'file': open(path_to_image, 'rb')} - - logger.debug(f"POST to {params['url']} to upload file {path_to_image}") - logo_upload = requests.post( - url=params['url'], - files=form, - ) - if logo_upload.status_code != 204: - raise MeetingsError(f'Logo upload process failed. {logo_upload.content}') - - def _add_logo_to_theme(self, theme_id: str, key: str): - params = {'keys': [key]} - return self._client.put( - self._meetings_api_host, - f'/themes/{theme_id}/finalizeLogos', - params, - auth_type=Meetings._auth_type, - ) - - @staticmethod - def set_start_and_end_params(start_id, end_id): - params = {} - if start_id is not None: - params['start_id'] = start_id - if end_id is not None: - params['end_id'] = end_id - return params diff --git a/src/vonage/messages.py b/src/vonage/messages.py deleted file mode 100644 index 23e7df20..00000000 --- a/src/vonage/messages.py +++ /dev/null @@ -1,113 +0,0 @@ -from ._internal import set_auth_type -from .errors import MessagesError - -import re - - -class Messages: - valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber_service'} - valid_message_types = { - 'sms': {'text'}, - 'mms': {'image', 'vcard', 'audio', 'video'}, - 'whatsapp': {'text', 'image', 'audio', 'video', 'file', 'template', 'sticker', 'custom'}, - 'messenger': {'text', 'image', 'audio', 'video', 'file'}, - 'viber_service': {'text', 'image', 'video', 'file'}, - } - - def __init__(self, client): - self._client = client - self._auth_type = set_auth_type(self._client) - - def send_message(self, params: dict): - self.validate_send_message_input(params) - - return self._client.post( - self._client.api_host(), - "/v1/messages", - params, - auth_type=self._auth_type, - ) - - def validate_send_message_input(self, params): - self._check_input_is_dict(params) - self._check_valid_message_channel(params) - self._check_valid_message_type(params) - self._check_valid_recipient(params) - self._check_valid_sender(params) - self._channel_specific_checks(params) - self._check_valid_client_ref(params) - - def _check_input_is_dict(self, params): - if type(params) is not dict: - raise MessagesError( - 'Parameters to the send_message method must be specified as a dictionary.' - ) - - def _check_valid_message_channel(self, params): - if params['channel'] not in Messages.valid_message_channels: - raise MessagesError( - f""" - "{params['channel']}" is an invalid message channel. - Must be one of the following types: {self.valid_message_channels}' - """ - ) - - def _check_valid_message_type(self, params): - if params['message_type'] not in self.valid_message_types[params['channel']]: - raise MessagesError( - f""" - "{params['message_type']}" is not a valid message type for channel "{params["channel"]}". - Must be one of the following types: {self.valid_message_types[params["channel"]]} - """ - ) - - def _check_valid_recipient(self, params): - if not isinstance(params['to'], str): - raise MessagesError(f'Message recipient ("to={params["to"]}") not in a valid format.') - elif params['channel'] != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', params['to']): - raise MessagesError( - f'Message recipient number ("to={params["to"]}") not in a valid format.' - ) - elif params['channel'] == 'messenger' and not 0 < len(params['to']) < 50: - raise MessagesError( - f'Message recipient ID ("to={params["to"]}") not in a valid format.' - ) - - def _check_valid_sender(self, params): - if not isinstance(params['from'], str) or params['from'] == "": - raise MessagesError( - f'Message sender ("frm={params["from"]}") set incorrectly. Set a valid name or number for the sender.' - ) - - def _channel_specific_checks(self, params): - if ( - ( - params['channel'] == 'whatsapp' - and params['message_type'] == 'template' - and 'whatsapp' not in params - ) - or ( - params['channel'] == 'whatsapp' - and params['message_type'] == 'sticker' - and 'sticker' not in params - ) - or (params['channel'] == 'viber_service' and 'viber_service' not in params) - ): - raise MessagesError( - f'''You must specify all required properties for message channel "{params["channel"]}".''' - ) - elif params['channel'] == 'whatsapp' and params['message_type'] == 'sticker': - self._check_valid_whatsapp_sticker(params['sticker']) - - def _check_valid_client_ref(self, params): - if 'client_ref' in params: - if len(params['client_ref']) <= 100: - self._client_ref = params['client_ref'] - else: - raise MessagesError('client_ref can be a maximum of 100 characters.') - - def _check_valid_whatsapp_sticker(self, sticker): - if ('id' not in sticker and 'url' not in sticker) or ('id' in sticker and 'url' in sticker): - raise MessagesError( - 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' - ) diff --git a/src/vonage/ncco_builder/__init__.py b/src/vonage/ncco_builder/__init__.py deleted file mode 100644 index f1908afe..00000000 --- a/src/vonage/ncco_builder/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .ncco import * diff --git a/src/vonage/ncco_builder/connect_endpoints.py b/src/vonage/ncco_builder/connect_endpoints.py deleted file mode 100644 index d77a0b8c..00000000 --- a/src/vonage/ncco_builder/connect_endpoints.py +++ /dev/null @@ -1,63 +0,0 @@ -from pydantic import BaseModel, HttpUrl, AnyUrl, constr, field_serializer -from typing import Dict -from typing_extensions import Literal - - -class ConnectEndpoints: - class Endpoint(BaseModel): - type: Literal['phone', 'app', 'websocket', 'sip', 'vbc'] = None - - class PhoneEndpoint(Endpoint): - type: Literal['phone'] = 'phone' - - number: constr(pattern=r'^[1-9]\d{6,14}$') - dtmfAnswer: constr(pattern='^[0-9*#p]+$') = None - onAnswer: Dict[str, HttpUrl] = None - - @field_serializer('onAnswer') - def serialize_dt(self, oa: Dict[str, HttpUrl], _info): - if oa is None: - return oa - - return {k: str(v) for k, v in oa.items()} - - class AppEndpoint(Endpoint): - type: Literal['app'] = 'app' - user: str - - class WebsocketEndpoint(Endpoint): - type: Literal['websocket'] = 'websocket' - - uri: AnyUrl - contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] - headers: dict = None - - @field_serializer('uri') - def serialize_uri(self, uri: AnyUrl, _info): - return str(uri) - - class SipEndpoint(Endpoint): - type: Literal['sip'] = 'sip' - uri: str - headers: dict = None - - class VbcEndpoint(Endpoint): - type: Literal['vbc'] = 'vbc' - extension: str - - @classmethod - def create_endpoint_model_from_dict(cls, d) -> Endpoint: - if d['type'] == 'phone': - return cls.PhoneEndpoint.model_validate(d) - elif d['type'] == 'app': - return cls.AppEndpoint.model_validate(d) - elif d['type'] == 'websocket': - return cls.WebsocketEndpoint.model_validate(d) - elif d['type'] == 'sip': - return cls.WebsocketEndpoint.model_validate(d) - elif d['type'] == 'vbc': - return cls.WebsocketEndpoint.model_validate(d) - else: - raise ValueError( - 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' - ) diff --git a/src/vonage/ncco_builder/input_types.py b/src/vonage/ncco_builder/input_types.py deleted file mode 100644 index 56737f24..00000000 --- a/src/vonage/ncco_builder/input_types.py +++ /dev/null @@ -1,26 +0,0 @@ -from pydantic import BaseModel, confloat, conint -from typing import List - - -class InputTypes: - class Dtmf(BaseModel): - timeOut: conint(ge=0, le=10) = None - maxDigits: conint(ge=1, le=20) = None - submitOnHash: bool = None - - class Speech(BaseModel): - uuid: str = None - endOnSilence: confloat(ge=0.4, le=10.0) = None - language: str = None - context: List[str] = None - startTimeout: conint(ge=1, le=60) = None - maxDuration: conint(ge=1, le=60) = None - saveAudio: bool = None - - @classmethod - def create_dtmf_model(cls, dict) -> Dtmf: - return cls.Dtmf.model_validate(dict) - - @classmethod - def create_speech_model(cls, dict) -> Speech: - return cls.Speech.model_validate(dict) diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py deleted file mode 100644 index ed1c96d1..00000000 --- a/src/vonage/ncco_builder/ncco.py +++ /dev/null @@ -1,259 +0,0 @@ -from pydantic import BaseModel, Field, ValidationInfo, field_validator, constr, confloat, conint -from typing import Any, Dict, Union, List -from typing_extensions import Annotated, Literal - -from .connect_endpoints import ConnectEndpoints -from .input_types import InputTypes -from .pay_prompts import PayPrompts - -from deprecated import deprecated - - -class Ncco: - class Action(BaseModel): - action: Literal['record', 'conversation', 'connect', - 'talk', 'stream', 'input', 'notify', 'pay'] = None - - class Record(Action): - """Use the record action to record a call or part of a call.""" - - action: Literal['record'] = 'record' - format: Literal['mp3', 'wav', 'ogg'] = None - split: Literal['conversation'] = None - channels: conint(ge=1, le=32) = None - endOnSilence: conint(ge=3, le=10) = None - endOnKey: constr(pattern='^[0-9*#]$') = None - timeOut: conint(ge=3, le=7200) = None - beepStart: bool = None - eventUrl: Union[List[str], str] = None - eventMethod: constr(to_upper=True) = None - - @field_validator('channels') - @classmethod - def enable_split(cls, v, info: ValidationInfo): - values = info.data - if values['split'] is None: - values['split'] = 'conversation' - return v - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - class Conversation(Action): - """You can use the conversation action to create standard or moderated conferences, - while preserving the communication context. - Using conversation with the same name reuses the same persisted conversation.""" - - action: Literal['conversation'] = 'conversation' - name: str - musicOnHoldUrl: Union[List[str], str] = None - startOnEnter: bool = None - endOnExit: bool = None - record: bool = None - canSpeak: List[str] = None - canHear: List[str] = None - mute: bool = None - - @field_validator('musicOnHoldUrl') - @classmethod - def ensure_url_in_list(cls, v: Any): - return Ncco._ensure_object_in_list(v) - - @field_validator('mute') - @classmethod - def can_mute(cls, v, info: ValidationInfo): - values = info.data - if 'canSpeak' in values and values['canSpeak'] is not None: - raise ValueError('Cannot use mute option if canSpeak option is specified.') - return v - - class Connect(Action): - """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" - - action: Literal['connect'] = 'connect' - endpoint: Union[dict, ConnectEndpoints.Endpoint, List] - from_: Annotated[str, Field(alias='from_', serialization_alias='from', - pattern=r'^[1-9]\d{6,14}$')] = None - - randomFromNumber: bool = None - eventType: Literal['synchronous'] = None - timeout: int = None - limit: conint(le=7200) = None - machineDetection: Literal['continue', 'hangup'] = None - advancedMachineDetection: dict = None - eventUrl: Union[List[str], str] = None - eventMethod: constr(to_upper=True) = None - ringbackTone: str = None - - @field_validator('endpoint') - @classmethod - def validate_endpoint(cls, v: Any): - - if type(v) is dict: - return [ConnectEndpoints.create_endpoint_model_from_dict(v)] - elif type(v) is list: - return [ConnectEndpoints.create_endpoint_model_from_dict(v[0])] - else: - return [v] - - @field_validator('randomFromNumber') - @classmethod - def check_from_not_set(cls, v, info: ValidationInfo): - values = info.data - if v is True and 'from_' in values: - if values['from_'] is not None: - raise ValueError( - 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' - ) - return v - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @field_validator('advancedMachineDetection') - @classmethod - def validate_advancedMachineDetection(cls, v): - if 'behavior' in v and v['behavior'] not in ('continue', 'hangup'): - raise ValueError( - 'advancedMachineDetection["behavior"] must be one of: "continue", "hangup".' - ) - if 'mode' in v and v['mode'] not in ('detect, detect_beep'): - raise ValueError( - 'advancedMachineDetection["mode"] must be one of: "detect", "detect_beep".' - ) - return v - - class Talk(Action): - """The talk action sends synthesized speech to a Conversation.""" - - action: Literal['talk'] = 'talk' - text: constr(max_length=1500) - bargeIn: bool = None - loop: conint(ge=0) = None - level: confloat(ge=-1, le=1) = None - language: str = None - style: int = None - premium: bool = None - - class Stream(Action): - """The stream action allows you to send an audio stream to a Conversation.""" - - action: Literal['stream'] = 'stream' - streamUrl: Union[List[str], str] - level: confloat(ge=-1, le=1) = None - bargeIn: bool = None - loop: conint(ge=0) = None - - @field_validator('streamUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - class Input(Action): - """Collect digits or speech input by the person you are are calling.""" - - action: Literal['input'] = 'input' - - type: Union[ - Literal['dtmf', 'speech'], - List[Literal['dtmf']], - List[Literal['speech']], - List[Literal['dtmf', 'speech']], - ] - dtmf: Union[InputTypes.Dtmf, dict] = None - speech: Union[InputTypes.Speech, dict] = None - eventUrl: Union[List[str], str] = None - eventMethod: constr(to_upper=True) = None - - @field_validator('type', 'eventUrl') - @classmethod - def ensure_value_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @field_validator('dtmf') - @classmethod - def ensure_input_object_is_dtmf_model(cls, v): - if type(v) is dict: - return InputTypes.create_dtmf_model(v) - else: - return v - - @field_validator('speech') - @classmethod - def ensure_input_object_is_speech_model(cls, v): - if type(v) is dict: - return InputTypes.create_speech_model(v) - else: - return v - - class Notify(Action): - """Use the notify action to send a custom payload to your event URL.""" - - action: Literal['notify'] = 'notify' - - payload: dict - eventUrl: Union[List[str], str] - eventMethod: constr(to_upper=True) = None - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @deprecated(version='3.2.3', reason='The Pay NCCO action has been deprecated.') - class Pay(Action): - """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" - - action: Literal['pay'] = 'pay' - amount: confloat(ge=0) - currency: constr(to_lower=True) = None - eventUrl: Union[List[str], str] = None - prompts: Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict] = None - voice: Union[PayPrompts.VoicePrompt, dict] = None - - @field_validator('amount') - @classmethod - def round_amount(cls, v): - return round(v, 2) - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @field_validator('prompts') - @classmethod - def ensure_text_model(cls, v): - if type(v) is dict: - return PayPrompts.create_text_model(v) - else: - return v - - @field_validator('voice') - @classmethod - def ensure_voice_model(cls, v): - if type(v) is dict: - return PayPrompts.create_voice_model(v) - else: - return v - - @staticmethod - def build_ncco(*args: Action, actions: List[Action] = None) -> str: - ncco = [] - if actions is not None: - for action in actions: - ncco.append(action.model_dump(exclude_none=True, by_alias=True)) - for action in args: - ncco.append(action.model_dump(exclude_none=True, by_alias=True)) - return ncco - - @staticmethod - def _ensure_object_in_list(obj): - if type(obj) != list: - return [obj] - else: - return obj diff --git a/src/vonage/ncco_builder/pay_prompts.py b/src/vonage/ncco_builder/pay_prompts.py deleted file mode 100644 index 116acd66..00000000 --- a/src/vonage/ncco_builder/pay_prompts.py +++ /dev/null @@ -1,54 +0,0 @@ -from pydantic import BaseModel, ValidationInfo, field_validator, validator -from typing import Dict -from typing_extensions import Literal - - -class PayPrompts: - class VoicePrompt(BaseModel): - language: str = None - style: int = None - - class TextPrompt(BaseModel): - type: Literal['CardNumber', 'ExpirationDate', 'SecurityCode'] - text: str - errors: Dict[ - Literal[ - 'InvalidCardType', - 'InvalidCardNumber', - 'InvalidExpirationDate', - 'InvalidSecurityCode', - 'Timeout', - ], - Dict[Literal['text'], str], - ] - - @field_validator('errors') - @classmethod - def check_valid_error_format(cls, v, info: ValidationInfo): - values = info.data - - if values['type'] == 'CardNumber': - allowed_values = {'InvalidCardType', 'InvalidCardNumber', 'Timeout'} - cls.check_allowed_values(v, allowed_values, values['type']) - elif values['type'] == 'ExpirationDate': - allowed_values = {'InvalidExpirationDate', 'Timeout'} - cls.check_allowed_values(v, allowed_values, values['type']) - elif values['type'] == 'SecurityCode': - allowed_values = {'InvalidSecurityCode', 'Timeout'} - cls.check_allowed_values(v, allowed_values, values['type']) - return v - - def check_allowed_values(errors, allowed_values, prompt_type): - for key in errors: - if key not in allowed_values: - raise ValueError( - f'Value "{key}" is not a valid error for the "{prompt_type}" prompt type.' - ) - - @classmethod - def create_voice_model(cls, dict) -> VoicePrompt: - return cls.VoicePrompt.model_validate(dict) - - @classmethod - def create_text_model(cls, dict) -> TextPrompt: - return cls.TextPrompt.model_validate(dict) diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py deleted file mode 100644 index 2b57dfb3..00000000 --- a/src/vonage/number_insight.py +++ /dev/null @@ -1,48 +0,0 @@ -from .errors import CallbackRequiredError - - -class NumberInsight: - auth_type = 'params' - - def __init__(self, client): - self._client = client - - def get_basic_number_insight(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), - "/ni/basic/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - - def get_standard_number_insight(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), - "/ni/standard/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), - "/ni/advanced/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if ( - "callback" in argoparams - and type(argoparams["callback"]) == str - and argoparams["callback"] != "" - ): - return self._client.get( - self._client.api_host(), - "/ni/advanced/async/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - else: - raise CallbackRequiredError("A callback is needed for async advanced number insight") diff --git a/src/vonage/number_management.py b/src/vonage/number_management.py deleted file mode 100644 index 41645372..00000000 --- a/src/vonage/number_management.py +++ /dev/null @@ -1,34 +0,0 @@ -class Numbers: - auth_type = 'header' - defaults = {'auth_type': auth_type, 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def get_account_numbers(self, params=None, **kwargs): - return self._client.get( - self._client.host(), "/account/numbers", params or kwargs, auth_type=Numbers.auth_type - ) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self._client.get( - self._client.host(), - "/number/search", - dict(params or kwargs, country=country_code), - auth_type=Numbers.auth_type, - ) - - def buy_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults - ) - - def cancel_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults - ) - - def update_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/update", params or kwargs, **Numbers.defaults - ) diff --git a/src/vonage/proactive_connect.py b/src/vonage/proactive_connect.py deleted file mode 100644 index a9197a17..00000000 --- a/src/vonage/proactive_connect.py +++ /dev/null @@ -1,187 +0,0 @@ -from .errors import ProactiveConnectError - -import requests -import logging -from typing import List - -logger = logging.getLogger("vonage") - - -class ProactiveConnect: - def __init__(self, client): - self._client = client - self._auth_type = 'jwt' - - def list_all_lists(self, page: int = None, page_size: int = None): - params = self._check_pagination_params(page, page_size) - return self._client.get( - self._client.proactive_connect_host(), - '/v0.1/bulk/lists', - params, - auth_type=self._auth_type, - ) - - def create_list(self, params: dict): - self._validate_list_params(params) - return self._client.post( - self._client.proactive_connect_host(), - '/v0.1/bulk/lists', - params, - auth_type=self._auth_type, - ) - - def get_list(self, list_id: str): - return self._client.get( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}', - auth_type=self._auth_type, - ) - - def update_list(self, list_id: str, params: dict): - self._validate_list_params(params) - return self._client.put( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}', - params, - auth_type=self._auth_type, - ) - - def delete_list(self, list_id: str): - return self._client.delete( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}', - auth_type=self._auth_type, - ) - - def clear_list(self, list_id: str): - return self._client.post( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/clear', - params=None, - auth_type=self._auth_type, - ) - - def sync_list_from_datasource(self, list_id: str): - return self._client.post( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/fetch', - params=None, - auth_type=self._auth_type, - ) - - def list_all_items(self, list_id: str, page: int = None, page_size: int = None): - params = self._check_pagination_params(page, page_size) - return self._client.get( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items', - params, - auth_type=self._auth_type, - ) - - def create_item(self, list_id: str, data: dict): - params = {'data': data} - return self._client.post( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items', - params, - auth_type=self._auth_type, - ) - - def get_item(self, list_id: str, item_id: str): - return self._client.get( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items/{item_id}', - auth_type=self._auth_type, - ) - - def update_item(self, list_id: str, item_id: str, data: dict): - params = {'data': data} - return self._client.put( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items/{item_id}', - params, - auth_type=self._auth_type, - ) - - def delete_item(self, list_id: str, item_id: str): - return self._client.delete( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items/{item_id}', - auth_type=self._auth_type, - ) - - def download_list_items(self, list_id: str, file_path: str) -> List[dict]: - uri = f'https://{self._client.proactive_connect_host()}/v0.1/bulk/lists/{list_id}/items/download' - logger.debug( - f'GET request with Proactive Connect to {repr(uri)}, downloading items from list {list_id} to file {file_path}' - ) - headers = {**self._client.headers, 'Authorization': self._client._create_jwt_auth_string()} - response = requests.get( - uri, - headers=headers, - ) - if 200 <= response.status_code < 300: - with open(file_path, 'wb') as file: - file.write(response.content) - else: - return self._client.parse(self._client.proactive_connect_host(), response) - - def upload_list_items(self, list_id: str, file_path: str): - uri = f'https://{self._client.proactive_connect_host()}/v0.1/bulk/lists/{list_id}/items/import' - with open(file_path, 'rb') as csv_file: - logger.debug( - f'POST request with Proactive Connect uploading {file_path} to {repr(uri)}' - ) - headers = { - **self._client.headers, - 'Authorization': self._client._create_jwt_auth_string(), - } - response = requests.post( - uri, - headers=headers, - files={'file': ('list_items.csv', csv_file, 'text/csv')}, - ) - return self._client.parse(self._client.proactive_connect_host(), response) - - def list_events(self, page: int = None, page_size: int = None): - params = self._check_pagination_params(page, page_size) - return self._client.get( - self._client.proactive_connect_host(), - '/v0.1/bulk/events', - params, - auth_type=self._auth_type, - ) - - def _check_pagination_params(self, page: int = None, page_size: int = None) -> dict: - params = {} - if page is not None: - if type(page) == int and page > 0: - params['page'] = page - elif page <= 0: - raise ProactiveConnectError('"page" must be an int > 0.') - if page_size is not None: - if type(page_size) == int and page_size > 0: - params['page_size'] = page_size - elif page_size and page_size <= 0: - raise ProactiveConnectError('"page_size" must be an int > 0.') - return params - - def _validate_list_params(self, params: dict): - if 'name' not in params: - raise ProactiveConnectError('You must supply a name for the new list.') - if ( - 'datasource' in params - and 'type' in params['datasource'] - and params['datasource']['type'] == 'salesforce' - ): - self._check_salesforce_params_correct(params['datasource']) - - def _check_salesforce_params_correct(self, datasource): - if 'integration_id' not in datasource or 'soql' not in datasource: - raise ProactiveConnectError( - 'You must supply a value for "integration_id" and "soql" when creating a list with Salesforce.' - ) - if type(datasource['integration_id']) is not str or type(datasource['soql']) is not str: - raise ProactiveConnectError( - 'You must supply values for "integration_id" and "soql" as strings.' - ) diff --git a/src/vonage/redact.py b/src/vonage/redact.py deleted file mode 100644 index c1ec18f5..00000000 --- a/src/vonage/redact.py +++ /dev/null @@ -1,31 +0,0 @@ -from .errors import RedactError - -from deprecated import deprecated - - -@deprecated( - version='3.0.0', - reason='This is a dev preview product and as such is not supported in this SDK.', -) -class Redact: - auth_type = 'header' - - allowed_product_names = {'sms', 'voice', 'number-insight', 'verify', 'verify-sdk', 'messages'} - - def __init__(self, client): - self._client = client - - def redact_transaction(self, id: str, product: str, type=None): - self._check_allowed_product_name(product) - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._client.post( - self._client.api_host(), "/v1/redact/transaction", params, auth_type=Redact.auth_type - ) - - def _check_allowed_product_name(self, product): - if product not in self.allowed_product_names: - raise RedactError( - f'Invalid product name in redact request. Must be one of {self.allowed_product_names}.' - ) diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py deleted file mode 100644 index 03150804..00000000 --- a/src/vonage/short_codes.py +++ /dev/null @@ -1,34 +0,0 @@ -class ShortCodes: - auth_type = 'params' - defaults = {'auth_type': auth_type, 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def send_2fa_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/sc/us/2fa/json", params or kwargs, **ShortCodes.defaults - ) - - def send_event_alert_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/sc/us/alert/json", params or kwargs, **ShortCodes.defaults - ) - - def send_marketing_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/sc/us/marketing/json", params or kwargs, **ShortCodes.defaults - ) - - def get_event_alert_numbers(self): - return self._client.get( - self._client.host(), "/sc/us/alert/opt-in/query/json", auth_type=ShortCodes.auth_type - ) - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), - "/sc/us/alert/opt-in/manage/json", - params or kwargs, - **ShortCodes.defaults, - ) diff --git a/src/vonage/sms.py b/src/vonage/sms.py deleted file mode 100644 index 1eee72ac..00000000 --- a/src/vonage/sms.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytz -from datetime import datetime -from ._internal import _format_date_param - - -class Sms: - defaults = {'auth_type': 'params', 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self._client.post( - self._client.host(), - "/sms/json", - params, - supports_signature_auth=True, - **Sms.defaults, - ) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Vonage that an SMS was successfully received. - - If you are using the Verify API for 2FA, this information is sent to Vonage automatically - so you do not need to use this method to submit conversion data about 2FA messages. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self._client.post( - self._client.api_host(), "/conversions/sms", params, **Sms.defaults - ) diff --git a/src/vonage/subaccounts.py b/src/vonage/subaccounts.py deleted file mode 100644 index c731f100..00000000 --- a/src/vonage/subaccounts.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING, Union - -from .errors import SubaccountsError - -if TYPE_CHECKING: - from vonage import Client - - -class Subaccounts: - """Class containing methods for working with the Vonage Subaccounts API.""" - - default_start_date = '1970-01-01T00:00:00Z' - - def __init__(self, client: Client): - self._client = client - self._api_key = self._client.api_key - self._api_host = self._client.api_host() - self._auth_type = 'header' - - def list_subaccounts(self): - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/subaccounts', - auth_type=self._auth_type, - ) - - def create_subaccount( - self, - name: str, - secret: str = None, - use_primary_account_balance: bool = None, - ): - params = {'name': name, 'secret': secret} - if self._is_boolean(use_primary_account_balance): - params['use_primary_account_balance'] = use_primary_account_balance - - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/subaccounts', - params=params, - auth_type=self._auth_type, - ) - - def get_subaccount(self, subaccount_key: str): - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/subaccounts/{subaccount_key}', - auth_type=self._auth_type, - ) - - def modify_subaccount( - self, - subaccount_key: str, - suspended: bool = None, - use_primary_account_balance: bool = None, - name: str = None, - ): - params = {'name': name} - if self._is_boolean(suspended): - params['suspended'] = suspended - if self._is_boolean(use_primary_account_balance): - params['use_primary_account_balance'] = use_primary_account_balance - - return self._client.patch( - self._api_host, - f'/accounts/{self._api_key}/subaccounts/{subaccount_key}', - params=params, - auth_type=self._auth_type, - ) - - def list_credit_transfers( - self, - start_date: str = default_start_date, - end_date: str = None, - subaccount: str = None, - ): - params = { - 'start_date': start_date, - 'end_date': end_date, - 'subaccount': subaccount, - } - - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/credit-transfers', - params=params, - auth_type=self._auth_type, - ) - - def transfer_credit( - self, - from_: str, - to: str, - amount: Union[float, int], - reference: str = None, - ): - params = { - 'from': from_, - 'to': to, - 'amount': amount, - 'reference': reference, - } - - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/credit-transfers', - params=params, - auth_type=self._auth_type, - ) - - def list_balance_transfers( - self, - start_date: str = default_start_date, - end_date: str = None, - subaccount: str = None, - ): - params = { - 'start_date': start_date, - 'end_date': end_date, - 'subaccount': subaccount, - } - - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/balance-transfers', - params=params, - auth_type=self._auth_type, - ) - - def transfer_balance( - self, - from_: str, - to: str, - amount: Union[float, int], - reference: str = None, - ): - params = {'from': from_, 'to': to, 'amount': amount, 'reference': reference} - - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/balance-transfers', - params=params, - auth_type=self._auth_type, - ) - - def transfer_number(self, from_: str, to: str, number: int, country: str): - params = {'from': from_, 'to': to, 'number': number, 'country': country} - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/transfer-number', - params=params, - auth_type=self._auth_type, - ) - - def _is_boolean(self, var): - if var is not None: - if type(var) == bool: - return True - else: - raise SubaccountsError( - f'If providing a value, it needs to be a boolean. You provided: "{var}"' - ) diff --git a/src/vonage/users.py b/src/vonage/users.py deleted file mode 100644 index 444e03d5..00000000 --- a/src/vonage/users.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from vonage import Client - -from .errors import UsersError -from ._internal import set_auth_type - - -class Users: - """Class containing methods for user management as part of the Application API.""" - - def __init__(self, client: Client): - self._client = client - self._auth_type = set_auth_type(self._client) - - def list_users( - self, - page_size: int = None, - order: str = 'asc', - cursor: str = None, - name: str = None, - ): - """ - Lists the name and user id of all users associated with the account. - For complete information on a user, call Users.get_user, passing in the user id. - """ - - if order.lower() not in ('asc', 'desc'): - raise UsersError( - 'Invalid order parameter. Must be one of: "asc", "desc", "ASC", "DESC".' - ) - - params = {'page_size': page_size, 'order': order.lower(), 'cursor': cursor, 'name': name} - return self._client.get( - self._client.api_host(), - '/v1/users', - params, - auth_type=self._auth_type, - ) - - def create_user(self, params: dict = None): - self._client.headers['Content-Type'] = 'application/json' - return self._client.post( - self._client.api_host(), - '/v1/users', - params, - auth_type=self._auth_type, - ) - - def get_user(self, user_id: str): - return self._client.get( - self._client.api_host(), - f'/v1/users/{user_id}', - auth_type=self._auth_type, - ) - - def update_user(self, user_id: str, params: dict): - return self._client.patch( - self._client.api_host(), - f'/v1/users/{user_id}', - params, - auth_type=self._auth_type, - ) - - def delete_user(self, user_id: str): - return self._client.delete( - self._client.api_host(), - f'/v1/users/{user_id}', - auth_type=self._auth_type, - ) diff --git a/src/vonage/ussd.py b/src/vonage/ussd.py deleted file mode 100644 index 98ade213..00000000 --- a/src/vonage/ussd.py +++ /dev/null @@ -1,15 +0,0 @@ -class Ussd: - defaults = {'auth_type': 'params', 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def send_ussd_push_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/ussd/json", params or kwargs, **Ussd.defaults - ) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/ussd-prompt/json", params or kwargs, **Ussd.defaults - ) diff --git a/src/vonage/verify.py b/src/vonage/verify.py deleted file mode 100644 index 1c7168b7..00000000 --- a/src/vonage/verify.py +++ /dev/null @@ -1,54 +0,0 @@ -class Verify: - auth_type = 'params' - defaults = {'auth_type': auth_type, 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def start_verification(self, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/json", - params or kwargs, - **Verify.defaults, - ) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - **Verify.defaults, - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host(), - "/verify/search/json", - {"request_id": request_id}, - auth_type=Verify.auth_type, - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - **Verify.defaults, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - **Verify.defaults, - ) - - def psd2(self, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/psd2/json", - params or kwargs, - **Verify.defaults, - ) diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py deleted file mode 100644 index cb13a353..00000000 --- a/src/vonage/verify2.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING -from typing_extensions import Annotated - -if TYPE_CHECKING: - from vonage import Client - -from pydantic import BaseModel, StringConstraints, ValidationError, field_validator, conint -from typing import List - -import copy -import re - -from ._internal import set_auth_type -from .errors import Verify2Error - - -class Verify2: - valid_channels = [ - 'sms', - 'whatsapp', - 'whatsapp_interactive', - 'voice', - 'email', - 'silent_auth', - ] - - def __init__(self, client: Client): - self._client = client - self._auth_type = set_auth_type(self._client) - - def new_request(self, params: dict): - self._remove_unnecessary_fraud_check(params) - try: - params_to_verify = copy.deepcopy(params) - Verify2.VerifyRequest.model_validate(params_to_verify) - except (ValidationError, Verify2Error) as err: - raise err - - return self._client.post( - self._client.api_host(), - '/v2/verify', - params, - auth_type=self._auth_type, - ) - - def check_code(self, request_id: str, code: str): - params = {'code': str(code)} - - return self._client.post( - self._client.api_host(), - f'/v2/verify/{request_id}', - params, - auth_type=self._auth_type, - ) - - def cancel_verification(self, request_id: str): - return self._client.delete( - self._client.api_host(), - f'/v2/verify/{request_id}', - auth_type=self._auth_type, - ) - - def _remove_unnecessary_fraud_check(self, params): - if 'fraud_check' in params and params['fraud_check'] != False: - del params['fraud_check'] - - class VerifyRequest(BaseModel): - brand: str - workflow: List[dict] - locale: str = None - channel_timeout: conint(ge=60, le=900) = None - client_ref: str = None - code_length: conint(ge=4, le=10) = None - fraud_check: bool = None - code: Annotated[str, StringConstraints( - min_length=4, max_length=10 - )] = None - - @field_validator('code') - @classmethod - def regex_check(cls, c: str): - re_for_code: re.Pattern[str] = re.compile('^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$') - - if not re_for_code.match(c): - raise ValueError("string does not match regex") - return c - - @field_validator('workflow') - @classmethod - def check_valid_workflow(cls, v): - for workflow in v: - Verify2._check_valid_channel(workflow) - Verify2._check_valid_recipient(workflow) - Verify2._check_app_hash(workflow) - if workflow['channel'] == 'whatsapp' and 'from' in workflow: - Verify2._check_whatsapp_sender(workflow) - if workflow['channel'] == 'silent_auth': - Verify2._check_silent_auth_workflow(workflow) - - def _check_valid_channel(workflow): - if 'channel' not in workflow or workflow['channel'] not in Verify2.valid_channels: - raise Verify2Error( - f'You must specify a valid verify channel inside the "workflow" object, one of: "{Verify2.valid_channels}"' - ) - - def _check_valid_recipient(workflow): - if 'to' not in workflow or ( - workflow['channel'] != 'email' and not re.search(r'^[1-9]\d{6,14}$', workflow['to']) - ): - raise Verify2Error( - f'You must specify a valid "to" value for channel "{workflow["channel"]}"' - ) - - def _check_app_hash(workflow): - if workflow['channel'] == 'sms' and 'app_hash' in workflow: - if type(workflow['app_hash']) != str or len(workflow['app_hash']) != 11: - raise Verify2Error( - 'Invalid "app_hash" specified. If specifying app_hash, \ - it must be passed as a string and contain exactly 11 characters.' - ) - elif workflow['channel'] != 'sms' and 'app_hash' in workflow: - raise Verify2Error( - 'Cannot specify a value for "app_hash" unless using SMS for authentication.' - ) - - def _check_whatsapp_sender(workflow): - if not re.search(r'^[1-9]\d{6,14}$', workflow['from']): - raise Verify2Error('You must specify a valid "from" value if included.') - - def _check_silent_auth_workflow(workflow): - if 'redirect_url' in workflow: - if type(workflow['redirect_url']) != str: - raise Verify2Error('"redirect_url" must be a string if specified.') - if 'sandbox' in workflow: - if type(workflow['sandbox']) != bool: - raise Verify2Error('"sandbox" must be a boolean if specified.') diff --git a/src/vonage/video.py b/src/vonage/video.py deleted file mode 100644 index d1f84419..00000000 --- a/src/vonage/video.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from vonage import Client - -from .errors import ( - InvalidRoleError, - TokenExpiryError, - SipError, - VideoError, -) - -import re -from time import time -from uuid import uuid4 - - -class Video: - auth_type = 'jwt' - archive_mode_values = {'manual', 'always'} - media_mode_values = {'routed', 'relayed'} - token_roles = {'subscriber', 'publisher', 'moderator'} - - def __init__(self, client: Client): - self._client = client - - def create_session(self, session_options: dict = None): - if session_options is None: - session_options = {} - - params = {'archiveMode': 'manual', 'p2p.preference': 'disabled', 'location': None} - if ( - 'archive_mode' in session_options - and session_options['archive_mode'] not in Video.archive_mode_values - ): - raise VideoError( - f'Invalid archive_mode value. Must be one of {Video.archive_mode_values}.' - ) - elif 'archive_mode' in session_options: - params['archiveMode'] = session_options['archive_mode'] - if ( - 'media_mode' in session_options - and session_options['media_mode'] not in Video.media_mode_values - ): - raise VideoError(f'Invalid media_mode value. Must be one of {Video.media_mode_values}.') - elif 'media_mode' in session_options: - if session_options['media_mode'] == 'routed': - params['p2p.preference'] = 'disabled' - elif session_options['media_mode'] == 'relayed': - if params['archiveMode'] == 'always': - raise VideoError( - 'Invalid combination: cannot specify "archive_mode": "always" and "media_mode": "relayed".' - ) - else: - params['p2p.preference'] = 'enabled' - if 'location' in session_options: - params['location'] = session_options['location'] - - session = self._client.post( - self._client.video_host(), - '/session/create', - params, - auth_type=Video.auth_type, - body_is_json=False, - )[0] - - media_mode = self.get_media_mode(params['p2p.preference']) - session_info = { - 'session_id': session['session_id'], - 'archive_mode': params['archiveMode'], - 'media_mode': media_mode, - 'location': params['location'], - } - - return session_info - - def get_media_mode(self, p2p_preference): - if p2p_preference == 'disabled': - return 'routed' - elif p2p_preference == 'enabled': - return 'relayed' - - def get_stream(self, session_id, stream_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream/{stream_id}', - auth_type=Video.auth_type, - ) - - def list_streams(self, session_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream', - auth_type=Video.auth_type, - ) - - def set_stream_layout(self, session_id, items): - return self._client.put( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream', - items, - auth_type=Video.auth_type, - ) - - def send_signal(self, session_id, type, data, connection_id=None): - if connection_id: - request_uri = f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}/signal' - else: - request_uri = f'/v2/project/{self._client.application_id}/session/{session_id}/signal' - - params = {'type': type, 'data': data} - - return self._client.post( - self._client.video_host(), request_uri, params, auth_type=Video.auth_type - ) - - def disconnect_client(self, session_id, connection_id): - return self._client.delete( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}', - auth_type=Video.auth_type, - ) - - def mute_stream(self, session_id, stream_id): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream/{stream_id}/mute', - params=None, - auth_type=Video.auth_type, - ) - - def mute_all_streams(self, session_id, active=True, excluded_stream_ids: list = []): - params = {'active': active, 'excludedStreamIds': excluded_stream_ids} - - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/mute', - params, - auth_type=Video.auth_type, - ) - - def disable_mute_all_streams(self, session_id, excluded_stream_ids: list = []): - return self.mute_all_streams( - session_id, active=False, excluded_stream_ids=excluded_stream_ids - ) - - def list_archives(self, filter_params=None, **filter_kwargs): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive', - filter_params or filter_kwargs, - auth_type=Video.auth_type, - ) - - def create_archive(self, params=None, **kwargs): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive', - params or kwargs, - auth_type=Video.auth_type, - ) - - def get_archive(self, archive_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}', - auth_type=Video.auth_type, - ) - - def delete_archive(self, archive_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}', - auth_type=Video.auth_type, - ) - - def add_stream_to_archive(self, archive_id, stream_id, has_audio=True, has_video=True): - params = {'addStream': stream_id, 'hasAudio': has_audio, 'hasvideo': has_video} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def remove_stream_from_archive(self, archive_id, stream_id): - params = {'removeStream': stream_id} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def stop_archive(self, archive_id): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/stop', - params=None, - auth_type=Video.auth_type, - ) - - def change_archive_layout(self, archive_id, params=None, **kwargs): - return self._client.put( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/layout', - params or kwargs, - auth_type=Video.auth_type, - ) - - def create_sip_call(self, session_id: str, token: str, sip: dict): - if 'uri' not in sip: - raise SipError('You must specify a uri when creating a SIP call.') - - params = {'sessionId': session_id, 'token': token, 'sip': sip} - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/dial', - params, - auth_type=Video.auth_type, - ) - - def play_dtmf(self, session_id: str, digits: str, connection_id: str = None): - if not re.search('^[0-9*#p]+$', digits): - raise VideoError('Only digits 0-9, *, #, and "p" are allowed.') - - params = {'digits': digits} - - if connection_id is not None: - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}/play-dtmf', - params, - auth_type=Video.auth_type, - ) - - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/play-dtmf', - params, - auth_type=Video.auth_type, - ) - - def list_broadcasts(self, offset: int = None, count: int = None, session_id: str = None): - if offset is not None and (type(offset) != int or offset < 0): - raise VideoError('Offset must be an int >= 0.') - if count is not None and (type(count) != int or count < 0 or count > 1000): - raise VideoError('Count must be an int between 0 and 1000.') - - params = {'offset': str(offset), 'count': str(count), 'sessionId': session_id} - - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast', - params, - auth_type=Video.auth_type, - ) - - def start_broadcast(self, params: dict): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast', - params, - auth_type=Video.auth_type, - ) - - def get_broadcast(self, broadcast_id: str): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}', - auth_type=Video.auth_type, - ) - - def stop_broadcast(self, broadcast_id: str): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}', - params={}, - auth_type=Video.auth_type, - ) - - def change_broadcast_layout(self, broadcast_id: str, params: dict): - return self._client.put( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/layout', - params=params, - auth_type=Video.auth_type, - ) - - def add_stream_to_broadcast( - self, broadcast_id: str, stream_id: str, has_audio=True, has_video=True - ): - params = {'addStream': stream_id, 'hasAudio': has_audio, 'hasvideo': has_video} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def remove_stream_from_broadcast(self, broadcast_id: str, stream_id: str): - params = {'removeStream': stream_id} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def generate_client_token(self, session_id, token_options={}): - now = int(time()) - claims = { - 'scope': 'session.connect', - 'session_id': session_id, - 'role': 'publisher', - 'initial_layout_class_list': '', - 'jti': str(uuid4()), - 'iat': now, - } - if 'role' in token_options: - claims['role'] = token_options['role'] - if 'data' in token_options: - claims['data'] = token_options['data'] - if 'initialLayoutClassList' in token_options: - claims['initial_layout_class_list'] = token_options['initialLayoutClassList'] - if 'expireTime' in token_options and token_options['expireTime'] > now: - claims['exp'] = token_options['expireTime'] - if 'jti' in token_options: - claims['jti'] = token_options['jti'] - if 'iat' in token_options: - claims['iat'] = token_options['iat'] - if 'subject' in token_options: - claims['subject'] = token_options['subject'] - if 'acl' in token_options: - claims['acl'] = token_options['acl'] - - self.validate_client_token_options(claims) - self._client.auth(claims) - return self._client._generate_application_jwt() - - def validate_client_token_options(self, claims): - now = int(time()) - if claims['role'] not in Video.token_roles: - raise InvalidRoleError( - f'Invalid role specified for the client token. Valid values are: {Video.token_roles}' - ) - if 'exp' in claims and claims['exp'] > now + 3600 * 24 * 30: - raise TokenExpiryError('Token expiry date must be less than 30 days from now.') diff --git a/src/vonage/voice.py b/src/vonage/voice.py deleted file mode 100644 index 08e2e116..00000000 --- a/src/vonage/voice.py +++ /dev/null @@ -1,100 +0,0 @@ -from urllib.parse import urlparse -from vonage_jwt.verify_jwt import verify_signature - - -class Voice: - auth_type = 'jwt' - - def __init__(self, client): - self._client = client - - # Creates a new call session - def create_call(self, params, **kwargs): - """ - Adding Random From Number Feature for the Voice API, - if set to `True`, the from number will be randomly selected - from the pool of numbers available to the application making - the call. - - :param params is a dictionary that holds the 'from' and 'random_from_number' - - """ - if not params: - params = kwargs - - key = 'from' - if key not in params: - params['random_from_number'] = True - - return self._client.post( - self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type - ) - - # Get call history paginated. Pass start and end dates to filter the retrieved information - def get_calls(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type - ) - - # Get a single call record by identifier - def get_call(self, uuid): - return self._client.get( - self._client.api_host(), f"/v1/calls/{uuid}", auth_type=Voice.auth_type - ) - - # Update call data using custom ncco - def update_call(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # Plays audio streaming into call in progress - stream_url parameter is required - def send_audio(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}/stream", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # Play an speech into specified call - text parameter (text to speech) is required - def send_speech(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}/talk", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # plays DTMF tones into the specified call - def send_dtmf(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}/dtmf", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # Stops audio recently played into specified call - def stop_audio(self, uuid): - return self._client.delete( - self._client.api_host(), f"/v1/calls/{uuid}/stream", auth_type=Voice.auth_type - ) - - # Stop a speech recently played into specified call - def stop_speech(self, uuid): - return self._client.delete( - self._client.api_host(), f"/v1/calls/{uuid}/talk", auth_type=Voice.auth_type - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - headers = self._client.headers - headers['Authorization'] = self._client._create_jwt_auth_string() - return self._client.parse(hostname, self._client.session.get(url, headers=headers)) - - def verify_signature(self, token: str, signature: str) -> bool: - return verify_signature(token, signature) diff --git a/subaccounts/BUILD b/subaccounts/BUILD new file mode 100644 index 00000000..8e4694e0 --- /dev/null +++ b/subaccounts/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-subaccounts', + dependencies=[ + ':pyproject', + ':readme', + 'subaccounts/src/vonage_subaccounts', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/subaccounts/CHANGES.md b/subaccounts/CHANGES.md new file mode 100644 index 00000000..a9efcbae --- /dev/null +++ b/subaccounts/CHANGES.md @@ -0,0 +1,14 @@ +# 1.0.3 +- Update dependency versions + +# 1.0.3 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.2 +- Add docstrings to data models + +# 1.0.1 +- Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 + +# 1.0.0 +- Initial upload diff --git a/subaccounts/README.md b/subaccounts/README.md new file mode 100644 index 00000000..ba98e49f --- /dev/null +++ b/subaccounts/README.md @@ -0,0 +1,103 @@ +# Vonage Subaccount Package + +This package contains the code to use Vonage's Subaccount API in Python. + +It includes methods for creating and modifying Vonage subaccounts and transferring credit, balances and numbers between subaccounts. + +## 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`. + + +### List Subaccounts + +```python +response = vonage_client.subaccounts.list_subaccounts() +print(response.model_dump) +``` + +### Create Subaccount + +```python +from vonage_subaccounts import SubaccountOptions + +response = vonage_client.subaccounts.create_subaccount( + SubaccountOptions( + name='test_subaccount', secret='1234asdfA', use_primary_account_balance=False + ) +) +print(response) +``` + +### Modify a Subaccount + +```python +from vonage_subaccounts import ModifySubaccountOptions + +response = vonage_client.subaccounts.modify_subaccount( + 'test_subaccount', + ModifySubaccountOptions( + suspended=True, + name='modified_test_subaccount', + ), +) +print(response) +``` + +### List Balance Transfers + +```python +from vonage_subaccounts import ListTransfersFilter + +filter = {'start_date': '2023-08-07T10:50:44Z'} +response = vonage_client.subaccounts.list_balance_transfers(ListTransfersFilter(**filter)) +for item in response: + print(item.model_dump()) +``` + +### Transfer Balance Between Subaccounts + +```python +from vonage_subaccounts import TransferRequest + +request = TransferRequest( + from_='test_api_key', to='test_subaccount', amount=0.02, reference='A reference' +) +response = vonage_client.subaccounts.transfer_balance(request) +print(response) +``` + +### List Credit Transfers + +```python +from vonage_subaccounts import ListTransfersFilter + +filter = {'start_date': '2023-08-07T10:50:44Z'} +response = vonage_client.subaccounts.list_credit_transfers(ListTransfersFilter(**filter)) +for item in response: + print(item.model_dump()) +``` + +### Transfer Credit Between Subaccounts + +```python +from vonage_subaccounts import TransferRequest + +request = TransferRequest( + from_='test_api_key', to='test_subaccount', amount=0.02, reference='A reference' +) +response = vonage_client.subaccounts.transfer_balance(request) +print(response) +``` + +### Transfer a Phone Number Between Subaccounts + +```python +from vonage_subaccounts import TransferNumberRequest + +request = TransferNumberRequest( + from_='test_api_key', to='test_subaccount', number='447700900000', country='GB' +) +response = vonage_client.subaccounts.transfer_number(request) +print(response) +``` \ No newline at end of file diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml new file mode 100644 index 00000000..367e77da --- /dev/null +++ b/subaccounts/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-subaccounts' +dynamic = ["version"] +description = 'Vonage Subaccounts API package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_subaccounts._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/subaccounts/src/vonage_subaccounts/BUILD b/subaccounts/src/vonage_subaccounts/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/subaccounts/src/vonage_subaccounts/__init__.py b/subaccounts/src/vonage_subaccounts/__init__.py new file mode 100644 index 00000000..4f2c2de4 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/__init__.py @@ -0,0 +1,35 @@ +from .errors import InvalidSecretError +from .requests import ( + ListTransfersFilter, + ModifySubaccountOptions, + SubaccountOptions, + TransferNumberRequest, + TransferRequest, +) +from .responses import ( + ListSubaccountsResponse, + NewSubaccount, + PrimaryAccount, + Subaccount, + Transfer, + TransferNumberResponse, + VonageAccount, +) +from .subaccounts import Subaccounts + +__all__ = [ + 'Subaccounts', + 'InvalidSecretError', + 'ListTransfersFilter', + 'SubaccountOptions', + 'ModifySubaccountOptions', + 'TransferNumberRequest', + 'TransferRequest', + 'VonageAccount', + 'PrimaryAccount', + 'Subaccount', + 'ListSubaccountsResponse', + 'NewSubaccount', + 'Transfer', + 'TransferNumberResponse', +] diff --git a/subaccounts/src/vonage_subaccounts/_version.py b/subaccounts/src/vonage_subaccounts/_version.py new file mode 100644 index 00000000..8a81504c --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.4' diff --git a/subaccounts/src/vonage_subaccounts/errors.py b/subaccounts/src/vonage_subaccounts/errors.py new file mode 100644 index 00000000..6041ca2b --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class InvalidSecretError(VonageError): + """Indicates that the secret provided was invalid.""" diff --git a/subaccounts/src/vonage_subaccounts/requests.py b/subaccounts/src/vonage_subaccounts/requests.py new file mode 100644 index 00000000..6bfa3ed5 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/requests.py @@ -0,0 +1,109 @@ +import re +from typing import Optional + +from pydantic import BaseModel, Field, field_validator +from vonage_subaccounts.errors import InvalidSecretError + + +class SubaccountOptions(BaseModel): + """Model for creating a subaccount. + + Args: + name (str): The name of the subaccount. + secret (str, Optional): The secret of the subaccount. + use_primary_account_balance (bool, Optional): Whether the subaccount uses the + primary account balance. + + Raises: + InvalidSecretError: If the secret is invalid. + """ + + name: str = Field(..., min_length=1, max_length=80) + secret: Optional[str] = None + use_primary_account_balance: Optional[bool] = None + + @field_validator('secret') + @classmethod + def check_valid_secret(cls, v): + if not _is_valid_secret(v): + raise InvalidSecretError( + 'Secret must be 8-25 characters long and contain at least one uppercase ' + 'letter, one lowercase letter, and one digit.' + ) + return v + + +def _is_valid_secret(secret: str) -> bool: + """Check if a secret is valid.""" + + if len(secret) < 8 or len(secret) > 25: + return False + if not re.search(r'[a-z]', secret): + return False + if not re.search(r'[A-Z]', secret): + return False + if not re.search(r'\d', secret): + return False + return True + + +class ModifySubaccountOptions(BaseModel): + """Model for modifying a subaccount. + + Args: + suspended (bool, Optional): Whether the subaccount is suspended. + use_primary_account_balance (bool, Optional): Whether the subaccount uses the + primary account balance. + name (str, Optional): The name of the subaccount. + """ + + suspended: Optional[bool] = None + use_primary_account_balance: Optional[bool] = None + name: Optional[str] = None + + +class ListTransfersFilter(BaseModel): + """Model with filters for listing transfers. + + Args: + start_date (str): The start date of the retrieval period. + end_date (str, Optional): The end date of the retrieval period. If not included, + all transfers up to the present are returned. + subaccount (str, Optional): The subaccount API key to filter on. + """ + + start_date: str + end_date: Optional[str] = None + subaccount: Optional[str] = None + + +class TransferRequest(BaseModel): + """Model for transferring credit/balance between accounts. + + Args: + from_ (str): The API key of the account the transfer is from. + to (str): The API key of the account the transfer is to. + amount (float): The amount of the transfer in EUR. + reference (str, Optional): A reference for the transfer. + """ + + from_: str = Field(..., serialization_alias='from') + to: str + amount: float + reference: Optional[str] = None + + +class TransferNumberRequest(BaseModel): + """Model for transferring a number between accounts. + + Args: + from_ (str): The API key of the account the number is from. + to (str): The API key of the account the number is to. + number (str): The number to transfer. + country (str, Optional): The two-letter country code (in ISO 3166-1 alpha-2 format). + """ + + from_: str = Field(..., serialization_alias='from') + to: str + number: str + country: Optional[str] = None diff --git a/subaccounts/src/vonage_subaccounts/responses.py b/subaccounts/src/vonage_subaccounts/responses.py new file mode 100644 index 00000000..5e3cad54 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/responses.py @@ -0,0 +1,130 @@ +from typing import Optional, Union + +from pydantic import BaseModel, Field + + +class VonageAccount(BaseModel): + """Model for a Vonage account/subaccount. + + Args: + api_key (str): The API key of the account. + name (str): The name of the account. + created_at (str): The date and time the account was created. + suspended (bool): Whether the account is suspended. + balance (float): The balance of the account. + credit_limit (float): The credit limit of the account. + """ + + api_key: str + name: str + created_at: str + suspended: bool + balance: Optional[float] + credit_limit: Optional[Union[int, float]] + + +class PrimaryAccount(VonageAccount): + """Model for a Vonage primary account. + + Args: + api_key (str): The API key of the account. + name (str): The name of the account. + created_at (str): The date and time the account was created. + suspended (bool): Whether the account is suspended. + balance (float): The balance of the account. + credit_limit (float): The credit limit of the account. + """ + + +class Subaccount(VonageAccount): + """Model for a Vonage subaccount. + + Args: + api_key (str): The API key of the account. + name (str): The name of the account. + primary_account_api_key (str): The API key of the primary account. + use_primary_account_balance (bool): Whether the subaccount uses the primary + account balance. + created_at (str): The date and time the account was created. + suspended (bool): Whether the account is suspended. + balance (float): The balance of the account. Value is null if balance is shared + with primary account. + credit_limit (float): The credit limit of the account. Value is null if balance + is shared with primary account. + """ + + primary_account_api_key: str + use_primary_account_balance: bool + + +class ListSubaccountsResponse(BaseModel): + """Model for a list of subaccounts. + + Args: + primary_account (PrimaryAccount): The primary account. See `PrimaryAccount`. + subaccounts (list[Subaccount]): The subaccounts. See `Subaccount`. + total_balance (float): The total balance of all subaccounts. + total_credit_limit (Union[int, float]): The total credit limit of all subaccounts. + """ + + primary_account: PrimaryAccount + subaccounts: list[Subaccount] + total_balance: float + total_credit_limit: Union[int, float] + + +class NewSubaccount(Subaccount): + """NewSubaccount: The new subaccount + + Args: + secret (str): The API secret of the subaccount. + api_key (str): The API key of the account. + name (str): The name of the account. + primary_account_api_key (str): The API key of the primary account. + use_primary_account_balance (bool): Whether the subaccount uses the primary + account balance. + created_at (str): The date and time the account was created. + suspended (bool): Whether the account is suspended. + balance (float): The balance of the account. Value is null if balance is shared + with primary account. + credit_limit (float): The credit limit of the account. Value is null if balance + is shared with primary account. + """ + + secret: str + + +class Transfer(BaseModel): + """Model for a credit/balance transfer between accounts. + + Args: + id (str): The Unique credit transfer ID. + amount (float): The amount of the transfer. + from_ (str): The API key of the account the transfer is from. + to (str): The API key of the account the transfer is to. + created_at (str): The date and time the transfer was created. + reference (str, Optional): A reference for the transfer. + """ + + id: str + amount: float + from_: str = Field(..., validation_alias='from') + to: str + created_at: str + reference: Optional[str] = None + + +class TransferNumberResponse(BaseModel): + """Model for a number transfer between accounts. + + Args: + number (str): The phone number in E.164 format. + country (str): The two-letter country code (in ISO 3166-1 alpha-2 format). + from_ (str): The API key of the account the number is being transferred from. + to (str): The API key of the account the number is being transferred to. + """ + + number: str + country: str + from_: str = Field(..., validation_alias='from') + to: str diff --git a/subaccounts/src/vonage_subaccounts/subaccounts.py b/subaccounts/src/vonage_subaccounts/subaccounts.py new file mode 100644 index 00000000..a9e3bf73 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/subaccounts.py @@ -0,0 +1,297 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_subaccounts.requests import ( + ListTransfersFilter, + ModifySubaccountOptions, + SubaccountOptions, + TransferNumberRequest, + TransferRequest, +) +from vonage_subaccounts.responses import ( + ListSubaccountsResponse, + NewSubaccount, + PrimaryAccount, + Subaccount, + Transfer, + TransferNumberResponse, +) + + +class Subaccounts: + """Class containing methods to manage Vonage subaccounts. + + Args: + http_client (HttpClient): The HTTP client to make requests to the Subaccounts API. + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'basic' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Subaccounts API. + + Returns: + HttpClient: The HTTP client used to make requests to the Subaccounts API. + """ + return self._http_client + + def list_subaccounts(self) -> ListSubaccountsResponse: + """List all subaccounts associated with the primary account. + + Returns: + ListSubaccountsResponse: A response containing the primary account and all subaccounts. + ListSubaccountsResponse contains the following attributes: + - primary_account (PrimaryAccount): The primary account. + - subaccounts (list[Subaccount]): A list of subaccounts. + - total_balance (float): The total balance of the primary account and all subaccounts. + - total_credit_limit (float): The total credit limit of the primary account and all subaccounts. + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/subaccounts', + auth_type=self._auth_type, + ) + + response = { + 'primary_account': PrimaryAccount(**response['_embedded']['primary_account']), + 'subaccounts': response['_embedded']['subaccounts'], + 'total_balance': response['total_balance'], + 'total_credit_limit': response['total_credit_limit'], + } + + return ListSubaccountsResponse(**response) + + @validate_call + def create_subaccount(self, options: SubaccountOptions) -> NewSubaccount: + """Create a subaccount. + + Args: + SubaccountOptions: The options for the new subaccount. Contains the following attributes: + - name (str): The name of the subaccount. + - secret (str): The secret of the subaccount. + - use_primary_account_balance (bool): Whether the subaccount uses the primary account balance + + Returns: + NewSubaccount: The new subaccount. Contains the following attributes: + - api_key (str): The API key of the subaccount. + - name (str): The name of the subaccount. + - created_at (str): The date and time the subaccount was created. + - suspended (bool): Whether the subaccount is suspended. + - primary_account_api_key (str): The API key of the primary account. + - use_primary_account_balance (bool): Whether the subaccount uses the primary account balance + - secret (str): The secret of the subaccount. + - balance (float): The balance of the subaccount. + - credit_limit (float): The credit limit of the subaccount. + """ + response = self._http_client.post( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/subaccounts', + options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + + return NewSubaccount(**response) + + @validate_call + def get_subaccount(self, subaccount_api_key: str) -> Subaccount: + """Get a subaccount by API key. + + Args: + subaccount_api_key (str): The API key of the subaccount to get. + + Returns: + Subaccount: The subaccount. Contains the following attributes: + - api_key (str): The API key of the subaccount. + - name (str): The name of the subaccount. + - created_at (str): The date and time the subaccount was created. + - suspended (bool): Whether the subaccount is suspended. + - primary_account_api_key (str): The API key of the primary account. + - use_primary_account_balance (bool): Whether the subaccount uses the primary account balance + - balance (float): The balance of the subaccount. + - credit_limit (float): The credit limit of the subaccount. + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/subaccounts/{subaccount_api_key}', + auth_type=self._auth_type, + ) + + return Subaccount(**response) + + @validate_call + def modify_subaccount( + self, subaccount_api_key: str, options: ModifySubaccountOptions + ) -> Subaccount: + """Modify a subaccount. + + Args: + subaccount_api_key (str): The API key of the subaccount to modify. + ModifySubaccountOptions: The options for modifying the subaccount. Contains the following attributes: + - suspended (bool): Whether the subaccount is suspended. + - use_primary_account_balance (bool): Whether the subaccount uses the primary account balance. + - name (str): The name of the subaccount. + + Returns: + Subaccount: The modified subaccount. Contains the following attributes: + - api_key (str): The API key of the subaccount. + - name (str): The name of the subaccount. + - created_at (str): The date and time the subaccount was created. + - suspended (bool): Whether the subaccount is suspended. + - primary_account_api_key (str): The API key of the primary account. + - use_primary_account_balance (bool): Whether the subaccount uses the primary account balance. + - balance (float): The balance of the subaccount. + - credit_limit (float): The credit limit of the subaccount. + """ + response = self._http_client.patch( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/subaccounts/{subaccount_api_key}', + options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + + return Subaccount(**response) + + def list_balance_transfers(self, filter: ListTransfersFilter) -> list[Transfer]: + """List all balance transfers. + + Args: + filter (ListTransfersFilter): The filter for the balance transfers. Contains the following attributes: + - start_date (str, required) + - end_date (str) + - subaccount (str): Show balance transfers relating to this subaccount. + + Returns: + list[Transfer]: A list of balance transfers. Each balance transfer contains the following attributes: + - id (str) + - amount (float) + - from_ (str) + - to (str) + - created_at (str) + - reference (str) + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/balance-transfers', + filter.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + + return [ + Transfer(**transfer) + for transfer in response['_embedded']['balance_transfers'] + ] + + def transfer_balance(self, params: TransferRequest) -> Transfer: + """Transfer balance between subaccounts. + + Args: + params (TransferRequest): The parameters for the balance transfer. Contains the following attributes: + - from_ (str): The API key of the subaccount to transfer balance from. + - to (str): The API key of the subaccount to transfer balance to. + - amount (float): The amount to transfer. + - reference (str): A reference for the transfer. + + Returns: + Transfer: The balance transfer. Contains the following attributes: + - id (str) + - amount (float) + - from_ (str) + - to (str) + - created_at (str) + - reference (str) + """ + response = self._http_client.post( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/balance-transfers', + params.model_dump(by_alias=True, exclude_none=True), + auth_type=self._auth_type, + ) + + return Transfer(**response) + + def list_credit_transfers(self, filter: ListTransfersFilter) -> list[Transfer]: + """List all credit transfers. + + Args: + filter (ListTransfersFilter): The filter for the credit transfers. Contains the following attributes: + - start_date (str, required) + - end_date (str) + - subaccount (str): Show credit transfers relating to this subaccount. + + Returns: + list[Transfer]: A list of credit transfers. Each credit transfer contains the following attributes: + - id (str) + - amount (float) + - from_ (str) + - to (str) + - created_at (str) + - reference (str) + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/credit-transfers', + filter.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + + return [ + Transfer(**transfer) for transfer in response['_embedded']['credit_transfers'] + ] + + @validate_call + def transfer_credit(self, params: TransferRequest) -> Transfer: + """Transfer credit between subaccounts. + + Args: + params (TransferRequest): The parameters for the credit transfer. Contains the following attributes: + - from_ (str): The API key of the subaccount to transfer credit from. + - to (str): The API key of the subaccount to transfer credit to. + - amount (float): The amount to transfer. + - reference (str): A reference for the transfer. + + Returns: + Transfer: The credit transfer. Contains the following attributes: + - id (str) + - amount (float) + - from_ (str) + - to (str) + - created_at (str) + - reference (str) + """ + response = self._http_client.post( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/credit-transfers', + params.model_dump(by_alias=True, exclude_none=True), + auth_type=self._auth_type, + ) + + return Transfer(**response) + + @validate_call + def transfer_number(self, params: TransferNumberRequest) -> TransferNumberResponse: + """Transfer a number between subaccounts. + + Args: + params (TransferNumberRequest): The parameters for the number transfer. Contains the following attributes: + - from_ (str): The API key of the subaccount to transfer the number from. + - to (str): The API key of the subaccount to transfer the number to. + - number (str): The number to transfer. + - country (str): The country code of the number. + + Returns: + TransferNumberResponse: The number transfer. Contains the following attributes: + - number (str) + - country (str) + - from_ (str) + - to (str) + """ + response = self._http_client.post( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/transfer-number', + params.model_dump(by_alias=True, exclude_none=True), + auth_type=self._auth_type, + ) + + return TransferNumberResponse(**response) diff --git a/subaccounts/tests/BUILD b/subaccounts/tests/BUILD new file mode 100644 index 00000000..c22c9646 --- /dev/null +++ b/subaccounts/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['subaccounts', 'testutils']) diff --git a/subaccounts/tests/data/create_subaccount.json b/subaccounts/tests/data/create_subaccount.json new file mode 100644 index 00000000..7189fc94 --- /dev/null +++ b/subaccounts/tests/data/create_subaccount.json @@ -0,0 +1,11 @@ +{ + "api_key": "1234qwer", + "secret": "SuperSecr3t", + "primary_account_api_key": "test_api_key", + "use_primary_account_balance": false, + "name": "test_subaccount", + "balance": 0.0000, + "credit_limit": 0.0000, + "suspended": false, + "created_at": "2024-08-28T14:11:32.239Z" +} \ No newline at end of file diff --git a/subaccounts/tests/data/get_subaccount.json b/subaccounts/tests/data/get_subaccount.json new file mode 100644 index 00000000..345667f1 --- /dev/null +++ b/subaccounts/tests/data/get_subaccount.json @@ -0,0 +1,10 @@ +{ + "api_key": "1234qwer", + "primary_account_api_key": "test_api_key", + "use_primary_account_balance": false, + "name": "test_subaccount", + "balance": 0.0000, + "credit_limit": 0.0000, + "suspended": false, + "created_at": "2024-08-28T14:11:32.000Z" +} \ No newline at end of file diff --git a/subaccounts/tests/data/list_balance_transfers.json b/subaccounts/tests/data/list_balance_transfers.json new file mode 100644 index 00000000..d2c0be9e --- /dev/null +++ b/subaccounts/tests/data/list_balance_transfers.json @@ -0,0 +1,27 @@ +{ + "_links": { + "self": { + "href": "/accounts/test_api_key/balance-transfers" + } + }, + "_embedded": { + "balance_transfers": [ + { + "from": "test_api_key", + "to": "asdfqwer", + "amount": 0.01, + "reference": "", + "id": "6917b0ae-aed3-453c-a918-e37f6ef7b21a", + "created_at": "2023-12-22T19:41:19.000Z" + }, + { + "from": "test_api_key", + "to": "asdfqwer", + "amount": 0.5, + "reference": "", + "id": "049adc07-5da1-4d13-bacd-0ee6a99ef948", + "created_at": "2023-12-22T19:40:36.000Z" + } + ] + } +} \ No newline at end of file diff --git a/subaccounts/tests/data/list_credit_transfers.json b/subaccounts/tests/data/list_credit_transfers.json new file mode 100644 index 00000000..d7f67449 --- /dev/null +++ b/subaccounts/tests/data/list_credit_transfers.json @@ -0,0 +1,27 @@ +{ + "_links": { + "self": { + "href": "/accounts/test_api_key/balance-transfers" + } + }, + "_embedded": { + "credit_transfers": [ + { + "from": "test_api_key", + "to": "asdfqwer", + "amount": 0.01, + "reference": "", + "id": "6917b0ae-aed3-453c-a918-e37f6ef7b21a", + "created_at": "2023-12-22T19:41:19.000Z" + }, + { + "from": "test_api_key", + "to": "asdfqwer", + "amount": 0.5, + "reference": "", + "id": "049adc07-5da1-4d13-bacd-0ee6a99ef948", + "created_at": "2023-12-22T19:40:36.000Z" + } + ] + } +} \ No newline at end of file diff --git a/subaccounts/tests/data/list_subaccounts.json b/subaccounts/tests/data/list_subaccounts.json new file mode 100644 index 00000000..9ac22505 --- /dev/null +++ b/subaccounts/tests/data/list_subaccounts.json @@ -0,0 +1,41 @@ +{ + "_links": { + "self": { + "href": "/accounts/test_api_key/subaccounts" + } + }, + "total_balance": 29.6672, + "total_credit_limit": 0.0000, + "_embedded": { + "primary_account": { + "api_key": "test_api_key", + "name": "SMPP Account", + "balance": 27.4572, + "credit_limit": 0.0000, + "suspended": false, + "created_at": "2024-08-28T02:02:14.626Z" + }, + "subaccounts": [ + { + "api_key": "qwer1234", + "primary_account_api_key": "test_api_key", + "use_primary_account_balance": false, + "name": "second own balance subacct", + "balance": 0.5, + "credit_limit": 0.0000, + "suspended": false, + "created_at": "2023-06-07T10:50:44.000Z" + }, + { + "api_key": "1234qwer", + "primary_account_api_key": "test_api_key", + "use_primary_account_balance": false, + "name": "own balance subaccount", + "balance": 1.71, + "credit_limit": 0.0000, + "suspended": false, + "created_at": "2023-06-09T13:52:43.000Z" + } + ] + } +} \ No newline at end of file diff --git a/subaccounts/tests/data/modify_subaccount.json b/subaccounts/tests/data/modify_subaccount.json new file mode 100644 index 00000000..d4bb0796 --- /dev/null +++ b/subaccounts/tests/data/modify_subaccount.json @@ -0,0 +1,10 @@ +{ + "api_key": "1234qwer", + "primary_account_api_key": "asdf1234", + "use_primary_account_balance": false, + "name": "modified_test_subaccount", + "balance": 0.0000, + "credit_limit": 0.0000, + "suspended": true, + "created_at": "2024-08-28T14:11:32.000Z" +} \ No newline at end of file diff --git a/subaccounts/tests/data/transfer.json b/subaccounts/tests/data/transfer.json new file mode 100644 index 00000000..bc20736c --- /dev/null +++ b/subaccounts/tests/data/transfer.json @@ -0,0 +1,14 @@ +{ + "masterAccountId": "test_api_key", + "_links": { + "self": { + "href": "/accounts/test_api_key/balance-transfers/a1a90387-fcf2-41dc-9beb-cfd82b6b994d" + } + }, + "from": "test_api_key", + "to": "asdfqwer", + "amount": 0.02, + "reference": "A reference", + "id": "a1a90387-fcf2-41dc-9beb-cfd82b6b994d", + "created_at": "2024-08-29T13:29:51.000Z" +} \ No newline at end of file diff --git a/subaccounts/tests/data/transfer_number.json b/subaccounts/tests/data/transfer_number.json new file mode 100644 index 00000000..709a0c02 --- /dev/null +++ b/subaccounts/tests/data/transfer_number.json @@ -0,0 +1,6 @@ +{ + "number": "447700900000", + "country": "GB", + "from": "test_api_key", + "to": "asdfqwer" +} \ No newline at end of file diff --git a/subaccounts/tests/data/transfer_number_error_suspended_account.json b/subaccounts/tests/data/transfer_number_error_suspended_account.json new file mode 100644 index 00000000..de291dde --- /dev/null +++ b/subaccounts/tests/data/transfer_number_error_suspended_account.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors/subaccounts#invalid-number-transfer", + "title": "Invalid Number Transfer", + "detail": "One of the accounts involved in the transfer is banned", + "instance": "ba2abf2a-e64d-4281-aca5-30f13947cbcd" +} \ No newline at end of file diff --git a/subaccounts/tests/test_subaccounts.py b/subaccounts/tests/test_subaccounts.py new file mode 100644 index 00000000..e25ac1a3 --- /dev/null +++ b/subaccounts/tests/test_subaccounts.py @@ -0,0 +1,255 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import ForbiddenError +from vonage_http_client.http_client import HttpClient +from vonage_subaccounts.errors import InvalidSecretError +from vonage_subaccounts.requests import ( + ListTransfersFilter, + SubaccountOptions, + TransferNumberRequest, + TransferRequest, +) +from vonage_subaccounts.subaccounts import Subaccounts + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +subaccounts = Subaccounts(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = subaccounts.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_list_subaccounts(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/accounts/test_api_key/subaccounts', + 'list_subaccounts.json', + ) + response = subaccounts.list_subaccounts() + + assert response.primary_account.api_key == 'test_api_key' + assert response.primary_account.name == 'SMPP Account' + assert response.primary_account.created_at == '2024-08-28T02:02:14.626Z' + assert response.primary_account.suspended is False + + assert len(response.subaccounts) == 2 + assert response.subaccounts[0].api_key == 'qwer1234' + assert response.subaccounts[0].name == 'second own balance subacct' + assert response.subaccounts[0].primary_account_api_key == 'test_api_key' + assert response.subaccounts[0].use_primary_account_balance is False + + assert response.total_balance == 29.6672 + assert response.total_credit_limit == 0 + + +@responses.activate +def test_create_subaccount(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/subaccounts', + 'create_subaccount.json', + ) + + response = subaccounts.create_subaccount( + SubaccountOptions( + name='test_subaccount', secret='1234asdfA', use_primary_account_balance=False + ) + ) + + assert response.api_key == '1234qwer' + assert response.secret == 'SuperSecr3t' + assert response.name == 'test_subaccount' + assert response.suspended is False + assert response.use_primary_account_balance is False + + +@responses.activate +def test_get_subaccount(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/accounts/test_api_key/subaccounts/1234qwer', + 'get_subaccount.json', + ) + + response = subaccounts.get_subaccount('1234qwer') + + assert response.api_key == '1234qwer' + assert response.name == 'test_subaccount' + assert response.suspended is False + assert response.use_primary_account_balance is False + + +@responses.activate +def test_modify_subaccount(): + build_response( + path, + 'PATCH', + 'https://api.nexmo.com/accounts/test_api_key/subaccounts/1234qwer', + 'modify_subaccount.json', + ) + + response = subaccounts.modify_subaccount( + '1234qwer', + { + 'suspended': True, + 'name': 'modified_test_subaccount', + }, + ) + + assert response.api_key == '1234qwer' + assert response.name == 'modified_test_subaccount' + assert response.suspended is True + assert response.use_primary_account_balance is False + + +@responses.activate +def test_list_balance_transfers(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/accounts/test_api_key/balance-transfers', + 'list_balance_transfers.json', + ) + + response = subaccounts.list_balance_transfers( + ListTransfersFilter(start_date='2023-08-07T10:50:44Z') + ) + + assert len(response) == 2 + assert response[0].id == '6917b0ae-aed3-453c-a918-e37f6ef7b21a' + assert response[0].amount == 0.01 + assert response[1].from_ == 'test_api_key' + assert response[1].to == 'asdfqwer' + assert response[1].created_at == '2023-12-22T19:40:36.000Z' + + +@responses.activate +def test_transfer_balance(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/balance-transfers', + 'transfer.json', + ) + + request = TransferRequest( + from_='test_api_key', to='asdfqwer', amount=0.02, reference='A reference' + ) + response = subaccounts.transfer_balance(request) + + assert response.id == 'a1a90387-fcf2-41dc-9beb-cfd82b6b994d' + assert response.amount == 0.02 + assert response.from_ == 'test_api_key' + assert response.to == 'asdfqwer' + assert response.reference == 'A reference' + + +@responses.activate +def test_list_credit_transfers(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/accounts/test_api_key/credit-transfers', + 'list_credit_transfers.json', + ) + + response = subaccounts.list_credit_transfers( + ListTransfersFilter(start_date='2023-08-07T10:50:44Z') + ) + + assert len(response) == 2 + assert response[0].id == '6917b0ae-aed3-453c-a918-e37f6ef7b21a' + assert response[0].amount == 0.01 + assert response[1].from_ == 'test_api_key' + assert response[1].to == 'asdfqwer' + assert response[1].created_at == '2023-12-22T19:40:36.000Z' + + +@responses.activate +def test_transfer_credit(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/credit-transfers', + 'transfer.json', + ) + + request = TransferRequest( + from_='test_api_key', to='asdfqwer', amount=0.02, reference='A reference' + ) + response = subaccounts.transfer_credit(request) + + assert response.id == 'a1a90387-fcf2-41dc-9beb-cfd82b6b994d' + assert response.amount == 0.02 + assert response.from_ == 'test_api_key' + assert response.to == 'asdfqwer' + assert response.reference == 'A reference' + + +@responses.activate +def test_transfer_number(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/transfer-number', + 'transfer_number.json', + ) + + request = TransferNumberRequest( + from_='test_api_key', to='asdfqwer', number='447700900000', country='GB' + ) + response = subaccounts.transfer_number(request) + + assert response.number == '447700900000' + assert response.country == 'GB' + assert response.from_ == 'test_api_key' + assert response.to == 'asdfqwer' + + +@responses.activate +def test_transfer_number_error_suspended_account(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/accounts/test_api_key/transfer-number', + 'transfer_number_error_suspended_account.json', + status_code=403, + ) + + request = TransferNumberRequest( + from_='test_api_key', to='asdfqwer', number='447700900000', country='GB' + ) + + with raises(ForbiddenError) as e: + subaccounts.transfer_number(request) + + assert 'Invalid Number Transfer' in str(e.value) + + +def test_invalid_secret(): + with raises(InvalidSecretError): + subaccounts.create_subaccount( + SubaccountOptions(name='test_subaccount', secret='asDF1') + ) + with raises(InvalidSecretError): + subaccounts.create_subaccount( + SubaccountOptions(name='test_subaccount', secret='1234asdf') + ) + with raises(InvalidSecretError): + subaccounts.create_subaccount( + SubaccountOptions(name='test_subaccount', secret='1234ASDF') + ) + with raises(InvalidSecretError): + subaccounts.create_subaccount( + SubaccountOptions(name='test_subaccount', secret='asdfASDF') + ) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 0290bc72..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,141 +0,0 @@ -import os -import os.path -import platform - -import pytest - - -# Ensure our client isn't being configured with real values! -os.environ.clear() - - -def read_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as input_file: - return input_file.read() - - -class DummyData(object): - def __init__(self): - import vonage - - self.api_key = "nexmo-api-key" - self.api_secret = "nexmo-api-secret" - self.signature_secret = "secret" - self.application_id = "nexmo-application-id" - self.private_key = read_file("data/private_key.txt") - self.public_key = read_file("data/public_key.txt") - self.user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()}" - self.host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" - self.meetings_api_host = "api-eu.vonage.com/beta/meetings" - - -@pytest.fixture(scope="session") -def dummy_data(): - return DummyData() - - -@pytest.fixture -def client(dummy_data): - import vonage - - return vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=dummy_data.private_key, - ) - - -# Represents an instance of the Voice class for testing -@pytest.fixture -def voice(client): - import vonage - - return vonage.Voice(client) - - -# Represents an instance of the Sms class for testing -@pytest.fixture -def sms(client): - import vonage - - return vonage.Sms(client) - - -# Represents an instance of the Verify class for testing -@pytest.fixture -def verify(client): - import vonage - - return vonage.Verify(client) - - -@pytest.fixture -def number_insight(client): - import vonage - - return vonage.NumberInsight(client) - - -@pytest.fixture -def account(client): - import vonage - - return vonage.Account(client) - - -@pytest.fixture -def numbers(client): - import vonage - - return vonage.Numbers(client) - - -@pytest.fixture -def ussd(client): - import vonage - - return vonage.Ussd(client) - - -@pytest.fixture -def short_codes(client): - import vonage - - return vonage.ShortCodes(client) - - -@pytest.fixture -def messages(client): - import vonage - - return vonage.Messages(client) - - -@pytest.fixture -def redact(client): - import vonage - - return vonage.Redact(client) - - -@pytest.fixture -def application_v2(client): - import vonage - - return vonage.ApplicationV2(client) - - -@pytest.fixture -def meetings(client): - import vonage - - return vonage.Meetings(client) - - -@pytest.fixture -def proc(client): - import vonage - - return vonage.ProactiveConnect(client) diff --git a/tests/data/account/secret_management/create-validation.json b/tests/data/account/secret_management/create-validation.json deleted file mode 100644 index c4f50dc4..00000000 --- a/tests/data/account/secret_management/create-validation.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/secret-management#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "invalid_parameters": [ - { - "name": "secret", - "reason": "Does not meet complexity requirements" - } - ], - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/create.json b/tests/data/account/secret_management/create.json deleted file mode 100644 index bf204c7c..00000000 --- a/tests/data/account/secret_management/create.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets/ad6dc56f-07b5-46e1-a527-85530e625800" - } - }, - "id": "ad6dc56f-07b5-46e1-a527-85530e625800", - "created_at": "2017-03-02T16:34:49Z" -} diff --git a/tests/data/account/secret_management/get.json b/tests/data/account/secret_management/get.json deleted file mode 100644 index bf204c7c..00000000 --- a/tests/data/account/secret_management/get.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets/ad6dc56f-07b5-46e1-a527-85530e625800" - } - }, - "id": "ad6dc56f-07b5-46e1-a527-85530e625800", - "created_at": "2017-03-02T16:34:49Z" -} diff --git a/tests/data/account/secret_management/last-secret.json b/tests/data/account/secret_management/last-secret.json deleted file mode 100644 index fe8f85a6..00000000 --- a/tests/data/account/secret_management/last-secret.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret", - "title": "Secret Deletion Forbidden", - "detail": "Can not delete the last secret. The account must always have at least 1 secret active at any time", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/list.json b/tests/data/account/secret_management/list.json deleted file mode 100644 index 3600f601..00000000 --- a/tests/data/account/secret_management/list.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets" - } - }, - "_embedded": { - "secrets": [ - { - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets/ad6dc56f-07b5-46e1-a527-85530e625800" - } - }, - "id": "ad6dc56f-07b5-46e1-a527-85530e625800", - "created_at": "2017-03-02T16:34:49Z" - } - ] - } -} diff --git a/tests/data/account/secret_management/max-secrets.json b/tests/data/account/secret_management/max-secrets.json deleted file mode 100644 index 22916988..00000000 --- a/tests/data/account/secret_management/max-secrets.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed", - "title": "Maxmimum number of secrets already met", - "detail": "This account has reached maximum number of '2' allowed secrets", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/missing.json b/tests/data/account/secret_management/missing.json deleted file mode 100644 index 279920e8..00000000 --- a/tests/data/account/secret_management/missing.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#invalid-api-key", - "title": "Invalid API Key", - "detail": "API key 'ABC123' does not exist, or you do not have access", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/unauthorized.json b/tests/data/account/secret_management/unauthorized.json deleted file mode 100644 index 8ee31d9a..00000000 --- a/tests/data/account/secret_management/unauthorized.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#unauthorized", - "title": "Invalid credentials supplied", - "detail": "You did not provide correct credentials.", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/applications/create_application.json b/tests/data/applications/create_application.json deleted file mode 100644 index 18ab0453..00000000 --- a/tests/data/applications/create_application.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "My Test Application", - "keys": { - "private_key": "-----BEGIN PRIVATE KEY-----\nABCDE\nabcde\n12345\n-----END PRIVATE KEY-----\n", - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": {}, - "_links": { - "self": { - "href": "/v2/applications/680754a2-86b9-11e9-9729-3200112428c0" - } - } -} \ No newline at end of file diff --git a/tests/data/applications/get_application.json b/tests/data/applications/get_application.json deleted file mode 100644 index 8f18afcd..00000000 --- a/tests/data/applications/get_application.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "My Test Application", - "keys": { - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": {}, - "_links": { - "self": { - "href": "/v2/applications/680754a2-86b9-11e9-9729-3200112428c0" - } - } -} \ No newline at end of file diff --git a/tests/data/applications/list_applications.json b/tests/data/applications/list_applications.json deleted file mode 100644 index c2f67537..00000000 --- a/tests/data/applications/list_applications.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "page_size": 1, - "page": 1, - "total_items": 30, - "total_pages": 1, - "_embedded": { - "applications": [ - { - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "My Test Application", - "keys": { - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": { - "voice": { - "webhooks": { - "event_url": { - "address": "https://example.org/event", - "http_method": "POST" - }, - "answer_url": { - "address": "https://example.org/answer", - "http_method": "GET" - } - } - } - } - } - ] - }, - "_links": { - "self": { - "href": "/v2/applications?page_size=10&page=1" - }, - "first": { - "href": "/v2/applications?page_size=10" - }, - "last": { - "href": "/v2/applications?page_size=10&page=3" - }, - "next": { - "href": "/v2/applications?page_size=10&page=2" - } - } -} \ No newline at end of file diff --git a/tests/data/applications/update_application.json b/tests/data/applications/update_application.json deleted file mode 100644 index 8a268167..00000000 --- a/tests/data/applications/update_application.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "A Better Name", - "keys": { - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": {}, - "_links": { - "self": { - "href": "/v2/applications/d678d143-e7fa-465a-9ee3-0b59621967d2" - } - } -} \ No newline at end of file diff --git a/tests/data/meetings/delete_recording_not_found.json b/tests/data/meetings/delete_recording_not_found.json deleted file mode 100644 index 0e38eb8f..00000000 --- a/tests/data/meetings/delete_recording_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Could not find recording", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/delete_theme_in_use.json b/tests/data/meetings/delete_theme_in_use.json deleted file mode 100644 index b4ebb38b..00000000 --- a/tests/data/meetings/delete_theme_in_use.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "message": "could not delete theme", - "name": "BadRequestError", - "errors": [ - "Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room" - ], - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/get_recording.json b/tests/data/meetings/get_recording.json deleted file mode 100644 index 3f7c28dd..00000000 --- a/tests/data/meetings/get_recording.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "e5b73c98-c087-4ee5-b61b-0ea08204fc65", - "session_id": "1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4", - "started_at": "2023-01-25T02:38:31.000Z", - "ended_at": "2023-01-25T02:38:40.000Z", - "status": "uploaded", - "_links": { - "url": { - "href": "https://prod-meetings-recordings.s3.amazonaws.com/46339892/e5b73c98-c087-4ee5-b61b-0ea08204fc65/archive.mp4?AWSAccessKeyId=ASIA5NAYMMB6PXEIQICC&Expires=1674687032&Signature=RosB66sKsizUgoRz%2FWlQD7wUUJY%3D&response-content-disposition=attachment%3B%20filename%3D%22test_recording_room_2023-01-25T02%253A38%253A31.000Z.mp4%22&response-content-type=video%2Fmp4&x-amz-security-token=IQoJb3JpZ2luX2VjEKH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJHMEUCIQC5%2FrQRRq%2FzlJCqfgI9MN4Bq9kqmJTMPgZCo2KyaJ79IAIgdVVs9eYiuxB%2Bcc7QYJz7X4XQjSPcofAsves5rrjrsDgqiwQIGhAAGgw5MjEzMjE2Mjc3NzIiDHpbvFCfDDlkhb%2FFCyroA7OrSBCZr7MyZHXnHHPHOB99ctR%2F7XMzr3GAqnWfZTR5DY1zSYLkAKavjbS6Uw%2FMZW1PeETwLUrdwvLcvkpkU6EaXh2PZV07ty8AB9wIEyazRR9%2BqrCP9o23dlN1yMKPDnHGKO%2FGvxNFrHC9xJeFmaKrOz3f4oeRlFzJ%2FcMnXOI3vnuMa5jFf2GHDQGYkCWF7ertH%2FnIrdmj80%2BNOGsCb2O5%2BezLLlbJAd12MNj8C4m4xw%2BY0fNHZKKAjrG4UTE8%2BBdZ%2FQrMfbKfHaz736be3mln4ArCL1vUWRdQOQFP8impDXRDSMGS56qIdKgsYhEr6fcT%2Fy5KpYuVXxL4Z8TzVgrWwfcmlTxAJuvDgA6HxQAzY8BN8eQLxbaBWaiq%2BD0z6hDPIHKhZDYLQ4CSsxDRfL1DN%2FB155Os9kmombG5rZb%2BR1poIYTrlC16LjIN2JWgCovI8fRPgcjJ3JYEIdi0oE6Jq%2B0PdXbjbSZlekpkQRny3eOPuKhheFlmpweiEjwabVPq3kGyUeC03ZvOZ6giN34LdtM3m2gblmcO9t%2F1KvJwm49t2LJYqGMX9JnqOf6pSWqAZXERCoeAaE0SLzsI15pIix07e9fSY8HZJ49OL2%2FYdz%2BlY4rmkTTVo2v2iELhXS57S3ihkz3lMLS6xZ4GOqUB%2BLq1xiXSn3RAfctKCWM6fGctNnh77Tmn9cwKd8LZtXSZUIxGNYESIu4k%2BbgPZh%2B%2BLf%2BNAsgj6DtnpicEwoSxnbeMwrM6PWw8IH7GE%2FeHN8HFZqGwWiWSogeOv1YeFGL%2BWlXVS%2F5mx7uNTJx%2FHryd2JSYu3kn3MpY7cBU67jcTyesZQzR%2BTHIhLnNgTMNhdQ4st7lD4RaCsvTGqFkv6EnOV%2BU17hv" - } - } -} \ No newline at end of file diff --git a/tests/data/meetings/get_recording_not_found.json b/tests/data/meetings/get_recording_not_found.json deleted file mode 100644 index 9c2f8d66..00000000 --- a/tests/data/meetings/get_recording_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Recording not-a-real-recording-id was not found", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/get_session_recordings.json b/tests/data/meetings/get_session_recordings.json deleted file mode 100644 index 782effe3..00000000 --- a/tests/data/meetings/get_session_recordings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "_embedded": { - "recordings": [ - { - "id": "e5b73c98-c087-4ee5-b61b-0ea08204fc65", - "session_id": "1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4", - "started_at": "2023-01-25T02:38:31.000Z", - "ended_at": "2023-01-25T02:38:40.000Z", - "status": "uploaded", - "_links": { - "url": { - "href": "https://prod-meetings-recordings.s3.amazonaws.com/46339892/e5b73c98-c087-4ee5-b61b-0ea08204fc65/archive.mp4?AWSAccessKeyId=ASIA5NAYMMB6JPDOLPNO&Expires=1674688058&Signature=0IzgnyLJFMP1TDkOyoBT4M54Le8%3D&response-content-disposition=attachment%3B%20filename%3D%22test_recording_room_2023-01-25T02%253A38%253A31.000Z.mp4%22&response-content-type=video%2Fmp4&x-amz-security-token=IQoJb3JpZ2luX2VjEKL%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJHMEUCIBE0ejVJPxkEDjAF6cMuDC9nIeOU%2BUnUTSnfhi2prlHtAiEA1wiXNTR96lN%2Bgsb2yeQPM%2BF%2F4e6%2BA6%2B5CylWsM1gW%2BMqiwQIGhAAGgw5MjEzMjE2Mjc3NzIiDHEAZDKegwDhiMhj3CroA1%2B2SNg3m%2B%2FCmq3ELZnnEx8t9oYXmlY0dDRovuKNBdy5n4d%2FUhhR5DaoxOj8cAY7Yu8xZRM1oYQCbO2Qrgiy2Nki7FgHNljLldhbMN6txOnf7%2BP8r2XWD6x0D7ZN8hhA4LAoeTGaF4N7ZT3Oabti%2F6z5qw%2Bp85dak9CMd%2BToeUzqcmKlRhB56SrMgTofr2B8BOXgxxmFfdmrKllmJxsi2og5iLwWWdHNExV87fPout%2FMlQ0u5D1vj3F%2FtQGfAjkPnf1RSol%2BIxwIHPmKEiqpUSu0hwtPai8Ra1l4tml2Zv9SGZ1E8AZEAtmROL2fM4rl%2BtUOEAXTUWGO3G%2BcjGs6cPZB4ihzo6TqIFyGJoQ95pPFu6yiRa%2F31Z5DEHcom5Ux4%2Fxs0TMimuLP2CJ%2BuqKRiAb9w20wchouM9MaGjVYvTXs%2BJsVXQIwdGdJACIkK9CKZXNkYAbKnYfAkz8bi7rfFoJT1mZ6hSxG%2BNGp%2FYr7Vk%2FTSvoLO4%2F4%2FpiSyJs2Y7r6QbohXgmCkZTejJW3KxC4tCRGheVwyVwRC%2F%2FCMQpm34wEb0FL0sEfqxhl7Kbkm0RT1HwOlb3N4GmLERJcEcIpmmegEIRPQcbM2ohGq%2BbMrWvD8lmu1qyfu01cSZkl9xe1NtLtEFpxoxVSMOPFxZ4GOqUB69If14rWCah8hqaxxteZbVoDSmXJo7CrMDc8uaRgFQbNR6tj4WC2t21q%2BhTBRd3C%2F5zYTAAbILP3jkDDRt3SanOamRICcOKqOJFlRa6aCz2G%2F175CWu0Bz1wDGokaGAwz3G1CL%2B2t91JH8aPUHQkX87%2FGxJliywZfL2od%2FbyCR6bM%2FGbVPRuPX8fSXsTQZPjCMCt4GJv5%2Fq%2F3h9t0lf0AfCG9Hx%2F" - } - } - } - ] - } -} \ No newline at end of file diff --git a/tests/data/meetings/get_session_recordings_not_found.json b/tests/data/meetings/get_session_recordings_not_found.json deleted file mode 100644 index 11ebca44..00000000 --- a/tests/data/meetings/get_session_recordings_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Failed to find session recordings by id: not-a-real-session-id", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/list_dial_in_numbers.json b/tests/data/meetings/list_dial_in_numbers.json deleted file mode 100644 index b2d4e165..00000000 --- a/tests/data/meetings/list_dial_in_numbers.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "number": "541139862166", - "locale": "es-AR", - "display_name": "Argentina" - }, - { - "number": "442381924626", - "locale": "en-GB", - "display_name": "United Kingdom" - } -] \ No newline at end of file diff --git a/tests/data/meetings/list_logo_upload_urls.json b/tests/data/meetings/list_logo_upload_urls.json deleted file mode 100644 index 8f4526b5..00000000 --- a/tests/data/meetings/list_logo_upload_urls.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", - "fields": { - "Content-Type": "image/png", - "key": "auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25", - "logoType": "white", - "bucket": "roomservice-whitelabel-logos-prod", - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": "some-credential", - "X-Amz-Date": "20230127T024303Z", - "X-Amz-Security-Token": "some-token", - "Policy": "some-policy", - "X-Amz-Signature": "some-signature" - } - }, - { - "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", - "fields": { - "Content-Type": "image/png", - "key": "auto-expiring-temp/logos/colored/c4e00bac-781b-4bf0-bd5f-b9ff2cbc1b6c", - "logoType": "colored", - "bucket": "roomservice-whitelabel-logos-prod", - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": "some-credential", - "X-Amz-Date": "20230127T024303Z", - "X-Amz-Security-Token": "some-token", - "Policy": "some-policy", - "X-Amz-Signature": "some-signature" - } - }, - { - "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", - "fields": { - "Content-Type": "image/png", - "key": "auto-expiring-temp/logos/favicon/d7a81477-38f7-460c-b51f-1462b8426df5", - "logoType": "favicon", - "bucket": "roomservice-whitelabel-logos-prod", - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": "some-credential", - "X-Amz-Date": "20230127T024303Z", - "X-Amz-Security-Token": "some-token", - "Policy": "some-policy", - "X-Amz-Signature": "some-signature" - } - } -] \ No newline at end of file diff --git a/tests/data/meetings/list_rooms_theme_id_not_found.json b/tests/data/meetings/list_rooms_theme_id_not_found.json deleted file mode 100644 index 410a75c4..00000000 --- a/tests/data/meetings/list_rooms_theme_id_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Failed to get rooms because theme id 90a21428-b74a-4221-adc3-783935d654dc not found", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/list_rooms_with_theme_id.json b/tests/data/meetings/list_rooms_with_theme_id.json deleted file mode 100644 index 1e791055..00000000 --- a/tests/data/meetings/list_rooms_with_theme_id.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "page_size": 5, - "_embedded": [ - { - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/updated_company_url/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0OTYzODg4fQ.46AYaDgMu_IdNPkmToKFGB_CqWYKM2xFpKU0vc3-E_E" - }, - "guest_url": { - "href": "https://meetings.vonage.com/updated_company_url/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": false, - "is_chat_available": false, - "is_whiteboard_available": false, - "is_locale_switcher_available": false - } - } - ], - "_links": { - "first": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20" - }, - "self": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009870" - }, - "prev": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2009869" - }, - "next": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009871" - } - }, - "total_items": 1 -} \ No newline at end of file diff --git a/tests/data/meetings/list_themes.json b/tests/data/meetings/list_themes.json deleted file mode 100644 index dc8b5f98..00000000 --- a/tests/data/meetings/list_themes.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "theme_id": "1fc39568-bc50-464f-82dc-01e13bed0908", - "theme_name": "my_other_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#FF0000", - "short_company_url": "my-other-company", - "brand_text": "My Other Company", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null - }, - { - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "theme_name": "my_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#12f64e", - "short_company_url": "my-company", - "brand_text": "My Company", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null - } -] \ No newline at end of file diff --git a/tests/data/meetings/logo_key_error.json b/tests/data/meetings/logo_key_error.json deleted file mode 100644 index ea9c9b18..00000000 --- a/tests/data/meetings/logo_key_error.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "message": "could not finalize logos", - "name": "BadRequestError", - "errors": [ - { - "logoKey": "not-a-key", - "code": "key_not_found" - } - ], - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/long_term_room.json b/tests/data/meetings/long_term_room.json deleted file mode 100644 index b73fcb09..00000000 --- a/tests/data/meetings/long_term_room.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0NjA3ODM3fQ.fm7q551LKnZaUcvZ30AmU62jRnvL94Do2sJKU0mHUmE" - }, - "guest_url": { - "href": "https://meetings.vonage.com/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/long_term_room_with_theme.json b/tests/data/meetings/long_term_room_with_theme.json deleted file mode 100644 index eb8ec86e..00000000 --- a/tests/data/meetings/long_term_room_with_theme.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/updated_company_url/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0Nzg2NDYwfQ.XFjcJFNZU9Ez_4x-uGIj079TTvttNHkkfA54JTDqglM" - }, - "guest_url": { - "href": "https://meetings.vonage.com/updated_company_url/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": false, - "is_chat_available": false, - "is_whiteboard_available": false, - "is_locale_switcher_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/meeting_room.json b/tests/data/meetings/meeting_room.json deleted file mode 100644 index a0b134f7..00000000 --- a/tests/data/meetings/meeting_room.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "id": "b3142c46-d1c1-4405-baa6-85683827ed69", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:30:38.629Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "412958792", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=412958792&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiM2ExNWFkZmYtNDFmYy00NWFjLTg3Y2QtZmM2YjYyYjAwMTczIiwiaWF0IjoxNjc0NTMwNDM4fQ.Q0BPbu3ZyISYf1QaW2bLVNOrZ1tjQJCQ7nsOP_0us1E" - }, - "guest_url": { - "href": "https://meetings.vonage.com/412958792" - } - }, - "created_at": "2023-01-24T03:20:38.629Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false, - "is_captions_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/multiple_fewer_rooms.json b/tests/data/meetings/multiple_fewer_rooms.json deleted file mode 100644 index 4a650eb0..00000000 --- a/tests/data/meetings/multiple_fewer_rooms.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "page_size": 2, - "_embedded": [ - { - "id": "4814804d-7c2d-4846-8c7d-4f6fae1f910a", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:25:23.341Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "697975707", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=697975707&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZDIxMzM0YmMtNjljNi00MGI2LWE4NmYtNDVjYzRlNmQ5MDVlIiwiaWF0IjoxNjc0NTcyODg1fQ.qOmyuJL1eVqUzdTlAGKZX-h5Q-dTZnoKG4Jto5AzWHs" - }, - "guest_url": { - "href": "https://meetings.vonage.com/697975707" - } - }, - "created_at": "2023-01-24T03:15:23.342Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "de34416a-2a4c-4a59-a16a-8cd7d3121ea0", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:26:46.521Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "254629696", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=254629696&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZGFhYzQ3YjEtMDZhNS00ZjA0LThjYmEtNDg1Y2VhZDdhYzYxIiwiaWF0IjoxNjc0NTcyODg1fQ.LOyItIhYtKHvhlGNmGFoE6diMH-dODckBVI0OraLB6A" - }, - "guest_url": { - "href": "https://meetings.vonage.com/254629696" - } - }, - "created_at": "2023-01-24T03:16:46.521Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - } - ], - "_links": { - "first": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20" - }, - "self": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006648" - }, - "prev": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2006647" - }, - "next": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006655" - } - }, - "total_items": 2 -} \ No newline at end of file diff --git a/tests/data/meetings/multiple_rooms.json b/tests/data/meetings/multiple_rooms.json deleted file mode 100644 index 66258c3c..00000000 --- a/tests/data/meetings/multiple_rooms.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "page_size": 20, - "_embedded": [ - { - "id": "4814804d-7c2d-4846-8c7d-4f6fae1f910a", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:25:23.341Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "697975707", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=697975707&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZDIxMzM0YmMtNjljNi00MGI2LWE4NmYtNDVjYzRlNmQ5MDVlIiwiaWF0IjoxNjc0NTcyODg1fQ.qOmyuJL1eVqUzdTlAGKZX-h5Q-dTZnoKG4Jto5AzWHs" - }, - "guest_url": { - "href": "https://meetings.vonage.com/697975707" - } - }, - "created_at": "2023-01-24T03:15:23.342Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "de34416a-2a4c-4a59-a16a-8cd7d3121ea0", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:26:46.521Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "254629696", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=254629696&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZGFhYzQ3YjEtMDZhNS00ZjA0LThjYmEtNDg1Y2VhZDdhYzYxIiwiaWF0IjoxNjc0NTcyODg1fQ.LOyItIhYtKHvhlGNmGFoE6diMH-dODckBVI0OraLB6A" - }, - "guest_url": { - "href": "https://meetings.vonage.com/254629696" - } - }, - "created_at": "2023-01-24T03:16:46.521Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "d44529db-d1fa-48d5-bba0-43034bf91ae4", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:28:32.740Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "659359326", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=659359326&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiNjU5MjZjMTUtMzcwYi00YjlmLWI2MDMtZjZkODFlNzIxNWFkIiwiaWF0IjoxNjc0NTcyODg1fQ.TYjAbWOYdlt7UsjyQh-Y7Qr0hfWElIDrJQTNrOQuLSg" - }, - "guest_url": { - "href": "https://meetings.vonage.com/659359326" - } - }, - "created_at": "2023-01-24T03:18:32.741Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "4f7dc750-6049-42ef-a25f-e7afa4953e32", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:30:21.506Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "752928832", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=752928832&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiMTMzYTY1MTctMDdhYS00NWUxLTg0OGMtNzVhZDM0YzUwODVkIiwiaWF0IjoxNjc0NTcyODg1fQ.afMtFPyLAgZvGsR66pPj0op7sgnNjfj4BHxhU1OP8_w" - }, - "guest_url": { - "href": "https://meetings.vonage.com/752928832" - } - }, - "created_at": "2023-01-24T03:20:21.508Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "b3142c46-d1c1-4405-baa6-85683827ed69", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:30:38.629Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "412958792", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=412958792&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiM2ExNWFkZmYtNDFmYy00NWFjLTg3Y2QtZmM2YjYyYjAwMTczIiwiaWF0IjoxNjc0NTcyODg1fQ.hCAmGR3dxnV7LkSyCXYyUXlXYr-LBfAANMjipm6PumM" - }, - "guest_url": { - "href": "https://meetings.vonage.com/412958792" - } - }, - "created_at": "2023-01-24T03:20:38.629Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - } - ], - "_links": { - "first": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20" - }, - "self": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006648" - }, - "prev": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2006647" - }, - "next": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006655" - } - }, - "total_items": 5 -} \ No newline at end of file diff --git a/tests/data/meetings/theme.json b/tests/data/meetings/theme.json deleted file mode 100644 index 63bf8494..00000000 --- a/tests/data/meetings/theme.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "theme_name": "my_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#12f64e", - "short_company_url": "my-company", - "brand_text": "My Company", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null -} \ No newline at end of file diff --git a/tests/data/meetings/theme_name_in_use.json b/tests/data/meetings/theme_name_in_use.json deleted file mode 100644 index f374bb11..00000000 --- a/tests/data/meetings/theme_name_in_use.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "theme_name already exists in application", - "name": "ConflictError", - "status": 409 -} \ No newline at end of file diff --git a/tests/data/meetings/theme_not_found.json b/tests/data/meetings/theme_not_found.json deleted file mode 100644 index d15a28cb..00000000 --- a/tests/data/meetings/theme_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "could not find theme 90a21428-b74a-4221-adc3-783935d654dc", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/transparent_logo.png b/tests/data/meetings/transparent_logo.png deleted file mode 100644 index 36f9b729a396b95eaa2b9a05fa9f2eba69ca8e09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8843 zcmcIo_dnHt^nW8O*{keqT}f8f^+~_Sezi+*Lfs6(C1+Y|~7_5sJG63ht3{2`DpCnwiWMO5@gIN$Bja`Q2C(OSR zZT8SeO0Y?`u#^A8J6+lk8NRu(aq{h?Z7FZ*^mEt{eGDU$aQg2e1fXF0L|O4WZ<-N| z(AyqE$_ksi#K-Wv|APnlM*!F&3k!`3#HpQ<)c69VfZv#eAXCrJi?|kJVJm1 zTB0F&ZVG&oF#p5?e7XwY%I+B6&eq)g_4yCTx0=rEVhrxo z3;cO6&;rb`B{GB0R{((HX!!lPyxQ}n-wQu~yDuR(_dXV#du{pht6txpJ#TwPdI?~- zH?%qFxVSh@=GQ}l3+nVLw*@9Gfd{ik_R>?-uWT-V2|r2>lRs7GDR^!CNtv7LG7Zg! zkjv|K$zRE;7t%j{=aQrTg3|M;@GaoKT=kqPd~w{_f6hL^)uyRS{g*Uv`y4C@ocBD5 zK1&rFo|k!RC(fF*4Nq(~<~QsKduN)ieZ3bo&$JRPwauG-Mx7Z#3t;1o@b?-i8~Ty> zs6Qm6$#n3MU_d=;_vSMWnsk7{Lq;m>001&OkJ8j3B*5Sc%r5|FTfS;nmc?$+O$7kj zuVQcDG$^mUXTjp>^4@c|;n_)@DYVtE2;!9)G$|HhxGulGnyqoo9IyEOQUi{R;}v7> zPlo#Ux8-S<=9uha*ZkNioURspxY`y^eM#dJ6|?ho#RNu|)bISCsn~N;FY!CWWHMqf zd@BhoCj1{i`C~}SH4*9dFoB(${wxY9cIwB^r#|sXC7mX<>E6zYtu-P0N*bj8u8fx> zsryyHaG(smeN1+d*Dyytk4j8qQR(pA<)AFwhwEU4|6J#}ULfQkLdWcw2@|C2%~-g1`MS_^DLooJFW409 zCdLe-I>O+YXCS=x+`IS30#$7cmhVM+({uXgMj0o)T?D8udw{i?h$VXq~d@`5( zCdHZK@sj?fuLbR30XOE6uX=~%3=)8)D6`Q*@bze)l&ekpEwhh-D z_qQZl6Zd*)=we%Yv~Cbge!>}iE5g7`E1<$1>z?kO|NFl|61wiUb6W}%dHSEPf2RE$ zag*$(+ReA)h${E0sH!`Y;gcPcE*t(EY#VEnn?Zj0>iOdN`-NhP*BBx;60Jb=m8sXY=sZ zHBh5tPD_$3?4uIi5U<#NHw{BWE0Qg(Lnl$yd2Fdr8>#0Imx za@|{o>qi3z20n>TW4~j2*Hab7^1KS1J0{wuPE($|f#!YGPnJvW$)3!9*iW8qn0wI>l7Obf1Upd{q_Dk#67^p#67MQsPxc3FlZnc>DRV~`6<&?PAKnMvGU;bcpZ?x zE6ZMT_jy0li{KaRX5OVqr9Oq>{isEbKP@8-qx;3^R`hh$g!k8(0hN!Dl_!Q0XwF{y zE|pDA3$a?XdG_*bdwFJ#r_K_auuX?XeuO zb!1*-yq9t=y`hvPPa$umxOGL9Rr3lptu?QIrZ1j-hx5o)dfG!gcbK)0DxcMc^H_2~ z*nnr8F*SaOYL+3&x8%rRWuTENVNMsB(9G+3ErRE5Nb|@06wxjsC$NSOn4(>AH??j%luFjtzZ!2;F|c6v!+{y^**;_)bts{1rgD zgv^!BTgWxf4s@+voGyCD{(I$fe?f?{wXF5?hw@KeGQFr(Ta4q=G`@A!XtC7Ckehku zG5U!Sw4*4c$mDHPv%Yf1?|e(tU-=oONUh@s@kTm9-O|)XrjW4oYZ>nZb`34H$A4gl zCjOhasgRYIl~-&^>zQ2-`6k&U+GOMofn?Gyg#GsJDb8v-v7L>wdm^BtY|s?P2?KoN+U*?t62n_4XWpC$yDKAlZyP zMB=u+Y0Ldk&#jTj{s;ZF*=NS{a8rvNMA-CBs%hNFn>SjJM^-YZ@~qx|pXxoEcdo3? zsQVV1h!3<$%BAqY6#W*_F}{=Njogq1KbsqLA2b%S@pzg&ZQWYeq) zUge9bY#%K=%KTM&zqBC#o|?+noawLwgVP)@dlUP153QjBHTJ+i22G9!y}Lh_((Jd| z{Py?{T&9GY?pCQ(tLR4go&AK>*!r%y9rSNlDOU&X8LlY}Z;nPEBk*GZ-wwY?*cXPi zZ+5S9Hu(t8a5W3H>>mdI-Cz&7{KsJ0K0Gb7=Cot-Y?bnuel@!O{H+__d+yK)?=;k9 zO}|M;{j}gb>tgij&k@}L-7x_jft0j-US3XGj`p4VpEG}!)|N64^+j1;gslZsF3q0J zpWGdSpMKw>IZ7VttPU&R_1~;4uTks~=;3Yq9Hz2ea$5CyDsr=Vvt_oYoi|7HRQ=p| zclg+~r}T@_f$Gx3^V3@gk9SXQ>dFs+O(vf!%+wG7!i51K<}m;qgF`XP01zYz0IN;_ zp!gC1*!}WaOq4+ZwI9qn007v8h(8iNQ^74zh}F~9Fb~b!mXWgX6d#EI)|i}>>V`@^!A(Pu25h3ERe>RQL~l+jxI)%#CyRV?>WUdC;;iFNW)W)p0fZ&yDrk^8?hSU3-k#K z9+$TZxoecWd!iY*%AGfI@#^hTjOh=bk-0NpyxZ;*+04~Nz=04ENqCx_)WRp|$i6Qj z+P!T@5odQ1+k}AY3Q9WFAe9%Ep(AyITTI)9>@}yMbMHaPU6SZ~PG5Kwygv;mW0pkc z)gTT>RB+#aGC)K~(l4!z9@~h&oPhZiMHDK8Y`t$9#f<~*LXx3rr%th~40>HWglHY* zx{W!~`cWM4*}!RwC+FUN9d|m5{oPX!0;JQ{m}Jr${)<^2sNuSUWYwNWYMpv?Nk>SO zQEkYHi#Ijwqi3tNr+Vq-_SG!I&mNn#aFAw03 z)ohde-6tIGn@guVa2cI?yoc<@1nJ(;DRrcNjh(&i{oN9(y}B#8T}W1wH(GIZMg}`3 zwnRQ>G+B`VQ~4`&EV6!*S2~y(y8rDpa~2U(a}(;}5IX(?w#2b!3@6zbOzLPaMKtB6_N`Th8>U z+^S&Ve!3{emBHMs-GXNN!>7SCqjy2?OUK6Tf7;--(!oTCFh>tYELu=tMJM%}HgU&> zt6Q@}W7+s`MU>Z}aEe3AJwC2{`nHvQUfahA%Ee`gjdaK}gs@bGYeMI^N}Yzt$CZ7^ zb^HOd{DYcjn-!Y;8?9-FYtxVy!8TmU@%su+?q5hEJoK$tnLLd=AxVF}d&Y`$u;I&7 zDX-a6~T1V#^B^Lu|oip_0VJ{y|DsbvkAfVf5&B&mZ{B_S%AY=~+0qsqvbqk=Yt z!m~rK2?+0q3v}2LTZsEXDSU8vk)G@wn-3ahLWw^dNc~L7lg2}EDx~}&)Pasz@Gs8Q z%h9*l#-x!WRc6KdJ!#1d`8(ttCJ1HjrUjJ=ol)(z_^Kr(3#+clyhGBkMHz5`b)(?< z`PHfUv~vxWjB<1^-%LwqDrjNsVn5QZ;v|9xwQQgLSM0^)Y}bs}aY=RbVxaGYP( zU|?x$;^rToFdq0onfu5?p(;_ue1wT6bC9aDhAesKCC69TLQB{|xA8dh5z8D%*Lp9# zIp}$)p`=_98kspekt0)G9 zJ3@KheFo1kIAMrR3I7d|b3+dmJ8M{bof$}iI}Oni919)Zy)SbwoFBvK=fc8Yn+#&C z8?T>F?hUBQxt++=#FH-?2yPU2RV8DnMiV+E+IOCt491s5fQ}P7d%U={Oae0<$Mnk% zNC#Q&0odt#4-XDLMVL<&CaPOALmgi~Wrf;qKm72>KdQR!lHY?pK0#_%QLnY50N4ZQ zu)|GKWvx?tyui?_!Zj)`!_$W4oB~T0?~f+^#ZCQ;)4{n02O9mr|EO;_61q@H#2#UC=1)JKxO$v(XbwdSmP zRU0`-(%)6N`As^WqZov>otT#1-5Z9bxn95di9B2xNG6?4%6D$F+s9QgtJjhk^&!`A-@o<}gp5*`UK3$8qCLDDQ(8><)7zpi2WB%> z%bD@mKB2Q)x$ceO#3Z{ymGdjJ#9JL!9nR*`dP69Jqoc#S@iKRAy*JL@`lr-eeTH&E znS^H|=aV}FK5}kr1~+JF`dXAW9)Qz(tO<@*4)12c>37SWQF~_>L~?Ujf@-rz+J`M( zMb`#Nya7|zD#fr!7X*E6%wYaZY5QBIl|%P`MaxdZqZ||vw<__N;vz~u;p`Hu-g`6A z2SpDc$s|LJ(d^rwkQIjRLcT=OQo^T7kTZH4)N5oQj20?C*d8CoL+A{5MmpEKtbuX2 zn2Vg>6ebdVqy)+2wA#auFc9X&I3`Ey4&Aicb zt3P#OIr3Afhh9C>dZ(HKW1zNnZhiux>c-m;xEl~h!PnZ^YRpKFH+CH8oG zPEF#U5*+V1DEufvw%>i{Ofs#1+PkBFNMqheWv@HbnSbVA!09t;0RW}1VVN1*Fv0XQQk}t8wFho z&*S>Q&^>_5{*p4IO2RZ2Xwy5L=@zIV>7N7_;W)RyC3pyb&lb`IiUA9@*Wb7gH3Tg`Ew;Wh#8Kdn(7vY%|V|=CYxB0|-YhYo#_7+XoW7n0aLKk}bzM=V|ksTwiJN8*b(IWeVX|`zRhiW`( zPgbn{5M2kDfg!q#TPBf#>tzN;(@cZzHjPX?Y(-i9ps=hJLtnw&++G@&Zbh39S~C~m z03J*(%;wSUtbM_`H<%Es1Ck4Fm4p#gJ^xAozDUlExq|oQkidCcxX_e^x__a(;`hzO z&Z}*vBXkl$5YM9JWWhEt&J2(Azt?0LTD`{BNCt6eS=$a7K_N-Pi;VM|E)Em@W$r>s zxre*UsCV}}I&4M@aIdhXSmZ2J5Q9;3vQyvhXVCiuub3$+-gI}+^HJJ@4Wgq>P`%+cc10s|L~NA!aFrG?w1YB3UPoB zg-;UC{} zo<0qktrxQ`=7yez?evMy(a5>o)ynK-(hM5Ww>fCXq^Pxyy*yCxmZv-n0F#U~#B^MH zgxkAs9M;Md9i?1XZa6VAyeA?a^DV{`tg)C<>`#y|BHjvig`b2+W5;18|dOpT~wXNLTD02iTj_pb8q#4hD zS}r_znM?QdXsb^NUjgDW^E864!c}`&+i%9QWZgsW~+xX?m z^8Ll(?$4ZVLl$mqy@+_3d%IC8k9JCl33qXptjV6fj7k05aY|MG^HjL*_#Ld-XJMvj z3FY0O%nG6Eda!%3S3iZi=O-INvYLboW`FvZnha)G`FA1alQ2pzEkzyIh||pmm?nho zh{s%9KX<9?yV>2)J0V6Wq_0t?6!$S}+(R9XM847+CX@V%ab5XXi+@ivEGv{|mv1|y z*I`#QS9Ef7Gf|D#A&mF1O>a!f*dah7&ulMX;w$jfJFbV3cTQ)hn!8;8j3+U0nlq6FOaka~1G5vR?&s zr;~MFIqNZY3pioXo4I`^vlnl!G#u#Y|e<3_}wJ4zj zP4|L5_66(c5P#UTIQ;! zDdx?Sn4W~r{vWmnvF-~IL}+a!*!yOU?q8V4KdJJku+NA+yG~v#>I)sV0>dWEnkO`~ zxph4mKhnVK#zV(%<}# zq}ZI>YHPnLjre?!Wa3oeVJXiL(#fpx_XxA)(vLSk5-}degPlzB#A|{?(IfyROy}Z} z>zg>~SQK}ffZG)F1>si?HF&aId*J2|^YvSkj-fp2^Ads`iJ^-W#ksNt9F_---RB=> zKJ8PDSWIdUwzza^gncPi8-r$Dn-QuDBR`s$q4@E zQkyj_P56pWDY|M@)+DgQkr6bYf4<3JQgOUnDVkMJ)}+2xY}=3^9Rbz{Wdyv@2XmJE z_nU^R!LZ;wI>G9XbcJ>LR~#~E$ErF%uKQ)KMa)5hub_j%cnQ+Yw5#@jq3V{6Axr+I zHgUECX*QTNb7ZRC|G1`eagUm<6w3+~F}oQjp7HE0-s?*Ai%1H%48ku_A>$t$lJ1GD zD!cFxwRgzmBOXUbv5n0~lRni2?*ruKcOdBtSm^#}^81j9rr05hRESWt)~Q*VM`NRy z?Zn@Zoc1fAz$7@gS904OwK1;w=iRu7>~gxvE$c_)G+Jnfo1dZ6b$dd$rp+ zG~&N4xT(J|h!zsUIc8woRWao=(CH`AO(%5C3#j`Ci-19pmN|#X$01Vu%k#8B;`Y@& zFtam5z~?#a-@y|?T z5+Oc>HC(Q`I%=i4?N7FyLV%2dJ#HNMPW3b&BuT+=_=c=W7Zbe|k=OnWCWH4$zTz-G zj@QABf3L~8Ws;k8Wwh8E5Y^xXd&4f{t_($Q9b!0Hb$v{l(X>9n`B~ zOw=p3-VsLRjxEQiTBwJDE6$%g{4VrmYTe;YJL*pxs@WG96hp|REsi67;h@eIo zM6X=esW*lJH5{o#ClZqgScyl3WW`rLl7MrE9u9&B-XwmK5trI|m`kt3a%2atMG&Rn ztwV0*3Ha%1;q=eFrBhjk;o!UAj68~%WG3+P=vuuR5Utr8@2UMEuvw!4PbybqAf}V6 z|N3+d+PjL|-XIzeI(kB^2MTS zm2;f<8^Wl}Nl@$;1tZRmCC)B)?tQcldq}oD@DcoM0Y9Bw%Y$W7efS-6=)`@E@BH^F zUMSA~Uryx#kt~q2UUy>zb=O+Z6z0)bQ9!& zavfT4QScf9?sh2U7D>BK7JR!rPY_C5d89w3?jnI}0Hq2D_+YRgQI%U{oVn)rGBELD zcAbJm92aF#%wn`9@ng!BzySkjbm?A$jo z;B(2cQM6z|ix%QbLvo=lt0C1A8@edFXKo=bi&ajcNG&?v(z(M>GeCMy4#yts=v9Bk z?bI@jF^@q&$Te-$c~s)A#%e^A$Jr!*DXUBJYN7&S*J9ht3xuIDuW7x0E|akOut=sN zf?~JLIdfAl4x@crUc}gzWqWmSgvtiXXOPKa<&{V;^!6KkQ5qLWHp#R$&TkJYeud_* z&P>tZckyKAyWItt@LDF%DbdJVEa`UQEGhtFdT{yw(=#^lQ9R{>vgF#|G-lV+1n@B* N(1YI7uGVyV^gpd}1$zJh diff --git a/tests/data/meetings/unauthorized.json b/tests/data/meetings/unauthorized.json deleted file mode 100644 index b6813760..00000000 --- a/tests/data/meetings/unauthorized.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Unauthorized", - "detail": "You did not provide correct credentials" -} \ No newline at end of file diff --git a/tests/data/meetings/update_application_theme.json b/tests/data/meetings/update_application_theme.json deleted file mode 100644 index 2fc0df4a..00000000 --- a/tests/data/meetings/update_application_theme.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "application_id": "my-application-id", - "account_id": "my-account-id", - "default_theme_id": "90a21428-b74a-4221-adc3-783935d654db" -} \ No newline at end of file diff --git a/tests/data/meetings/update_application_theme_id_not_found.json b/tests/data/meetings/update_application_theme_id_not_found.json deleted file mode 100644 index 329d8a96..00000000 --- a/tests/data/meetings/update_application_theme_id_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Failed to update application because theme id not-a-real-theme-id not found", - "name": "BadRequestError", - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/update_no_keys.json b/tests/data/meetings/update_no_keys.json deleted file mode 100644 index 0b9fc5ce..00000000 --- a/tests/data/meetings/update_no_keys.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "\"update_details\" must have at least 1 key", - "name": "InputValidationError", - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/update_room.json b/tests/data/meetings/update_room.json deleted file mode 100644 index 64926ff5..00000000 --- a/tests/data/meetings/update_room.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0NjA3ODM3fQ.fm7q551LKnZaUcvZ30AmU62jRnvL94Do2sJKU0mHUmE" - }, - "guest_url": { - "href": "https://meetings.vonage.com/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": false, - "is_chat_available": false, - "is_whiteboard_available": false, - "is_locale_switcher_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/update_room_type_error.json b/tests/data/meetings/update_room_type_error.json deleted file mode 100644 index d70fb112..00000000 --- a/tests/data/meetings/update_room_type_error.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "The room with id: b3142c46-d1c1-4405-baa6-85683827ed69 could not be updated because of its type: temporary", - "name": "BadRequestError", - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/update_theme_already_exists.json b/tests/data/meetings/update_theme_already_exists.json deleted file mode 100644 index f374bb11..00000000 --- a/tests/data/meetings/update_theme_already_exists.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "theme_name already exists in application", - "name": "ConflictError", - "status": 409 -} \ No newline at end of file diff --git a/tests/data/meetings/updated_theme.json b/tests/data/meetings/updated_theme.json deleted file mode 100644 index 514b7652..00000000 --- a/tests/data/meetings/updated_theme.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "theme_name": "updated_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#FF0000", - "short_company_url": "updated_company_url", - "brand_text": "My Updated Company Name", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null -} \ No newline at end of file diff --git a/tests/data/meetings/upload_to_aws_error.xml b/tests/data/meetings/upload_to_aws_error.xml deleted file mode 100644 index 467b8984..00000000 --- a/tests/data/meetings/upload_to_aws_error.xml +++ /dev/null @@ -1 +0,0 @@ -\nSignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.ASIA5NAYMMB6M7A2QEARb2f311449e26692a174ab2c7ca2afab24bd19c509cc611a4cef7cb2c5bb2ea9a5ZS7MSFN46X89NXAf+HV7uSpeawLv5lFvN+QiYP6swbiTMd/XaJeVGC+/pqKHlwlgKZ6vg+qBjV/ufb1e5WS/bxBM/Y= \ No newline at end of file diff --git a/tests/data/no_content.json b/tests/data/no_content.json deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/proactive_connect/create_list_400.json b/tests/data/proactive_connect/create_list_400.json deleted file mode 100644 index 307817dd..00000000 --- a/tests/data/proactive_connect/create_list_400.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "Request data did not validate", - "detail": "Bad Request", - "instance": "b6740287-41ad-41de-b950-f4e2d54cee86", - "errors": [ - "name must be longer than or equal to 1 and shorter than or equal to 255 characters", - "name must be a string" - ] -} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_basic.json b/tests/data/proactive_connect/create_list_basic.json deleted file mode 100644 index ea3e77c2..00000000 --- a/tests/data/proactive_connect/create_list_basic.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "6994fd17-7691-4463-be16-172ab1430d97", - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - }, - "name": "my_list", - "created_at": "2023-04-28T13:42:49.031Z", - "updated_at": "2023-04-28T13:42:49.031Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_manual.json b/tests/data/proactive_connect/create_list_manual.json deleted file mode 100644 index 1241af8e..00000000 --- a/tests/data/proactive_connect/create_list_manual.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - }, - "name": "my_list", - "description": "my description", - "tags": [ - "vip", - "sport" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "created_at": "2023-04-28T13:56:12.920Z", - "updated_at": "2023-04-28T13:56:12.920Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_salesforce.json b/tests/data/proactive_connect/create_list_salesforce.json deleted file mode 100644 index c7729f78..00000000 --- a/tests/data/proactive_connect/create_list_salesforce.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "salesforce", - "integration_id": "salesforce_credentials", - "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact" - }, - "id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "sync_status": { - "value": "configured", - "metadata_modified": true, - "data_modified": true, - "dirty": true - }, - "name": "my_salesforce_list", - "description": "my salesforce description", - "tags": [ - "vip", - "sport" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "created_at": "2023-04-28T14:16:49.375Z", - "updated_at": "2023-04-28T14:16:49.375Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/csv_to_upload.csv b/tests/data/proactive_connect/csv_to_upload.csv deleted file mode 100644 index 06cbdfbb..00000000 --- a/tests/data/proactive_connect/csv_to_upload.csv +++ /dev/null @@ -1,4 +0,0 @@ -user,phone -alice,1234 -bob,5678 -charlie,9012 diff --git a/tests/data/proactive_connect/fetch_list_400.json b/tests/data/proactive_connect/fetch_list_400.json deleted file mode 100644 index 7e68f7f6..00000000 --- a/tests/data/proactive_connect/fetch_list_400.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "Request data did not validate", - "detail": "Cannot Fetch a manual list", - "instance": "4c34affd-df25-4bdc-b7c0-30076d3df003" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/get_list.json b/tests/data/proactive_connect/get_list.json deleted file mode 100644 index 2920e6f9..00000000 --- a/tests/data/proactive_connect/get_list.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", - "created_at": "2023-04-28T13:56:12.920Z", - "updated_at": "2023-04-28T13:56:12.920Z", - "name": "my_list", - "description": "my description", - "tags": [ - "vip", - "sport" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/item.json b/tests/data/proactive_connect/item.json deleted file mode 100644 index 5c5ecf96..00000000 --- a/tests/data/proactive_connect/item.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "firstName": "John", - "lastName": "Doe", - "phone": "123456789101" - }, - "created_at": "2023-05-02T21:07:25.790Z", - "updated_at": "2023-05-02T21:07:25.790Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/item_400.json b/tests/data/proactive_connect/item_400.json deleted file mode 100644 index 778065b0..00000000 --- a/tests/data/proactive_connect/item_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "Request data did not validate", - "detail": "Bad Request", - "instance": "8e2dd3f1-1718-48fc-98de-53e1d289d0b4", - "errors": [ - "data must be an object" - ] -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_404.json b/tests/data/proactive_connect/list_404.json deleted file mode 100644 index 80ba7ad7..00000000 --- a/tests/data/proactive_connect/list_404.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "The requested resource does not exist", - "detail": "Not Found", - "instance": "3e661bd2-e429-4887-b0d4-8f37352ab1d3" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_all_items.json b/tests/data/proactive_connect/list_all_items.json deleted file mode 100644 index 28a6a795..00000000 --- a/tests/data/proactive_connect/list_all_items.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "total_items": 2, - "page": 1, - "page_size": 100, - "order": "asc", - "_embedded": { - "items": [ - { - "id": "04c7498c-bae9-40f9-bdcb-c4eabb0418fe", - "created_at": "2023-05-02T21:04:47.507Z", - "updated_at": "2023-05-02T21:04:47.507Z", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "test": 0, - "test2": 1 - } - }, - { - "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", - "created_at": "2023-05-02T21:07:25.790Z", - "updated_at": "2023-05-02T21:07:25.790Z", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "phone": "123456789101", - "lastName": "Doe", - "firstName": "John" - } - } - ] - }, - "total_pages": 1, - "_links": { - "first": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists/246d17c4-79e6-4a25-8b4e-b777a83f6c30/items?page_size=100&order=asc&page=1" - }, - "self": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists/246d17c4-79e6-4a25-8b4e-b777a83f6c30/items?page_size=100&order=asc&page=1" - } - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_events.json b/tests/data/proactive_connect/list_events.json deleted file mode 100644 index b00ef80c..00000000 --- a/tests/data/proactive_connect/list_events.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "total_items": 1, - "page": 1, - "page_size": 100, - "total_pages": 1, - "_links": { - "self": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - }, - "prev": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - }, - "next": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - }, - "first": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - } - }, - "_embedded": { - "events": [ - { - "occurred_at": "2022-08-07T13:18:21.970Z", - "type": "action-call-succeeded", - "id": "e8e1eb4d-61e0-4099-8fa7-c96f1c0764ba", - "job_id": "c68e871a-c239-474d-a905-7b95f4563b7e", - "src_ctx": "et-e4ab4b75-9e7c-4f26-9328-394a5b842648", - "action_id": "26c5bbe2-113e-4201-bd93-f69e0a03d17f", - "data": { - "url": "https://postman-echo.com/post", - "args": {}, - "data": { - "from": "" - }, - "form": {}, - "json": { - "from": "" - }, - "files": {}, - "headers": { - "host": "postman-echo.com", - "user-agent": "got (https://github.com/sindresorhus/got)", - "content-type": "application/json", - "content-length": "11", - "accept-encoding": "gzip, deflate, br", - "x-amzn-trace-id": "Root=1-62efbb9e-53636b7b794accb87a3d662f", - "x-forwarded-port": "443", - "x-nexmo-trace-id": "8a6fed94-7296-4a39-9c52-348f12b4d61a", - "x-forwarded-proto": "https" - } - }, - "run_id": "7d0d4e5f-6453-4c63-87cf-f95b04377324", - "recipient_id": "14806904549" - }, - { - "occurred_at": "2022-08-07T13:18:20.289Z", - "type": "recipient-response", - "id": "8c8e9894-81be-4f6e-88d4-046b6c70ff8c", - "job_id": "c68e871a-c239-474d-a905-7b95f4563b7e", - "src_ctx": "et-e4ab4b75-9e7c-4f26-9328-394a5b842648", - "data": { - "from": "441632960411", - "text": "hello there" - }, - "run_id": "7d0d4e5f-6453-4c63-87cf-f95b04377324", - "recipient_id": "441632960758" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_items.csv b/tests/data/proactive_connect/list_items.csv deleted file mode 100644 index 0c167367..00000000 --- a/tests/data/proactive_connect/list_items.csv +++ /dev/null @@ -1,4 +0,0 @@ -"favourite_number","least_favourite_number" -0,1 -1,0 -0,0 diff --git a/tests/data/proactive_connect/list_lists.json b/tests/data/proactive_connect/list_lists.json deleted file mode 100644 index c734e99e..00000000 --- a/tests/data/proactive_connect/list_lists.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "page": 1, - "page_size": 100, - "total_items": 2, - "total_pages": 1, - "_links": { - "self": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - }, - "prev": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - }, - "next": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - }, - "first": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - } - }, - "_embedded": { - "lists": [ - { - "name": "Recipients for demo", - "description": "List of recipients for demo", - "tags": [ - "vip" - ], - "attributes": [ - { - "name": "firstName" - }, - { - "name": "lastName", - "key": false - }, - { - "name": "number", - "alias": "Phone", - "key": true - } - ], - "datasource": { - "type": "manual" - }, - "items_count": 1000, - "sync_status": { - "value": "configured", - "dirty": false, - "data_modified": false, - "metadata_modified": false - }, - "id": "af8a84b6-c712-4252-ac8d-6e28ac9317ce", - "created_at": "2022-06-23T13:13:16.491Z", - "updated_at": "2022-06-23T13:13:16.491Z" - }, - { - "name": "Salesforce contacts", - "description": "Salesforce contacts for campaign", - "tags": [ - "salesforce" - ], - "attributes": [ - { - "name": "Id", - "key": false - }, - { - "name": "Phone", - "key": true - }, - { - "name": "Email", - "key": false - } - ], - "datasource": { - "type": "salesforce", - "integration_id": "salesforce", - "soql": "SELECT Id, LastName, FirstName, Phone, Email, OtherCountry FROM Contact" - } - } - ] - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/not_found.json b/tests/data/proactive_connect/not_found.json deleted file mode 100644 index 02f8ec01..00000000 --- a/tests/data/proactive_connect/not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "The requested resource does not exist", - "detail": "Not Found", - "instance": "04730b29-c292-4899-9419-f8cad88ec288" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_item.json b/tests/data/proactive_connect/update_item.json deleted file mode 100644 index 7166b458..00000000 --- a/tests/data/proactive_connect/update_item.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", - "created_at": "2023-05-02T21:07:25.790Z", - "updated_at": "2023-05-03T19:50:33.207Z", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "first_name": "John", - "last_name": "Doe", - "phone": "447007000000" - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_list.json b/tests/data/proactive_connect/update_list.json deleted file mode 100644 index 99df4c42..00000000 --- a/tests/data/proactive_connect/update_list.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", - "created_at": "2023-04-28T13:56:12.920Z", - "updated_at": "2023-04-28T21:39:17.825Z", - "name": "my_list", - "description": "my updated description", - "tags": [ - "vip", - "sport", - "football" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_list_salesforce.json b/tests/data/proactive_connect/update_list_salesforce.json deleted file mode 100644 index 7e9eef48..00000000 --- a/tests/data/proactive_connect/update_list_salesforce.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "created_at": "2023-04-28T14:16:49.375Z", - "updated_at": "2023-04-28T22:23:37.054Z", - "name": "my_list", - "description": "my updated description", - "tags": [ - "music" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "details": "failed to get secret: salesforce_credentials", - "dirty": false - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/upload_from_csv.json b/tests/data/proactive_connect/upload_from_csv.json deleted file mode 100644 index bbdfa8fc..00000000 --- a/tests/data/proactive_connect/upload_from_csv.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "inserted": 3 -} \ No newline at end of file diff --git a/tests/data/subaccounts/balance_transfer.json b/tests/data/subaccounts/balance_transfer.json deleted file mode 100644 index 70fdbd80..00000000 --- a/tests/data/subaccounts/balance_transfer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "masterAccountId": "1234asdf", - "_links": { - "self": { - "href": "/accounts/1234asdf/balance-transfers/83c4da50-9d42-434d-aaa9-76cf3109e9a5" - } - }, - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test balance transfer", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:00.000Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/credit_transfer.json b/tests/data/subaccounts/credit_transfer.json deleted file mode 100644 index 5687a271..00000000 --- a/tests/data/subaccounts/credit_transfer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "masterAccountId": "1234asdf", - "_links": { - "self": { - "href": "/accounts/1234asdf/credit-transfers/83c4da50-9d42-434d-aaa9-76cf3109e9a5" - } - }, - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test credit transfer", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:00.000Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/forbidden.json b/tests/data/subaccounts/forbidden.json deleted file mode 100644 index 39227a21..00000000 --- a/tests/data/subaccounts/forbidden.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#unprovisioned", - "title": "Authorisation error", - "detail": "Account 1234adsf is not provisioned to access Subaccount Provisioning API", - "instance": "158b8f199c45014ab7b08bfe9cc1c12c" -} \ No newline at end of file diff --git a/tests/data/subaccounts/insufficient_credit.json b/tests/data/subaccounts/insufficient_credit.json deleted file mode 100644 index dec73938..00000000 --- a/tests/data/subaccounts/insufficient_credit.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers", - "title": "Transfer amount is invalid", - "detail": "Insufficient Credit", - "instance": "70160200-6424-4fa3-a57d-21ed8be2c0b1" -} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_credentials.json b/tests/data/subaccounts/invalid_credentials.json deleted file mode 100644 index 79ff6d15..00000000 --- a/tests/data/subaccounts/invalid_credentials.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#unauthorized", - "title": "Invalid credentials supplied", - "detail": "You did not provide correct credentials", - "instance": "798b8f199c45014ab7b08bfe9cc1c12c" -} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_number_transfer.json b/tests/data/subaccounts/invalid_number_transfer.json deleted file mode 100644 index f5c29518..00000000 --- a/tests/data/subaccounts/invalid_number_transfer.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#invalid-number-transfer", - "title": "Invalid Number Transfer", - "detail": "Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode is not owned by from account", - "instance": "632768d8-84ea-47e0-91a4-7bda1409a89f" -} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_transfer.json b/tests/data/subaccounts/invalid_transfer.json deleted file mode 100644 index 9726e28e..00000000 --- a/tests/data/subaccounts/invalid_transfer.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers", - "title": "Invalid Transfer", - "detail": "Transfers are only allowed between a primary account and its subaccount", - "instance": "85a351ee-a180-4b17-a594-fe1df12616d0" -} \ No newline at end of file diff --git a/tests/data/subaccounts/list_balance_transfers.json b/tests/data/subaccounts/list_balance_transfers.json deleted file mode 100644 index 33cadaad..00000000 --- a/tests/data/subaccounts/list_balance_transfers.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/1234asdf/balance-transfers" - } - }, - "_embedded": { - "balance_transfers": [ - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test transfer", - "id": "7380eecb-b82c-46e8-9478-af6b5793af1b", - "created_at": "2023-06-12T17:31:48.000Z" - }, - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:01.000Z" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/subaccounts/list_credit_transfers.json b/tests/data/subaccounts/list_credit_transfers.json deleted file mode 100644 index 8169cfc1..00000000 --- a/tests/data/subaccounts/list_credit_transfers.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/1234asdf/credit-transfers" - } - }, - "_embedded": { - "credit_transfers": [ - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test credit transfer", - "id": "7380eecb-b82c-46e8-9478-af6b5793af1b", - "created_at": "2023-06-12T17:31:48.000Z" - }, - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:01.000Z" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/subaccounts/list_subaccounts.json b/tests/data/subaccounts/list_subaccounts.json deleted file mode 100644 index 97836dc7..00000000 --- a/tests/data/subaccounts/list_subaccounts.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/1234asdf/subaccounts" - } - }, - "total_balance": 9.9999, - "total_credit_limit": 0.0, - "_embedded": { - "primary_account": { - "api_key": "1234asdf", - "name": null, - "balance": 9.9999, - "credit_limit": 0.0, - "suspended": false, - "created_at": "2022-03-28T14:16:56.000Z" - }, - "subaccounts": [ - { - "api_key": "qwerasdf", - "primary_account_api_key": "1234asdf", - "use_primary_account_balance": true, - "name": "test_subaccount", - "balance": null, - "credit_limit": null, - "suspended": false, - "created_at": "2023-06-07T10:50:44.000Z" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/subaccounts/modified_subaccount.json b/tests/data/subaccounts/modified_subaccount.json deleted file mode 100644 index 1fcdac2f..00000000 --- a/tests/data/subaccounts/modified_subaccount.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "api_key": "asdfzxcv", - "primary_account_api_key": "1234asdf", - "use_primary_account_balance": false, - "name": "my modified subaccount", - "balance": 0, - "credit_limit": 0, - "suspended": true, - "created_at": "2023-06-09T14:42:55.000Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/must_be_number.json b/tests/data/subaccounts/must_be_number.json deleted file mode 100644 index ae356b4f..00000000 --- a/tests/data/subaccounts/must_be_number.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "b4fb726d-0a83-4f97-b4b8-30b64d1aeac7", - "invalid_parameters": [ - { - "reason": "Only positive values of data type JSON number are allowed", - "name": "amount" - } - ] -} \ No newline at end of file diff --git a/tests/data/subaccounts/not_found.json b/tests/data/subaccounts/not_found.json deleted file mode 100644 index 7eddfd67..00000000 --- a/tests/data/subaccounts/not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#invalid-api-key", - "title": "Invalid API Key", - "detail": "API key '1234asdf' does not exist, or you do not have access", - "instance": "158b8f199c45014ab7b08bfe9cc1c12c" -} \ No newline at end of file diff --git a/tests/data/subaccounts/number_not_found.json b/tests/data/subaccounts/number_not_found.json deleted file mode 100644 index 31d8c771..00000000 --- a/tests/data/subaccounts/number_not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer", - "title": "Invalid Number Transfer", - "detail": "Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found", - "instance": "37f2f76c-ade3-4f26-a448-a1adee0ff85e" -} \ No newline at end of file diff --git a/tests/data/subaccounts/same_from_and_to_accounts.json b/tests/data/subaccounts/same_from_and_to_accounts.json deleted file mode 100644 index 171a2c1c..00000000 --- a/tests/data/subaccounts/same_from_and_to_accounts.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "5499d21c-35e2-42c9-a50e-e96bdccdb34c", - "invalid_parameters": [ - { - "reason": "Invalid accounts. From and To accounts should be different", - "name": "from" - } - ] -} \ No newline at end of file diff --git a/tests/data/subaccounts/subaccount.json b/tests/data/subaccounts/subaccount.json deleted file mode 100644 index 2ead67ee..00000000 --- a/tests/data/subaccounts/subaccount.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "api_key": "asdfzxcv", - "secret": "Password123", - "primary_account_api_key": "1234asdf", - "use_primary_account_balance": true, - "name": "my subaccount", - "balance": null, - "credit_limit": null, - "suspended": false, - "created_at": "2023-06-09T02:23:21.327Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/transfer_number.json b/tests/data/subaccounts/transfer_number.json deleted file mode 100644 index de6c818f..00000000 --- a/tests/data/subaccounts/transfer_number.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "from": "1234asdf", - "to": "asdfzxcv", - "number": "12345678901", - "country": "US", - "masterAccountId": "1234asdf" -} \ No newline at end of file diff --git a/tests/data/subaccounts/transfer_validation_error.json b/tests/data/subaccounts/transfer_validation_error.json deleted file mode 100644 index 229a49df..00000000 --- a/tests/data/subaccounts/transfer_validation_error.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "c0b6fae7-7c83-4cdc-9c0b-00672e284da9", - "invalid_parameters": [ - { - "reason": "Malformed", - "name": "start_date" - } - ] -} \ No newline at end of file diff --git a/tests/data/subaccounts/validation_error.json b/tests/data/subaccounts/validation_error.json deleted file mode 100644 index a4b0aad9..00000000 --- a/tests/data/subaccounts/validation_error.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "fb97a734-7087-4b3a-8ec3-88d271e27fb2", - "invalid_parameters": [ - { - "reason": "Transitioning from 'use_primary_account_balance = false' to 'use_primary_account_balance = true' is not supported", - "name": "use_primary_account_balance" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/invalid_content_type.json b/tests/data/users/invalid_content_type.json deleted file mode 100644 index d818e1a2..00000000 --- a/tests/data/users/invalid_content_type.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Bad request.", - "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", - "code": "http:error:validation-fail", - "detail": "Invalid Content-Type.", - "instance": "9d0e245d-fac0-450e-811f-52343041df61", - "invalid_parameters": [ - { - "name": "content-type", - "reason": "content-type \"application/octet-stream\" is not supported. Supported versions are [application/json]" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/list_users_400.json b/tests/data/users/list_users_400.json deleted file mode 100644 index 10b68249..00000000 --- a/tests/data/users/list_users_400.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Bad request.", - "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", - "code": "http:error:validation-fail", - "detail": "Input validation failure.", - "instance": "04ee4d32-78c9-4acf-bdc1-b7d1fa860c92", - "invalid_parameters": [ - { - "name": "page_size", - "reason": "\"page_size\" must be a number" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/list_users_404.json b/tests/data/users/list_users_404.json deleted file mode 100644 index 7e985e23..00000000 --- a/tests/data/users/list_users_404.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Not found.", - "type": "https://developer.nexmo.com/api/conversation#user:error:not-found", - "code": "user:error:not-found", - "detail": "User does not exist, or you do not have access.", - "instance": "29c78817-eeb9-4de0-b2f9-a5ca816bc907" -} \ No newline at end of file diff --git a/tests/data/users/list_users_500.json b/tests/data/users/list_users_500.json deleted file mode 100644 index 25aa46c5..00000000 --- a/tests/data/users/list_users_500.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Internal Error.", - "type": "https://developer.nexmo.com/api/conversation#system:error:internal-error", - "code": "system:error:internal-error", - "detail": "Something went wrong.", - "instance": "00a5916655d650e920ccf0daf40ef4ee" -} \ No newline at end of file diff --git a/tests/data/users/list_users_basic.json b/tests/data/users/list_users_basic.json deleted file mode 100644 index ec7bf4ad..00000000 --- a/tests/data/users/list_users_basic.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "page_size": 10, - "_embedded": { - "users": [ - { - "id": "USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8", - "name": "NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8" - } - } - }, - { - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - } - }, - { - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "display_name": "My User Name", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - } - } - ] - }, - "_links": { - "first": { - "href": "https://api-us-3.vonage.com/v1/users?order=asc&page_size=10" - }, - "self": { - "href": "https://api-us-3.vonage.com/v1/users?order=asc&page_size=10&cursor=QAuYbTXFALruTxAIRAKiHvdCAqJQjTuYkDNhN9PYWcDajgUTgd9lQPo%3D" - } - } -} \ No newline at end of file diff --git a/tests/data/users/list_users_options.json b/tests/data/users/list_users_options.json deleted file mode 100644 index 3c2e74d8..00000000 --- a/tests/data/users/list_users_options.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "page_size": 2, - "_embedded": { - "users": [ - { - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "display_name": "My User Name", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - } - }, - { - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - } - } - ] - }, - "_links": { - "first": { - "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2" - }, - "self": { - "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2&cursor=Tw2iIH8ISR4SuJRJUrK9xC78rhfI10HHRKOZ20zBN9A8SDiczcOqBj8%3D" - }, - "next": { - "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2&cursor=FBWj1Oxid%2FVkxP6BT%2FCwMZZ2C0uOby0QXCrebkNoNo4A3PU%2FQTOOoD%2BWHib6ewsVLygsQBJy7di8HI9m30A3ujVuv1578w4Lqitgbv6CAnxdzPMeLCcAxNYWxl8%3D" - } - } -} \ No newline at end of file diff --git a/tests/data/users/rate_limit.json b/tests/data/users/rate_limit.json deleted file mode 100644 index 679dc079..00000000 --- a/tests/data/users/rate_limit.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Too Many Requests.", - "type": "https://developer.nexmo.com/api/conversation#http:error:too-many-request", - "code": "http:error:too-many-request", - "detail": "You have exceeded your request limit. You can try again shortly.", - "instance": "00a5916655d650e920ccf0daf40ef4ee" -} \ No newline at end of file diff --git a/tests/data/users/user_400.json b/tests/data/users/user_400.json deleted file mode 100644 index 6269d4f7..00000000 --- a/tests/data/users/user_400.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Bad request.", - "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", - "code": "http:error:validation-fail", - "detail": "Input validation failure.", - "instance": "00a5916655d650e920ccf0daf40ef4ee", - "invalid_parameters": [ - { - "name": "name", - "reason": "\"name\" must be a string" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/user_404.json b/tests/data/users/user_404.json deleted file mode 100644 index cde74ea9..00000000 --- a/tests/data/users/user_404.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Not found.", - "type": "https://developer.nexmo.com/api/conversation#user:error:not-found", - "code": "user:error:not-found", - "detail": "User does not exist, or you do not have access.", - "instance": "9b3b0ea8-987a-4117-b75a-8425e04910c4" -} \ No newline at end of file diff --git a/tests/data/users/user_basic.json b/tests/data/users/user_basic.json deleted file mode 100644 index 794c3102..00000000 --- a/tests/data/users/user_basic.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", - "properties": { - "custom_data": {} - }, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - }, - "channels": {} -} \ No newline at end of file diff --git a/tests/data/users/user_options.json b/tests/data/users/user_options.json deleted file mode 100644 index 1f1f0ce3..00000000 --- a/tests/data/users/user_options.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "image_url": "https://example.com/image.png", - "display_name": "My User Name", - "properties": { - "custom_data": { - "custom_key": "custom_value" - } - }, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - }, - "channels": { - "pstn": [ - { - "number": 123457 - } - ], - "sip": [ - { - "uri": "sip:4442138907@sip.example.com;transport=tls", - "username": "New SIP", - "password": "Password" - } - ], - "vbc": [ - { - "extension": "403" - } - ], - "websocket": [ - { - "uri": "wss://example.com/socket", - "content-type": "audio/l16;rate=16000", - "headers": { - "customer_id": "ABC123" - } - } - ], - "sms": [ - { - "number": "447700900000" - } - ], - "mms": [ - { - "number": "447700900000" - } - ], - "whatsapp": [ - { - "number": "447700900000" - } - ], - "viber": [ - { - "number": "447700900000" - } - ], - "messenger": [ - { - "id": "12345abcd" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/users/user_updated.json b/tests/data/users/user_updated.json deleted file mode 100644 index be4b884d..00000000 --- a/tests/data/users/user_updated.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "updated_name", - "properties": { - "custom_data": {} - }, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - }, - "channels": { - "whatsapp": [ - { - "number": "447700900000" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_network.json b/tests/data/verify/blocked_with_network.json deleted file mode 100644 index 06c71609..00000000 --- a/tests/data/verify/blocked_with_network.json +++ /dev/null @@ -1 +0,0 @@ -{"status":"7","error_text":"The number you are trying to verify is blacklisted for verification","network":"25503"} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_network_and_request_id.json b/tests/data/verify/blocked_with_network_and_request_id.json deleted file mode 100644 index 971f276b..00000000 --- a/tests/data/verify/blocked_with_network_and_request_id.json +++ /dev/null @@ -1 +0,0 @@ -{"request_id":"12345678","status":"7","error_text":"The number you are trying to verify is blacklisted for verification","network":"25503"} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_request_id.json b/tests/data/verify/blocked_with_request_id.json deleted file mode 100644 index 61c8446f..00000000 --- a/tests/data/verify/blocked_with_request_id.json +++ /dev/null @@ -1 +0,0 @@ -{"request_id":"12345678","status":"7","error_text":"The number you are trying to verify is blacklisted for verification"} \ No newline at end of file diff --git a/tests/data/verify2/already_verified.json b/tests/data/verify2/already_verified.json deleted file mode 100644 index c1a557ff..00000000 --- a/tests/data/verify2/already_verified.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#not-found", - "title": "Not Found", - "detail": "Request '5fcc26ef-1e54-48a6-83ab-c47546a19824' was not found or it has been verified already.", - "instance": "02cabfcc-2e09-4b5d-b098-1fa7ccef4607" -} \ No newline at end of file diff --git a/tests/data/verify2/check_code.json b/tests/data/verify2/check_code.json deleted file mode 100644 index 2016cb21..00000000 --- a/tests/data/verify2/check_code.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "request_id": "e043d872-459b-4750-a20c-d33f91d6959f", - "status": "completed" -} \ No newline at end of file diff --git a/tests/data/verify2/code_not_supported.json b/tests/data/verify2/code_not_supported.json deleted file mode 100644 index e690eb1e..00000000 --- a/tests/data/verify2/code_not_supported.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Conflict", - "detail": "The current Verify workflow step does not support a code.", - "instance": "690c48de-c5d1-49f2-8712-b3b0a840f911", - "type": "https://developer.nexmo.com/api-errors#conflict" -} \ No newline at end of file diff --git a/tests/data/verify2/create_request.json b/tests/data/verify2/create_request.json deleted file mode 100644 index 106dc1cc..00000000 --- a/tests/data/verify2/create_request.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "request_id": "c11236f4-00bf-4b89-84ba-88b25df97315" -} \ No newline at end of file diff --git a/tests/data/verify2/create_request_silent_auth.json b/tests/data/verify2/create_request_silent_auth.json deleted file mode 100644 index d313581a..00000000 --- a/tests/data/verify2/create_request_silent_auth.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "request_id": "b3a2f4bd-7bda-4e5e-978a-81514702d2ce", - "check_url": "https://api-eu-3.vonage.com/v2/verify/b3a2f4bd-7bda-4e5e-978a-81514702d2ce/silent-auth/redirect" -} \ No newline at end of file diff --git a/tests/data/verify2/error_conflict.json b/tests/data/verify2/error_conflict.json deleted file mode 100644 index 69eebdc7..00000000 --- a/tests/data/verify2/error_conflict.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Conflict", - "type": "https://www.developer.vonage.com/api-errors/verify#conflict", - "detail": "Concurrent verifications to the same number are not allowed.", - "instance": "738f9313-418a-4259-9b0d-6670f06fa82d", - "request_id": "575a2054-aaaf-4405-994e-290be7b9a91f" -} \ No newline at end of file diff --git a/tests/data/verify2/fraud_check_invalid_account.json b/tests/data/verify2/fraud_check_invalid_account.json deleted file mode 100644 index ee5d7053..00000000 --- a/tests/data/verify2/fraud_check_invalid_account.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#forbidden", - "title": "Forbidden", - "detail": "Your account does not have permission to perform this action.", - "instance": "1995bc0d-c850-4bf0-aa1e-6c40da43d3bf" -} \ No newline at end of file diff --git a/tests/data/verify2/invalid_email.json b/tests/data/verify2/invalid_email.json deleted file mode 100644 index 34cb0edc..00000000 --- a/tests/data/verify2/invalid_email.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "title": "Invalid params", - "detail": "The value of one or more parameters is invalid", - "instance": "e151c892-76b2-4486-8a37-b88faa70babd", - "type": "https://www.nexmo.com/messages/Errors#InvalidParams", - "invalid_parameters": [ - { - "name": "workflow[0]", - "reason": "`to` Email address is invalid" - } - ] -} \ No newline at end of file diff --git a/tests/data/verify2/invalid_sender.json b/tests/data/verify2/invalid_sender.json deleted file mode 100644 index dc3cda26..00000000 --- a/tests/data/verify2/invalid_sender.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Invalid sender", - "detail": "The `from` parameter is invalid.", - "instance": "1711258a-12e2-48ad-99a2-43fe3315409c", - "type": "https://developer.nexmo.com/api-errors#invalid-param" -} \ No newline at end of file diff --git a/tests/data/verify2/request_not_found.json b/tests/data/verify2/request_not_found.json deleted file mode 100644 index 45abf814..00000000 --- a/tests/data/verify2/request_not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#not-found", - "title": "Not Found", - "detail": "Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already.", - "instance": "a5f25ba1-c760-4966-81d4-6bdbb19f29d7" -} \ No newline at end of file diff --git a/tests/data/video/broadcast.json b/tests/data/video/broadcast.json deleted file mode 100644 index 25122cf5..00000000 --- a/tests/data/video/broadcast.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "1748b7070a81464c9759c46ad10d3734", - "sessionId": "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4", - "multiBroadcastTag": "broadcast_tag_provided", - "applicationId": "abc123", - "createdAt": 1437676551000, - "updatedAt": 1437676551000, - "maxDuration": 5400, - "maxBitrate": 2000000, - "broadcastUrls": { - "hls": "hlsurl", - "rtmp": [ - { - "id": "abc123", - "status": "abc123", - "serverUrl": "abc123", - "streamName": "abc123" - } - ] - }, - "settings": { - "hls": { - "lowLatency": false, - "dvr": false - } - }, - "resolution": "640x480", - "hasAudio": true, - "hasVideo": true, - "streamMode": "auto", - "status": "started", - "streams": [ - { - "streamId": "70a81464c9759c46ad10d3734", - "hasAudio": true, - "hasVideo": true - } - ] -} \ No newline at end of file diff --git a/tests/data/video/create_archive.json b/tests/data/video/create_archive.json deleted file mode 100644 index 725c8eb3..00000000 --- a/tests/data/video/create_archive.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "createdAt" : 1384221730555, - "duration" : 0, - "hasAudio" : true, - "hasVideo" : true, - "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name" : "my_new_archive", - "outputMode" : "composed", - "projectId" : 234567, - "reason" : "", - "resolution" : "640x480", - "sessionId" : "my_session_id", - "size" : 0, - "status" : "started", - "streamMode" : "auto", - "url" : null -} \ No newline at end of file diff --git a/tests/data/video/create_sip_call.json b/tests/data/video/create_sip_call.json deleted file mode 100644 index 29cbbab6..00000000 --- a/tests/data/video/create_sip_call.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "b0a5a8c7-dc38-459f-a48d-a7f2008da853", - "connectionId": "e9f8c166-6c67-440d-994a-04fb6dfed007", - "streamId": "482bce73-f882-40fd-8ca5-cb74ff416036" -} \ No newline at end of file diff --git a/tests/data/video/disable_mute_multiple_streams.json b/tests/data/video/disable_mute_multiple_streams.json deleted file mode 100644 index 878f6eaa..00000000 --- a/tests/data/video/disable_mute_multiple_streams.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "status": "ACTIVE", - "name": "Joe Montana", - "environment": "standard", - "createdAt": 1414642898000 -} \ No newline at end of file diff --git a/tests/data/video/get_archive.json b/tests/data/video/get_archive.json deleted file mode 100644 index be0ece31..00000000 --- a/tests/data/video/get_archive.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "createdAt" : 1384221730000, - "duration" : 5049, - "hasAudio" : true, - "hasVideo" : true, - "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name" : "Foo", - "outputMode" : "composed", - "projectId" : 123456, - "reason" : "", - "resolution" : "640x480", - "sessionId" : "2_MX40NzIwMzJ-flR1ZSBPY3QgMjkgMTI6MTM6MjMgUERUIDIwMTN-MC45NDQ2MzE2NH4", - "size" : 247748791, - "status" : "available", - "streamMode" : "auto", - "streams" : [], - "url" : "https://tokbox.com.archive2.s3.amazonaws.com/123456/09141e29-8770-439b-b180-337d7e637545/archive.mp4" -} \ No newline at end of file diff --git a/tests/data/video/get_stream.json b/tests/data/video/get_stream.json deleted file mode 100644 index 5e8fb98d..00000000 --- a/tests/data/video/get_stream.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "8b732909-0a06-46a2-8ea8-074e64d43422", - "videoType": "camera", - "name": "", - "layoutClassList": [ - "full" - ] -} \ No newline at end of file diff --git a/tests/data/video/list_archives.json b/tests/data/video/list_archives.json deleted file mode 100644 index 140d59c9..00000000 --- a/tests/data/video/list_archives.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "count": 1, - "items": [ - { - "createdAt": 1384221730000, - "duration": 5049, - "hasAudio": true, - "hasVideo": true, - "id": "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name": "Foo", - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "reason": "", - "resolution": "abc123", - "sessionId": "my_session_id", - "size": 247748791, - "status": "available", - "streamMode": "manual", - "streams": [ - { - "streamId": "abc123", - "hasAudio": true, - "hasVideo": true - } - ], - "url": "https://tokbox.com.archive2.s3.amazonaws.com/123456/09141e29-8770-439b-b180-337d7e637545/archive.mp4" - } - ] -} \ No newline at end of file diff --git a/tests/data/video/list_broadcasts.json b/tests/data/video/list_broadcasts.json deleted file mode 100644 index 2d82a315..00000000 --- a/tests/data/video/list_broadcasts.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "count": "1", - "items": [ - { - "id": "1748b7070a81464c9759c46ad10d3734", - "sessionId": "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4", - "multiBroadcastTag": "broadcast_tag_provided", - "applicationId": "abc123", - "createdAt": 1437676551000, - "updatedAt": 1437676551000, - "maxDuration": 5400, - "maxBitrate": 2000000, - "broadcastUrls": { - "hls": "hlsurl", - "rtmp": [ - { - "id": "abc123", - "status": "abc123", - "serverUrl": "abc123", - "streamName": "abc123" - } - ] - }, - "settings": { - "hls": { - "lowLatency": false, - "dvr": false - } - }, - "resolution": "abc123", - "hasAudio": false, - "hasVideo": false, - "streamMode": "manual", - "status": "abc123", - "streams": [ - { - "streamId": "abc123", - "hasAudio": "abc123", - "hasVideo": "abc123" - } - ] - } - ] -} \ No newline at end of file diff --git a/tests/data/video/list_streams.json b/tests/data/video/list_streams.json deleted file mode 100644 index fe50c8d0..00000000 --- a/tests/data/video/list_streams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "count": 1, - "items": [ - { - "id": "8b732909-0a06-46a2-8ea8-074e64d43422", - "videoType": "camera", - "name": "", - "layoutClassList": [ - "full" - ] - } - ] - } \ No newline at end of file diff --git a/tests/data/video/mute_multiple_streams.json b/tests/data/video/mute_multiple_streams.json deleted file mode 100644 index 878f6eaa..00000000 --- a/tests/data/video/mute_multiple_streams.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "status": "ACTIVE", - "name": "Joe Montana", - "environment": "standard", - "createdAt": 1414642898000 -} \ No newline at end of file diff --git a/tests/data/video/mute_specific_stream.json b/tests/data/video/mute_specific_stream.json deleted file mode 100644 index 878f6eaa..00000000 --- a/tests/data/video/mute_specific_stream.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "status": "ACTIVE", - "name": "Joe Montana", - "environment": "standard", - "createdAt": 1414642898000 -} \ No newline at end of file diff --git a/tests/data/video/null.json b/tests/data/video/null.json deleted file mode 100644 index ec747fa4..00000000 --- a/tests/data/video/null.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/tests/data/video/play_dtmf_invalid_error.json b/tests/data/video/play_dtmf_invalid_error.json deleted file mode 100644 index b783100c..00000000 --- a/tests/data/video/play_dtmf_invalid_error.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 400, - "message": "One of the properties digits or sessionId is invalid." -} \ No newline at end of file diff --git a/tests/data/video/stop_archive.json b/tests/data/video/stop_archive.json deleted file mode 100644 index 630b1d8b..00000000 --- a/tests/data/video/stop_archive.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "createdAt" : 1384221730555, - "duration" : 60, - "hasAudio" : true, - "hasVideo" : true, - "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name" : "my_new_archive", - "projectId" : 234567, - "reason" : "", - "resolution" : "640x480", - "sessionId" : "flR1ZSBPY3QgMjkgMTI6MTM6MjMgUERUIDIwMTN", - "size" : 0, - "status" : "stopped", - "url" : null -} \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py deleted file mode 100644 index 142bbbcf..00000000 --- a/tests/test_account.py +++ /dev/null @@ -1,222 +0,0 @@ -import platform - -from util import * - -import vonage -from vonage.errors import PricingTypeError - - -@responses.activate -def test_get_balance(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - assert isinstance(account.get_balance(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_application_info_options(dummy_data): - app_name, app_version = "ExampleApp", "X.Y.Z" - - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - app_name=app_name, - app_version=app_version, - ) - user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()} {app_name}/{app_version}" - - account = client.account - assert isinstance(account.get_balance(), dict) - assert request_user_agent() == user_agent - - -@responses.activate -def test_get_country_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound/sms") - - assert isinstance(account.get_country_pricing("GB"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=GB" in request_query() - - -@responses.activate -def test_get_all_countries_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-full-pricing/outbound/sms") - - assert isinstance(account.get_all_countries_pricing(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_get_prefix_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound/sms") - - assert isinstance(account.get_prefix_pricing(44), dict) - assert request_user_agent() == dummy_data.user_agent - assert "prefix=44" in request_query() - - -@responses.activate -def test_get_sms_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") - - assert isinstance(account.get_sms_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_get_voice_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice") - - assert isinstance(account.get_voice_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -def test_invalid_pricing_type_throws_error(account): - with pytest.raises(PricingTypeError): - account.get_country_pricing('GB', 'not_a_valid_pricing_type') - - -@responses.activate -def test_update_default_sms_webhook(account, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/settings") - - params = {"moCallBackUrl": "http://example.com/callback"} - - assert isinstance(account.update_default_sms_webhook(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() - - -@responses.activate -def test_topup(account, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/top-up") - - params = {"trx": "00X123456Y7890123Z"} - - assert isinstance(account.topup(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "trx=00X123456Y7890123Z" in request_body() - - -@responses.activate -def test_list_secrets(account): - stub( - responses.GET, - "https://api.nexmo.com/accounts/myaccountid/secrets", - fixture_path="account/secret_management/list.json", - ) - - secrets = account.list_secrets("myaccountid") - assert_basic_auth() - assert secrets["_embedded"]["secrets"][0]["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_list_secrets_missing(account): - stub( - responses.GET, - "https://api.nexmo.com/accounts/myaccountid/secrets", - status_code=404, - fixture_path="account/secret_management/missing.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - account.list_secrets("myaccountid") - assert_basic_auth() - assert ( - str(ce.value) - == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - ) - - -@responses.activate -def test_get_secret(account): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - fixture_path="account/secret_management/get.json", - ) - - secret = account.get_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret(account): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/create.json", - ) - - secret = account.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret_max_secrets(account): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=403, - fixture_path="account/secret_management/max-secrets.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - account.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) - == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - ) - - -@responses.activate -def test_create_secret_validation(account): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=400, - fixture_path="account/secret_management/create-validation.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - account.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) - == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - ) - - -@responses.activate -def test_delete_secret(account): - stub(responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret") - - account.revoke_secret("meaccountid", "mahsecret") - assert_basic_auth() - - -@responses.activate -def test_delete_secret_last_secret(account): - stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - status_code=403, - fixture_path="account/secret_management/last-secret.json", - ) - with pytest.raises(vonage.ClientError) as ce: - account.revoke_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) - == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - ) diff --git a/tests/test_application.py b/tests/test_application.py deleted file mode 100644 index 101f0513..00000000 --- a/tests/test_application.py +++ /dev/null @@ -1,276 +0,0 @@ -import json -from util import * - -import vonage - - -@responses.activate -def test_deprecated_list_applications(application_v2, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/list_applications.json", - ) - - apps = application_v2.list_applications() - assert_basic_auth() - assert isinstance(apps, dict) - assert apps["total_items"] == 30 - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_get_application(application_v2, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/get_application.json", - ) - - app = application_v2.get_application("xx-xx-xx-xx") - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_create_application(application_v2, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/create_application.json", - ) - - params = {"name": "Example App", "type": "voice"} - - app = application_v2.create_application(params) - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - body_data = json.loads(request_body().decode("utf-8")) - assert body_data["type"] == "voice" - - -@responses.activate -def test_deprecated_update_application(application_v2, dummy_data): - stub( - responses.PUT, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/update_application.json", - ) - - params = {"answer_url": "https://example.com/ncco"} - - app = application_v2.update_application("xx-xx-xx-xx", params) - assert_basic_auth() - assert isinstance(app, dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b'"answer_url": "https://example.com/ncco"' in request_body() - - assert app["name"] == "A Better Name" - - -@responses.activate -def test_deprecated_delete_application(application_v2, dummy_data): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=204, - ) - - assert application_v2.delete_application("xx-xx-xx-xx") is None - assert_basic_auth() - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_authentication_error(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=401, - ) - with pytest.raises(vonage.AuthenticationError): - application_v2.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_deprecated_client_error(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body=json.dumps( - { - "type": "nope_error", - "title": "Nope", - "detail": "You really shouldn't have done that", - } - ), - ) - with pytest.raises(vonage.ClientError) as exc_info: - application_v2.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - - -@responses.activate -def test_deprecated_client_error_no_decode(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body="{this: isnot_json", - ) - with pytest.raises(vonage.ClientError) as exc_info: - application_v2.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "430 response from api.nexmo.com" - - -@responses.activate -def test_deprecated_server_error(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(vonage.ServerError): - application_v2.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_list_applications(client, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/list_applications.json", - ) - - apps = client.application.list_applications() - assert_basic_auth() - assert isinstance(apps, dict) - assert apps["total_items"] == 30 - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_get_application(client, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/get_application.json", - ) - - app = client.application.get_application("xx-xx-xx-xx") - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_create_application(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/create_application.json", - ) - - params = {"name": "Example App", "type": "voice"} - - app = client.application.create_application(params) - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - body_data = json.loads(request_body().decode("utf-8")) - assert body_data["type"] == "voice" - - -@responses.activate -def test_update_application(client, dummy_data): - stub( - responses.PUT, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/update_application.json", - ) - - params = {"answer_url": "https://example.com/ncco"} - - app = client.application.update_application("xx-xx-xx-xx", params) - assert_basic_auth() - assert isinstance(app, dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b'"answer_url": "https://example.com/ncco"' in request_body() - - assert app["name"] == "A Better Name" - - -@responses.activate -def test_delete_application(client, dummy_data): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=204, - ) - - assert client.application.delete_application("xx-xx-xx-xx") is None - assert_basic_auth() - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_authentication_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=401, - ) - with pytest.raises(vonage.AuthenticationError): - client.application.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_client_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body=json.dumps( - { - "type": "nope_error", - "title": "Nope", - "detail": "You really shouldn't have done that", - } - ), - ) - with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - - -@responses.activate -def test_client_error_no_decode(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body="{this: isnot_json", - ) - with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "430 response from api.nexmo.com" - - -@responses.activate -def test_server_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(vonage.ServerError): - client.application.delete_application("xx-xx-xx-xx") diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index cb73ea44..00000000 --- a/tests/test_client.py +++ /dev/null @@ -1,36 +0,0 @@ -import vonage -from util import * -from vonage.errors import InvalidAuthenticationTypeError - - -def test_client_doesnt_require_api_key(dummy_data): - client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) - assert client is not None - assert client.api_key is None - assert client.api_secret is None - - -@responses.activate -def test_client_can_make_application_requests_without_api_key(dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) - voice = vonage.Voice(client) - voice.create_call("123455") - - -def test_invalid_auth_type_raises_error(client): - with pytest.raises(InvalidAuthenticationTypeError): - client.get(client.host(), 'my/request/uri', auth_type='magic') - - -@responses.activate -def test_timeout_is_set_on_client_calls(dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - client = vonage.Client(application_id="myid", private_key=dummy_data.private_key, timeout=1) - voice = vonage.Voice(client) - voice.create_call("123455") - - assert len(responses.calls) == 1 - assert responses.calls[0].request.req_kwargs["timeout"] == 1 diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py deleted file mode 100644 index d11caa3b..00000000 --- a/tests/test_getters_setters.py +++ /dev/null @@ -1,13 +0,0 @@ -def test_getters(client, dummy_data): - assert client.host() == dummy_data.host - assert client.api_host() == dummy_data.api_host - - -def test_setters(client, dummy_data): - try: - client.host('host.vonage.com') - client.api_host('host.vonage.com') - assert client.host() != dummy_data.host - assert client.api_host() != dummy_data.api_host - except: - assert False diff --git a/tests/test_jwt.py b/tests/test_jwt.py deleted file mode 100644 index fc51336d..00000000 --- a/tests/test_jwt.py +++ /dev/null @@ -1,54 +0,0 @@ -from time import time -from unittest.mock import patch -from pytest import raises - -from vonage import Client, ClientError - -now = int(time()) - - -def test_auth_sets_claims_from_kwargs(client): - client.auth(jti='asdfzxcv1234', nbf=now + 100, exp=now + 1000) - assert client._jwt_claims['jti'] == 'asdfzxcv1234' - assert client._jwt_claims['nbf'] == now + 100 - assert client._jwt_claims['exp'] == now + 1000 - - -def test_auth_sets_claims_from_dict(client): - custom_jwt_claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100, 'exp': now + 1000} - client.auth(custom_jwt_claims) - assert client._jwt_claims['jti'] == 'asdfzxcv1234' - assert client._jwt_claims['nbf'] == now + 100 - assert client._jwt_claims['exp'] == now + 1000 - - -test_jwt = b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBsaWNhdGlvbl9pZCI6ImFzZGYxMjM0IiwiaWF0IjoxNjg1NzMxMzkxLCJqdGkiOiIwYzE1MDJhZS05YmI5LTQ4YzQtYmQyZC0yOGFhNWUxYjZkMTkiLCJleHAiOjE2ODU3MzIyOTF9.mAkGeVgWOb7Mrzka7DSj32vSM8RaFpYse_2E7jCQ4DuH8i32wq9FxXGgfwdBQDHzgku3RYIjLM1xlVrGjNM3MsnZgR7ymQ6S4bdTTOmSK0dKbk91SrN7ZAC9k2a6JpCC2ZYgXpZ5BzpDTdy9BYu6msHKmkL79_aabFAhrH36Nk26pLvoI0-KiGImEex-aRR4iiaXhOebXBeqiQTRPKoKizREq4-8zBQv_j6yy4AiEYvBatQ8L_sjHsLj9jjITreX8WRvEW-G4TPpPLMaHACHTDMpJSOZAnegAkzTV2frVRmk6DyVXnemm4L0RQD1XZDaH7JPsKk24Hd2WZQyIgHOqQ' - - -def vonage_jwt_mock(self, claims): - return test_jwt - - -def test_generate_application_jwt(client): - with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): - jwt = client._generate_application_jwt() - assert jwt == test_jwt - - -def test_create_jwt_auth_string(client): - headers = client.headers - with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): - headers['Authorization'] = client._create_jwt_auth_string() - assert headers['Accept'] == 'application/json' - assert headers['Authorization'] == b'Bearer ' + test_jwt - - -def test_create_jwt_error_no_application_id_or_private_key(): - empty_client = Client() - - with raises(ClientError) as err: - empty_client._generate_application_jwt() - assert ( - str(err.value) - == 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' - ) diff --git a/tests/test_meetings.py b/tests/test_meetings.py deleted file mode 100644 index 74182836..00000000 --- a/tests/test_meetings.py +++ /dev/null @@ -1,783 +0,0 @@ -from util import * -from vonage.errors import MeetingsError, ClientError - -import responses -import json -from pytest import raises - - -@responses.activate -def test_create_instant_room(meetings, dummy_data): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/rooms", - fixture_path='meetings/meeting_room.json', - ) - - params = {'display_name': 'my_test_room'} - meeting = meetings.create_room(params) - - assert isinstance(meeting, dict) - assert request_user_agent() == dummy_data.user_agent - assert meeting['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' - assert meeting['display_name'] == 'my_test_room' - assert meeting['expires_at'] == '2023-01-24T03:30:38.629Z' - assert meeting['join_approval_level'] == 'none' - - -def test_create_instant_room_error_expiry(meetings, dummy_data): - params = {'display_name': 'my_test_room', 'expires_at': '2023-01-24T03:30:38.629Z'} - with raises(MeetingsError) as err: - meetings.create_room(params) - assert str(err.value) == 'Cannot set "expires_at" for an instant room.' - - -@responses.activate -def test_create_long_term_room(meetings, dummy_data): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/rooms", - fixture_path='meetings/long_term_room.json', - ) - - params = { - 'display_name': 'test_long_term_room', - 'type': 'long_term', - 'expires_at': '2023-01-30T00:47:04+0000', - } - meeting = meetings.create_room(params) - - assert isinstance(meeting, dict) - assert request_user_agent() == dummy_data.user_agent - assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert meeting['display_name'] == 'test_long_term_room' - assert meeting['expires_at'] == '2023-01-30T00:47:04.000Z' - - -def test_create_room_error(meetings): - with raises(MeetingsError) as err: - meetings.create_room() - assert ( - str(err.value) - == 'You must include a value for display_name as a field in the params dict when creating a meeting room.' - ) - - -def test_create_long_term_room_error(meetings): - params = { - 'display_name': 'test_long_term_room', - 'type': 'long_term', - } - with raises(MeetingsError) as err: - meetings.create_room(params) - assert str(err.value) == 'You must set a value for "expires_at" for a long-term room.' - - -@responses.activate -def test_get_room(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', - fixture_path='meetings/meeting_room.json', - ) - meeting = meetings.get_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69') - - assert isinstance(meeting, dict) - assert meeting['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' - assert meeting['display_name'] == 'my_test_room' - assert meeting['expires_at'] == '2023-01-24T03:30:38.629Z' - assert meeting['join_approval_level'] == 'none' - assert meeting['ui_settings']['language'] == 'default' - assert meeting['available_features']['is_locale_switcher_available'] == False - assert meeting['available_features']['is_captions_available'] == False - - -def test_get_room_error_no_room_specified(meetings): - with raises(TypeError): - meetings.get_room() - - -@responses.activate -def test_list_rooms(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms', - fixture_path='meetings/multiple_rooms.json', - ) - response = meetings.list_rooms() - - assert isinstance(response, dict) - assert response['_embedded'][0]['id'] == '4814804d-7c2d-4846-8c7d-4f6fae1f910a' - assert response['_embedded'][1]['id'] == 'de34416a-2a4c-4a59-a16a-8cd7d3121ea0' - assert response['_embedded'][2]['id'] == 'd44529db-d1fa-48d5-bba0-43034bf91ae4' - assert response['_embedded'][3]['id'] == '4f7dc750-6049-42ef-a25f-e7afa4953e32' - assert response['_embedded'][4]['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' - assert response['total_items'] == 5 - - -@responses.activate -def test_list_rooms_with_page_size(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms', - fixture_path='meetings/multiple_fewer_rooms.json', - ) - response = meetings.list_rooms(page_size=2) - - assert isinstance(response, dict) - assert response['_embedded'][0]['id'] == '4814804d-7c2d-4846-8c7d-4f6fae1f910a' - assert response['_embedded'][1]['id'] == 'de34416a-2a4c-4a59-a16a-8cd7d3121ea0' - assert response['page_size'] == 2 - assert response['total_items'] == 2 - - -@responses.activate -def test_error_unauthorized(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms', - fixture_path='meetings/unauthorized.json', - status_code=401, - ) - with raises(ClientError) as err: - meetings.list_rooms() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_update_room(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', - fixture_path='meetings/update_room.json', - ) - - params = { - 'update_details': { - "available_features": { - "is_recording_available": False, - "is_chat_available": False, - "is_whiteboard_available": False, - } - } - } - meeting = meetings.update_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69', params=params) - - assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert meeting['available_features']['is_recording_available'] == False - assert meeting['available_features']['is_chat_available'] == False - assert meeting['available_features']['is_whiteboard_available'] == False - - -@responses.activate -def test_add_theme_to_room(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', - fixture_path='meetings/long_term_room_with_theme.json', - ) - - meeting = meetings.add_theme_to_room( - room_id='33791484-231c-421b-8349-96e1a44e27d2', - theme_id='90a21428-b74a-4221-adc3-783935d654db', - ) - - assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert meeting['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - - -@responses.activate -def test_update_room_error_no_room_specified(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', - fixture_path='meetings/update_room_type_error.json', - status_code=400, - ) - with raises(ClientError) as err: - meetings.update_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69', params={}) - assert ( - str(err.value) - == 'Status Code 400: BadRequestError: The room with id: b3142c46-d1c1-4405-baa6-85683827ed69 could not be updated because of its type: temporary' - ) - - -@responses.activate -def test_update_room_error_no_params_specified(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', - fixture_path='meetings/update_room_type_error.json', - status_code=400, - ) - with raises(TypeError) as err: - meetings.update_room(room_id='33791484-231c-421b-8349-96e1a44e27d2') - assert "update_room() missing 1 required positional argument: 'params'" in str(err.value) - - -@responses.activate -def test_get_recording(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', - fixture_path='meetings/get_recording.json', - ) - - recording = meetings.get_recording(recording_id='e5b73c98-c087-4ee5-b61b-0ea08204fc65') - assert ( - recording['session_id'] - == '1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4' - ) - assert recording['started_at'] == '2023-01-25T02:38:31.000Z' - assert recording['status'] == 'uploaded' - - -@responses.activate -def test_get_recording_not_found(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/recordings/not-a-real-recording-id', - fixture_path='meetings/get_recording_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.get_recording(recording_id='not-a-real-recording-id') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: Recording not-a-real-recording-id was not found' - ) - - -@responses.activate -def test_delete_recording(meetings): - stub( - responses.DELETE, - 'https://api-eu.vonage.com/v1/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', - fixture_path='no_content.json', - ) - - assert meetings.delete_recording(recording_id='e5b73c98-c087-4ee5-b61b-0ea08204fc65') == None - - -@responses.activate -def test_delete_recording_not_uploaded(meetings, client): - stub( - responses.DELETE, - 'https://api-eu.vonage.com/v1/meetings/recordings/881f0dbe-3d91-4fd6-aeea-0eca4209b512', - fixture_path='meetings/delete_recording_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.delete_recording(recording_id='881f0dbe-3d91-4fd6-aeea-0eca4209b512') - assert str(err.value) == 'Status Code 404: NotFoundError: Could not find recording' - - -@responses.activate -def test_get_session_recordings(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/sessions/1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4/recordings', - fixture_path='meetings/get_session_recordings.json', - ) - - session = meetings.get_session_recordings( - session_id='1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4' - ) - assert session['_embedded']['recordings'][0]['id'] == 'e5b73c98-c087-4ee5-b61b-0ea08204fc65' - assert session['_embedded']['recordings'][0]['started_at'] == '2023-01-25T02:38:31.000Z' - assert session['_embedded']['recordings'][0]['status'] == 'uploaded' - - -@responses.activate -def test_get_session_recordings_not_found(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/sessions/not-a-real-session-id/recordings', - fixture_path='meetings/get_session_recordings_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.get_session_recordings(session_id='not-a-real-session-id') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: Failed to find session recordings by id: not-a-real-session-id' - ) - - -@responses.activate -def test_list_dial_in_numbers(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/dial-in-numbers', - fixture_path='meetings/list_dial_in_numbers.json', - ) - - numbers = meetings.list_dial_in_numbers() - assert numbers[0]['number'] == '541139862166' - assert numbers[0]['display_name'] == 'Argentina' - assert numbers[1]['number'] == '442381924626' - assert numbers[1]['locale'] == 'en-GB' - - -@responses.activate -def test_list_themes(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes', - fixture_path='meetings/list_themes.json', - ) - - themes = meetings.list_themes() - assert themes[0]['theme_id'] == '1fc39568-bc50-464f-82dc-01e13bed0908' - assert themes[0]['main_color'] == '#FF0000' - assert themes[0]['brand_text'] == 'My Other Company' - assert themes[1]['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert themes[1]['main_color'] == '#12f64e' - assert themes[1]['brand_text'] == 'My Company' - - -@responses.activate -def test_list_themes_no_themes(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes', - fixture_path='meetings/empty_themes.json', - ) - - assert meetings.list_themes() == {} - - -@responses.activate -def test_create_theme(meetings): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/themes", - fixture_path='meetings/theme.json', - ) - - params = { - 'theme_name': 'my_theme', - 'main_color': '#12f64e', - 'brand_text': 'My Company', - 'short_company_url': 'my-company', - } - - theme = meetings.create_theme(params) - assert theme['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert theme['main_color'] == '#12f64e' - assert theme['brand_text'] == 'My Company' - assert theme['domain'] == 'VCP' - - -def test_create_theme_missing_required_params(meetings): - with raises(MeetingsError) as err: - meetings.create_theme({}) - assert str(err.value) == 'Values for "main_color" and "brand_text" must be specified' - - -@responses.activate -def test_create_theme_name_already_in_use(meetings): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/themes", - fixture_path='meetings/theme_name_in_use.json', - status_code=409, - ) - - params = { - 'theme_name': 'my_theme', - 'main_color': '#12f64e', - 'brand_text': 'My Company', - } - - with raises(ClientError) as err: - meetings.create_theme(params) - assert ( - str(err.value) == 'Status Code 409: ConflictError: theme_name already exists in application' - ) - - -@responses.activate -def test_get_theme(meetings): - stub( - responses.GET, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/theme.json', - ) - - theme = meetings.get_theme('90a21428-b74a-4221-adc3-783935d654db') - assert theme['main_color'] == '#12f64e' - assert theme['brand_text'] == 'My Company' - - -@responses.activate -def test_get_theme_not_found(meetings): - stub( - responses.GET, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.get_theme('90a21428-b74a-4221-adc3-783935d654dc') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) - - -@responses.activate -def test_delete_theme(meetings): - stub( - responses.DELETE, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='no_content.json', - ) - - theme = meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') - assert theme == None - - -@responses.activate -def test_delete_theme_not_found(meetings): - stub( - responses.DELETE, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654dc') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) - - -@responses.activate -def test_delete_theme_in_use(meetings): - stub( - responses.DELETE, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/delete_theme_in_use.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') - assert ( - str(err.value) - == 'Status Code 400: BadRequestError: could not delete theme\nError: Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room' - ) - - -@responses.activate -def test_update_theme(meetings): - stub( - responses.PATCH, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/updated_theme.json', - ) - - params = { - 'update_details': { - 'theme_name': 'updated_theme', - 'main_color': '#FF0000', - 'brand_text': 'My Updated Company Name', - 'short_company_url': 'updated_company_url', - } - } - - theme = meetings.update_theme('90a21428-b74a-4221-adc3-783935d654db', params) - assert theme['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert theme['main_color'] == '#FF0000' - assert theme['brand_text'] == 'My Updated Company Name' - assert theme['short_company_url'] == 'updated_company_url' - - -@responses.activate -def test_update_theme_no_keys(meetings): - stub( - responses.PATCH, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/update_no_keys.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings.update_theme('90a21428-b74a-4221-adc3-783935d654db', {'update_details': {}}) - assert ( - str(err.value) - == 'Status Code 400: InputValidationError: "update_details" must have at least 1 key' - ) - - -@responses.activate -def test_update_theme_not_found(meetings): - stub( - responses.PATCH, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.update_theme( - '90a21428-b74a-4221-adc3-783935d654dc', - {'update_details': {'theme_name': 'my_new_name'}}, - ) - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) - - -@responses.activate -def test_update_theme_name_already_exists(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db', - fixture_path='meetings/update_theme_already_exists.json', - status_code=409, - ) - - with raises(ClientError) as err: - meetings.update_theme( - '90a21428-b74a-4221-adc3-783935d654db', - {'update_details': {'theme_name': 'my_other_theme'}}, - ) - assert ( - str(err.value) == 'Status Code 409: ConflictError: theme_name already exists in application' - ) - - -@responses.activate -def test_list_rooms_with_options(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/rooms', - fixture_path='meetings/list_rooms_with_theme_id.json', - ) - - rooms = meetings.list_rooms_with_theme_id( - '90a21428-b74a-4221-adc3-783935d654db', - page_size=5, - start_id=0, - end_id=99999999, - ) - assert rooms['_embedded'][0]['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert rooms['_embedded'][0]['display_name'] == 'test_long_term_room' - assert rooms['_embedded'][0]['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert rooms['page_size'] == 5 - assert ( - rooms['_links']['self']['href'] - == 'api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009870' - ) - - -@responses.activate -def test_list_rooms_with_theme_id_not_found(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/rooms', - fixture_path='meetings/list_rooms_theme_id_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.list_rooms_with_theme_id( - '90a21428-b74a-4221-adc3-783935d654dc', start_id=0, end_id=99999999 - ) - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: Failed to get rooms because theme id 90a21428-b74a-4221-adc3-783935d654dc not found' - ) - - -@responses.activate -def test_update_application_theme(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/applications', - fixture_path='meetings/update_application_theme.json', - ) - - response = meetings.update_application_theme(theme_id='90a21428-b74a-4221-adc3-783935d654db') - assert response['application_id'] == 'my-application-id' - assert response['account_id'] == 'my-account-id' - assert response['default_theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - - -@responses.activate -def test_update_application_theme_bad_request(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/applications', - fixture_path='meetings/update_application_theme_id_not_found.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings.update_application_theme(theme_id='not-a-real-theme-id') - assert ( - str(err.value) - == 'Status Code 400: BadRequestError: Failed to update application because theme id not-a-real-theme-id not found' - ) - - -@responses.activate -def test_upload_logo_to_theme(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/logos-upload-urls', - fixture_path='meetings/list_logo_upload_urls.json', - ) - stub( - responses.POST, - 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod', - fixture_path='no_content.json', - status_code=204, - ) - stub_bytes( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', - body=b'OK', - ) - - response = meetings.upload_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654db', - path_to_image='tests/data/meetings/transparent_logo.png', - logo_type='white', - ) - assert response == 'Logo upload to theme: 90a21428-b74a-4221-adc3-783935d654db was successful.' - - -@responses.activate -def test_get_logo_upload_url(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/logos-upload-urls', - fixture_path='meetings/list_logo_upload_urls.json', - ) - - url_w = meetings._get_logo_upload_url('white') - assert url_w['url'] == 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod' - assert url_w['fields']['X-Amz-Credential'] == 'some-credential' - assert ( - url_w['fields']['key'] - == 'auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25' - ) - assert url_w['fields']['logoType'] == 'white' - url_c = meetings._get_logo_upload_url('colored') - assert ( - url_c['fields']['key'] - == 'auto-expiring-temp/logos/colored/c4e00bac-781b-4bf0-bd5f-b9ff2cbc1b6c' - ) - assert url_c['fields']['logoType'] == 'colored' - url_f = meetings._get_logo_upload_url('favicon') - assert ( - url_f['fields']['key'] - == 'auto-expiring-temp/logos/favicon/d7a81477-38f7-460c-b51f-1462b8426df5' - ) - assert url_f['fields']['logoType'] == 'favicon' - - with raises(MeetingsError) as err: - meetings._get_logo_upload_url('not-a-valid-option') - assert str(err.value) == 'Cannot find the upload URL for the specified logo type.' - - -@responses.activate -def test_upload_to_aws(meetings): - stub( - responses.POST, - 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod', - fixture_path='no_content.json', - status_code=204, - ) - - with open('tests/data/meetings/list_logo_upload_urls.json') as file: - urls = json.load(file) - params = urls[0] - meetings._upload_to_aws(params, 'tests/data/meetings/transparent_logo.png') - - -@responses.activate -def test_upload_to_aws_error(meetings): - stub( - responses.POST, - 'https://s3.amazonaws.com/not-a-valid-url', - status_code=403, - fixture_path='meetings/upload_to_aws_error.xml', - ) - - with open('tests/data/meetings/list_logo_upload_urls.json') as file: - urls = json.load(file) - - params = urls[0] - params['url'] = 'https://s3.amazonaws.com/not-a-valid-url' - with raises(MeetingsError) as err: - meetings._upload_to_aws(params, 'tests/data/meetings/transparent_logo.png') - assert ( - str(err.value) - == 'Logo upload process failed. b\'\\\\nSignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.ASIA5NAYMMB6M7A2QEARb2f311449e26692a174ab2c7ca2afab24bd19c509cc611a4cef7cb2c5bb2ea9a5ZS7MSFN46X89NXAf+HV7uSpeawLv5lFvN+QiYP6swbiTMd/XaJeVGC+/pqKHlwlgKZ6vg+qBjV/ufb1e5WS/bxBM/Y=\'' - ) - - -@responses.activate -def test_add_logo_to_theme(meetings): - stub_bytes( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', - body=b'OK', - ) - - response = meetings._add_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654db', - key='auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25', - ) - assert response == b'OK' - - -@responses.activate -def test_add_logo_to_theme_key_error(meetings): - stub( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', - fixture_path='meetings/logo_key_error.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings._add_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654dc', - key='an-invalid-key', - ) - assert ( - str(err.value) - == "Status Code 400: BadRequestError: could not finalize logos\nError: {'logoKey': 'not-a-key', 'code': 'key_not_found'}" - ) - - -@responses.activate -def test_add_logo_to_theme_not_found_error(meetings): - stub( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings._add_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654dc', - key='auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25', - ) - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py deleted file mode 100644 index 242ccbc7..00000000 --- a/tests/test_messages_send_message.py +++ /dev/null @@ -1,42 +0,0 @@ -from util import * - - -@responses.activate -def test_send_sms_with_messages_api(messages, dummy_data): - stub(responses.POST, 'https://api.nexmo.com/v1/messages') - - params = { - 'channel': 'sms', - 'message_type': 'text', - 'to': '447123456789', - 'from': 'Vonage', - 'text': 'Hello from Vonage', - } - - assert isinstance(messages.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert b'"from": "Vonage"' in request_body() - assert b'"to": "447123456789"' in request_body() - assert b'"text": "Hello from Vonage"' in request_body() - - -@responses.activate -def test_send_whatsapp_image_with_messages_api(messages, dummy_data): - stub(responses.POST, 'https://api.nexmo.com/v1/messages') - - params = { - 'channel': 'whatsapp', - 'message_type': 'image', - 'to': '447123456789', - 'from': '440123456789', - 'image': {'url': 'https://example.com/image.jpg', 'caption': 'fake test image'}, - } - - assert isinstance(messages.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert b'"from": "440123456789"' in request_body() - assert b'"to": "447123456789"' in request_body() - assert ( - b'"image": {"url": "https://example.com/image.jpg", "caption": "fake test image"}' - in request_body() - ) diff --git a/tests/test_messages_validate_input.py b/tests/test_messages_validate_input.py deleted file mode 100644 index 6ee2810d..00000000 --- a/tests/test_messages_validate_input.py +++ /dev/null @@ -1,315 +0,0 @@ -from util import * -from vonage.errors import MessagesError - - -def test_invalid_send_message_params_object(messages): - with pytest.raises(MessagesError) as err: - messages.send_message('hi') - assert ( - str(err.value) == 'Parameters to the send_message method must be specified as a dictionary.' - ) - - -def test_invalid_message_channel(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'carrier_pigeon', - 'message_type': 'text', - 'to': '12345678', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert '"carrier_pigeon" is an invalid message channel.' in str(err.value) - - -def test_invalid_message_type(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'video', - 'to': '12345678', - 'from': 'vonage', - 'video': 'my_url.com', - } - ) - assert '"video" is not a valid message type for channel "sms".' in str(err.value) - - -def test_invalid_recipient_not_string(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': 12345678, - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert str(err.value) == 'Message recipient ("to=12345678") not in a valid format.' - - -def test_invalid_recipient_number(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '+441234567890', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert str(err.value) == 'Message recipient number ("to=+441234567890") not in a valid format.' - - -def test_invalid_messenger_recipient(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'messenger', - 'message_type': 'text', - 'to': '', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert str(err.value) == 'Message recipient ID ("to=") not in a valid format.' - - -def test_invalid_sender(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 1234, - 'text': 'my important message', - } - ) - assert ( - str(err.value) - == 'Message sender ("frm=1234") set incorrectly. Set a valid name or number for the sender.' - ) - - -def test_set_client_ref(messages): - messages._check_valid_client_ref( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 'vonage', - 'text': 'my important message', - 'client_ref': 'my client reference', - } - ) - assert messages._client_ref == 'my client reference' - - -def test_invalid_client_ref(messages): - with pytest.raises(MessagesError) as err: - messages._check_valid_client_ref( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 'vonage', - 'text': 'my important message', - 'client_ref': 'my client reference that is, in fact, a small, but significant amount longer than the 100 character limit imposed at this present juncture.', - } - ) - assert str(err.value) == 'client_ref can be a maximum of 100 characters.' - - -def test_whatsapp_template(messages): - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'template', - 'to': '4412345678912', - 'from': 'vonage', - 'template': {'name': 'namespace:mytemplate'}, - 'whatsapp': {'policy': 'deterministic', 'locale': 'en-GB'}, - } - ) - - -def test_set_messenger_optional_attribute(messages): - messages.validate_send_message_input( - { - 'channel': 'messenger', - 'message_type': 'text', - 'to': 'user_messenger_id', - 'from': 'vonage', - 'text': 'my important message', - 'messenger': {'category': 'response', 'tag': 'ACCOUNT_UPDATE'}, - } - ) - - -def test_set_viber_service_optional_attribute(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'text'}, - } - ) - - -def test_viber_service_video(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'video', - 'to': '44123456789', - 'from': 'vonage', - 'video': { - 'url': 'https://example.com/video.mp4', - 'caption': 'Look at this video', - 'thumb_url': 'https://example.com/thumbnail.jpg', - }, - 'viber_service': { - 'category': 'transaction', - 'duration': '120', - 'ttl': 30, - 'type': 'string', - }, - } - ) - - -def test_viber_service_file(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'file', - 'to': '44123456789', - 'from': 'vonage', - 'video': {'url': 'https://example.com/files', 'name': 'example.pdf'}, - 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'string'}, - } - ) - - -def test_viber_service_text_action_button(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - 'viber_service': { - 'category': 'transaction', - 'ttl': 30, - 'type': 'string', - 'action': {'url': 'https://example.com/page1.html', 'text': 'Find out more'}, - }, - } - ) - - -def test_viber_service_image_action_button(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'image', - 'to': '44123456789', - 'from': 'vonage', - 'image': { - 'url': 'https://example.com/image.jpg', - 'caption': 'Check out this new promotion', - }, - 'viber_service': { - 'category': 'transaction', - 'ttl': 30, - 'type': 'string', - 'action': {'url': 'https://example.com/page1.html', 'text': 'Find out more'}, - }, - } - ) - - -def test_incomplete_input(messages): - with pytest.raises(MessagesError) as err: - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert ( - str(err.value) - == 'You must specify all required properties for message channel "viber_service".' - ) - - -def test_whatsapp_sticker_id(messages): - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': {'id': '13aaecab-2485-4255-a0a7-97a2be6906b9'}, - 'to': '44123456789', - 'from': 'vonage', - } - ) - - -def test_whatsapp_sticker_url(messages): - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': {'url': 'https://example.com/sticker1.webp'}, - 'to': '44123456789', - 'from': 'vonage', - } - ) - - -def test_whatsapp_sticker_invalid_input_error(messages): - with pytest.raises(MessagesError) as err: - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': {'my_sticker'}, - 'to': '44123456789', - 'from': 'vonage', - } - ) - assert ( - str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' - ) - - -def test_whatsapp_sticker_exclusive_keys_error(messages): - with pytest.raises(MessagesError) as err: - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': { - 'id': '13aaecab-2485-4255-a0a7-97a2be6906b9', - 'url': 'https://example.com/sticker1.webp', - }, - 'to': '44123456789', - 'from': 'vonage', - } - ) - assert ( - str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' - ) diff --git a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py deleted file mode 100644 index 85a11568..00000000 --- a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py +++ /dev/null @@ -1,55 +0,0 @@ -record_full = '{"action": "record", "format": "wav", "split": "conversation", "channels": 4, "endOnSilence": 5, "endOnKey": "*", "timeOut": 100, "beepStart": true, "eventUrl": ["http://example.com"], "eventMethod": "PUT"}' - -record_url_as_str = '{"action": "record", "eventUrl": ["http://example.com/events"]}' - -record_add_split = '{"action": "record", "split": "conversation", "channels": 4}' - -conversation_basic = '{"action": "conversation", "name": "my_conversation"}' - -conversation_full = '{"action": "conversation", "name": "my_conversation", "musicOnHoldUrl": ["http://example.com/music.mp3"], "startOnEnter": true, "endOnExit": true, "record": true, "canSpeak": ["asdf", "qwer"], "canHear": ["asdf"]}' - -conversation_mute_option = '{"action": "conversation", "name": "my_conversation", "mute": true}' - -connect_phone = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000", "dtmfAnswer": "1p2p3p#**903#", "onAnswer": {"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}}]}' - -connect_app = '{"action": "connect", "endpoint": [{"type": "app", "user": "test_user"}]}' - -connect_websocket = '{"action": "connect", "endpoint": [{"type": "websocket", "uri": "ws://example.com/socket", "contentType": "audio/l16;rate=8000", "headers": {"language": "en-GB"}}]}' - -connect_sip = '{"action": "connect", "endpoint": [{"type": "sip", "uri": "sip:rebekka@sip.mcrussell.com", "headers": {"location": "New York City", "occupation": "developer"}}]}' - -connect_vbc = '{"action": "connect", "endpoint": [{"type": "vbc", "extension": "111"}]}' - -connect_full = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "randomFromNumber": false, "eventType": "synchronous", "timeout": 15, "limit": 1000, "machineDetection": "hangup", "eventUrl": ["http://example.com"], "eventMethod": "PUT", "ringbackTone": "http://example.com"}' - -connect_advancedMachineDetection = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "advancedMachineDetection": {"behavior": "continue", "mode": "detect"}, "eventUrl": ["http://example.com"]}' - -talk_basic = '{"action": "talk", "text": "hello"}' - -talk_full = '{"action": "talk", "text": "hello", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}' - -stream_basic = '{"action": "stream", "streamUrl": ["https://example.com/stream/music.mp3"]}' - -stream_full = '{"action": "stream", "streamUrl": ["https://example.com/stream/music.mp3"], "level": 0.1, "bargeIn": true, "loop": 10}' - -input_basic_dtmf = '{"action": "input", "type": ["dtmf"]}' - -input_basic_dtmf_speech = '{"action": "input", "type": ["dtmf", "speech"]}' - -input_dtmf_and_speech_full = '{"action": "input", "type": ["dtmf", "speech"], "dtmf": {"timeOut": 5, "maxDigits": 12, "submitOnHash": true}, "speech": {"uuid": "my-uuid", "endOnSilence": 2.5, "language": "en-GB", "context": ["sales", "billing"], "startTimeout": 20, "maxDuration": 30, "saveAudio": true}, "eventUrl": ["http://example.com/speech"], "eventMethod": "PUT"}' - -notify_basic = ( - '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}' -) - -notify_full = '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"], "eventMethod": "POST"}' - -pay_basic = '{"action": "pay", "amount": 10.0}' - -pay_voice_full = '{"action": "pay", "amount": 99.99, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "voice": {"language": "en-GB", "style": 1}}' - -pay_text = '{"action": "pay", "amount": 12.35, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "prompts": {"type": "CardNumber", "text": "Enter your card number.", "errors": {"InvalidCardType": {"text": "The card you are trying to use is not valid for this purchase."}}}}' - -pay_text_multiple_prompts = '{"action": "pay", "amount": 12.0, "prompts": [{"type": "CardNumber", "text": "Enter your card number.", "errors": {"InvalidCardType": {"text": "The card you are trying to use is not valid for this purchase."}}}, {"type": "ExpirationDate", "text": "Enter your card expiration date.", "errors": {"InvalidExpirationDate": {"text": "You have entered an invalid expiration date."}, "Timeout": {"text": "Please enter your card\'s expiration date."}}}, {"type": "SecurityCode", "text": "Enter your 3-digit security code.", "errors": {"InvalidSecurityCode": {"text": "You have entered an invalid security code."}, "Timeout": {"text": "Please enter your card\'s security code."}}}]}' - -two_notify_ncco = '[{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}, {"action": "notify", "payload": {"message": "world"}, "eventUrl": ["http://example.com"], "eventMethod": "PUT"}]' diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py deleted file mode 100644 index 7e3c012a..00000000 --- a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py +++ /dev/null @@ -1,183 +0,0 @@ -import pytest -from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts - -record = Ncco.Record(eventUrl='http://example.com/events') - -conversation = Ncco.Conversation(name='my_conversation') - -connect = Ncco.Connect( - endpoint=ConnectEndpoints.PhoneEndpoint(number='447000000000'), - from_='447400000000', - randomFromNumber=False, - eventType='synchronous', - timeout=15, - limit=1000, - machineDetection='hangup', - eventUrl='http://example.com', - eventMethod='PUT', - ringbackTone='http://example.com', -) - -connect_advancedMachineDetection = Ncco.Connect( - endpoint=ConnectEndpoints.PhoneEndpoint(number='447000000000'), - advancedMachineDetection={'behavior': 'continue', 'mode': 'detect'}, -) - - -talk_minimal = Ncco.Talk(text='hello') - -talk = Ncco.Talk( - text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True -) - -stream = Ncco.Stream( - streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 -) - -input = Ncco.Input( - type=['dtmf', 'speech'], - dtmf=InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True), - speech=InputTypes.Speech( - uuid='my-uuid', - endOnSilence=2.5, - language='en-GB', - context=['sales', 'billing'], - startTimeout=20, - maxDuration=30, - saveAudio=True, - ), - eventUrl='http://example.com/speech', - eventMethod='put', -) - -notify = Ncco.Notify( - payload={"message": "world"}, eventUrl=["http://example.com"], eventMethod='PUT' -) - - -def get_pay_voice_prompt(): - with pytest.deprecated_call(): - return Ncco.Pay( - amount=99.99, - currency='gbp', - eventUrl='https://example.com/payment', - voice=PayPrompts.VoicePrompt(language='en-GB', style=1), - ) - - -def get_pay_text_prompt(): - with pytest.deprecated_call(): - return Ncco.Pay( - amount=12.345, - currency='gbp', - eventUrl='https://example.com/payment', - prompts=PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ), - ) - - -basic_ncco = [{"action": "talk", "text": "hello"}] - -two_part_ncco = [ - { - 'action': 'record', - 'eventUrl': ['http://example.com/events'], - }, - {'action': 'talk', 'text': 'hello'}, -] - -three_part_advancedMachineDetection_ncco = [ - {'action': 'record', 'eventUrl': ['http://example.com/events']}, - { - 'action': 'connect', - 'endpoint': [{'type': 'phone', 'number': '447000000000'}], - 'advancedMachineDetection': {'behavior': 'continue', 'mode': 'detect'}, - }, - {'action': 'talk', 'text': 'hello'}, -] - -insane_ncco = [ - {'action': 'record', 'eventUrl': ['http://example.com/events']}, - {'action': 'conversation', 'name': 'my_conversation'}, - { - 'action': 'connect', - 'endpoint': [{'number': '447000000000', 'type': 'phone'}], - 'eventMethod': 'PUT', - 'eventType': 'synchronous', - 'eventUrl': ['http://example.com'], - 'from': '447400000000', - 'limit': 1000, - 'machineDetection': 'hangup', - 'randomFromNumber': False, - 'ringbackTone': 'http://example.com', - 'timeout': 15, - }, - { - 'action': 'talk', - 'bargeIn': True, - 'language': 'en-GB', - 'level': 0.5, - 'loop': 3, - 'premium': True, - 'style': 1, - 'text': 'hello', - }, - { - 'action': 'stream', - 'bargeIn': True, - 'level': 0.1, - 'loop': 10, - 'streamUrl': ['https://example.com/stream/music.mp3'], - }, - { - 'action': 'input', - 'dtmf': {'maxDigits': 12, 'submitOnHash': True, 'timeOut': 5}, - 'eventMethod': 'PUT', - 'eventUrl': ['http://example.com/speech'], - 'speech': { - 'context': ['sales', 'billing'], - 'endOnSilence': 2.5, - 'language': 'en-GB', - 'maxDuration': 30, - 'saveAudio': True, - 'startTimeout': 20, - 'uuid': 'my-uuid', - }, - 'type': ['dtmf', 'speech'], - }, - { - 'action': 'notify', - 'eventMethod': 'PUT', - 'eventUrl': ['http://example.com'], - 'payload': {'message': 'world'}, - }, - { - 'action': 'pay', - 'amount': 99.99, - 'currency': 'gbp', - 'eventUrl': ['https://example.com/payment'], - 'voice': {'language': 'en-GB', 'style': 1}, - }, - { - 'action': 'pay', - 'amount': 12.35, - 'currency': 'gbp', - 'eventUrl': ['https://example.com/payment'], - 'prompts': { - 'errors': { - 'InvalidCardType': { - 'text': 'The card you are trying ' 'to use is not valid for ' 'this purchase.' - } - }, - 'text': 'Enter your card number.', - 'type': 'CardNumber', - }, - }, -] diff --git a/tests/test_ncco_builder/test_connect_endpoints.py b/tests/test_ncco_builder/test_connect_endpoints.py deleted file mode 100644 index 6fa378e2..00000000 --- a/tests/test_ncco_builder/test_connect_endpoints.py +++ /dev/null @@ -1,64 +0,0 @@ -from vonage import ConnectEndpoints, Ncco -import ncco_samples.ncco_action_samples as nas - -import json -import pytest -from pydantic import ValidationError - - -def _action_as_dict(action: Ncco.Action): - return action.model_dump(exclude_none=True) - - -def test_connect_all_endpoints_from_model(): - phone = ConnectEndpoints.PhoneEndpoint( - number='447000000000', - dtmfAnswer='1p2p3p#**903#', - onAnswer={ - "url": "https://example.com/answer", - "ringbackTone": "http://example.com/ringbackTone.wav", - }, - ) - connect_phone = Ncco.Connect(endpoint=phone) - assert json.dumps(_action_as_dict(connect_phone)) == nas.connect_phone - - app = ConnectEndpoints.AppEndpoint(user='test_user') - connect_app = Ncco.Connect(endpoint=app) - assert json.dumps(_action_as_dict(connect_app)) == nas.connect_app - - websocket = ConnectEndpoints.WebsocketEndpoint( - uri='ws://example.com/socket', - contentType='audio/l16;rate=8000', - headers={"language": "en-GB"}, - ) - connect_websocket = Ncco.Connect(endpoint=websocket) - assert json.dumps(_action_as_dict(connect_websocket)) == nas.connect_websocket - - sip = ConnectEndpoints.SipEndpoint( - uri='sip:rebekka@sip.mcrussell.com', - headers={"location": "New York City", "occupation": "developer"}, - ) - connect_sip = Ncco.Connect(endpoint=sip) - assert json.dumps(_action_as_dict(connect_sip)) == nas.connect_sip - - vbc = ConnectEndpoints.VbcEndpoint(extension='111') - connect_vbc = Ncco.Connect(endpoint=vbc) - assert json.dumps(_action_as_dict(connect_vbc)) == nas.connect_vbc - - -def test_connect_endpoints_errors(): - with pytest.raises(ValidationError) as err: - ConnectEndpoints.PhoneEndpoint(number='447000000000', onAnswer={'url': 'not-a-valid-url'}) - - with pytest.raises(ValidationError) as err: - ConnectEndpoints.PhoneEndpoint( - number='447000000000', - onAnswer={'url': 'http://example.com/answer', 'ringbackTone': 'not-a-valid-url'}, - ) - - with pytest.raises(ValueError) as err: - ConnectEndpoints.create_endpoint_model_from_dict({'type': 'carrier_pigeon'}) - assert ( - str(err.value) - == 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' - ) diff --git a/tests/test_ncco_builder/test_input_types.py b/tests/test_ncco_builder/test_input_types.py deleted file mode 100644 index 592e0518..00000000 --- a/tests/test_ncco_builder/test_input_types.py +++ /dev/null @@ -1,47 +0,0 @@ -from vonage import InputTypes - - -def test_create_dtmf_model(): - dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=2, submitOnHash=True) - assert type(dtmf) == InputTypes.Dtmf - assert dtmf.model_dump() == {'maxDigits': 2, 'submitOnHash': True, 'timeOut': 5} - - -def test_create_dtmf_model_from_dict(): - dtmf_dict = {'timeOut': 3, 'maxDigits': 4, 'submitOnHash': True} - dtmf_model = InputTypes.create_dtmf_model(dtmf_dict) - assert type(dtmf_model) == InputTypes.Dtmf - assert dtmf_model.model_dump() == {'maxDigits': 4, 'submitOnHash': True, 'timeOut': 3} - - -def test_create_speech_model(): - speech = InputTypes.Speech( - uuid='my-uuid', - endOnSilence=2.5, - language='en-GB', - context=['sales', 'billing'], - startTimeout=20, - maxDuration=30, - saveAudio=True, - ) - assert type(speech) == InputTypes.Speech - assert speech.model_dump() == { - 'uuid': 'my-uuid', - 'endOnSilence': 2.5, - 'language': 'en-GB', - 'context': ['sales', 'billing'], - 'startTimeout': 20, - 'maxDuration': 30, - 'saveAudio': True, - } - - -def test_create_speech_model_from_dict(): - speech_dict = {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} - speech_model = InputTypes.create_speech_model(speech_dict) - assert type(speech_model) == InputTypes.Speech - assert speech_model.model_dump(exclude_none=True) == { - 'uuid': 'my-uuid', - 'endOnSilence': 2.5, - 'maxDuration': 30, - } diff --git a/tests/test_ncco_builder/test_ncco_actions.py b/tests/test_ncco_builder/test_ncco_actions.py deleted file mode 100644 index 361e8bbd..00000000 --- a/tests/test_ncco_builder/test_ncco_actions.py +++ /dev/null @@ -1,332 +0,0 @@ -from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts -import ncco_samples.ncco_action_samples as nas - -import json -import pytest -from pydantic import ValidationError - - -def _action_as_dict(action: Ncco.Action): - return action.model_dump(exclude_none=True, by_alias=True) - - -def test_record_full(): - record = Ncco.Record( - format='wav', - split='conversation', - channels=4, - endOnSilence=5, - endOnKey='*', - timeOut=100, - beepStart=True, - eventUrl=['http://example.com'], - eventMethod='PUT', - ) - assert type(record) == Ncco.Record - assert json.dumps(_action_as_dict(record)) == nas.record_full - - -def test_record_url_passed_as_str(): - record = Ncco.Record(eventUrl='http://example.com/events') - assert json.dumps(_action_as_dict(record)) == nas.record_url_as_str - - -def test_record_channels_adds_split_parameter(): - record = Ncco.Record(channels=4) - assert json.dumps(_action_as_dict(record)) == nas.record_add_split - - -def test_record_model_errors(): - with pytest.raises(ValidationError): - Ncco.Record(format='mp4') - with pytest.raises(ValidationError): - Ncco.Record(endOnKey='asdf') - - -def test_conversation_basic(): - conversation = Ncco.Conversation(name='my_conversation') - assert type(conversation) == Ncco.Conversation - assert json.dumps(_action_as_dict(conversation)) == nas.conversation_basic - - -def test_conversation_full(): - conversation = Ncco.Conversation( - name='my_conversation', - musicOnHoldUrl='http://example.com/music.mp3', - startOnEnter=True, - endOnExit=True, - record=True, - canSpeak=['asdf', 'qwer'], - canHear=['asdf'], - ) - assert json.dumps(_action_as_dict(conversation)) == nas.conversation_full - - -def test_conversation_field_type_error(): - with pytest.raises(ValidationError): - Ncco.Conversation(name='my_conversation', startOnEnter='asdf') - - -def test_conversation_mute(): - conversation = Ncco.Conversation(name='my_conversation', mute=True) - assert json.dumps(_action_as_dict(conversation)) == nas.conversation_mute_option - - -def test_conversation_incompatible_options_error(): - with pytest.raises(ValidationError) as err: - Ncco.Conversation(name='my_conversation', canSpeak=['asdf', 'qwer'], mute=True) - str(err.value) == 'Cannot use mute option if canSpeak option is specified.+' - - -def test_connect_phone_endpoint_from_dict(): - connect = Ncco.Connect( - endpoint={ - "type": "phone", - "number": "447000000000", - "dtmfAnswer": "1p2p3p#**903#", - "onAnswer": { - "url": "https://example.com/answer", - "ringbackTone": "http://example.com/ringbackTone.wav", - }, - } - ) - assert type(connect) is Ncco.Connect - assert json.dumps(_action_as_dict(connect)) == nas.connect_phone - - -def test_connect_phone_endpoint_from_list(): - connect = Ncco.Connect( - endpoint=[ - { - "type": "phone", - "number": "447000000000", - "dtmfAnswer": "1p2p3p#**903#", - "onAnswer": { - "url": "https://example.com/answer", - "ringbackTone": "http://example.com/ringbackTone.wav", - }, - } - ] - ) - assert json.dumps(_action_as_dict(connect)) == nas.connect_phone - - -def test_connect_options(): - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - connect = Ncco.Connect( - endpoint=endpoint, - from_='447400000000', - randomFromNumber=False, - eventType='synchronous', - timeout=15, - limit=1000, - machineDetection='hangup', - eventUrl='http://example.com', - eventMethod='PUT', - ringbackTone='http://example.com', - ) - assert json.dumps(_action_as_dict(connect)) == nas.connect_full - - -def test_connect_advanced_machine_detection(): - advancedMachineDetectionParams = {'behavior': 'continue', 'mode': 'detect'} - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - connect = Ncco.Connect( - endpoint=endpoint, - from_='447400000000', - advancedMachineDetection=advancedMachineDetectionParams, - eventUrl='http://example.com', - ) - assert json.dumps(_action_as_dict(connect)) == nas.connect_advancedMachineDetection - - -def test_connect_random_from_number_error(): - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - with pytest.raises(ValueError) as err: - Ncco.Connect(endpoint=endpoint, from_='447400000000', randomFromNumber=True) - - assert ( - 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' - in str(err.value) - ) - - -def test_connect_validation_errors(): - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, from_=1234) - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, eventType='asynchronous') - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, limit=7201) - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, machineDetection='do_nothing') - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, advancedMachineDetection={'behavior': 'do_nothing'}) - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, advancedMachineDetection={'mode': 'detect_nothing'}) - - -def test_talk_basic(): - talk = Ncco.Talk(text='hello') - assert type(talk) == Ncco.Talk - assert json.dumps(_action_as_dict(talk)) == nas.talk_basic - - -def test_talk_optional_params(): - talk = Ncco.Talk( - text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True - ) - assert json.dumps(_action_as_dict(talk)) == nas.talk_full - - -def test_talk_validation_error(): - with pytest.raises(ValidationError): - Ncco.Talk(text='hello', bargeIn='go ahead') - - -def test_stream_basic(): - stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3') - assert type(stream) == Ncco.Stream - assert json.dumps(_action_as_dict(stream)) == nas.stream_basic - - -def test_stream_full(): - stream = Ncco.Stream( - streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 - ) - assert json.dumps(_action_as_dict(stream)) == nas.stream_full - - -def test_input_basic(): - input = Ncco.Input(type='dtmf') - assert type(input) == Ncco.Input - assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf - - -def test_input_basic_list(): - input = Ncco.Input(type=['dtmf', 'speech']) - assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf_speech - - -def test_input_dtmf_and_speech_options(): - dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True) - speech = InputTypes.Speech( - uuid='my-uuid', - endOnSilence=2.5, - language='en-GB', - context=['sales', 'billing'], - startTimeout=20, - maxDuration=30, - saveAudio=True, - ) - input = Ncco.Input( - type=['dtmf', 'speech'], - dtmf=dtmf, - speech=speech, - eventUrl='http://example.com/speech', - eventMethod='put', - ) - assert json.dumps(_action_as_dict(input)) == nas.input_dtmf_and_speech_full - - -def test_input_validation_error(): - with pytest.raises(ValidationError): - Ncco.Input(type='invalid_type') - - -def test_notify_basic(): - notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com']) - assert type(notify) == Ncco.Notify - assert json.dumps(_action_as_dict(notify)) == nas.notify_basic - - -def test_notify_basic_str_in_event_url(): - notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl='http://example.com') - assert type(notify) == Ncco.Notify - assert json.dumps(_action_as_dict(notify)) == nas.notify_basic - - -def test_notify_full(): - notify = Ncco.Notify( - payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST' - ) - assert type(notify) == Ncco.Notify - assert json.dumps(_action_as_dict(notify)) == nas.notify_full - - -def test_notify_validation_error(): - with pytest.raises(ValidationError): - Ncco.Notify(payload={'message', 'hello'}, eventUrl=['http://example.com']) - - -def test_pay_voice_basic(): - with pytest.deprecated_call(): - pay = Ncco.Pay(amount='10.00') - assert type(pay) == Ncco.Pay - assert json.dumps(_action_as_dict(pay)) == nas.pay_basic - - -def test_pay_voice_full(): - voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) - with pytest.deprecated_call(): - pay = Ncco.Pay( - amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings - ) - assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full - - -def test_pay_text(): - text_prompts = PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - with pytest.deprecated_call(): - pay = Ncco.Pay( - amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts - ) - assert json.dumps(_action_as_dict(pay)) == nas.pay_text - - -def test_pay_text_multiple_prompts(): - card_prompt = PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - expiration_date_prompt = PayPrompts.TextPrompt( - type='ExpirationDate', - text='Enter your card expiration date.', - errors={ - 'InvalidExpirationDate': {'text': 'You have entered an invalid expiration date.'}, - 'Timeout': {'text': 'Please enter your card\'s expiration date.'}, - }, - ) - security_code_prompt = PayPrompts.TextPrompt( - type='SecurityCode', - text='Enter your 3-digit security code.', - errors={ - 'InvalidSecurityCode': {'text': 'You have entered an invalid security code.'}, - 'Timeout': {'text': 'Please enter your card\'s security code.'}, - }, - ) - - text_prompts = [card_prompt, expiration_date_prompt, security_code_prompt] - with pytest.deprecated_call(): - pay = Ncco.Pay(amount=12, prompts=text_prompts) - assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts - - -def test_pay_validation_error(): - with pytest.raises(ValidationError): - with pytest.deprecated_call(): - Ncco.Pay(amount='not-valid') diff --git a/tests/test_ncco_builder/test_ncco_builder.py b/tests/test_ncco_builder/test_ncco_builder.py deleted file mode 100644 index f3850b5d..00000000 --- a/tests/test_ncco_builder/test_ncco_builder.py +++ /dev/null @@ -1,40 +0,0 @@ -import json - -from vonage import Ncco -import ncco_samples.ncco_builder_samples as nbs - - -def test_build_basic_ncco(): - ncco = Ncco.build_ncco(nbs.talk_minimal) - assert ncco == nbs.basic_ncco - - -def test_build_ncco_from_args(): - ncco = Ncco.build_ncco(nbs.record, nbs.talk_minimal) - assert ncco == nbs.two_part_ncco - assert ( - json.dumps(ncco) - == '[{"action": "record", "eventUrl": ["http://example.com/events"]}, {"action": "talk", "text": "hello"}]' - ) - - -def test_build_ncco_from_list(): - action_list = [nbs.record, nbs.connect_advancedMachineDetection, nbs.talk_minimal] - ncco = Ncco.build_ncco(actions=action_list) - assert ncco == nbs.three_part_advancedMachineDetection_ncco - - -def test_build_insane_ncco(): - action_list = [ - nbs.record, - nbs.conversation, - nbs.connect, - nbs.talk, - nbs.stream, - nbs.input, - nbs.notify, - nbs.get_pay_voice_prompt(), - nbs.get_pay_text_prompt(), - ] - ncco = Ncco.build_ncco(actions=action_list) - assert ncco == nbs.insane_ncco diff --git a/tests/test_ncco_builder/test_pay_prompts.py b/tests/test_ncco_builder/test_pay_prompts.py deleted file mode 100644 index abb949ce..00000000 --- a/tests/test_ncco_builder/test_pay_prompts.py +++ /dev/null @@ -1,71 +0,0 @@ -from vonage import PayPrompts - -import pytest -from pydantic import ValidationError - - -def test_create_voice_model(): - voice_prompt = PayPrompts.VoicePrompt(language='en-GB', style=1) - assert (type(voice_prompt)) == PayPrompts.VoicePrompt - - -def test_create_voice_model_from_dict(): - voice_dict = {'language': 'en-GB', 'style': 1} - voice_prompt = PayPrompts.create_voice_model(voice_dict) - assert (type(voice_prompt)) == PayPrompts.VoicePrompt - - -def test_create_text_model(): - text_prompt = PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - assert type(text_prompt) == PayPrompts.TextPrompt - - -def test_create_text_model_from_dict(): - text_dict = { - 'type': 'CardNumber', - 'text': 'Enter your card number.', - 'errors': { - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - } - text_prompt = PayPrompts.create_text_model(text_dict) - assert type(text_prompt) == PayPrompts.TextPrompt - - -def test_error_message_not_in_subdictionary(): - with pytest.raises(ValidationError): - PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': 'The card you are trying to use is not valid for this purchase.' - }, - ) - - -def test_invalid_error_type_for_prompt(): - with pytest.raises(ValueError) as err: - PayPrompts.TextPrompt( - type='SecurityCode', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - - assert ( - 'Value "InvalidCardType" is not a valid error for the "SecurityCode" prompt type.' - in str(err.value) - ) diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py deleted file mode 100644 index 40ab9d19..00000000 --- a/tests/test_number_insight.py +++ /dev/null @@ -1,50 +0,0 @@ -from util import * -from vonage.errors import CallbackRequiredError - - -@responses.activate -def test_get_basic_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/basic/json") - - assert isinstance(number_insight.get_basic_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_standard_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/standard/json") - - assert isinstance(number_insight.get_standard_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_advanced_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") - - assert isinstance(number_insight.get_advanced_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_async_advanced_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") - - params = {"number": "447525856424", "callback": "https://example.com"} - - assert isinstance(number_insight.get_async_advanced_number_insight(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - assert "callback=https%3A%2F%2Fexample.com" in request_query() - - -def test_callback_required_error_async_advanced_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") - - params = {"number": "447525856424", "callback": ""} - - with pytest.raises(CallbackRequiredError): - number_insight.get_async_advanced_number_insight(params) diff --git a/tests/test_number_management.py b/tests/test_number_management.py deleted file mode 100644 index f774be3c..00000000 --- a/tests/test_number_management.py +++ /dev/null @@ -1,57 +0,0 @@ -from util import * - - -@responses.activate -def test_get_account_numbers(numbers, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") - - assert isinstance(numbers.get_account_numbers(size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] - - -@responses.activate -def test_get_available_numbers(numbers, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/number/search") - - assert isinstance(numbers.get_available_numbers("CA", size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=CA" in request_query() - assert "size=25" in request_query() - - -@responses.activate -def test_buy_number(numbers, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/buy") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(numbers.buy_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - - -@responses.activate -def test_cancel_number(numbers, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/cancel") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(numbers.cancel_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - - -@responses.activate -def test_update_number(numbers, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/update") - - params = {"country": "US", "msisdn": "number", "moHttpUrl": "callback"} - - assert isinstance(numbers.update_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - assert "moHttpUrl=callback" in request_body() diff --git a/tests/test_packages.py b/tests/test_packages.py deleted file mode 100644 index 49d5dbcf..00000000 --- a/tests/test_packages.py +++ /dev/null @@ -1,14 +0,0 @@ -import os - - -def test_subdirectories_are_python_packages(): - subdirs = [ - os.path.join('src/vonage', o) - for o in os.listdir('src/vonage') - if os.path.isdir(os.path.join('src/vonage', o)) - ] - for subdir in subdirs: - if '__pycache__' in subdir or os.path.isfile(f'{subdir}/__init__.py'): - continue - else: - raise Exception(f'Subfolder {subdir} doesn\'t have an __init__.py file') diff --git a/tests/test_proactive_connect.py b/tests/test_proactive_connect.py deleted file mode 100644 index 9105d31c..00000000 --- a/tests/test_proactive_connect.py +++ /dev/null @@ -1,649 +0,0 @@ -from vonage.errors import ProactiveConnectError, ClientError -from util import * - -import responses -from pytest import raises -import csv - - -@responses.activate -def test_list_all_lists(proc, dummy_data): - stub( - responses.GET, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/list_lists.json', - ) - - lists = proc.list_all_lists() - assert request_user_agent() == dummy_data.user_agent - assert lists['total_items'] == 2 - assert lists['_embedded']['lists'][0]['name'] == 'Recipients for demo' - assert lists['_embedded']['lists'][0]['id'] == 'af8a84b6-c712-4252-ac8d-6e28ac9317ce' - assert lists['_embedded']['lists'][1]['name'] == 'Salesforce contacts' - assert lists['_embedded']['lists'][1]['datasource']['type'] == 'salesforce' - - -@responses.activate -def test_list_all_lists_options(proc): - stub( - responses.GET, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/list_lists.json', - ) - - lists = proc.list_all_lists(page=1, page_size=5) - assert lists['total_items'] == 2 - assert lists['_embedded']['lists'][0]['name'] == 'Recipients for demo' - assert lists['_embedded']['lists'][0]['id'] == 'af8a84b6-c712-4252-ac8d-6e28ac9317ce' - assert lists['_embedded']['lists'][1]['name'] == 'Salesforce contacts' - - -def test_pagination_errors(proc): - with raises(ProactiveConnectError) as err: - proc.list_all_lists(page=-1) - assert str(err.value) == '"page" must be an int > 0.' - - with raises(ProactiveConnectError) as err: - proc.list_all_lists(page_size=-1) - assert str(err.value) == '"page_size" must be an int > 0.' - - -@responses.activate -def test_create_list_basic(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_basic.json', - status_code=201, - ) - - list = proc.create_list({'name': 'my_list'}) - assert list['id'] == '6994fd17-7691-4463-be16-172ab1430d97' - assert list['name'] == 'my_list' - - -@responses.activate -def test_create_list_manual(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_manual.json', - status_code=201, - ) - - params = { - "name": "my name", - "description": "my description", - "tags": ["vip", "sport"], - "attributes": [{"name": "phone_number", "alias": "phone"}], - "datasource": {"type": "manual"}, - } - - list = proc.create_list(params) - assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - assert list['name'] == 'my_list' - assert list['description'] == 'my description' - assert list['tags'] == ['vip', 'sport'] - assert list['attributes'][0]['name'] == 'phone_number' - - -@responses.activate -def test_create_list_salesforce(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_salesforce.json', - status_code=201, - ) - - params = { - "name": "my name", - "description": "my description", - "tags": ["vip", "sport"], - "attributes": [{"name": "phone_number", "alias": "phone"}], - "datasource": { - "type": "salesforce", - "integration_id": "salesforce_credentials", - "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact", - }, - } - - list = proc.create_list(params) - assert list['id'] == '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - assert list['name'] == 'my_salesforce_list' - assert list['description'] == 'my salesforce description' - assert list['datasource']['type'] == 'salesforce' - assert list['datasource']['integration_id'] == 'salesforce_credentials' - assert list['datasource']['soql'] == 'select Id, LastName, FirstName, Phone, Email FROM Contact' - - -def test_create_list_errors(proc): - params = { - "name": "my name", - "datasource": { - "type": "salesforce", - "integration_id": 1234, - "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact", - }, - } - - with raises(ProactiveConnectError) as err: - proc.create_list({}) - assert str(err.value) == 'You must supply a name for the new list.' - - with raises(ProactiveConnectError) as err: - proc.create_list(params) - assert str(err.value) == 'You must supply values for "integration_id" and "soql" as strings.' - - with raises(ProactiveConnectError) as err: - params['datasource'].pop('integration_id') - proc.create_list(params) - assert ( - str(err.value) - == 'You must supply a value for "integration_id" and "soql" when creating a list with Salesforce.' - ) - - -@responses.activate -def test_create_list_invalid_name_error(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.create_list({'name': 1234}) - assert ( - str(err.value) - == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: name must be longer than or equal to 1 and shorter than or equal to 255 characters\nError: name must be a string' - ) - - -@responses.activate -def test_get_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/get_list.json', - ) - - list = proc.get_list(list_id) - assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - assert list['name'] == 'my_list' - assert list['tags'] == ['vip', 'sport'] - - -@responses.activate -def test_get_list_404(proc): - list_id = 'a508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.get_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_update_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/update_list.json', - ) - - params = {'name': 'my_list', 'tags': ['vip', 'sport', 'football']} - list = proc.update_list(list_id, params) - assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - assert list['tags'] == ['vip', 'sport', 'football'] - assert list['description'] == 'my updated description' - assert list['updated_at'] == '2023-04-28T21:39:17.825Z' - - -@responses.activate -def test_update_list_salesforce(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/update_list_salesforce.json', - ) - - params = {'name': 'my_list', 'tags': ['music']} - list = proc.update_list(list_id, params) - assert list['id'] == list_id - assert list['tags'] == ['music'] - assert list['updated_at'] == '2023-04-28T22:23:37.054Z' - - -def test_update_list_name_error(proc): - with raises(ProactiveConnectError) as err: - proc.update_list( - '9508e7b8-fe99-4fdf-b022-65d7e461db2d', {'description': 'my new description'} - ) - assert str(err.value) == 'You must supply a name for the new list.' - - -@responses.activate -def test_delete_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='no_content.json', - status_code=204, - ) - - assert proc.delete_list(list_id) == None - - -@responses.activate -def test_delete_list_404(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - proc.delete_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_clear_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='no_content.json', - status_code=202, - ) - - assert proc.clear_list(list_id) == None - - -@responses.activate -def test_clear_list_404(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - proc.clear_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_sync_list_from_datasource(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', - fixture_path='no_content.json', - status_code=202, - ) - - assert proc.sync_list_from_datasource(list_id) == None - - -@responses.activate -def test_sync_list_manual_datasource_error(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', - fixture_path='proactive_connect/fetch_list_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.sync_list_from_datasource(list_id) == None - assert ( - str(err.value) - == 'Request data did not validate: Cannot Fetch a manual list (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_sync_list_from_datasource_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - proc.clear_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_list_all_items(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/list_all_items.json', - ) - - items = proc.list_all_items(list_id, page=1, page_size=10) - assert items['total_items'] == 2 - assert items['_embedded']['items'][0]['id'] == '04c7498c-bae9-40f9-bdcb-c4eabb0418fe' - assert items['_embedded']['items'][1]['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - - -@responses.activate -def test_list_all_items_error_not_found(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.list_all_items(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_create_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/item.json', - status_code=201, - ) - - data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} - item = proc.create_item(list_id, data) - - assert item['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - assert item['data']['phone'] == '123456789101' - - -@responses.activate -def test_create_item_error_invalid_data(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/item_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.create_item(list_id, {'data': 1234}) - assert ( - str(err.value) - == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: data must be an object' - ) - - -@responses.activate -def test_create_item_error_not_found(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} - with raises(ClientError) as err: - proc.create_item(list_id, data) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_download_list_items(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/download', - fixture_path='proactive_connect/list_items.csv', - ) - - proc.download_list_items( - list_id, os.path.join(os.path.dirname(__file__), 'data/proactive_connect/list_items.csv') - ) - items = _read_csv_file( - os.path.join(os.path.dirname(__file__), 'data/proactive_connect/list_items.csv') - ) - assert items[0]['favourite_number'] == '0' - assert items[1]['least_favourite_number'] == '0' - - -@responses.activate -def test_download_list_items_error_not_found(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/download', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.download_list_items(list_id, 'data/proactive_connect_list_items.csv') - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_get_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/item.json', - ) - - item = proc.get_item(list_id, item_id) - assert item['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - assert item['data']['phone'] == '123456789101' - - -@responses.activate -def test_get_item_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.get_item(list_id, item_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_update_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - data = {'first_name': 'John', 'last_name': 'Doe', 'phone': '447007000000'} - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/update_item.json', - ) - - updated_item = proc.update_item(list_id, item_id, data) - - assert updated_item['id'] == item_id - assert updated_item['data'] == data - assert updated_item['updated_at'] == '2023-05-03T19:50:33.207Z' - - -@responses.activate -def test_update_item_error_invalid_data(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - data = 'asdf' - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/item_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.update_item(list_id, item_id, data) - assert ( - str(err.value) - == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: data must be an object' - ) - - -@responses.activate -def test_update_item_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - data = {'first_name': 'John', 'last_name': 'Doe', 'phone': '447007000000'} - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.update_item(list_id, item_id, data) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_delete_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='no_content.json', - status_code=204, - ) - - response = proc.delete_item(list_id, item_id) - assert response is None - - -@responses.activate -def test_delete_item_404(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'e91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.delete_item(list_id, item_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_upload_list_items_from_csv(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - file_path = os.path.join(os.path.dirname(__file__), 'data/proactive_connect/csv_to_upload.csv') - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/import', - fixture_path='proactive_connect/upload_from_csv.json', - ) - - response = proc.upload_list_items(list_id, file_path) - assert response['inserted'] == 3 - - -@responses.activate -def test_upload_list_items_from_csv_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - file_path = os.path.join(os.path.dirname(__file__), 'data/proactive_connect/csv_to_upload.csv') - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/import', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.upload_list_items(list_id, file_path) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_list_events(proc): - stub( - responses.GET, - 'https://api-eu.vonage.com/v0.1/bulk/events', - fixture_path='proactive_connect/list_events.json', - ) - - lists = proc.list_events() - assert lists['total_items'] == 1 - assert lists['_embedded']['events'][0]['occurred_at'] == '2022-08-07T13:18:21.970Z' - assert lists['_embedded']['events'][0]['type'] == 'action-call-succeeded' - assert lists['_embedded']['events'][0]['run_id'] == '7d0d4e5f-6453-4c63-87cf-f95b04377324' - - -def _read_csv_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as csv_file: - reader = csv.DictReader(csv_file) - dict_list = [row for row in reader] - return dict_list diff --git a/tests/test_redact.py b/tests/test_redact.py deleted file mode 100644 index 609e5d9e..00000000 --- a/tests/test_redact.py +++ /dev/null @@ -1,36 +0,0 @@ -from util import * -from vonage.errors import RedactError - - -def test_redact_invalid_product_name(redact): - with pytest.raises(RedactError): - redact.redact_transaction(id='not-a-real-id', product='fake-product') - - -@responses.activate -def test_redact_transaction(redact, dummy_data): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/redact/transaction", - body=None, - status=204, - ) - - assert redact.redact_transaction(id="not-a-real-id", product="sms") is None - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_redact_transaction_with_type(redact, dummy_data): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/redact/transaction", - body=None, - status=204, - ) - - assert redact.redact_transaction(id="some-id", product="sms", type="xyz") is None - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b"xyz" in request_body() diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py deleted file mode 100644 index 56fdb550..00000000 --- a/tests/test_rest_calls.py +++ /dev/null @@ -1,139 +0,0 @@ -from util import * -from vonage.errors import InvalidAuthenticationTypeError - - -@responses.activate -def test_get_with_query_params_auth(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.get(host, request_uri, params=params, auth_type='params') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_query() - assert "bbb=yyy" in request_query() - - -@responses.activate -def test_get_with_header_auth(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.get(host, request_uri, params=params, auth_type='header') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_query() - assert "bbb=yyy" in request_query() - assert_basic_auth() - - -@responses.activate -def test_post_with_query_params_auth(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params, auth_type='params', body_is_json=False) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_body() - assert "bbb=yyy" in request_body() - - -@responses.activate -def test_post_with_header_auth(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params, auth_type='header', body_is_json=False) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_body() - assert "bbb=yyy" in request_body() - assert_basic_auth() - - -@responses.activate -def test_put_with_header_auth(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.put(host, request_uri, params=params, auth_type='header') - assert_basic_auth() - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() - - -@responses.activate -def test_delete_with_header_auth(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - response = client.delete(host, request_uri, auth_type='header') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert_basic_auth() - - -@responses.activate -def test_patch(client, dummy_data): - stub(responses.PATCH, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.patch(host, request_uri, params=params, auth_type='jwt') - assert request_headers()['Content-Type'] == 'application/json' - assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() - - -@responses.activate -def test_patch_no_content(client, dummy_data): - stub( - responses.PATCH, - f"https://api.nexmo.com/v2/project", - status_code=204, - fixture_path='no_content.json', - ) - host = "api.nexmo.com" - request_uri = "/v2/project" - params = {"test_param_1": "test1", "test_param_2": "test2"} - client.patch(host, request_uri, params=params, auth_type='jwt') - assert request_headers()['Content-Type'] == 'application/json' - assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None - assert request_user_agent() == dummy_data.user_agent - assert b"test_param_1" in request_body() - assert b"test1" in request_body() - assert b"test_param_2" in request_body() - assert b"test2" in request_body() - - -def test_patch_invalid_auth_type(client): - host = "api.nexmo.com" - request_uri = "/v2/project" - params = {"test_param_1": "test1", "test_param_2": "test2"} - with pytest.raises(InvalidAuthenticationTypeError): - client.patch(host, request_uri, params=params, auth_type='params') - - -@responses.activate -def test_get_with_jwt_auth(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - host = "api.nexmo.com" - request_uri = "/v1/calls" - response = client.get(host, request_uri, auth_type='jwt') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_short_codes.py b/tests/test_short_codes.py deleted file mode 100644 index 212dfcf6..00000000 --- a/tests/test_short_codes.py +++ /dev/null @@ -1,64 +0,0 @@ -from util import * - - -@responses.activate -def test_send_2fa_message(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") - - params = {"to": "16365553226", "pin": "1234"} - - assert isinstance(short_codes.send_2fa_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "pin=1234" in request_body() - - -@responses.activate -def test_send_event_alert_message(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/json") - - params = {"to": "16365553226", "server": "host", "link": "http://example.com/"} - - assert isinstance(short_codes.send_event_alert_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "server=host" in request_body() - assert "link=http%3A%2F%2Fexample.com%2F" in request_body() - - -@responses.activate -def test_send_marketing_message(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/marketing/json") - - params = { - "from": "short-code", - "to": "16365553226", - "keyword": "NEXMO", - "text": "Hello", - } - - assert isinstance(short_codes.send_marketing_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=short-code" in request_body() - assert "to=16365553226" in request_body() - assert "keyword=NEXMO" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_get_event_alert_numbers(short_codes, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/sc/us/alert/opt-in/query/json") - - assert isinstance(short_codes.get_event_alert_numbers(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_resubscribe_event_alert_number(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/opt-in/manage/json") - - params = {"msisdn": "441632960960"} - - assert isinstance(short_codes.resubscribe_event_alert_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "msisdn=441632960960" in request_body() diff --git a/tests/test_signature.py b/tests/test_signature.py deleted file mode 100644 index 8ae86468..00000000 --- a/tests/test_signature.py +++ /dev/null @@ -1,86 +0,0 @@ -import vonage -from util import * - - -def test_check_signature(dummy_data): - params = { - "a": "1", - "b": "2", - "timestamp": "1461605396", - "sig": "6af838ef94998832dbfc29020b564830", - } - - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - - assert client.check_signature(params) - - -def test_signature(client, dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - assert client.signature(params) == "6af838ef94998832dbfc29020b564830" - - -def test_signature_adds_timestamp(dummy_data): - params = {"a=7": "1", "b": "2 & 5"} - - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - - client.signature(params) - assert params["timestamp"] is not None - - -def test_signature_md5(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="md5", - ) - assert client.signature(params) == "c15c21ced558c93a226c305f58f902f2" - - -def test_signature_sha1(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha1", - ) - assert client.signature(params) == "3e19a4e6880fdc2c1426bfd0587c98b9532f0210" - - -def test_signature_sha256(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha256", - ) - assert ( - client.signature(params) - == "a321e824b9b816be7c3f28859a31749a098713d39f613c80d455bbaffae1cd24" - ) - - -def test_signature_sha512(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha512", - ) - assert ( - client.signature(params) - == "812a18f76680fa0fe1b8bd9ee1625466ceb1bd96242e4d050d2cfd9a7b40166c63ed26ec9702168781b6edcf1633db8ff95af9341701004eec3fcf9550572ee8" - ) diff --git a/tests/test_sms.py b/tests/test_sms.py deleted file mode 100644 index cdb8cb36..00000000 --- a/tests/test_sms.py +++ /dev/null @@ -1,50 +0,0 @@ -import vonage -from util import * - - -@responses.activate -def test_send_message(sms, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(sms.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(vonage.AuthenticationError): - sms.send_message({}) - - -@responses.activate -def test_client_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(vonage.ClientError) as excinfo: - sms.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(vonage.ServerError) as excinfo: - sms.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(sms): - responses.add(responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK") - - sms.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() diff --git a/tests/test_subaccounts.py b/tests/test_subaccounts.py deleted file mode 100644 index 1278e7ea..00000000 --- a/tests/test_subaccounts.py +++ /dev/null @@ -1,730 +0,0 @@ -from vonage import Client, ClientError, SubaccountsError -from util import stub - -from pytest import raises -import responses - -api_key = '1234asdf' -api_secret = 'qwerasdfzxcv' -client = Client(key=api_key, secret=api_secret) -subaccount_key = 'asdfzxcv' - - -@responses.activate -def test_list_subaccounts(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/list_subaccounts.json', - ) - subaccounts = client.subaccounts.list_subaccounts() - assert subaccounts['total_balance'] == 9.9999 - assert subaccounts['_embedded']['primary_account']['api_key'] == api_key - assert subaccounts['_embedded']['primary_account']['balance'] == 9.9999 - assert subaccounts['_embedded']['subaccounts'][0]['api_key'] == 'qwerasdf' - assert subaccounts['_embedded']['subaccounts'][0]['name'] == 'test_subaccount' - - -@responses.activate -def test_list_subaccounts_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - with raises(ClientError) as err: - client.subaccounts.list_subaccounts() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_list_subaccounts_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.list_subaccounts() - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_list_subaccounts_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - client.subaccounts.list_subaccounts() - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_create_subaccount(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/subaccount.json', - ) - subaccount = client.subaccounts.create_subaccount( - name='my subaccount', secret='Password123', use_primary_account_balance=True - ) - assert subaccount['api_key'] == 'asdfzxcv' - assert subaccount['secret'] == 'Password123' - assert subaccount['primary_account_api_key'] == api_key - assert subaccount['use_primary_account_balance'] == True - - -@responses.activate -def test_create_subaccount_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.create_subaccount('failed subaccount') - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_create_subaccount_error_forbidden(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - - with raises(ClientError) as err: - client.subaccounts.create_subaccount('failed subaccount') - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_create_subaccount_error_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.create_subaccount('failed subaccount') - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -def test_create_subaccount_error_non_boolean(): - with raises(SubaccountsError) as err: - client.subaccounts.create_subaccount( - 'failed subaccount', use_primary_account_balance='yes please' - ) - assert ( - str(err.value) - == 'If providing a value, it needs to be a boolean. You provided: "yes please"' - ) - - -@responses.activate -def test_get_subaccount(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/subaccount.json', - ) - subaccount = client.subaccounts.get_subaccount(subaccount_key) - assert subaccount['api_key'] == 'asdfzxcv' - assert subaccount['secret'] == 'Password123' - assert subaccount['primary_account_api_key'] == api_key - assert subaccount['use_primary_account_balance'] == True - - -@responses.activate -def test_get_subaccount_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - with raises(ClientError) as err: - client.subaccounts.get_subaccount(subaccount_key) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_get_subaccount_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.get_subaccount(subaccount_key) - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_get_subaccount_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - client.subaccounts.get_subaccount(subaccount_key) - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_modify_subaccount(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/modified_subaccount.json', - ) - subaccount = client.subaccounts.modify_subaccount( - subaccount_key, - suspended=True, - use_primary_account_balance=False, - name='my modified subaccount', - ) - assert subaccount['api_key'] == 'asdfzxcv' - assert subaccount['name'] == 'my modified subaccount' - assert subaccount['suspended'] == True - assert subaccount['primary_account_api_key'] == api_key - assert subaccount['use_primary_account_balance'] == False - - -@responses.activate -def test_modify_subaccount_error_authentication(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, suspended=True) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_modify_subaccount_error_forbidden(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, use_primary_account_balance=False) - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_modify_subaccount_error_not_found(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, name='my modified subaccount name') - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_modify_subaccount_validation_error(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/validation_error.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, use_primary_account_balance=True) - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_list_credit_transfers(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/list_credit_transfers.json', - ) - transfers = client.subaccounts.list_credit_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount='asdfzxcv', - ) - assert transfers['_embedded']['credit_transfers'][0]['from'] == '1234asdf' - assert transfers['_embedded']['credit_transfers'][0]['reference'] == 'test credit transfer' - assert transfers['_embedded']['credit_transfers'][1]['to'] == 'asdfzxcv' - assert transfers['_embedded']['credit_transfers'][1]['created_at'] == '2023-06-12T17:20:01.000Z' - - -@responses.activate -def test_list_credit_transfers_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_list_credit_transfers_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers() - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_list_credit_transfers_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers() - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_list_credit_transfers_validation_error(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/transfer_validation_error.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers(start_date='invalid-date-format') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_transfer_credit(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/credit_transfer.json', - ) - transfer = client.subaccounts.transfer_credit( - from_='1234asdf', to='asdfzxcv', amount=0.50, reference='test credit transfer' - ) - assert transfer['from'] == '1234asdf' - assert transfer['to'] == 'asdfzxcv' - assert transfer['amount'] == 0.5 - assert transfer['reference'] == 'test credit transfer' - - -@responses.activate -def test_transfer_credit_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzxcv', amount=0.1) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_transfer_credit_invalid_transfer(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/invalid_transfer.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='asdfzxcv', to='qwerasdf', amount=1) - assert ( - str(err.value) - == 'Invalid Transfer: Transfers are only allowed between a primary account and its subaccount (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' - ) - - -@responses.activate -def test_transfer_credit_insufficient_credit(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/insufficient_credit.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='asdfzxcv', to='qwerasdf', amount=1) - assert ( - str(err.value) - == 'Transfer amount is invalid: Insufficient Credit (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' - ) - - -@responses.activate -def test_transfer_credit_error_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzcv', amount=0.1) - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_transfer_credit_validation_error(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/must_be_number.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzxcv', amount='0.50') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_list_balance_transfers(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/list_balance_transfers.json', - ) - transfers = client.subaccounts.list_balance_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount='asdfzxcv', - ) - assert transfers['_embedded']['balance_transfers'][0]['from'] == '1234asdf' - assert transfers['_embedded']['balance_transfers'][0]['reference'] == 'test transfer' - assert transfers['_embedded']['balance_transfers'][1]['to'] == 'asdfzxcv' - assert ( - transfers['_embedded']['balance_transfers'][1]['created_at'] == '2023-06-12T17:20:01.000Z' - ) - - -@responses.activate -def test_list_balance_transfers_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_list_balance_transfers_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers() - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_list_balance_transfers_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers() - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_list_balance_transfers_validation_error(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/transfer_validation_error.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers(start_date='invalid-date-format') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_transfer_balance(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/balance_transfer.json', - ) - transfer = client.subaccounts.transfer_balance( - from_='1234asdf', to='asdfzxcv', amount=0.50, reference='test balance transfer' - ) - assert transfer['from'] == '1234asdf' - assert transfer['to'] == 'asdfzxcv' - assert transfer['amount'] == 0.5 - assert transfer['reference'] == 'test balance transfer' - - -@responses.activate -def test_transfer_balance_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzxcv', amount=0.1) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_transfer_balance_invalid_transfer(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/invalid_transfer.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='asdfzxcv', to='qwerasdf', amount=1) - assert ( - str(err.value) - == 'Invalid Transfer: Transfers are only allowed between a primary account and its subaccount (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' - ) - - -@responses.activate -def test_transfer_balance_error_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzcv', amount=0.1) - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_transfer_balance_validation_error(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/must_be_number.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzxcv', amount='0.50') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_transfer_number(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/transfer_number.json', - ) - transfer = client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert transfer['from'] == '1234asdf' - assert transfer['to'] == 'asdfzxcv' - assert transfer['number'] == '12345678901' - assert transfer['country'] == 'US' - assert transfer['masterAccountId'] == '1234asdf' - - -@responses.activate -def test_transfer_number_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_transfer_number_invalid_transfer(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/invalid_number_transfer.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode is not owned by from account (https://developer.nexmo.com/api-errors/account/subaccounts#invalid-number-transfer)' - ) - - -@responses.activate -def test_transfer_number_error_number_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/number_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found (https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer)' - ) - - -@responses.activate -def test_transfer_number_error_number_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/number_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found (https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer)' - ) - - -@responses.activate -def test_transfer_number_validation_error(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/same_from_and_to_accounts.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='asdfzxcv', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 3a468548..00000000 --- a/tests/test_users.py +++ /dev/null @@ -1,369 +0,0 @@ -from vonage import Client, Users -from util import * -from vonage.errors import UsersError, ClientError, ServerError - -from pytest import raises -import responses - -client = Client() -users = Users(client) -host = client.api_host() - - -@responses.activate -def test_list_users_basic(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_basic.json', - ) - - all_users = users.list_users() - assert all_users['page_size'] == 10 - assert all_users['_embedded']['users'][0]['name'] == 'NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c' - assert all_users['_embedded']['users'][1]['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - assert all_users['_embedded']['users'][2]['name'] == 'my_user_name' - - -@responses.activate -def test_list_users_options(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_options.json', - ) - - all_users = users.list_users(page_size=2, order='desc') - assert all_users['page_size'] == 2 - assert all_users['_embedded']['users'][0]['name'] == 'my_user_name' - assert all_users['_embedded']['users'][1]['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - - -def test_list_users_order_error(): - with raises(UsersError) as err: - users.list_users(order='Why, ascending of course!') - assert ( - str(err.value) == 'Invalid order parameter. Must be one of: "asc", "desc", "ASC", "DESC".' - ) - - -@responses.activate -def test_list_users_400(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - users.list_users(page_size='asdf') - assert 'Input validation failure.' in str(err.value) - - -@responses.activate -def test_list_users_404(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_404.json', - status_code=404, - ) - - with raises(ClientError) as err: - users.list_users(name='asdf') - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_list_users_429(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.list_users() - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_list_users_500(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_500.json', - status_code=500, - ) - - with raises(ServerError) as err: - users.list_users() - assert str(err.value) == '500 response from api.nexmo.com' - - -@responses.activate -def test_create_user_basic(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/user_basic.json', - status_code=201, - ) - - user = users.create_user() - assert user['id'] == 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - assert user['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - ) - - -@responses.activate -def test_create_user_options(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/user_options.json', - status_code=201, - ) - - params = { - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "image_url": "https://example.com/image.png", - "display_name": "My User Name", - "properties": {"custom_data": {"custom_key": "custom_value"}}, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - }, - "channels": { - "pstn": [{"number": 123457}], - "sip": [ - { - "uri": "sip:4442138907@sip.example.com;transport=tls", - "username": "New SIP", - "password": "Password", - } - ], - "vbc": [{"extension": "403"}], - "websocket": [ - { - "uri": "wss://example.com/socket", - "content-type": "audio/l16;rate=16000", - "headers": {"customer_id": "ABC123"}, - } - ], - "sms": [{"number": "447700900000"}], - "mms": [{"number": "447700900000"}], - "whatsapp": [{"number": "447700900000"}], - "viber": [{"number": "447700900000"}], - "messenger": [{"id": "12345abcd"}], - }, - } - - user = users.create_user(params) - assert user['id'] == 'USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' - assert user['name'] == 'my_user_name' - assert user['display_name'] == 'My User Name' - assert user['properties']['custom_data']['custom_key'] == 'custom_value' - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' - ) - assert user['channels']['vbc'][0]['extension'] == '403' - - -@responses.activate -def test_create_user_400(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/user_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - users.create_user(params={'name': 1234}) - assert 'Input validation failure.' in str(err.value) - - -@responses.activate -def test_create_user_429(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.create_user() - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_get_user(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.GET, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/user_basic.json', - ) - - user = users.get_user(user_id) - assert user['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - assert user['properties']['custom_data'] == {} - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - ) - - -@responses.activate -def test_get_user_404(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.GET, - f'https://{host}/v1/users/{user_id}', - status_code=404, - fixture_path='users/user_404.json', - ) - - with raises(ClientError) as err: - users.get_user(user_id) - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_get_user_429(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.GET, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.get_user(user_id) - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_update_user(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/user_updated.json', - ) - - params = { - 'name': 'updated_name', - 'channels': { - 'whatsapp': [ - {'number': '447700900000'}, - ] - }, - } - user = users.update_user(user_id, params) - assert user['name'] == 'updated_name' - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - ) - assert user['channels']['whatsapp'][0]['number'] == '447700900000' - - -@responses.activate -def test_update_user_400(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/user_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - users.update_user(user_id, params={'name': 1234}) - assert 'Input validation failure.' in str(err.value) - - -@responses.activate -def test_update_user_404(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - status_code=404, - fixture_path='users/user_404.json', - ) - - with raises(ClientError) as err: - users.update_user(user_id, params={'name': 'updated_user_name'}) - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_update_user_429(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.update_user(user_id, params={'name': 'updated_user_name'}) - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_delete_user(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.DELETE, - f'https://{host}/v1/users/{user_id}', - status_code=204, - fixture_path='no_content.json', - ) - - response = users.delete_user(user_id) - assert response == None - - -@responses.activate -def test_delete_user_404(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.DELETE, - f'https://{host}/v1/users/{user_id}', - status_code=404, - fixture_path='users/user_404.json', - ) - - with raises(ClientError) as err: - users.delete_user(user_id) - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_delete_user_429(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.DELETE, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.delete_user(user_id) - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) diff --git a/tests/test_ussd.py b/tests/test_ussd.py deleted file mode 100644 index 27fb41a7..00000000 --- a/tests/test_ussd.py +++ /dev/null @@ -1,27 +0,0 @@ -from util import * - - -@responses.activate -def test_send_ussd_push_message(ussd, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd/json") - - params = {"from": "MyCompany20", "to": "447525856424", "text": "Hello"} - - assert isinstance(ussd.send_ussd_push_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=MyCompany20" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_send_ussd_prompt_message(ussd, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") - - params = {"from": "long-virtual-number", "to": "447525856424", "text": "Hello"} - - assert isinstance(ussd.send_ussd_prompt_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=long-virtual-number" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py deleted file mode 100644 index 576dbf62..00000000 --- a/tests/test_verify.py +++ /dev/null @@ -1,204 +0,0 @@ -from util import * - - -@responses.activate -def test_start_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance(verify.check("8g88g88eg8g8gg9g90", code="123445"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(verify, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(verify.search("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_start_psd2_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.psd2(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_start_verification_blacklisted_error_with_network(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_network.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.start_verification(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_verification_blacklisted_error_with_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.start_verification(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_network_and_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.start_verification(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_psd2_verification_blacklisted_error_with_network(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_network.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.psd2(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_psd2_verification_blacklisted_error_with_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.psd2(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_psd2_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_network_and_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.psd2(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) diff --git a/tests/test_verify2.py b/tests/test_verify2.py deleted file mode 100644 index 601a78ee..00000000 --- a/tests/test_verify2.py +++ /dev/null @@ -1,647 +0,0 @@ -from vonage import Client, Verify2 -from util import * -from vonage.errors import ClientError, Verify2Error - -from pydantic import ValidationError -from pytest import raises -import responses - -verify2 = Verify2(Client()) - - -@responses.activate -def test_new_request_sms_basic(dummy_data): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} - verify_request = verify2.new_request(params) - - assert request_user_agent() == dummy_data.user_agent - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_sms_full(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'locale': 'en-gb', - 'channel_timeout': 120, - 'client_ref': 'my client ref', - 'code_length': 8, - 'fraud_check': False, - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': 'asdfghjklqw'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_sms_custom_code(dummy_data): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'code': 'asdfghjk', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert request_user_agent() == dummy_data.user_agent - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_error_fraud_check_invalid_account(dummy_data): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/fraud_check_invalid_account.json', - status_code=403, - ) - - params = { - 'brand': 'ACME, Inc', - 'fraud_check': False, - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Forbidden: Your account does not have permission to perform this action. (https://developer.nexmo.com/api-errors#forbidden)' - ) - - -def test_new_request_sms_custom_code_length_error(): - params = { - 'code_length': 4, - 'brand': 'ACME, Inc', - 'code': 'a', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ValidationError) as err: - verify2.new_request(params) - assert 'String should have at least 4 characters' in str(err.value) - - -def test_new_request_sms_custom_code_character_error(): - params = { - 'code_length': 4, - 'brand': 'ACME, Inc', - 'code': '?!@%', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ValidationError) as err: - verify2.new_request(params) - assert 'string does not match regex' in str(err.value) - - -def test_new_request_invalid_channel_error(): - params = { - 'code_length': 4, - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'carrier_pigeon', 'to': '447700900000'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'You must specify a valid verify channel inside the "workflow" object, one of: "[\'sms\', \'whatsapp\', \'whatsapp_interactive\', \'voice\', \'email\', \'silent_auth\']"' - ) - - -def test_new_request_code_length_error(): - params = { - 'code_length': 1000, - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ValidationError) as err: - verify2.new_request(params) - assert 'Input should be less than or equal to 10' in str(err.value) - - -def test_new_request_to_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '123'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert 'You must specify a valid "to" value for channel "sms"' in str(err.value) - - -def test_new_request_sms_app_hash_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': '00'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert 'Invalid "app_hash" specified.' in str(err.value) - - -def test_new_request_whatsapp_app_hash_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'app_hash': 'asdfqwerzxc'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Cannot specify a value for "app_hash" unless using SMS for authentication.' - ) - - -@responses.activate -def test_new_request_whatsapp(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}]} - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_whatsapp_custom_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'code': 'asdfghjk', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_whatsapp_from_field(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447000000000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_whatsapp_invalid_sender_error(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/invalid_sender.json', - status_code=422, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': 'asdfghjkl'}], - } - with pytest.raises(ClientError) as err: - verify2.new_request(params) - assert str(err.value) == 'You must specify a valid "from" value if included.' - - -@responses.activate -def test_new_request_whatsapp_sender_unregistered_error(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/invalid_sender.json', - status_code=422, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447999999999'}], - } - with pytest.raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Invalid sender: The `from` parameter is invalid. (https://developer.nexmo.com/api-errors#invalid-param)' - ) - - -@responses.activate -def test_new_request_whatsapp_interactive(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp_interactive', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_voice(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'voice', 'to': '447700900000'}]} - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_voice_custom_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'code': 'asdfhjkl', - 'workflow': [{'channel': 'voice', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_email(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'email', 'to': 'recipient@example.com'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_email_additional_fields(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'locale': 'en-gb', - 'channel_timeout': 120, - 'client_ref': 'my client ref', - 'code_length': 8, - 'brand': 'ACME, Inc', - 'code': 'asdfhjkl', - 'workflow': [ - {'channel': 'email', 'to': 'recipient@example.com', 'from': 'sender@example.com'} - ], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_email_error(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/invalid_email.json', - status_code=422, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'email', 'to': 'not-an-email-address'}], - } - with pytest.raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Invalid params: The value of one or more parameters is invalid (https://www.nexmo.com/messages/Errors#InvalidParams)' - ) - - -@responses.activate -def test_new_request_silent_auth(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request_silent_auth.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - { - 'channel': 'silent_auth', - 'to': '447000000000', - 'redirect_url': 'https://acme-app.com/sa/redirect', - 'sandbox': False, - } - ], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'b3a2f4bd-7bda-4e5e-978a-81514702d2ce' - assert ( - verify_request['check_url'] - == 'https://api-eu-3.vonage.com/v2/verify/b3a2f4bd-7bda-4e5e-978a-81514702d2ce/silent-auth/redirect' - ) - - -def test_silent_auth_redirect_url_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - { - 'channel': 'silent_auth', - 'to': '447000000000', - 'redirect_url': ['https://acme-app.com/sa/redirect'], - } - ], - } - with raises(Verify2Error) as err: - verify2.new_request(params) - assert str(err.value) == '"redirect_url" must be a string if specified.' - - -def test_silent_auth_sandbox_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - { - 'channel': 'silent_auth', - 'to': '447000000000', - 'sandbox': 'true', - } - ], - } - with raises(Verify2Error) as err: - verify2.new_request(params) - assert str(err.value) == '"sandbox" must be a boolean if specified.' - - -@responses.activate -def test_new_request_error_conflict(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/error_conflict.json', - status_code=409, - ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} - - with raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == "Conflict: Concurrent verifications to the same number are not allowed. (https://www.developer.vonage.com/api-errors/verify#conflict)" - ) - - -@responses.activate -def test_new_request_rate_limit(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/rate_limit.json', - status_code=429, - ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} - - with raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == "Rate Limit Hit: Please wait, then retry your request (https://www.developer.vonage.com/api-errors#throttled)" - ) - - -@responses.activate -def test_check_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/check_code.json', - ) - - response = verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '1234') - assert response['request_id'] == 'e043d872-459b-4750-a20c-d33f91d6959f' - assert response['status'] == 'completed' - - -@responses.activate -def test_check_code_invalid_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/invalid_code.json', - status_code=400, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == 'Invalid Code: The code you provided does not match the expected value. (https://developer.nexmo.com/api-errors#bad-request)' - ) - - -@responses.activate -def test_check_code_already_verified(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/already_verified.json', - status_code=404, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == "Not Found: Request '5fcc26ef-1e54-48a6-83ab-c47546a19824' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" - ) - - -@responses.activate -def test_check_code_workflow_not_supported(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/code_not_supported.json', - status_code=409, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == 'Conflict: The current Verify workflow step does not support a code. (https://developer.nexmo.com/api-errors#conflict)' - ) - - -@responses.activate -def test_check_code_too_many_invalid_code_attempts(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/too_many_code_attempts.json', - status_code=410, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == 'Invalid Code: An incorrect code has been provided too many times. Workflow terminated. (https://developer.nexmo.com/api-errors#gone)' - ) - - -@responses.activate -def test_check_code_rate_limit(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - assert ( - str(err.value) - == "Rate Limit Hit: Please wait, then retry your request (https://www.developer.vonage.com/api-errors#throttled)" - ) - - -@responses.activate -def test_cancel_verification(): - stub( - responses.DELETE, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='no_content.json', - status_code=204, - ) - - assert verify2.cancel_verification('c11236f4-00bf-4b89-84ba-88b25df97315') == None - - -@responses.activate -def test_cancel_verification_error_not_found(): - stub( - responses.DELETE, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/request_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - verify2.cancel_verification('c11236f4-00bf-4b89-84ba-88b25df97315') - assert ( - str(err.value) - == "Not Found: Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" - ) - - -@responses.activate -def test_new_request_multiple_workflows(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - {'channel': 'whatsapp_interactive', 'to': '447700900000'}, - {'channel': 'sms', 'to': '4477009999999'}, - ], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -def test_remove_unnecessary_fraud_check(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - 'fraud_check': True, - } - verify2._remove_unnecessary_fraud_check(params) - - assert 'fraud_check' not in params diff --git a/tests/test_video.py b/tests/test_video.py deleted file mode 100644 index d1c98945..00000000 --- a/tests/test_video.py +++ /dev/null @@ -1,678 +0,0 @@ -from util import * -from vonage import Client -from vonage.errors import ( - ClientError, - VideoError, - InvalidRoleError, - TokenExpiryError, - SipError, -) - -import jwt -from time import time - - -session_id = 'my_session_id' -stream_id = 'my_stream_id' -connection_id = '1234-5678' -archive_id = '1234-abcd' -broadcast_id = '1748b7070a81464c9759c46ad10d3734' - - -@responses.activate -def test_create_default_session(client: Client, dummy_data): - stub( - responses.POST, - "https://video.api.vonage.com/session/create", - fixture_path="video/create_session.json", - ) - - session_info = client.video.create_session() - assert isinstance(session_info, dict) - assert request_user_agent() == dummy_data.user_agent - assert session_info['session_id'] == session_id - assert session_info['archive_mode'] == 'manual' - assert session_info['media_mode'] == 'routed' - assert session_info['location'] == None - - -@responses.activate -def test_create_session_custom_archive_mode_and_location(client: Client): - stub( - responses.POST, - "https://video.api.vonage.com/session/create", - fixture_path="video/create_session.json", - ) - - session_options = {'archive_mode': 'always', 'location': '192.0.1.1', 'media_mode': 'routed'} - session_info = client.video.create_session(session_options) - assert isinstance(session_info, dict) - assert session_info['session_id'] == session_id - assert session_info['archive_mode'] == 'always' - assert session_info['media_mode'] == 'routed' - assert session_info['location'] == '192.0.1.1' - - -@responses.activate -def test_create_session_custom_media_mode(client: Client): - stub( - responses.POST, - "https://video.api.vonage.com/session/create", - fixture_path="video/create_session.json", - ) - - session_options = {'media_mode': 'relayed'} - session_info = client.video.create_session(session_options) - assert isinstance(session_info, dict) - assert session_info['session_id'] == session_id - assert session_info['archive_mode'] == 'manual' - assert session_info['media_mode'] == 'relayed' - assert session_info['location'] == None - - -def test_create_session_invalid_archive_mode(client: Client): - session_options = {'archive_mode': 'invalid_option'} - with pytest.raises(VideoError) as excinfo: - client.video.create_session(session_options) - assert 'Invalid archive_mode value. Must be one of ' in str(excinfo.value) - - -def test_create_session_invalid_media_mode(client: Client): - session_options = {'media_mode': 'invalid_option'} - with pytest.raises(VideoError) as excinfo: - client.video.create_session(session_options) - assert 'Invalid media_mode value. Must be one of ' in str(excinfo.value) - - -def test_create_session_invalid_mode_combination(client: Client): - session_options = {'archive_mode': 'always', 'media_mode': 'relayed'} - with pytest.raises(VideoError) as excinfo: - client.video.create_session(session_options) - assert ( - str(excinfo.value) - == 'Invalid combination: cannot specify "archive_mode": "always" and "media_mode": "relayed".' - ) - - -def test_generate_client_token_all_defaults(client: Client): - token = client.video.generate_client_token(session_id) - decoded_token = jwt.decode(token, algorithms='RS256', options={'verify_signature': False}) - assert decoded_token['application_id'] == 'nexmo-application-id' - assert decoded_token['scope'] == 'session.connect' - assert decoded_token['session_id'] == 'my_session_id' - assert decoded_token['role'] == 'publisher' - assert decoded_token['initial_layout_class_list'] == '' - - -def test_generate_client_token_custom_options(client: Client): - now = int(time()) - token_options = { - 'role': 'moderator', - 'data': 'some token data', - 'initialLayoutClassList': ['1234', '5678', '9123'], - 'expireTime': now + 60, - 'jti': 1234, - 'iat': now, - 'subject': 'test_subject', - 'acl': ['1', '2', '3'], - } - - token = client.video.generate_client_token(session_id, token_options) - decoded_token = jwt.decode(token, algorithms='RS256', options={'verify_signature': False}) - assert decoded_token['application_id'] == 'nexmo-application-id' - assert decoded_token['scope'] == 'session.connect' - assert decoded_token['session_id'] == 'my_session_id' - assert decoded_token['role'] == 'moderator' - assert decoded_token['initial_layout_class_list'] == ['1234', '5678', '9123'] - assert decoded_token['data'] == 'some token data' - assert decoded_token['jti'] == 1234 - assert decoded_token['subject'] == 'test_subject' - assert decoded_token['acl'] == ['1', '2', '3'] - - -def test_check_client_token_headers(client: Client): - token = client.video.generate_client_token(session_id) - headers = jwt.get_unverified_header(token) - assert headers['alg'] == 'RS256' - assert headers['typ'] == 'JWT' - - -def test_generate_client_token_invalid_role(client: Client): - with pytest.raises(InvalidRoleError): - client.video.generate_client_token(session_id, {'role': 'observer'}) - - -def test_generate_client_token_invalid_expire_time(client: Client): - now = int(time()) - with pytest.raises(TokenExpiryError): - client.video.generate_client_token(session_id, {'expireTime': now + 3600 * 24 * 30 + 1}) - - -@responses.activate -def test_get_stream(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream/{stream_id}", - fixture_path="video/get_stream.json", - ) - - stream = client.video.get_stream(session_id, stream_id) - assert isinstance(stream, dict) - assert stream['videoType'] == 'camera' - - -@responses.activate -def test_list_streams( - client: Client, -): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream", - fixture_path="video/list_streams.json", - ) - - stream_list = client.video.list_streams(session_id) - assert isinstance(stream_list, dict) - assert stream_list['items'][0]['videoType'] == 'camera' - - -@responses.activate -def test_change_stream_layout(client: Client): - stub( - responses.PUT, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream", - ) - - items = [{'id': 'stream-1234', 'layoutClassList': ["full"]}] - - assert isinstance(client.video.set_stream_layout(session_id, items), dict) - assert request_content_type() == "application/json" - - -@responses.activate -def test_send_signal_to_all_participants(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/signal", - ) - - assert isinstance( - client.video.send_signal(session_id, type='chat', data='hello from a test case'), dict - ) - assert request_content_type() == "application/json" - - -@responses.activate -def test_send_signal_to_single_participant(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/{connection_id}/signal", - ) - - assert isinstance( - client.video.send_signal( - session_id, type='chat', data='hello from a test case', connection_id=connection_id - ), - dict, - ) - assert request_content_type() == "application/json" - - -@responses.activate -def test_disconnect_client(client: Client): - stub( - responses.DELETE, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/{connection_id}", - ) - - assert isinstance(client.video.disconnect_client(session_id, connection_id=connection_id), dict) - - -@responses.activate -def test_mute_specific_stream(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream/{stream_id}/mute", - fixture_path="video/mute_specific_stream.json", - ) - - response = client.video.mute_stream(session_id, stream_id) - assert isinstance(response, dict) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_mute_all_streams(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", - fixture_path="video/mute_multiple_streams.json", - ) - - response = client.video.mute_all_streams(session_id) - assert isinstance(response, dict) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_mute_all_streams_except_excluded_list(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", - fixture_path="video/mute_multiple_streams.json", - ) - - response = client.video.mute_all_streams( - session_id, excluded_stream_ids=['excluded_stream_id_1', 'excluded_stream_id_2'] - ) - assert isinstance(response, dict) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_disable_mute_all_streams(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", - fixture_path="video/disable_mute_multiple_streams.json", - ) - - response = client.video.disable_mute_all_streams( - session_id, excluded_stream_ids=['excluded_stream_id_1', 'excluded_stream_id_2'] - ) - assert isinstance(response, dict) - assert ( - request_body() - == b'{"active": false, "excludedStreamIds": ["excluded_stream_id_1", "excluded_stream_id_2"]}' - ) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_list_archives_with_filters_applied(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive", - fixture_path="video/list_archives.json", - ) - - response = client.video.list_archives(offset=0, count=1, session_id=session_id) - assert isinstance(response, dict) - assert response['items'][0]['createdAt'] == 1384221730000 - assert response['items'][0]['streams'][0]['streamId'] == 'abc123' - - -@responses.activate -def test_create_new_archive(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive", - fixture_path="video/create_archive.json", - ) - - response = client.video.create_archive( - session_id=session_id, name='my_new_archive', outputMode='individual' - ) - assert isinstance(response, dict) - assert response['name'] == 'my_new_archive' - assert response['createdAt'] == 1384221730555 - - -@responses.activate -def test_get_archive(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}", - fixture_path="video/get_archive.json", - ) - - response = client.video.get_archive(archive_id=archive_id) - assert isinstance(response, dict) - assert response['duration'] == 5049 - assert response['size'] == 247748791 - assert response['streams'] == [] - - -@responses.activate -def test_delete_archive(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}", - status_code=204, - fixture_path='no_content.json', - ) - - assert client.video.delete_archive(archive_id=archive_id) == None - - -@responses.activate -def test_add_stream_to_archive(client: Client): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert ( - client.video.add_stream_to_archive( - archive_id=archive_id, stream_id='1234', has_audio=True, has_video=True - ) - == None - ) - - -@responses.activate -def test_remove_stream_from_archive(client: Client): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert client.video.remove_stream_from_archive(archive_id=archive_id, stream_id='1234') == None - - -@responses.activate -def test_stop_archive(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/stop", - fixture_path="video/stop_archive.json", - ) - - response = client.video.stop_archive(archive_id=archive_id) - assert response['name'] == 'my_new_archive' - assert response['createdAt'] == 1384221730555 - assert response['status'] == 'stopped' - - -@responses.activate -def test_change_archive_layout(client: Client): - stub( - responses.PUT, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/layout", - ) - - params = {'type': 'bestFit', 'screenshareType': 'horizontalPresentation'} - - assert isinstance(client.video.change_archive_layout(archive_id, params), dict) - assert request_content_type() == "application/json" - - -@responses.activate -def test_create_sip_call(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/dial', - fixture_path='video/create_sip_call.json', - ) - - sip = {'uri': 'sip:user@sip.partner.com;transport=tls'} - - sip_call = client.video.create_sip_call(session_id, 'my_token', sip) - assert sip_call['id'] == 'b0a5a8c7-dc38-459f-a48d-a7f2008da853' - assert sip_call['connectionId'] == 'e9f8c166-6c67-440d-994a-04fb6dfed007' - assert sip_call['streamId'] == '482bce73-f882-40fd-8ca5-cb74ff416036' - - -@responses.activate -def test_create_sip_call_not_found_error(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/dial', - status_code=404, - ) - sip = {'uri': 'sip:user@sip.partner.com;transport=tls'} - with pytest.raises(ClientError): - client.video.create_sip_call('an-invalid-session-id', 'my_token', sip) - - -def test_create_sip_call_no_uri_error(client): - sip = {} - with pytest.raises(SipError) as err: - client.video.create_sip_call(session_id, 'my_token', sip) - - assert str(err.value) == 'You must specify a uri when creating a SIP call.' - - -@responses.activate -def test_play_dtmf(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/play-dtmf', - fixture_path='no_content.json', - ) - - assert client.video.play_dtmf(session_id, '1234') == None - - -@responses.activate -def test_play_dtmf_specific_connection(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/my-connection-id/play-dtmf', - fixture_path='no_content.json', - ) - - assert client.video.play_dtmf(session_id, '1234', connection_id='my-connection-id') == None - - -@responses.activate -def test_play_dtmf_invalid_session_id_error(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/play-dtmf', - fixture_path='video/play_dtmf_invalid_error.json', - status_code=400, - ) - - with pytest.raises(ClientError) as err: - client.video.play_dtmf(session_id, '1234') - assert 'One of the properties digits or sessionId is invalid.' in str(err.value) - - -def test_play_dtmf_invalid_input_error(client): - with pytest.raises(VideoError) as err: - client.video.play_dtmf(session_id, '!@£$%^&()asdfghjkl;') - - assert str(err.value) == 'Only digits 0-9, *, #, and "p" are allowed.' - - -@responses.activate -def test_list_broadcasts(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/list_broadcasts.json', - ) - - broadcasts = client.video.list_broadcasts() - assert broadcasts['count'] == '1' - assert broadcasts['items'][0]['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcasts['items'][0]['applicationId'] == 'abc123' - - -@responses.activate -def test_list_broadcasts_options(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/list_broadcasts.json', - ) - - broadcasts = client.video.list_broadcasts( - count=1, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - ) - assert broadcasts['count'] == '1' - assert broadcasts['items'][0]['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - assert broadcasts['items'][0]['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcasts['items'][0]['applicationId'] == 'abc123' - - -@responses.activate -def test_list_broadcasts_invalid_options_errors(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/list_broadcasts.json', - ) - - with pytest.raises(VideoError) as err: - client.video.list_broadcasts(offset=-2, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') - assert str(err.value) == 'Offset must be an int >= 0.' - - with pytest.raises(VideoError) as err: - client.video.list_broadcasts(count=9999, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') - assert str(err.value) == 'Count must be an int between 0 and 1000.' - - with pytest.raises(VideoError) as err: - client.video.list_broadcasts(offset='10', session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') - assert str(err.value) == 'Offset must be an int >= 0.' - - -@responses.activate -def test_start_broadcast_required_params(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/broadcast.json', - ) - - params = { - "sessionId": "2_MX40NTMyODc3Mn5-fg", - "outputs": { - "rtmp": [ - { - "id": "foo", - "serverUrl": "rtmps://myfooserver/myfooapp", - "streamName": "myfoostream", - } - ] - }, - } - - broadcast = client.video.start_broadcast(params) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['createdAt'] == 1437676551000 - assert broadcast['maxBitrate'] == 2000000 - assert broadcast['broadcastUrls']['rtmp'][0]['id'] == 'abc123' - - -@responses.activate -def test_start_broadcast_all_params(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/broadcast.json', - ) - - params = { - "sessionId": "2_MX40NTMyODc3Mn5-fg", - "layout": { - "type": "custom", - "stylesheet": "the layout stylesheet (only used with type == custom)", - "screenshareType": "horizontalPresentation", - }, - "maxDuration": 5400, - "outputs": { - "rtmp": [ - { - "id": "foo", - "serverUrl": "rtmps://myfooserver/myfooapp", - "streamName": "myfoostream", - } - ] - }, - "resolution": "1920x1080", - "streamMode": "manual", - "multiBroadcastTag": "foo", - } - - broadcast = client.video.start_broadcast(params) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['createdAt'] == 1437676551000 - assert broadcast['maxBitrate'] == 2000000 - assert broadcast['broadcastUrls']['rtmp'][0]['id'] == 'abc123' - - -@responses.activate -def test_get_broadcast(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}', - fixture_path='video/broadcast.json', - ) - - broadcast = client.video.get_broadcast(broadcast_id) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - assert broadcast['updatedAt'] == 1437676551000 - assert broadcast['resolution'] == '640x480' - - -@responses.activate -def test_stop_broadcast(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}', - fixture_path='video/broadcast.json', - ) - - broadcast = client.video.stop_broadcast(broadcast_id) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - assert broadcast['updatedAt'] == 1437676551000 - assert broadcast['resolution'] == '640x480' - - -@responses.activate -def test_change_broadcast_layout(client): - stub( - responses.PUT, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/layout', - fixture_path='no_content.json', - ) - - params = { - "type": "bestFit", - "stylesheet": "stream.instructor {position: absolute; width: 100%; height:50%;}", - "screenshareType": "pip", - } - - assert client.video.change_broadcast_layout(broadcast_id, params) == None - - -@responses.activate -def test_add_stream_to_broadcast(client: Client, dummy_data): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert ( - client.video.add_stream_to_broadcast( - broadcast_id=broadcast_id, stream_id='1234', has_audio=True, has_video=True - ) - == None - ) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_remove_stream_from_broadcast(client: Client, dummy_data): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert ( - client.video.remove_stream_from_broadcast(broadcast_id=broadcast_id, stream_id='1234') - == None - ) - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_voice.py b/tests/test_voice.py deleted file mode 100644 index cc6dda52..00000000 --- a/tests/test_voice.py +++ /dev/null @@ -1,224 +0,0 @@ -import os.path -import time -import jwt -from unittest.mock import patch - -from vonage import Client, Voice, Ncco -from util import * - - -@responses.activate -def test_create_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_params_with_random_number(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "random_from_number": True, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_create_call_with_ncco_builder(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - talk = Ncco.Talk( - text='Hello from Vonage!', - bargeIn=True, - loop=3, - level=0.5, - language='en-GB', - style=1, - premium=True, - ) - ncco = Ncco.build_ncco(talk) - voice.create_call( - { - 'to': [{'type': 'phone', 'number': '447449815316'}], - 'from': {'type': 'phone', 'number': '447418370240'}, - 'ncco': ncco, - } - ) - assert ( - request_body() - == b'{"to": [{"type": "phone", "number": "447449815316"}], "from": {"type": "phone", "number": "447418370240"}, "ncco": [{"action": "talk", "text": "Hello from Vonage!", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}]}' - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(voice.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-application-id" - client = Client(application_id=application_id, private_key=dummy_data.private_key) - - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(nbf=nbf, exp=exp) - client.voice.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - voice = Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_get_recording(voice, dummy_data): - stub_bytes( - responses.GET, - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", - body=b'THISISANMP3', - ) - - assert isinstance( - voice.get_recording("https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957"), - bytes, - ) - assert request_user_agent() == dummy_data.user_agent - - -def test_verify_jwt_signature(voice: Voice): - with patch('vonage.Voice.verify_signature') as mocked_verify_signature: - mocked_verify_signature.return_value = True - assert voice.verify_signature('valid_token', 'valid_signature') - - -def test_verify_jwt_invalid_signature(voice: Voice): - with patch('vonage.Voice.verify_signature') as mocked_verify_signature: - mocked_verify_signature.return_value = False - assert voice.verify_signature('token', 'invalid_signature') is False diff --git a/tests/util.py b/tests/util.py deleted file mode 100644 index e9c8f289..00000000 --- a/tests/util.py +++ /dev/null @@ -1,63 +0,0 @@ -import os.path -import re - -import pytest - -from urllib.parse import urlparse, parse_qs - -import responses - - -def request_body(): - return responses.calls[0].request.body - - -def request_query(): - return urlparse(responses.calls[0].request.url).query - - -def request_params(): - """Obtain the query params, as a dict.""" - return parse_qs(request_query()) - - -def request_headers(): - return responses.calls[0].request.headers - - -def request_user_agent(): - return responses.calls[0].request.headers["User-Agent"] - - -def request_authorization(): - return responses.calls[0].request.headers["Authorization"].decode("utf-8") - - -def request_content_type(): - return responses.calls[0].request.headers["Content-Type"] - - -def stub(method, url, fixture_path=None, status_code=200): - body = load_fixture(fixture_path) if fixture_path else '{"key":"value"}' - responses.add(method, url, body=body, status=status_code, content_type="application/json") - - -def stub_bytes(method, url, body): - responses.add(method, url, body, status=200) - - -def assert_re(pattern, string): - __tracebackhide__ = True - if not re.search(pattern, string): - pytest.fail(f"Cannot find pattern {repr(pattern)} in {repr(string)}") - - -def assert_basic_auth(): - params = request_params() - assert "api_key" not in params - assert "api_secret" not in params - assert request_headers()["Authorization"] == "Basic bmV4bW8tYXBpLWtleTpuZXhtby1hcGktc2VjcmV0" - - -def load_fixture(fixture_path): - return open(os.path.join(os.path.dirname(__file__), "data", fixture_path)).read() diff --git a/testutils/BUILD b/testutils/BUILD new file mode 100644 index 00000000..ec72fc27 --- /dev/null +++ b/testutils/BUILD @@ -0,0 +1,3 @@ +file(name='fake_private_key', source='data/fake_private_key.txt') + +python_sources(dependencies=[':fake_private_key']) diff --git a/testutils/__init__.py b/testutils/__init__.py new file mode 100644 index 00000000..4cfb4d9d --- /dev/null +++ b/testutils/__init__.py @@ -0,0 +1,4 @@ +from .mock_auth import get_mock_api_key_auth, get_mock_jwt_auth +from .testutils import build_response + +__all__ = ['build_response', 'get_mock_api_key_auth', 'get_mock_jwt_auth'] diff --git a/testutils/data/fake_private_key.txt b/testutils/data/fake_private_key.txt new file mode 100644 index 00000000..163ff367 --- /dev/null +++ b/testutils/data/fake_private_key.txt @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQdAHqJHs/a+Ra +2ubvSd1vz/aWlJ9BqnMUtB7guTlyggdENAbleIkzep6mUHepDJdQh8Qv6zS3lpUe +K0UkDfr1/FvsvxurGw/YYPagUEhP/HxMbs2rnQTiAdWOT+Ux9vPABoyNYvZB90xN +IVhBDRWgkz1HPQBRNjFcm3NOol83h5Uwp5YroGTWx+rpmIiRhQj3mv6luk102d95 +4ulpPpzcYWKIpJNdclJrEkBZaghDZTOpbv79qd+ds9AVp1j8i9cG/owBJpsJWxfw +StMDpNeEZqopeQWmA121sSEsxpAbKJ5DA7F/lmckx74sulKHX1fDWT76cRhloaEQ +VmETdj0VAgMBAAECggEAZ+SBtchz8vKbsBqtAbM/XcR5Iqi1TR2eWMHDJ/65HpSm ++XuyujjerN0e6EZvtT4Uxmq8QaPJNP0kmhI31hXvsB0UVcUUDa4hshb1pIYO3Gq7 +Kr8I29EZB2mhndm9Ii9yYhEBiVA66zrNeR225kkWr97iqjhBibhoVr8Vc6oiqcIP +nFy5zSFtQSkhucaPge6rW00JSOD3wg2GM+rgS6r22t8YmqTzAwvwfil5pQfUngal +oywqLOf6CUYXPBleJc1KgaIIP/cSvqh6b/t25o2VXnI4rpRhtleORvYBbH6K6xLa +OWgg6B58T+0/QEqtZIAn4miYtVCkYLB78Ormc7Q9ewKBgQDuSytuYqxdZh/L/RDU +CErFcNO5I1e9fkLAs5dQEBvvdQC74+oA1MsDEVv0xehFa1JwPKSepmvB2UznZg9L +CtR7QKMDZWvS5xx4j0E/b+PiNQ/tlcFZB2UZ0JwviSxdd7omOTscq9c3RIhFHar1 +Y38Fixkfm44Ij/K3JqIi2v2QMwKBgQDf8TYOOmAr9UuipUDxMsRSqTGVIY8B+aEJ +W+2aLrqJVkLGTRfrbjzXWYo3+n7kNJjFgNkltDq6HYtufHMYRs/0PPtNR0w0cDPS +Xr7m2LNHTDcBalC/AS4yKZJLNLm+kXA84vkw4qiTjc0LSFxJkouTQzkea0l8EWHt +zRMv/qYVlwKBgBaJOWRJJK/4lo0+M7c5yYh+sSdTNlsPc9Sxp1/FBj9RO26JkXne +pgx2OdIeXWcjTTqcIZ13c71zhZhkyJF6RroZVNFfaCEcBk9IjQ0o0c504jq/7Pc0 +gdU9K2g7etykFBDFXNfLUKFDc/fFZIOskzi8/PVGStp4cqXrm23cdBqNAoGBAKtf +A2bP9ViuVjsZCyGJIAPBxlfBXpa8WSe4WZNrvwPqJx9pT6yyp4yE0OkVoJUyStaZ +S5M24NocUd8zDUC+r9TP9d+leAOI+Z87MgumOUuOX2mN2kzQsnFgrrsulhXnZmSx +rNBkI20HTqobrcP/iSAgiU1l/M4c3zwDe3N3A9HxAoGBAM2hYu0Ij6htSNgo/WWr +IEYYXuwf8hPkiuwzlaiWhD3eocgd4S8SsBu/bTCY19hQ2QbBPaYyFlNem+ynQyXx +IOacrgIHCrYnRCxjPfFF/MxgUHJb8ZoiexprP/FME5p0PoRQIEFYa+jVht3hT5wC +9aedWufq4JJb+akO6MVUjTvs +-----END PRIVATE KEY----- diff --git a/testutils/mock_auth.py b/testutils/mock_auth.py new file mode 100644 index 00000000..ac724e81 --- /dev/null +++ b/testutils/mock_auth.py @@ -0,0 +1,25 @@ +from os.path import dirname, join + +from vonage_http_client.auth import Auth + + +def read_file(path): + """Read a file from the testutils/data directory.""" + + with open(join(dirname(__file__), path)) as input_file: + return input_file.read() + + +def get_mock_api_key_auth(): + """Return an Auth object with an API key and secret.""" + + return Auth(api_key='test_api_key', api_secret='test_api_secret') + + +def get_mock_jwt_auth(): + """Return an Auth object with a JWT.""" + + return Auth( + application_id='test_application_id', + private_key=read_file('data/fake_private_key.txt'), + ) diff --git a/testutils/testutils.py b/testutils/testutils.py new file mode 100644 index 00000000..f2a547c6 --- /dev/null +++ b/testutils/testutils.py @@ -0,0 +1,55 @@ +from os.path import dirname, join +from typing import Literal + +import responses +from pydantic import validate_call + + +def _load_mock_data(caller_file_path: str, mock_path: str): + """Load mock data from a file.""" + + with open(join(dirname(caller_file_path), 'data', mock_path)) as file: + return file.read() + + +def _filter_none_values(data: dict) -> dict: + """Filter out None values from a dictionary.""" + + return {k: v for (k, v) in data.items() if v is not None} + + +@validate_call +def build_response( + file_path: str, + method: Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], + url: str, + mock_path: str = None, + status_code: int = 200, + content_type: str = 'application/json', + match: list = None, +): + """Build a response for a mock request. + + Args: + file_path (str): The path to the file calling this function. + method (Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE']): The HTTP method. + url (str): The URL to match. + mock_path (str, optional): The path to the mock data file. + status_code (int, optional): The status code to return. + content_type (str, optional): The content type to return. + match (list, optional): The match parameters. + """ + + body = _load_mock_data(file_path, mock_path) if mock_path else None + responses.add( + **_filter_none_values( + { + 'method': method, + 'url': url, + 'body': body, + 'status': status_code, + 'content_type': content_type, + 'match': match, + } + ) + ) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index efeb1ff9..00000000 --- a/tox.ini +++ /dev/null @@ -1,13 +0,0 @@ -[tox] -envlist = py3.8, py3.11, coverage-report - -[testenv] -deps = -r requirements.txt -commands = coverage run --parallel -m pytest tests - -[testenv:coverage-report] -deps = coverage -skip_install = true -commands = - coverage combine - coverage report diff --git a/users/BUILD b/users/BUILD new file mode 100644 index 00000000..0b0c6b13 --- /dev/null +++ b/users/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-users', + dependencies=[ + ':pyproject', + ':readme', + 'users/src/vonage_users', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/users/CHANGES.md b/users/CHANGES.md new file mode 100644 index 00000000..579ff93d --- /dev/null +++ b/users/CHANGES.md @@ -0,0 +1,26 @@ +# 1.2.0 +- Expose more properties in the top-level `vonage_users` scope +- Update dependency versions + +# 1.1.4 +- Support for Python 3.13, drop support for 3.8 + +# 1.1.3 +- Add docstrings to data models + +# 1.1.2 +- Internal refactoring + +# 1.1.1 +- Update minimum dependency version + +# 1.1.0 +- Add `http_client` property +- Rename `ListUsersRequest` -> `ListUsersFilter` +- Internal refactoring + +# 1.0.1 +- Internal refactoring + +# 1.0.0 +- Initial upload diff --git a/users/README.md b/users/README.md new file mode 100644 index 00000000..48aa6469 --- /dev/null +++ b/users/README.md @@ -0,0 +1,65 @@ +# Vonage Users Package + +This package contains the code to use Vonage's Users API in Python. + +It includes methods for managing users. + +## 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`. + +### List Users + +With no custom options specified, this method will get the last 100 users. It returns a tuple consisting of a list of `UserSummary` objects and a string describing the cursor to the next page of results. + +```python +from vonage_users import ListUsersRequest + +users, _ = vonage_client.users.list_users() + +# With options +params = ListUsersRequest( + page_size=10, + cursor=my_cursor, + order='desc', +) +users, next_cursor = vonage_client.users.list_users(params) +``` + +### Create a New User + +```python +from vonage_users import User, Channels, SmsChannel +user_options = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')]), +) +user = vonage_client.users.create_user(user_options) +``` + +### Get a User + +```python +user = client.users.get_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') +user_as_dict = user.model_dump(exclude_none=True) +``` + +### Update a User +```python +from vonage_users import User, Channels, SmsChannel, WhatsappChannel +user_options = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')], whatsapp=[WhatsappChannel(number='9876543210')]), +) +user = vonage_client.users.update_user(id, user_options) +``` + +### Delete a User + +```python +vonage_client.users.delete_user(id) +``` \ No newline at end of file diff --git a/users/pyproject.toml b/users/pyproject.toml new file mode 100644 index 00000000..4aa2f5d4 --- /dev/null +++ b/users/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-users' +dynamic = ["version"] +description = 'Vonage Users package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_users._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/users/src/vonage_users/BUILD b/users/src/vonage_users/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/users/src/vonage_users/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/users/src/vonage_users/__init__.py b/users/src/vonage_users/__init__.py new file mode 100644 index 00000000..de647262 --- /dev/null +++ b/users/src/vonage_users/__init__.py @@ -0,0 +1,35 @@ +from .common import ( + Channels, + MessengerChannel, + MmsChannel, + Properties, + PstnChannel, + SipChannel, + SmsChannel, + User, + VbcChannel, + ViberChannel, + WebsocketChannel, + WhatsappChannel, +) +from .requests import ListUsersFilter +from .responses import UserSummary +from .users import Users + +__all__ = [ + 'User', + 'PstnChannel', + 'SipChannel', + 'WebsocketChannel', + 'VbcChannel', + 'SmsChannel', + 'MmsChannel', + 'WhatsappChannel', + 'ViberChannel', + 'MessengerChannel', + 'Channels', + 'Properties', + 'ListUsersFilter', + 'UserSummary', + 'Users', +] diff --git a/users/src/vonage_users/_version.py b/users/src/vonage_users/_version.py new file mode 100644 index 00000000..58d478ab --- /dev/null +++ b/users/src/vonage_users/_version.py @@ -0,0 +1 @@ +__version__ = '1.2.0' diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py new file mode 100644 index 00000000..0fb785a8 --- /dev/null +++ b/users/src/vonage_users/common.py @@ -0,0 +1,172 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import ResourceLink +from vonage_utils.types import PhoneNumber + + +class PstnChannel(BaseModel): + """Model for a PSTN channel. + + Args: + number (int): The PSTN number. + """ + + number: int + + +class SipChannel(BaseModel): + """Model for a SIP channel. + + Args: + uri (str): The SIP URI. + username (str, Optional): The username for the SIP channel. + password (str, Optional): The password for the SIP channel. + """ + + uri: str = Field(..., pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)') + username: str = None + password: str = None + + +class VbcChannel(BaseModel): + """Model for a VBC channel. + + Args: + extension (str): The VBC extension. + """ + + extension: str + + +class WebsocketChannel(BaseModel): + """Model for a WebSocket channel. + + Args: + uri (str): URI for the WebSocket. + content_type (str, Optional): Content type for the WebSocket. + headers (dict, Optional): Headers sent to the WebSocket. + """ + + 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): + """Model for an SMS channel. + + Args: + number (PhoneNumber): The phone number for the SMS channel. + """ + + number: PhoneNumber + + +class MmsChannel(BaseModel): + """Model for an MMS channel. + + Args: + number (PhoneNumber): The phone number for the MMS channel. + """ + + number: PhoneNumber + + +class WhatsappChannel(BaseModel): + """Model for a WhatsApp channel. + + Args: + number (PhoneNumber): The phone number for the WhatsApp channel. + """ + + number: PhoneNumber + + +class ViberChannel(BaseModel): + """Model for a Viber channel. + + Args: + number (PhoneNumber): The phone number for the Viber channel. + """ + + number: PhoneNumber + + +class MessengerChannel(BaseModel): + """Model for a Messenger channel. + + Args: + id (str): The ID for the Messenger channel. + """ + + id: str + + +class Channels(BaseModel): + """Model for channels associated with a user account. + + Args: + sms (list[SmsChannel], Optional): A list of SMS channels. + mms (list[MmsChannel], Optional): A list of MMS channels. + whatsapp (list[WhatsappChannel], Optional): A list of WhatsApp channels. + viber (list[ViberChannel], Optional): A list of Viber channels. + messenger (list[MessengerChannel], Optional): A list of Messenger channels. + pstn (list[PstnChannel], Optional): A list of PSTN channels. + sip (list[SipChannel], Optional): A list of SIP channels. + websocket (list[WebsocketChannel], Optional): A list of WebSocket channels. + vbc (list[VbcChannel], Optional): A list of VBC channels. + """ + + 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): + """Model for properties associated with a user account. + + Args: + custom_data (dict, Optional): Custom data associated with the user. + """ + + custom_data: Optional[dict] = None + + +class User(BaseModel): + """Model for a user. + + Args: + name (str, Optional): The name of the user. + display_name (str, Optional): A string to be displayed as user name. It does not + need to be unique. + image_url (str, Optional): An image URL that you associate with the user. + channels (Channels, Optional): The channels associated with the user. + properties (Properties, Optional): The properties associated with the user. + links (ResourceLink, Optional): Links associated with the user. + link (str, Optional): The `_self` link. + id (str, Optional): The ID of the user. + """ + + 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') + def get_link(self): + if self.links is not None: + self.link = self.links.self.href + return self diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py new file mode 100644 index 00000000..564c36dc --- /dev/null +++ b/users/src/vonage_users/requests.py @@ -0,0 +1,23 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class ListUsersFilter(BaseModel): + """Request object for listing users. + + Args: + page_size (int, Optional): The number of users to return per response. + order (str, Optional): Return the records in ascending or descending order. + cursor (str, Optional): The cursor to start returning results from. You must + follow the url provided in the response tuple which contains a cursor value. + name (str, Optional): The name of the user to filter by. + """ + + page_size: Optional[int] = Field(100, 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 diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py new file mode 100644 index 00000000..6e21051a --- /dev/null +++ b/users/src/vonage_users/responses.py @@ -0,0 +1,68 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import Link, ResourceLink + + +class Links(BaseModel): + """Model for links following a version of the HAL standard. + + Args: + self (Link): The self link. + first (Link): The first link. + next (Link, Optional): The next link. + prev (Link, Optional): The previous link. + """ + + self: Link + first: Link + next: Optional[Link] = None + prev: Optional[Link] = None + + +class UserSummary(BaseModel): + """Model for a user summary - a subset of user information. + + Args: + id (str, Optional): The user ID. + name (str, Optional): The name of the user. + display_name (str, Optional): The display name of the user. + links (ResourceLink, Optional): Links to the user resource. + link (str, Optional): The `_self` link. + """ + + id: Optional[str] + name: Optional[str] + display_name: Optional[str] = None + links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True) + link: Optional[str] = None + + @model_validator(mode='after') + def get_link(self): + if self.links is not None: + self.link = self.links.self.href + return self + + +class Embedded(BaseModel): + """Model for embedded resources. + + Args: + users (list[UserSummary]): A list of user summaries. + """ + + users: list[UserSummary] = [] + + +class ListUsersResponse(BaseModel): + """Model for a response containing a list of users. + + Args: + page_size (int): The number of users returned in the response. + embedded (Embedded): Embedded resources. + links (Links): Links to other pages of users. + """ + + page_size: int + embedded: Embedded = Field(..., validation_alias='_embedded') + links: Links = Field(..., validation_alias='_links') diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py new file mode 100644 index 00000000..bb39727a --- /dev/null +++ b/users/src/vonage_users/users.py @@ -0,0 +1,128 @@ +from typing import Optional +from urllib.parse import parse_qs, urlparse + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .common import User +from .requests import ListUsersFilter +from .responses import ListUsersResponse, UserSummary + + +class Users: + """Class containing methods for user management. + + When using APIs that require a Vonage Application to be created, you can create users + to associate with that application. + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'jwt' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Users API. + + Returns: + HttpClient: The HTTP client used to make requests to the Users API. + """ + return self._http_client + + @validate_call + def list_users( + self, filter: ListUsersFilter = ListUsersFilter() + ) -> tuple[list[UserSummary], Optional[str]]: + """List all users. + + Retrieves a list of all users. Gets 100 users by default. + If you want to see more information about a specific user, you can use the + `Users.get_user` method. + + Args: + params (ListUsersFilter, optional): An instance of the `ListUsersFilter` + class that allows you to specify additional parameters for the user listing. + + Returns: + tuple[list[UserSummary], Optional[str]]: A tuple containing a list of `UserSummary` + objects representing the users and a string representing the next cursor for + pagination, if there are more results than the specified `page_size`. + """ + response = self._http_client.get( + self._http_client.api_host, + '/v1/users', + filter.model_dump(exclude_none=True), + self._auth_type, + ) + + users_response = ListUsersResponse(**response) + if users_response.links.next is None: + return users_response.embedded.users, None + + parsed_url = urlparse(users_response.links.next.href) + query_params = parse_qs(parsed_url.query) + next_cursor = query_params.get('cursor', [None])[0] + return users_response.embedded.users, next_cursor + + @validate_call + def create_user(self, params: Optional[User] = None) -> User: + """Create a new user. + + Args: + params (Optional[User]): An optional `User` object containing the parameters for creating a new user. + + Returns: + User: A `User` object representing the newly created user. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v1/users', + params.model_dump(exclude_none=True) if params is not None else None, + self._auth_type, + ) + return User(**response) + + @validate_call + def get_user(self, id: str) -> User: + """Get a user by ID. + + Args: + id (str): The ID of the user to retrieve. + + Returns: + User: The user object. + """ + response = self._http_client.get( + self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type + ) + return User(**response) + + @validate_call + def update_user(self, id: str, params: User) -> User: + """Update a user. + + Args: + id (str): The ID of the user to update. + params (User): The updated user object. + + Returns: + User: The updated user object. + """ + response = self._http_client.patch( + self._http_client.api_host, + f'/v1/users/{id}', + params.model_dump(exclude_none=True), + self._auth_type, + ) + return User(**response) + + @validate_call + def delete_user(self, id: str) -> None: + """Delete a user. + + Args: + id (str): The ID of the user to delete. + """ + self._http_client.delete( + self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type + ) diff --git a/users/tests/BUILD b/users/tests/BUILD new file mode 100644 index 00000000..7dfd162c --- /dev/null +++ b/users/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['users', 'testutils']) diff --git a/users/tests/data/list_users.json b/users/tests/data/list_users.json new file mode 100644 index 00000000..4faf2cc9 --- /dev/null +++ b/users/tests/data/list_users.json @@ -0,0 +1,82 @@ +{ + "_embedded": { + "users": [ + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af9" + } + }, + "id": "USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af9", + "name": "NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33d" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-d3a1b6cd-15b1-48e5-bef6-457c447adff5" + } + }, + "id": "USR-d3a1b6cd-15b1-48e5-bef6-457c447adff5", + "name": "NAM-9c31641c-03c3-476b-827a-9b0dd1570eed" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-37a8299f-eaad-417c-a0b3-431b6555c4bf" + } + }, + "display_name": "My Other User Name", + "id": "USR-37a8299f-eaad-417c-a0b3-431b6555c4bf", + "name": "my_other_user_name" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef423" + } + }, + "display_name": "My User Name", + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef423", + "name": "my_user_name" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b" + } + }, + "display_name": "My New Renamed User Name", + "id": "USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b", + "name": "new name!" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-caa2617b-1ea3-4d92-b780-e3c68279022e" + } + }, + "display_name": "My Third User Name", + "id": "USR-caa2617b-1ea3-4d92-b780-e3c68279022e", + "name": "third_user_name" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e2" + } + }, + "id": "USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e2", + "name": "update_name" + } + ] + }, + "_links": { + "first": { + "href": "https://api-us-3.vonage.com/v1/users?page_size=10" + }, + "self": { + "href": "https://api-us-3.vonage.com/v1/users?page_size=10&cursor=9DiU1E9z%2B6q7pNXgk7VTuJcyY2npz310oI6Ohjq5Sy0rKDf2ld0dYCE%3D" + } + }, + "page_size": 10 +} \ No newline at end of file diff --git a/users/tests/data/list_users_options.json b/users/tests/data/list_users_options.json new file mode 100644 index 00000000..c7c0faea --- /dev/null +++ b/users/tests/data/list_users_options.json @@ -0,0 +1,41 @@ +{ + "page_size": 2, + "_embedded": { + "users": [ + { + "id": "USR-37a8299f-eaad-417c-a0b3-431b6555c4be", + "name": "my_other_user_name", + "display_name": "My Other User Name", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-37a8299f-eaad-417c-a0b3-431b6555c4be" + } + } + }, + { + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", + "name": "my_user_name", + "display_name": "My User Name", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" + } + } + } + ] + }, + "_links": { + "first": { + "href": "https://api-us-3.vonage.com/v1/users?page_size=2" + }, + "self": { + "href": "https://api-us-3.vonage.com/v1/users?page_size=2&cursor=ItiNOQpJ7IOaL%2FvgHcixE8j8yw8VV0viPWw9nZEeO4%2Fp2DrDr4Qa7CtLAi5ST94XVpiwIJvkUBJ1U%2BL4S%2BK3cSLht3QkP3hmL1pKgkNGW6IdOzHGkpr7v0WsMOY%3D" + }, + "next": { + "href": "https://api-us-3.vonage.com/v1/users?page_size=2&cursor=Rv1d7qE3lDuOuwSFjRGHJ2JpKG28CdI1iNjSKNwy0NIr7uicrn7SGpIyaDtvkEEBfyH5xyjSonpeoYNLdw19SQ%3D%3D" + }, + "prev": { + "href": "https://api-us-3.vonage.com/v1/users?page_size=2&cursor=6nFju6mYCT5FYbsnxvlJ4XFD1ekcwh6DP0%2BT5BVLvRdZTsqB0EA9j%2B0Bwfpr63xTF%2BZVe7R9QHqv2wH6nQhf7hFz%2B0Ux3g%3D%3D" + } + } +} \ No newline at end of file diff --git a/users/tests/data/updated_user.json b/users/tests/data/updated_user.json new file mode 100644 index 00000000..b3523419 --- /dev/null +++ b/users/tests/data/updated_user.json @@ -0,0 +1,23 @@ +{ + "id": "USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b", + "name": "new name!", + "display_name": "My New Renamed User Name", + "properties": {}, + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b" + } + }, + "channels": { + "sms": [ + { + "number": "1234567890" + } + ], + "pstn": [ + { + "number": 123456 + } + ] + } +} diff --git a/users/tests/data/user.json b/users/tests/data/user.json new file mode 100644 index 00000000..b334403f --- /dev/null +++ b/users/tests/data/user.json @@ -0,0 +1,20 @@ +{ + "id": "USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b", + "name": "my_user_name", + "display_name": "My User Name", + "properties": { + "custom_data": {} + }, + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b" + } + }, + "channels": { + "sms": [ + { + "number": "1234567890" + } + ] + } +} diff --git a/users/tests/data/user_not_found.json b/users/tests/data/user_not_found.json new file mode 100644 index 00000000..868b5561 --- /dev/null +++ b/users/tests/data/user_not_found.json @@ -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" +} \ No newline at end of file diff --git a/users/tests/test_users.py b/users/tests/test_users.py new file mode 100644 index 00000000..0112b705 --- /dev/null +++ b/users/tests/test_users.py @@ -0,0 +1,258 @@ +from os.path import abspath + +import responses +from vonage_http_client.errors import NotFoundError +from vonage_http_client.http_client import HttpClient +from vonage_users import Users +from vonage_users.common import * +from vonage_users.requests import ListUsersFilter + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +users = Users(HttpClient(get_mock_jwt_auth())) + + +def test_create_list_users_request(): + params = { + 'page_size': 20, + 'order': 'desc', + 'cursor': '7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=', + 'name': 'my_user', + } + list_users_request = ListUsersFilter(**params) + + assert list_users_request.model_dump() == params + + +@responses.activate +def test_list_users(): + build_response(path, 'GET', 'https://api.nexmo.com/v1/users', 'list_users.json') + users_list, _ = users.list_users() + assert len(users_list) == 7 + assert users_list[0].id == 'USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af9' + assert users_list[0].name == 'NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33d' + assert users_list[3].id == 'USR-5ab17d58-b8b3-427d-ac42-c31dab7ef423' + assert users_list[3].name == 'my_user_name' + assert users_list[3].display_name == 'My User Name' + assert ( + users_list[0].link + == 'https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af9' + ) + assert ( + users_list[6].link + == 'https://api-us-3.vonage.com/v1/users/USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e2' + ) + + +@responses.activate +def test_list_users_options(): + build_response( + path, 'GET', 'https://api.nexmo.com/v1/users', 'list_users_options.json' + ) + + params = ListUsersFilter( + page_size=2, + order='asc', + cursor='zAmuSchIBsUF1QaaohGdaf32NgHOkP130XeQrZkoOPEuGPnIxFb0Xj3iqCfOzxSSq9Es/S/2h+HYumKt3HS0V9ewjis+j74oMcsvYBLN1PwFEupI6ENEWHYC7lk=', + ) + users_list, next = users.list_users(params) + + assert users_list[0].id == 'USR-37a8299f-eaad-417c-a0b3-431b6555c4be' + assert users_list[0].name == 'my_other_user_name' + assert users_list[0].display_name == 'My Other User Name' + assert ( + users_list[0].link + == 'https://api-us-3.vonage.com/v1/users/USR-37a8299f-eaad-417c-a0b3-431b6555c4be' + ) + assert users_list[1].id == 'USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' + assert ( + next + == 'Rv1d7qE3lDuOuwSFjRGHJ2JpKG28CdI1iNjSKNwy0NIr7uicrn7SGpIyaDtvkEEBfyH5xyjSonpeoYNLdw19SQ==' + ) + + +def test_create_user_model_from_dict(): + user_dict = { + 'name': 'my_user_name', + 'display_name': 'My User Name', + 'image_url': 'https://example.com/image.jpg', + 'properties': {'custom_data': {'key': 'value'}}, + 'channels': { + 'sms': [{'number': '1234567890'}], + 'mms': [{'number': '1234567890'}], + 'whatsapp': [{'number': '1234567890'}], + 'viber': [{'number': '1234567890'}], + 'messenger': [{'id': 'asdf1234'}], + 'pstn': [{'number': 1234}], + 'sip': [ + { + 'uri': 'sip:4442138907@sip.example.com;transport=tls', + 'username': 'My User SIP', + 'password': 'Password', + } + ], + 'websocket': [ + { + 'uri': 'wss://example.com/socket', + 'content-type': 'audio/l16;rate=16000', + 'headers': {'customer_id': 'ABC123'}, + } + ], + 'vbc': [{'extension': '403'}], + }, + } + + user = User(**user_dict) + assert user.model_dump(by_alias=True, exclude_none=True) == user_dict + + +def test_create_user_model_from_models(): + user = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')]), + ) + assert user.model_dump(exclude_none=True) == { + 'name': 'my_user_name', + 'display_name': 'My User Name', + 'properties': {}, + 'channels': {'sms': [{'number': '1234567890'}]}, + } + + +@responses.activate +def test_create_user(): + build_response(path, 'POST', 'https://api.nexmo.com/v1/users', 'user.json', 201) + user_params = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')]), + ) + user = users.create_user(user_params) + assert user.id == 'USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b' + assert user.name == 'my_user_name' + assert user.display_name == 'My User Name' + assert user.channels.sms[0].number == '1234567890' + assert ( + user.link + == 'https://api-us-3.vonage.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b' + ) + + +@responses.activate +def test_get_user(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', + 'user.json', + 200, + ) + + user = users.get_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') + assert user.id == 'USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b' + assert user.name == 'my_user_name' + assert user.display_name == 'My User Name' + assert ( + user.link + == 'https://api-us-3.vonage.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b' + ) + + +@responses.activate +def test_get_user_not_found_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', + 'user_not_found.json', + 404, + ) + + try: + users.get_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') + except NotFoundError as err: + assert ( + '404 response from https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b.' + in err.message + ) + + +@responses.activate +def test_update_user(): + build_response( + path, + 'PATCH', + 'https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', + 'updated_user.json', + 200, + ) + user_params = User( + name='new name!', + display_name='My New Renamed User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels( + sms=[SmsChannel(number='1234567890')], pstn=[PstnChannel(number=123456)] + ), + ) + user = users.update_user( + id='USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', params=user_params + ) + assert user.id == 'USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b' + assert user.name == 'new name!' + assert user.display_name == 'My New Renamed User Name' + assert user.channels.sms[0].number == '1234567890' + assert user.channels.pstn[0].number == 123456 + assert ( + user.link + == 'https://api-us-3.vonage.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b' + ) + + +@responses.activate +def test_update_user_not_found_error(): + build_response( + path, + 'PATCH', + 'https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', + 'user_not_found.json', + 404, + ) + + try: + users.update_user( + id='USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', + params=User( + name='new name!', + display_name='My New Renamed User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels( + sms=[SmsChannel(number='1234567890')], + pstn=[PstnChannel(number=123456)], + ), + ), + ) + except NotFoundError as err: + assert ( + '404 response from https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b.' + in err.message + ) + + +@responses.activate +def test_delete_user(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v1/users/USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b', + status=204, + ) + assert users.delete_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') is None + + +def test_http_client_property(): + http_client = users.http_client + assert isinstance(http_client, HttpClient) 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..769d8e2b --- /dev/null +++ b/verify/CHANGES.md @@ -0,0 +1,21 @@ +# 2.0.0 +- Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. This package now contains code for the Verify v2 API +- Update dependency versions + +# 1.1.4 +- Support for Python 3.13, drop support for 3.8 + +# 1.1.3 +- Add docstrings for data models + +# 1.1.2 +- Allow minimum `channel_timeout` value to be 15 seconds + +# 1.1.1 +- Update minimum dependency version + +# 1.1.0 +- Add `http_client` property + +# 1.0.0 +- Initial upload diff --git a/verify/README.md b/verify/README.md new file mode 100644 index 00000000..24d8097f --- /dev/null +++ b/verify/README.md @@ -0,0 +1,54 @@ +# Vonage Verify Package + +This package contains the code to use [Vonage's Verify API](https://developer.vonage.com/en/verify/overview) in Python. This package includes methods for working with 2-factor authentication (2FA) messages sent via SMS, Voice, WhatsApp and Email. You can also make Silent Authentication requests with Verify to give your end user a more seamless experience. + +## 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 + +```python +from vonage_verify import VerifyRequest, SmsChannel +# All channels have associated models +sms_channel = SmsChannel(to='1234567890') +params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], +} +verify_request = VerifyRequest(**params) + +response = vonage_client.verify.start_verification(verify_request) +``` + +If using silent authentication, the response will include a `check_url` field with a url that should be accessed on the user's device to proceed with silent authentication. If used, silent auth must be the first element in the `workflow` list. + +```python +silent_auth_channel = SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890') +sms_channel = SmsChannel(to='1234567890') +params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], +} +verify_request = VerifyRequest(**params) + +response = vonage_client.verify.start_verification(verify_request) +``` + +### Check a Verification Code + +```python +vonage_client.verify.check_code(request_id='my_request_id', code='1234') +``` + +### Cancel a Verification + +```python +vonage_client.verify.cancel_verification('my_request_id') +``` + +### Trigger the Next Workflow Event + +```python +vonage_client.verify.trigger_next_workflow('my_request_id') +``` \ No newline at end of file diff --git a/verify/pyproject.toml b/verify/pyproject.toml new file mode 100644 index 00000000..6fa5e68e --- /dev/null +++ b/verify/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-verify' +dynamic = ["version"] +description = 'Vonage verify package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_verify._version.__version__" } + +[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..90218321 --- /dev/null +++ b/verify/src/vonage_verify/__init__.py @@ -0,0 +1,27 @@ +from .enums import ChannelType, Locale +from .errors import VerifyError +from .requests import ( + EmailChannel, + SilentAuthChannel, + SmsChannel, + VerifyRequest, + VoiceChannel, + WhatsappChannel, +) +from .responses import CheckCodeResponse, StartVerificationResponse +from .verify import Verify + +__all__ = [ + 'Verify', + 'VerifyError', + 'ChannelType', + 'CheckCodeResponse', + 'Locale', + 'VerifyRequest', + 'SilentAuthChannel', + 'SmsChannel', + 'WhatsappChannel', + 'VoiceChannel', + 'EmailChannel', + 'StartVerificationResponse', +] diff --git a/verify/src/vonage_verify/_version.py b/verify/src/vonage_verify/_version.py new file mode 100644 index 00000000..afced147 --- /dev/null +++ b/verify/src/vonage_verify/_version.py @@ -0,0 +1 @@ +__version__ = '2.0.0' diff --git a/verify/src/vonage_verify/enums.py b/verify/src/vonage_verify/enums.py new file mode 100644 index 00000000..0871f945 --- /dev/null +++ b/verify/src/vonage_verify/enums.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class ChannelType(str, Enum): + SILENT_AUTH = 'silent_auth' + SMS = 'sms' + WHATSAPP = 'whatsapp' + VOICE = 'voice' + EMAIL = 'email' + + +class Locale(str, Enum): + EN_US = 'en-us' + EN_GB = 'en-gb' + ES_ES = 'es-es' + ES_MX = 'es-mx' + ES_US = 'es-us' + IT_IT = 'it-it' + FR_FR = 'fr-fr' + DE_DE = 'de-de' + RU_RU = 'ru-ru' + HI_IN = 'hi-in' + PT_BR = 'pt-br' + PT_PT = 'pt-pt' + ID_ID = 'id-id' diff --git a/verify/src/vonage_verify/errors.py b/verify/src/vonage_verify/errors.py new file mode 100644 index 00000000..b2832d2f --- /dev/null +++ b/verify/src/vonage_verify/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class VerifyError(VonageError): + """Indicates an error when using the Vonage Verify API.""" diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py new file mode 100644 index 00000000..43d00c6b --- /dev/null +++ b/verify/src/vonage_verify/requests.py @@ -0,0 +1,184 @@ +from re import search +from typing import Optional, Union + +from pydantic import BaseModel, Field, field_validator, model_validator +from vonage_utils.types import PhoneNumber + +from .enums import ChannelType, Locale +from .errors import VerifyError + + +class Channel(BaseModel): + """Base model for a channel to use in a verification request. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + """ + + to: PhoneNumber + + +class SilentAuthChannel(Channel): + """Model for a Silent Authentication channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + redirect_url (str, Optional): Optional final redirect added at the end of the + check_url request/response lifecycle. Will contain the `request_id` and + `code` as a url fragment after the URL. + sandbox (bool, Optional): Whether you are using the sandbox to test Silent + Authentication integrations. + """ + + redirect_url: Optional[str] = None + sandbox: Optional[bool] = None + channel: ChannelType = ChannelType.SILENT_AUTH + + +class SmsChannel(Channel): + """Model for an SMS channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + from_ (Union[PhoneNumber, str], Optional): The sender of the SMS. This can be + a phone number in E.164 format without a leading `+` or `00`, or a string + of 3-11 alphanumeric characters. + app_hash (str, Optional): Optional Android Application Hash Key for automatic + code detection on a user's device. + entity_id (str, Optional): Optional PEID required for SMS delivery using Indian + carriers. + content_id (str, Optional): Optional PEID required for SMS delivery using Indian + carriers. + + Raises: + VerifyError: If the `from_` field is not a valid phone number. + """ + + from_: Optional[Union[PhoneNumber, str]] = Field(None, serialization_alias='from') + app_hash: Optional[str] = Field(None, min_length=11, max_length=11) + entity_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') + content_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') + channel: ChannelType = ChannelType.SMS + + @field_validator('from_') + @classmethod + def check_valid_from_field(cls, v): + if ( + v is not None + and type(v) is not PhoneNumber + and not search(r'^[a-zA-Z0-9]{3,11}$', v) + ): + raise VerifyError( + 'You must specify a valid "from_" value if included. ' + 'It must be a valid phone number without the leading +, or a string of 3-11 alphanumeric characters. ' + f'You set "from_": "{v}".' + ) + return v + + +class WhatsappChannel(Channel): + """Model for a WhatsApp channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + from_ (Union[PhoneNumber, str]): A WhatsApp Business Account (WABA)-connected + sender number, in the E.164 format. Don't use a leading + or 00 when entering + a phone number. + + Raises: + VerifyError: If the `from_` field is not a valid phone number or string of 3-11 + alphanumeric characters. + """ + + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + channel: ChannelType = ChannelType.WHATSAPP + + @field_validator('from_') + @classmethod + def check_valid_sender(cls, v): + if type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{3,11}$', v): + raise VerifyError( + f'You must specify a valid "from_" value. ' + 'It must be a valid phone number without the leading +, or a string of 3-11 alphanumeric characters. ' + f'You set "from_": "{v}".' + ) + return v + + +class VoiceChannel(Channel): + """Model for a Voice channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + """ + + channel: ChannelType = ChannelType.VOICE + + +class EmailChannel(Channel): + """Model for an Email channel. + + Args: + to (str): The email address to send the verification code to. + from_ (str, Optional): The email address of the sender. + """ + + to: str + from_: Optional[str] = Field(None, serialization_alias='from') + channel: ChannelType = ChannelType.EMAIL + + +class VerifyRequest(BaseModel): + """Request object for a verification request. + + Args: + brand (str): The name of the company or service that is sending the verification + request. This will appear in the body of the SMS or TTS message. + workflow (list[Union[SilentAuthChannel, SmsChannel, WhatsappChannel, VoiceChannel, EmailChannel]]): + The list of channels to use in the verification workflow. They will be used + in the order they are listed. + locale (Locale, Optional): The locale to use for the verification message. + channel_timeout (int, Optional): The time in seconds to wait between attempts to + deliver the verification code. + client_ref (str, Optional): A unique identifier for the verification request. If + the client_ref is set when the request is sent, it will be included in the + callbacks. + code_length (int, Optional): The length of the verification code to generate. + code (str, Optional): An optional alphanumeric custom code to use, if you don't + want Vonage to generate the code. + + Raises: + VerifyError: If the `workflow` list contains a Silent Authentication channel that + is not the first channel in the list. + """ + + brand: str = Field(..., min_length=1, max_length=16) + workflow: list[ + Union[ + SilentAuthChannel, + SmsChannel, + WhatsappChannel, + VoiceChannel, + EmailChannel, + ] + ] + locale: Optional[Locale] = None + channel_timeout: Optional[int] = Field(None, ge=15, le=900) + client_ref: Optional[str] = Field(None, min_length=1, max_length=16) + code_length: Optional[int] = Field(None, ge=4, le=10) + code: Optional[str] = Field(None, pattern=r'^[a-zA-Z0-9]{4,10}$') + + @model_validator(mode='after') + def check_silent_auth_first_if_present(self): + if len(self.workflow) > 1: + for i in range(1, len(self.workflow)): + if isinstance(self.workflow[i], SilentAuthChannel): + raise VerifyError( + 'If using Silent Authentication, it must be the first channel in the "workflow" list.' + ) + return self diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py new file mode 100644 index 00000000..f7acb4e0 --- /dev/null +++ b/verify/src/vonage_verify/responses.py @@ -0,0 +1,28 @@ +from typing import Optional + +from pydantic import BaseModel + + +class StartVerificationResponse(BaseModel): + """Model for the response of a start verification request. + + Args: + request_id (str): The request ID. + check_url (str, Optional): URL for Silent Authentication Verify workflow + completion (only shows if using Silent Auth). + """ + + request_id: str + check_url: Optional[str] = None + + +class CheckCodeResponse(BaseModel): + """Model for the response of a check code request. + + Args: + request_id (str): The request ID. + status (str): The status of the verification request. + """ + + request_id: str + status: str diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py new file mode 100644 index 00000000..20eabc22 --- /dev/null +++ b/verify/src/vonage_verify/verify.py @@ -0,0 +1,80 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .requests import VerifyRequest +from .responses import CheckCodeResponse, StartVerificationResponse + + +class Verify: + """Calls Vonage's Verify API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Verify API. + + Returns: + HttpClient: The HTTP client used to make requests to the Verify API. + """ + return self._http_client + + @validate_call + def start_verification( + self, verify_request: VerifyRequest + ) -> StartVerificationResponse: + """Start a verification process. + + Args: + verify_request (VerifyRequest): The verification request object. + + Returns: + StartVerificationResponse: The response object containing the `request_id`. + If requesting Silent Authentication, it will also contain a `check_url` field. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v2/verify', + verify_request.model_dump(by_alias=True, exclude_none=True), + ) + + return StartVerificationResponse(**response) + + @validate_call + def check_code(self, request_id: str, code: str) -> CheckCodeResponse: + """Check a verification code. + + Args: + request_id (str): The request ID. + code (str): The verification code. + + Returns: + CheckCodeResponse: The response object containing the verification result. + """ + response = self._http_client.post( + self._http_client.api_host, f'/v2/verify/{request_id}', {'code': code} + ) + return CheckCodeResponse(**response) + + @validate_call + def cancel_verification(self, request_id: str) -> None: + """Cancel a verification request. + + Args: + request_id (str): The request ID. + """ + self._http_client.delete(self._http_client.api_host, f'/v2/verify/{request_id}') + + @validate_call + def trigger_next_workflow(self, request_id: str) -> None: + """Trigger the next workflow event in the list of workflows passed in when making + the request. + + Args: + request_id (str): The request ID. + """ + self._http_client.post( + self._http_client.api_host, + f'/v2/verify/{request_id}/next_workflow', + ) 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/check_code.json b/verify/tests/data/check_code.json new file mode 100644 index 00000000..2fbe4b8e --- /dev/null +++ b/verify/tests/data/check_code.json @@ -0,0 +1,4 @@ +{ + "request_id": "36e7060d-2b23-4257-bad0-773ab47f85ef", + "status": "completed" +} \ No newline at end of file diff --git a/tests/data/verify2/invalid_code.json b/verify/tests/data/check_code_400.json similarity index 75% rename from tests/data/verify2/invalid_code.json rename to verify/tests/data/check_code_400.json index 6e6d7b17..23690a83 100644 --- a/tests/data/verify2/invalid_code.json +++ b/verify/tests/data/check_code_400.json @@ -2,5 +2,5 @@ "type": "https://developer.nexmo.com/api-errors#bad-request", "title": "Invalid Code", "detail": "The code you provided does not match the expected value.", - "instance": "16d6bca6-c0dc-4add-94b2-0dbc12cba83b" + "instance": "475343c0-9239-4715-aed1-72b4a18379d1" } \ No newline at end of file diff --git a/tests/data/verify2/too_many_code_attempts.json b/verify/tests/data/check_code_410.json similarity index 76% rename from tests/data/verify2/too_many_code_attempts.json rename to verify/tests/data/check_code_410.json index 50b05c09..9d2534c7 100644 --- a/tests/data/verify2/too_many_code_attempts.json +++ b/verify/tests/data/check_code_410.json @@ -1,6 +1,6 @@ { "title": "Invalid Code", "detail": "An incorrect code has been provided too many times. Workflow terminated.", - "instance": "060246db-1c9f-4fdf-b9fa-2bd8b772f5d9", + "instance": "f79d7a15-30b7-498a-bc99-4e879b836b18", "type": "https://developer.nexmo.com/api-errors#gone" } \ No newline at end of file diff --git a/verify/tests/data/trigger_next_workflow_error.json b/verify/tests/data/trigger_next_workflow_error.json new file mode 100644 index 00000000..befd87a7 --- /dev/null +++ b/verify/tests/data/trigger_next_workflow_error.json @@ -0,0 +1,6 @@ +{ + "title": "Conflict", + "detail": "There are no more events left to trigger.", + "instance": "4d731cb7-25d3-487a-9ea0-f6b5811b534f", + "type": "https://developer.nexmo.com/api-errors#conflict" +} \ No newline at end of file diff --git a/verify/tests/data/verify_request.json b/verify/tests/data/verify_request.json new file mode 100644 index 00000000..719396cc --- /dev/null +++ b/verify/tests/data/verify_request.json @@ -0,0 +1,4 @@ +{ + "request_id": "2c59e3f4-a047-499f-a14f-819cd1989d2e", + "check_url": "https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect" +} \ No newline at end of file diff --git a/verify/tests/data/verify_request_error.json b/verify/tests/data/verify_request_error.json new file mode 100644 index 00000000..f40b1b12 --- /dev/null +++ b/verify/tests/data/verify_request_error.json @@ -0,0 +1,6 @@ +{ + "title": "Conflict", + "detail": "Concurrent verifications to the same number are not allowed", + "instance": "229ececf-382e-4ab6-b380-d8e0e830fd44", + "request_id": "f8386e0f-6873-4617-aa99-19016217b2aa" +} \ No newline at end of file diff --git a/verify/tests/test_models.py b/verify/tests/test_models.py new file mode 100644 index 00000000..88c075a5 --- /dev/null +++ b/verify/tests/test_models.py @@ -0,0 +1,138 @@ +from pytest import raises +from vonage_verify.enums import ChannelType, Locale +from vonage_verify.errors import VerifyError +from vonage_verify.requests import * + + +def test_create_silent_auth_channel(): + params = { + 'channel': ChannelType.SILENT_AUTH, + 'to': '1234567890', + 'redirect_url': 'https://example.com', + 'sandbox': True, + } + channel = SilentAuthChannel(**params) + + assert channel.model_dump() == params + + +def test_create_sms_channel(): + params = { + 'channel': ChannelType.SMS, + 'to': '1234567890', + 'from_': 'Vonage', + 'entity_id': '12345678901234567890', + 'content_id': '12345678901234567890', + 'app_hash': '12345678901', + } + channel = SmsChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + SmsChannel(**params) + + +def test_create_whatsapp_channel(): + params = { + 'channel': ChannelType.WHATSAPP, + 'to': '1234567890', + 'from_': 'Vonage', + } + channel = WhatsappChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + WhatsappChannel(**params) + + +def test_create_voice_channel(): + params = { + 'channel': ChannelType.VOICE, + 'to': '1234567890', + } + channel = VoiceChannel(**params) + + assert channel.model_dump() == params + + +def test_create_email_channel(): + params = { + 'channel': ChannelType.EMAIL, + 'to': 'customer@example.com', + 'from_': 'vonage@vonage.com', + } + channel = EmailChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'vonage@vonage.com' + + +def test_create_verify_request(): + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + # Basic request + + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [sms_channel] + + # Multiple channel request + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == workflow + + # All fields + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel, sms_channel], + 'locale': Locale.EN_GB, + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [silent_auth_channel, sms_channel, sms_channel] + assert verify_request.locale == Locale.EN_GB + assert verify_request.channel_timeout == 60 + assert verify_request.client_ref == 'my-client-ref' + assert verify_request.code_length == 6 + assert verify_request.code == '123456' + + +def test_create_verify_request_error(): + params = { + 'brand': 'Vonage', + 'workflow': [ + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + ], + } + with raises(VerifyError) as e: + VerifyRequest(**params) + assert e.match('must be the first channel') diff --git a/verify/tests/test_verify.py b/verify/tests/test_verify.py new file mode 100644 index 00000000..40251c6b --- /dev/null +++ b/verify/tests/test_verify.py @@ -0,0 +1,189 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_verify.requests import * +from vonage_verify.verify import Verify + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +verify = Verify(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert ( + response.check_url + == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' + ) + assert verify._http_client.last_response.status_code == 202 + + +@responses.activate +def test_make_verify_request_full(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + 'locale': 'en-gb', + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + + +@responses.activate +def test_verify_request_concurrent_verifications_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify', + 'verify_request_error.json', + 409, + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + request = VerifyRequest(**params) + + with raises(HttpRequestError) as e: + verify.start_verification(request) + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] + == 'Concurrent verifications to the same number are not allowed' + ) + + +@responses.activate +def test_check_code(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', + ) + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' + ) + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' + + +@responses.activate +def test_check_code_invalid_code_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_400.json', + 400, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 400 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_check_code_too_many_attempts(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_410.json', + 410, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 410 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_cancel_verification(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, + ) + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 + + +@responses.activate +def test_trigger_next_workflow(): + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, + ) + assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 200 + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + 'trigger_next_workflow_error.json', + status_code=409, + ) + + with raises(HttpRequestError) as e: + verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] == 'There are no more events left to trigger.' + ) + + +def test_http_client_property(): + http_client = verify.http_client + assert isinstance(http_client, HttpClient) diff --git a/verify_legacy/BUILD b/verify_legacy/BUILD new file mode 100644 index 00000000..5459756b --- /dev/null +++ b/verify_legacy/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_legacy/src/vonage_verify_legacy', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/verify_legacy/CHANGES.md b/verify_legacy/CHANGES.md new file mode 100644 index 00000000..9a43ef2c --- /dev/null +++ b/verify_legacy/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload as `legacy` package diff --git a/verify_legacy/README.md b/verify_legacy/README.md new file mode 100644 index 00000000..89f0812b --- /dev/null +++ b/verify_legacy/README.md @@ -0,0 +1,63 @@ +# Vonage Legacy Verify Package + +This package contains the code to use Vonage's legacy Verify API in Python. This package includes methods for working with 2-factor authentication (2FA) messages sent via SMS or TTS. + +Note: There is a more current package available: [Vonage's Verify API](https://developer.vonage.com/en/verify/overview), which is recommended for most use cases. The newer API lets you send messages via multiple channels, including Email, SMS, MMS, WhatsApp, Messenger and others. You can also make Silent Authentication requests with the new Verify package to give an end user a more seamless experience. + +## 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 + +```python +from vonage_verify_legacy import VerifyRequest +params = {'number': '1234567890', 'brand': 'Acme Inc.'} +request = VerifyRequest(**params) +response = vonage_client.verify_legacy.start_verification(request) +``` + +### Make a PSD2 (Payment Services Directive v2) Request + +```python +from vonage_verify_legacy import Psd2Request +params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} +request = VerifyRequest(**params) +response = vonage_client.verify_legacy.start_verification(request) +``` + +### Check a Verification Code + +```python +vonage_client.verify_legacy.check_code(request_id='my_request_id', code='1234') +``` + +### Search Verification Requests + +```python +# Search for single request +response = vonage_client.verify_legacy.search('my_request_id') + +# Search for multiple requests +response = vonage_client.verify_legacy.search(['my_request_id_1', 'my_request_id_2']) +``` + +### Cancel a Verification + +```python +response = vonage_client.verify_legacy.cancel_verification('my_request_id') +``` + +### Trigger the Next Workflow Event + +```python +response = vonage_client.verify_legacy.trigger_next_event('my_request_id') +``` + +### Request a Network Unblock + +Note: Network Unblock is switched off by default. Contact Sales to enable the Network Unblock API for your account. + +```python +response = vonage_client.verify_legacy.request_network_unblock('23410') +``` diff --git a/verify_legacy/pyproject.toml b/verify_legacy/pyproject.toml new file mode 100644 index 00000000..f2811534 --- /dev/null +++ b/verify_legacy/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-verify-legacy' +dynamic = ["version"] +description = 'Vonage legacy verify package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_verify_legacy._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/verify_legacy/src/vonage_verify_legacy/BUILD b/verify_legacy/src/vonage_verify_legacy/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/verify_legacy/src/vonage_verify_legacy/__init__.py b/verify_legacy/src/vonage_verify_legacy/__init__.py new file mode 100644 index 00000000..c15ddeaf --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/__init__.py @@ -0,0 +1,25 @@ +from .errors import VerifyError +from .language_codes import LanguageCode, Psd2LanguageCode +from .requests import Psd2Request, VerifyRequest +from .responses import ( + CheckCodeResponse, + NetworkUnblockStatus, + StartVerificationResponse, + VerifyControlStatus, + VerifyStatus, +) +from .verify_legacy import VerifyLegacy + +__all__ = [ + 'VerifyError', + 'VerifyLegacy', + 'LanguageCode', + 'Psd2LanguageCode', + 'Psd2Request', + 'VerifyRequest', + 'CheckCodeResponse', + 'NetworkUnblockStatus', + 'StartVerificationResponse', + 'VerifyControlStatus', + 'VerifyStatus', +] diff --git a/verify_legacy/src/vonage_verify_legacy/_version.py b/verify_legacy/src/vonage_verify_legacy/_version.py new file mode 100644 index 00000000..1f356cc5 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.0' diff --git a/verify_legacy/src/vonage_verify_legacy/errors.py b/verify_legacy/src/vonage_verify_legacy/errors.py new file mode 100644 index 00000000..6d2358a8 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class VerifyError(VonageError): + """Indicates an error when using the legacy Vonage Verify API.""" diff --git a/verify_legacy/src/vonage_verify_legacy/language_codes.py b/verify_legacy/src/vonage_verify_legacy/language_codes.py new file mode 100644 index 00000000..3c17b851 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/language_codes.py @@ -0,0 +1,71 @@ +from enum import Enum + + +class LanguageCode(str, Enum): + """Language code used in a specific Verify request.""" + + 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(str, Enum): + """Language code used in a specific Verify PSD2 request.""" + + 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_legacy/src/vonage_verify_legacy/requests.py b/verify_legacy/src/vonage_verify_legacy/requests.py new file mode 100644 index 00000000..bc191d75 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/requests.py @@ -0,0 +1,120 @@ +from logging import getLogger +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types 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. + + Args: + number (PhoneNumber): The phone number to verify. Unless you are setting country + explicitly, this number must be in E.164 format. + country (str, Optional): If you do not provide `number` in international format + or you are not sure if `number` is correctly formatted, specify the + two-character country code in country. Verify will then format the number for + you. + code_length (int, Optional): The length of the verification code to generate. + pin_expiry (int, Optional): How long the generated verification code is valid + for, in seconds. When you specify both `pin_expiry` and `next_event_wait` + then `pin_expiry` must be an integer multiple of `next_event_wait` otherwise + `pin_expiry` is defaulted to equal `next_event_wait`. + next_event_wait (int, Optional): The wait time in seconds between attempts to + deliver the verification code. + workflow_id (int, Optional): Selects the predefined sequence of SMS and TTS (Text + To Speech) actions to use in order to convey the PIN to your user. + """ + + number: PhoneNumber + country: Optional[str] = Field(None, max_length=2) + code_length: Optional[Literal[4, 6]] = None + pin_expiry: Optional[int] = Field(None, ge=60, le=3600) + next_event_wait: Optional[int] = Field(None, ge=60, le=900) + workflow_id: Optional[int] = Field(None, ge=1, le=7) + + @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.warning( + f'The pin_expiry should be a multiple of next_event_wait.' + f'\nThe current values are: pin_expiry={self.pin_expiry}, next_event_wait={self.next_event_wait}.' + f'\nThe 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. + + Args: + number (PhoneNumber): The phone number to verify. Unless you are setting country + explicitly, this number must be in E.164 format. + country (str, Optional): If you do not provide `number` in international format + or you are not sure if `number` is correctly formatted, specify the + two-character country code in country. Verify will then format the number for + you. + brand (str): The name of the company or service that is sending the verification + request. This will appear in the body of the SMS or TTS message. + code_length (int, Optional): The length of the verification code to generate. + pin_expiry (int, Optional): How long the generated verification code is valid + for, in seconds. When you specify both `pin_expiry` and `next_event_wait` + then `pin_expiry` must be an integer multiple of `next_event_wait` otherwise + `pin_expiry` is defaulted to equal `next_event_wait`. + next_event_wait (int, Optional): The wait time in seconds between attempts to + deliver the verification code. + workflow_id (int, Optional): Selects the predefined sequence of SMS and TTS (Text + To Speech) actions to use in order to convey the PIN to your user. + sender_id (str, Optional): An 11-character alphanumeric string that represents the + sender of the verification request. Depending on the location of the phone + number, restrictions may apply. + lg (LanguageCode, Optional): The language to use for the verification message. + pin_code (str, Optional): The verification code to send to the user. If you do not + provide this, Vonage will generate a code for you. + """ + + brand: str = Field(..., max_length=18) + sender_id: Optional[str] = Field(None, 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. + + Args: + number (PhoneNumber): The phone number to verify. Unless you are setting country + explicitly, this number must be in E.164 format. + payee (str): An alphanumeric string to indicate to the user the name of the + recipient that they are confirming a payment to. + amount (float): The decimal amount of the payment to be confirmed, in Euros. + country (str, Optional): If you do not provide `number` in international + format or you are not sure if `number` is correctly formatted, specify the + two-character country code in `country`. Verify will then format the number for + you. + lg (Psd2LanguageCode, Optional): The language to use for the verification message. + code_length (int, Optional): The length of the verification code to generate. + pin_expiry (int, Optional): How long the generated verification code is valid + for, in seconds. When you specify both `pin_expiry` and `next_event_wait` + then `pin_expiry` must be an integer multiple of `next_event_wait` otherwise + `pin_expiry` is defaulted to equal `next_event_wait`. + next_event_wait (int, Optional): The wait time in seconds between attempts to + deliver the verification code. + workflow_id (int, Optional): Selects the predefined sequence of SMS and TTS (Text + To Speech) actions to use in order to convey the PIN to your user. + """ + + payee: str = Field(..., max_length=18) + amount: float + lg: Optional[Psd2LanguageCode] = None diff --git a/verify_legacy/src/vonage_verify_legacy/responses.py b/verify_legacy/src/vonage_verify_legacy/responses.py new file mode 100644 index 00000000..84d6f11f --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/responses.py @@ -0,0 +1,137 @@ +from typing import Optional + +from pydantic import BaseModel + + +class StartVerificationResponse(BaseModel): + """Response object for starting a verification process. + + Args: + request_id (str): The unique ID of the Verify request. You need this `request_id` + for the Verify check. + status (str): Indicates the outcome of the request; zero is success. + """ + + request_id: str + status: str + + +class CheckCodeResponse(BaseModel): + """Response object for checking a verification code. + + Args: + request_id (str): The unique ID of the Verify request to check. + status (str): Indicates the outcome of the request; zero is success. + event_id (str): The ID of the verification event, such as an SMS or TTS call. + price (str): The cost incurred for this request. + currency (str): The currency code. + estimated_price_messages_sent (str, Optional): Cost (in EUR) of the calls made + and messages sent for the verification process. + """ + + request_id: str + status: str + event_id: str + price: str + currency: str + estimated_price_messages_sent: Optional[str] = None + + +class Check(BaseModel): + """The list of checks made for a specific verification and their outcomes. + + Args: + date_received (str, Optional): The date and time this check was received (in the + format YYYY-MM-DD HH:MM:SS) + code (str, Optional): The code supplied with this check request. + status (str, Optional): The status of the check. + ip_address (str, Optional): The IP address of the check. This field is no longer + used. + """ + + date_received: Optional[str] = None + code: Optional[str] = None + status: Optional[str] = None + ip_address: Optional[str] = None + + +class Event(BaseModel): + """The events that have taken place to verify this number, and their unique + identifiers. + + Args: + type (str, Optional): The type of event. + id (str, Optional): The ID of the event. + """ + + type: Optional[str] = None + id: Optional[str] = None + + +class VerifyStatus(BaseModel): + """The status of a verification request. + + Args: + request_id (str, Optional): The `request_id` that you received in the response to + the Verify request and used in the Verify search request. + account_id (str, Optional): The Vonage account ID the request was for. + status (str, Optional): The status of the verification request. + number (str, Optional): The phone number used in the request. + price (str, Optional): The cost of this verification. + currency (str, Optional): The currency code. + sender_id (str, Optional): The sender ID provided in the Verify request. + date_submitted (str, Optional): The date and time this verification request was + submitted (in the format YYYY-MM-DD HH:MM:SS). + date_finalized (str, Optional): The date and time this verification request was + completed (in the format YYYY-MM-DD HH:MM:SS). + first_event_date (str, Optional): The date and time of the first verification + attempt (in the format YYYY-MM-DD HH:MM:SS). + last_event_date (str, Optional): The date and time of the last verification + attempt (in the format YYYY-MM-DD HH:MM:SS). + checks (list[Check], Optional): The list of checks made for this verification and + their outcomes. + events (list[Event], Optional): The events that have taken place to verify this + number, and their unique identifiers. + estimated_price_messages_sent (str, Optional): Cost (in EUR) of the calls made + and messages sent for the verification process. + """ + + request_id: Optional[str] = None + account_id: Optional[str] = None + status: Optional[str] = None + number: Optional[str] = None + price: Optional[str] = None + currency: Optional[str] = None + sender_id: Optional[str] = None + date_submitted: Optional[str] = None + date_finalized: Optional[str] = None + first_event_date: Optional[str] = None + last_event_date: Optional[str] = None + checks: Optional[list[Check]] = None + events: Optional[list[Event]] = None + estimated_price_messages_sent: Optional[str] = None + + +class VerifyControlStatus(BaseModel): + """The status of a verification control request. + + Args: + status (str): The status of the control request. + command (str): The command that was requested when cancelling a verify request + or triggering the next workflow in a request. + """ + + status: str + command: str + + +class NetworkUnblockStatus(BaseModel): + """The status of a network unblock request. + + Args: + network (str): The unique network ID of the network that was unblocked. + unblocked_until (str): The date and time until which the network is unblocked. + """ + + network: str + unblocked_until: str diff --git a/verify_legacy/src/vonage_verify_legacy/verify_legacy.py b/verify_legacy/src/vonage_verify_legacy/verify_legacy.py new file mode 100644 index 00000000..4ee46860 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/verify_legacy.py @@ -0,0 +1,239 @@ +from typing import Optional, Union + +from pydantic import Field, validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import VerifyError +from .requests import BaseVerifyRequest, Psd2Request, VerifyRequest +from .responses import ( + CheckCodeResponse, + NetworkUnblockStatus, + StartVerificationResponse, + VerifyControlStatus, + VerifyStatus, +) + + +class VerifyLegacy: + """Calls Vonage's Legacy Verify API. If you are just starting to use the Verify API, + please use the `Verify` class instead. + + This class provides methods to interact with Vonage's Legacy Verify API for verifying + users. + + Args: + http_client (HttpClient): The HTTP client used to make requests to the Verify API. + + Raises: + VerifyError: If an error is found in the response. + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._sent_data_type = 'form' + self._auth_type = 'body' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Verify API. + + Returns: + HttpClient: The HTTP client used to make requests to the Verify API. + """ + return self._http_client + + @validate_call + def start_verification( + self, verify_request: VerifyRequest + ) -> StartVerificationResponse: + """Start a verification process. + + Args: + verify_request (VerifyRequest): The verification request object. + + Returns: + StartVerificationResponse: The response object containing the verification result. + """ + return self._make_verify_request(verify_request) + + @validate_call + def start_psd2_verification( + self, verify_request: Psd2Request + ) -> StartVerificationResponse: + """Start a PSD2 verification process. + + Args: + verify_request (Psd2Request): The PSD2 verification request object. + + Returns: + StartVerificationResponse: The response object containing the verification result. + """ + return self._make_verify_request(verify_request) + + @validate_call + def check_code(self, request_id: str, code: str) -> CheckCodeResponse: + """Check a verification code. + + Args: + request_id (str): The request ID. + code (str): The verification code. + + Returns: + CheckCodeResponse: The response object containing the verification result. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/check/json', + {'request_id': request_id, 'code': code}, + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + return CheckCodeResponse(**response) + + @validate_call + def search( + self, request: Union[str, list[str]] + ) -> Union[VerifyStatus, list[VerifyStatus]]: + """Search for past or current verification requests. + + Args: + request (str | list[str]): The request ID, or a list of request IDs. + + Returns: + Union[VerifyStatus, list[VerifyStatus]]: Either the response object + containing the verification result, or a list of response objects. + """ + params = {} + if type(request) == str: + params['request_id'] = request + elif type(request) == list: + params['request_ids'] = request + + response = self._http_client.get( + self._http_client.api_host, '/verify/search/json', params, self._auth_type + ) + + if 'verification_requests' in response: + parsed_response = [] + for verification_request in response['verification_requests']: + parsed_response.append(VerifyStatus(**verification_request)) + return parsed_response + elif 'error_text' in response: + error_message = f'Error with the following details: {response}' + raise VerifyError(error_message) + else: + parsed_response = VerifyStatus(**response) + return parsed_response + + @validate_call + def cancel_verification(self, request_id: str) -> VerifyControlStatus: + """Cancel a verification request. + + Args: + request_id (str): The request ID. + + Returns: + VerifyControlStatus: The response object containing details of the submitted + verification control. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/control/json', + {'request_id': request_id, 'cmd': 'cancel'}, + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + + return VerifyControlStatus(**response) + + @validate_call + def trigger_next_event(self, request_id: str) -> VerifyControlStatus: + """Trigger the next event in the verification process. + + Args: + request_id (str): The request ID. + + Returns: + VerifyControlStatus: The response object containing details of the submitted + verification control. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/control/json', + {'request_id': request_id, 'cmd': 'trigger_next_event'}, + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + + return VerifyControlStatus(**response) + + @validate_call + def request_network_unblock( + self, network: str, unblock_duration: Optional[int] = Field(None, ge=0, le=86400) + ) -> NetworkUnblockStatus: + """Request to unblock a network that has been blocked due to potential fraud + detection. + + Note: The network unblock feature is switched off by default. + Please contact Sales to enable the Network Unblock API for your account. + + Args: + network (str): The network code of the network to unblock. + unblock_duration (int, optional): How long (in seconds) to unblock the network for. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/network-unblock', + {'network': network, 'duration': unblock_duration}, + self._auth_type, + ) + + return NetworkUnblockStatus(**response) + + def _make_verify_request( + self, verify_request: BaseVerifyRequest + ) -> StartVerificationResponse: + """Make a verify request. + + This method makes a verify request to the Vonage Verify API. + + Args: + verify_request (BaseVerifyRequest): The verify request object. + + Returns: + VerifyResponse: The response object containing the verification result. + """ + 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, exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + + return StartVerificationResponse(**response) + + def _check_for_error(self, response: dict) -> None: + """Check for error in the response. + + This method checks if the response contains a non-zero status code + and raises a VerifyError if this is found. + + Args: + response (dict): The response object. + + Raises: + VerifyError: If an error is found in the response. + """ + if int(response['status']) != 0: + error_message = f'Error with the following details: {response}' + raise VerifyError(error_message) diff --git a/verify_legacy/tests/BUILD b/verify_legacy/tests/BUILD new file mode 100644 index 00000000..cb195efa --- /dev/null +++ b/verify_legacy/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['verify_legacy', 'testutils']) diff --git a/verify_legacy/tests/data/cancel_verification.json b/verify_legacy/tests/data/cancel_verification.json new file mode 100644 index 00000000..8bfcf7bf --- /dev/null +++ b/verify_legacy/tests/data/cancel_verification.json @@ -0,0 +1,4 @@ +{ + "status": "0", + "command": "cancel" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/cancel_verification_error.json b/verify_legacy/tests/data/cancel_verification_error.json new file mode 100644 index 00000000..e74e6622 --- /dev/null +++ b/verify_legacy/tests/data/cancel_verification_error.json @@ -0,0 +1,4 @@ +{ + "status": "6", + "error_text": "The requestId 'cc121958d8fb4368aa3bb762bb9a0f75' does not exist or its no longer active." +} \ No newline at end of file diff --git a/verify_legacy/tests/data/check_code.json b/verify_legacy/tests/data/check_code.json new file mode 100644 index 00000000..37267b48 --- /dev/null +++ b/verify_legacy/tests/data/check_code.json @@ -0,0 +1,8 @@ +{ + "request_id": "c5037cb8b47449158ed6611afde58990", + "status": "0", + "event_id": "390f7296-aeff-45ba-8931-84a13f3f76d7", + "price": "0.05000000", + "currency": "EUR", + "estimated_price_messages_sent": "0.04675" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/check_code_error.json b/verify_legacy/tests/data/check_code_error.json new file mode 100644 index 00000000..29ac3017 --- /dev/null +++ b/verify_legacy/tests/data/check_code_error.json @@ -0,0 +1,5 @@ +{ + "request_id": "cc121958d8fb4368aa3bb762bb9a0f74", + "status": "16", + "error_text": "The code provided does not match the expected value" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/network_unblock.json b/verify_legacy/tests/data/network_unblock.json new file mode 100644 index 00000000..6620d3f4 --- /dev/null +++ b/verify_legacy/tests/data/network_unblock.json @@ -0,0 +1,4 @@ +{ + "network": "23410", + "unblocked_until": "2024-04-22T08:34:58Z" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/network_unblock_error.json b/verify_legacy/tests/data/network_unblock_error.json new file mode 100644 index 00000000..bf7cadba --- /dev/null +++ b/verify_legacy/tests/data/network_unblock_error.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.vonage.com/api-errors#bad-request", + "title": "Not Found", + "detail": "The network you provided does not have an active block.", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/search_request.json b/verify_legacy/tests/data/search_request.json new file mode 100644 index 00000000..4330554c --- /dev/null +++ b/verify_legacy/tests/data/search_request.json @@ -0,0 +1,32 @@ +{ + "request_id": "cc121958d8fb4368aa3bb762bb9a0f74", + "account_id": "abcdef01", + "status": "EXPIRED", + "number": "1234567890", + "price": "0", + "currency": "EUR", + "sender_id": "Acme Inc.", + "date_submitted": "2024-04-03 02:22:37", + "date_finalized": "2024-04-03 02:27:38", + "first_event_date": "2024-04-03 02:22:37", + "last_event_date": "2024-04-03 02:24:38", + "checks": [ + { + "date_received": "2024-04-03 02:23:04", + "code": "1234", + "status": "INVALID", + "ip_address": "" + } + ], + "events": [ + { + "type": "sms", + "id": "23f3a13d-6d03-4262-8f4d-67f12a56e1c8" + }, + { + "type": "sms", + "id": "09ef3984-3f62-453d-8f9c-1a161b373dba" + } + ], + "estimated_price_messages_sent": "0.09350" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/search_request_error.json b/verify_legacy/tests/data/search_request_error.json new file mode 100644 index 00000000..a0d020f6 --- /dev/null +++ b/verify_legacy/tests/data/search_request_error.json @@ -0,0 +1,4 @@ +{ + "status": "101", + "error_text": "No response found" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/search_request_list.json b/verify_legacy/tests/data/search_request_list.json new file mode 100644 index 00000000..939e9ed2 --- /dev/null +++ b/verify_legacy/tests/data/search_request_list.json @@ -0,0 +1,64 @@ +{ + "verification_requests": [ + { + "request_id": "cc121958d8fb4368aa3bb762bb9a0f74", + "account_id": "abcdef01", + "number": "1234567890", + "sender_id": "verify", + "date_submitted": "2024-04-03 02:22:37", + "date_finalized": "2024-04-03 02:27:38", + "checks": [ + { + "date_received": "2024-04-03 02:23:04", + "code": "1234", + "status": "INVALID", + "ip_address": "" + } + ], + "first_event_date": "2024-04-03 02:22:37", + "last_event_date": "2024-04-03 02:24:38", + "price": "0", + "currency": "EUR", + "status": "EXPIRED", + "estimated_price_messages_sent": "0.09350", + "events": [ + { + "id": "23f3a13d-6d03-4262-8f4d-67f12a56e1c8", + "type": "sms" + }, + { + "id": "09ef3984-3f62-453d-8f9c-1a161b373dba", + "type": "sms" + } + ] + }, + { + "request_id": "c5037cb8b47449158ed6611afde58990", + "account_id": "abcdef01", + "number": "1234567890", + "sender_id": "verify", + "date_submitted": "2024-04-03 02:09:22", + "date_finalized": "2024-04-03 02:09:59", + "checks": [ + { + "date_received": "2024-04-03 02:09:59", + "code": "5700", + "status": "VALID", + "ip_address": "" + } + ], + "first_event_date": "2024-04-03 02:09:23", + "last_event_date": "2024-04-03 02:09:23", + "price": "0.05000000", + "currency": "EUR", + "status": "SUCCESS", + "estimated_price_messages_sent": "0.04675", + "events": [ + { + "id": "390f7296-aeff-45ba-8931-84a13f3f76d7", + "type": "sms" + } + ] + } + ] +} \ No newline at end of file diff --git a/verify_legacy/tests/data/trigger_next_event.json b/verify_legacy/tests/data/trigger_next_event.json new file mode 100644 index 00000000..7939ad17 --- /dev/null +++ b/verify_legacy/tests/data/trigger_next_event.json @@ -0,0 +1,4 @@ +{ + "status": "0", + "command": "trigger_next_event" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/trigger_next_event_error.json b/verify_legacy/tests/data/trigger_next_event_error.json new file mode 100644 index 00000000..1a52f3a4 --- /dev/null +++ b/verify_legacy/tests/data/trigger_next_event_error.json @@ -0,0 +1,4 @@ +{ + "status": "19", + "error_text": "No more events are left to execute for the request ['2c021d25cf2e47a9b277a996f4325b81']" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/verify_request.json b/verify_legacy/tests/data/verify_request.json new file mode 100644 index 00000000..74136a5b --- /dev/null +++ b/verify_legacy/tests/data/verify_request.json @@ -0,0 +1,4 @@ +{ + "request_id": "abcdef0123456789abcdef0123456789", + "status": "0" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/verify_request_error.json b/verify_legacy/tests/data/verify_request_error.json new file mode 100644 index 00000000..934d0fad --- /dev/null +++ b/verify_legacy/tests/data/verify_request_error.json @@ -0,0 +1,5 @@ +{ + "request_id": "b6fc2b91d23c43f9b8ea05f9be64415c", + "status": "10", + "error_text": "Concurrent verifications to the same number are not allowed" +} \ No newline at end of file diff --git a/verify_legacy/tests/data/verify_request_error_with_network.json b/verify_legacy/tests/data/verify_request_error_with_network.json new file mode 100644 index 00000000..7714083b --- /dev/null +++ b/verify_legacy/tests/data/verify_request_error_with_network.json @@ -0,0 +1,6 @@ +{ + "request_id": "b6fc2b91d23c43f9b8ea05f9be64415c", + "status": "10", + "error_text": "Concurrent verifications to the same number are not allowed", + "network": "244523" +} \ No newline at end of file diff --git a/verify_legacy/tests/test_verify_legacy.py b/verify_legacy/tests/test_verify_legacy.py new file mode 100644 index 00000000..21482b2f --- /dev/null +++ b/verify_legacy/tests/test_verify_legacy.py @@ -0,0 +1,304 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import NotFoundError +from vonage_http_client.http_client import HttpClient +from vonage_verify_legacy.errors import VerifyError +from vonage_verify_legacy.language_codes import LanguageCode, Psd2LanguageCode +from vonage_verify_legacy.requests import Psd2Request, VerifyRequest +from vonage_verify_legacy.responses import NetworkUnblockStatus, VerifyControlStatus +from vonage_verify_legacy.verify_legacy import VerifyLegacy + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + + +verify = VerifyLegacy(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_http_client_property(): + verify = VerifyLegacy(HttpClient(get_mock_api_key_auth())) + assert isinstance(verify.http_client, HttpClient) + + +def test_create_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_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 + + +def test_create_verify_request_model_invalid_pin_expiry(caplog): + data['pin_expiry'] = 301 + data['next_event_wait'] = 150 + params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', **data} + VerifyRequest(**params) + + assert 'The current values are: pin_expiry=301, next_event_wait=150.' in caplog.text + + +@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' + + +@responses.activate +def test_verify_request_error(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request_error.json' + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + with raises(VerifyError) as e: + verify.start_verification(request) + + assert e.match( + "'error_text': 'Concurrent verifications to the same number are not allowed'" + ) + + +@responses.activate +def test_verify_request_error_with_network(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/json', + 'verify_request_error_with_network.json', + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + with raises(VerifyError) as e: + verify.start_verification(request) + + assert e.match("'network': '244523'") + + +@responses.activate +def test_check_code(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code.json' + ) + response = verify.check_code( + request_id='c5037cb8b47449158ed6611afde58990', code='1234' + ) + assert response.request_id == 'c5037cb8b47449158ed6611afde58990' + assert response.status == '0' + assert response.event_id == '390f7296-aeff-45ba-8931-84a13f3f76d7' + assert response.price == '0.05000000' + assert response.currency == 'EUR' + assert response.estimated_price_messages_sent == '0.04675' + + +@responses.activate +def test_check_code_error(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code_error.json' + ) + + with raises(VerifyError) as e: + verify.check_code(request_id='c5037cb8b47449158ed6611afde58990', code='1234') + + assert e.match( + "'status': '16', 'error_text': 'The code provided does not match the expected value'" + ) + + +@responses.activate +def test_search(): + build_response( + path, 'GET', 'https://api.nexmo.com/verify/search/json', 'search_request.json' + ) + response = verify.search('c5037cb8b47449158ed6611afde58990') + + assert response.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' + assert response.account_id == 'abcdef01' + assert response.status == 'EXPIRED' + assert response.number == '1234567890' + assert response.price == '0' + assert response.currency == 'EUR' + assert response.sender_id == 'Acme Inc.' + assert response.date_submitted == '2024-04-03 02:22:37' + assert response.date_finalized == '2024-04-03 02:27:38' + assert response.first_event_date == '2024-04-03 02:22:37' + assert response.last_event_date == '2024-04-03 02:24:38' + assert response.estimated_price_messages_sent == '0.09350' + assert response.checks[0].date_received == '2024-04-03 02:23:04' + assert response.checks[0].code == '1234' + assert response.checks[0].status == 'INVALID' + assert response.checks[0].ip_address == '' + assert response.events[0].type == 'sms' + assert response.events[0].id == '23f3a13d-6d03-4262-8f4d-67f12a56e1c8' + + +@responses.activate +def test_search_list_of_ids(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/verify/search/json', + 'search_request_list.json', + ) + response0, response1 = verify.search( + ['cc121958d8fb4368aa3bb762bb9a0f75', 'c5037cb8b47449158ed6611afde58990'] + ) + assert response0.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' + assert response1.request_id == 'c5037cb8b47449158ed6611afde58990' + assert response1.status == 'SUCCESS' + assert response1.checks[0].status == 'VALID' + + +@responses.activate +def test_search_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/verify/search/json', + 'search_request_error.json', + ) + + with raises(VerifyError) as e: + verify.search('c5037cb8b47449158ed6611afde58990') + + assert e.match("{'status': '101', 'error_text': 'No response found'}") + + +@responses.activate +def test_cancel_verification(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'cancel_verification.json', + ) + response = verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + + assert type(response) == VerifyControlStatus + assert response.status == '0' + assert response.command == 'cancel' + + +@responses.activate +def test_cancel_verification_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'cancel_verification_error.json', + ) + + with raises(VerifyError) as e: + verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + + assert e.match( + "The requestId 'cc121958d8fb4368aa3bb762bb9a0f75' does not exist or its no longer active." + ) + + +@responses.activate +def test_trigger_next_event(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'trigger_next_event.json', + ) + response = verify.trigger_next_event('c5037cb8b47449158ed6611afde58990') + + assert type(response) == VerifyControlStatus + assert response.status == '0' + assert response.command == 'trigger_next_event' + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'trigger_next_event_error.json', + ) + + with raises(VerifyError) as e: + verify.trigger_next_event('2c021d25cf2e47a9b277a996f4325b81') + + assert e.match("'status': '19") + assert e.match('No more events are left to execute for the request') + + +@responses.activate +def test_request_network_unblock(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/network-unblock', + 'network_unblock.json', + 202, + ) + + response = verify.request_network_unblock('23410') + + assert verify._http_client.last_response.status_code == 202 + assert type(response) == NetworkUnblockStatus + assert response.network == '23410' + assert response.unblocked_until == '2024-04-22T08:34:58Z' + + +@responses.activate +def test_request_network_unblock_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/network-unblock', + 'network_unblock_error.json', + 404, + ) + + try: + verify.request_network_unblock('23410') + except NotFoundError as e: + assert ( + e.response.json()['detail'] + == 'The network you provided does not have an active block.' + ) + assert e.response.json()['title'] == 'Not Found' diff --git a/video/BUILD b/video/BUILD new file mode 100644 index 00000000..ff749aab --- /dev/null +++ b/video/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-video', + dependencies=[ + ':pyproject', + ':readme', + 'video/src/vonage_video', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/video/CHANGES.md b/video/CHANGES.md new file mode 100644 index 00000000..ecaf37cf --- /dev/null +++ b/video/CHANGES.md @@ -0,0 +1,8 @@ +# 1.0.2 +- Update dependency versions + +# 1.0.1 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.0 +- Initial upload diff --git a/video/OPENTOK_TO_VONAGE_MIGRATION.md b/video/OPENTOK_TO_VONAGE_MIGRATION.md new file mode 100644 index 00000000..efb7dd7d --- /dev/null +++ b/video/OPENTOK_TO_VONAGE_MIGRATION.md @@ -0,0 +1,112 @@ +# Migration guide from OpenTok Python SDK to Vonage Python SDK + +This is a guide to help you migrate from using the OpenTok Python SDK to the Vonage Python SDK to access Video API functionality. You can interact with the Vonage Video API via the Vonage Python SDK to use all the same features available in the `opentok` package. + +The OpenTok package includes methods to manage a video application in Python. It includes features like archiving, broadcasting, live captioning and more. All of these features are now available in the Vonage Python SDK, which is the recommended way to access them. + +## Contents + +- [Improvements](#improvements) +- [Installation](#installation) +- [Configuration](#configuration) +- [Accessing Video API Methods](#accessing-video-api-methods) +- [Accessing Video API Data Models](#accessing-video-api-data-models) +- [New Methods](#new-methods) +- [Changed Methods](#changed-methods) +- [Additional Resources](#additional-resources) + +## Improvements + +Vonage Video adds data models to help you construct video objects. There's also finer-grained and more descriptive errors. We now authenticate with JWTs, improving security. + +You can now manage all your Vonage usage from the Developer Dashboard, including setting callbacks for different video functions as well as things like application configuration and billing. + +## Installation + +You can now interact with Vonage's Video API using the `vonage-video` PyPI package rather than the `opentok` PyPI package. You shouldn't use this directly for most use cases, it's easier to use the global `vonage` SDK package which includes video functionality. To do this, create a virtual environment and install the `vonage` package in your virtual environment using this command: + +```bash +python3 -m venv venv +. ./venv/bin/activate +pip install vonage +``` + +`vonage-video` will be installed as a dependency so there's no need to install directly. + +## Configuration + +Whereas the `opentok` package used an `api_key` and `api_secret` for authorization, the Vonage Video API uses JWTs. The SDK handles JWT generation in the background for you, but will require an `application_id` and `private_key` as credentials in order to generate the token. You can obtain these by setting up a Vonage Application, which you can create via the [Developer Dashboard](https://dashboard.nexmo.com/applications). (The Vonage Application is also where you can set other settings such as callback URLs, storage preferences, etc). + +These credentials are then passed in when instantiating a `vonage.Vonage` object: + +```python +from vonage import Vonage, Auth + +vonage_client = Vonage( + Auth( + application_id='VONAGE_APPLICATION_ID', + private_key='VONAGE_PRIVATE_KEY_PATH', + ) +) +``` + +## Accessing Video API Methods + +You can access the Video API via the `Video` class stored at `Vonage.video`. To call methods related to the Video API, use this syntax: + +```python +vonage_client.video.video_api_method... +``` + +## Accessing Video API Data Models + +You can access data models for the Video API, e.g. as arguments to video methods, by importing them from the `vonage_video.models` package, e.g. + +```python +from vonage_video.models import SessionOptions + +session_options = SessionOptions(...) + +vonage_client.video.create_session(session_options) +``` + +## New Methods + +`video.list_broadcasts` + +## Changed Methods + +There are some changes to methods between the `opentok` SDK and the Video API implementation in the `vonage-video` SDK. + +- Any positional parameters in method signatures have been replaced with data models in the `vonage-video` package, stored at `vonage_video.models`. +- Methods now return responses as Pydantic data models. +- Some methods have been renamed, for clarity and/or to better reflect what the method does. These are listed below: + +| OpenTok Method Name | Vonage Video Method Name | +|---|---| +| `opentok.generate_token` | `video.generate_client_token` | +| `opentok.add_archive_stream` | `video.add_stream_to_archive` | +| `opentok.remove_archive_stream` | `video.remove_stream_from_archive` | +| `opentok.set_archive_layout` | `video.change_archive_layout` | +| `opentok.add_broadcast_stream` | `video.add_stream_to_broadcast` | +| `opentok.remove_broadcast_stream` | `video.remove_stream_from_broadcast` | +| `opentok.set_broadcast_layout` | `video.change_broadcast_layout` | +| `opentok.set_stream_class_lists` | `video.change_stream_layout` | +| `opentok.force_disconnect` | `video.disconnect_client` | +| `opentok.mute_all` | `video.mute_all_streams` | +| `opentok.disable_force_mute` | `video.disable_mute_all_streams`| +| `opentok.dial` | `video.initiate_sip_call`| +| `opentok.start_render` | `video.start_experience_composer`| +| `opentok.list_renders` | `video.list_experience_composers`| +| `opentok.get_render` | `video.get_experience_composer`| +| `opentok.stop_render` | `video.stop_experience_composer`| +| `opentok.connect_audio_to_websocket` | `video.start_audio_connector`| +| `opentok.connect_audio_to_websocket` | `video.start_audio_connector`| + +## Additional Resources + +- [Vonage Video API Developer Documentation](https://developer.vonage.com/en/video/overview) +- [Vonage Video API Specification](https://developer.vonage.com/en/api/video) +- [Link to the Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) +- [Join the Vonage Developer Community Slack](https://developer.vonage.com/en/community/slack) +- [Submit a Vonage Video API Support Request](https://api.support.vonage.com/hc/en-us) \ No newline at end of file diff --git a/video/README.md b/video/README.md new file mode 100644 index 00000000..d26ac46b --- /dev/null +++ b/video/README.md @@ -0,0 +1,311 @@ +# Vonage Video API + +This package contains the code to use [Vonage's Video API](https://developer.vonage.com/en/video/overview) in Python. This package includes methods for working with video sessions, streams, signals, and more. + +## 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`. + +You will use the custom Pydantic data models to make most of the API calls in this package. They are accessed from the `vonage_video.models` package. + +### Generate a Client Token + +```python +from vonage_video.models import TokenOptions + +token_options = TokenOptions(session_id='your_session_id', role='publisher') +client_token = vonage_client.video.generate_client_token(token_options) +``` + +### Create a Session + +```python +from vonage_video.models import SessionOptions + +session_options = SessionOptions(media_mode='routed') +video_session = vonage_client.video.create_session(session_options) +``` + +### List Streams + +```python +streams = vonage_client.video.list_streams(session_id='your_session_id') +``` + +### Get a Stream + +```python +stream_info = vonage_client.video.get_stream(session_id='your_session_id', stream_id='your_stream_id') +``` + +### Change Stream Layout + +```python +from vonage_video.models import StreamLayoutOptions + +layout_options = StreamLayoutOptions(type='bestFit') +updated_streams = vonage_client.video.change_stream_layout(session_id='your_session_id', stream_layout_options=layout_options) +``` + +### Send a Signal + +```python +from vonage_video.models import SignalData + +signal_data = SignalData(type='chat', data='Hello, World!') +vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) +``` + +### Disconnect a Client + +```python +vonage_client.video.disconnect_client(session_id='your_session_id', connection_id='your_connection_id') +``` + +### Mute a Stream + +```python +vonage_client.video.mute_stream(session_id='your_session_id', stream_id='your_stream_id') +``` + +### Mute All Streams + +```python +vonage_client.video.mute_all_streams(session_id='your_session_id', excluded_stream_ids=['stream_id_1', 'stream_id_2']) +``` + +### Disable Mute All Streams + +```python +vonage_client.video.disable_mute_all_streams(session_id='your_session_id') +``` + +### Start Captions + +```python +from vonage_video.models import CaptionsOptions + +captions_options = CaptionsOptions(language='en-US') +captions_data = vonage_client.video.start_captions(captions_options) +``` + +### Stop Captions + +```python +from vonage_video.models import CaptionsData + +captions_data = CaptionsData(captions_id='your_captions_id') +vonage_client.video.stop_captions(captions_data) +``` + +### Start Audio Connector + +```python +from vonage_video.models import AudioConnectorOptions + +audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') +audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) +``` + +### Start Experience Composer + +```python +from vonage_video.models import ExperienceComposerOptions + +experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') +experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) +``` + +### List Experience Composers + +```python +from vonage_video.models import ListExperienceComposersFilter + +filter = ListExperienceComposersFilter(page_size=10) +experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) +print(experience_composers) +``` + +### Get Experience Composer + +```python +experience_composer = vonage_client.video.get_experience_composer(experience_composer_id='experience_composer_id') +``` + +### Stop Experience Composer + +```python +vonage_client.video.stop_experience_composer(experience_composer_id='experience_composer_id') +``` + +### List Archives + +```python +from vonage_video.models import ListArchivesFilter + +filter = ListArchivesFilter(offset=2) +archives, count, next_page_offset = vonage_client.video.list_archives(filter) +print(archives) +``` + +### Start Archive + +```python +from vonage_video.models import CreateArchiveRequest + +archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') +archive = vonage_client.video.start_archive(archive_options) +``` + +### Get Archive + +```python +archive = vonage_client.video.get_archive(archive_id='your_archive_id') +print(archive) +``` + +### Delete Archive + +```python +vonage_client.video.delete_archive(archive_id='your_archive_id') +``` + +### Add Stream to Archive + +```python +from vonage_video.models import AddStreamRequest + +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) +``` + +### Remove Stream from Archive + +```python +vonage_client.video.remove_stream_from_archive(archive_id='your_archive_id', stream_id='your_stream_id') +``` + +### Stop Archive + +```python +archive = vonage_client.video.stop_archive(archive_id='your_archive_id') +print(archive) +``` + +### Change Archive Layout + +```python +from vonage_video.models import ComposedLayout + +layout = ComposedLayout(type='bestFit') +archive = vonage_client.video.change_archive_layout(archive_id='your_archive_id', layout=layout) +print(archive) +``` + +### List Broadcasts + +```python +from vonage_video.models import ListBroadcastsFilter + +filter = ListBroadcastsFilter(page_size=10) +broadcasts, count, next_page_offset = vonage_client.video.list_broadcasts(filter) +print(broadcasts) +``` + +### Start Broadcast + +```python +from vonage_video.models import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp + +broadcast_options = CreateBroadcastRequest(session_id='your_session_id', outputs=BroadcastOutputSettings( + hls=BroadcastHls(dvr=True, low_latency=False), + rtmp=[ + BroadcastRtmp( + id='test', + server_url='rtmp://a.rtmp.youtube.com/live2', + stream_name='stream-key', + ) + ], +) +) +broadcast = vonage_client.video.start_broadcast(broadcast_options) +print(broadcast) +``` + +### Get Broadcast + +```python +broadcast = vonage_client.video.get_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) +``` + +### Stop Broadcast + +```python +broadcast = vonage_client.video.stop_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) +``` + +### Change Broadcast Layout + +```python +from vonage_video.models import ComposedLayout + +layout = ComposedLayout(type='bestFit') +broadcast = vonage_client.video.change_broadcast_layout(broadcast_id='your_broadcast_id', layout=layout) +print(broadcast) +``` + +### Add Stream to Broadcast + +```python +from vonage_video.models import AddStreamRequest + +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_broadcast(broadcast_id='your_broadcast_id', params=add_stream_request) +``` + +### Remove Stream from Broadcast + +```python +vonage_client.video.remove_stream_from_broadcast(broadcast_id='your_broadcast_id', stream_id='your_stream_id') +``` + +### Initiate SIP Call + +```python +from vonage_video.models import InitiateSipRequest, SipOptions, SipAuth + +sip_request_params = InitiateSipRequest( + session_id='your_session_id', + token='your_token', + sip=SipOptions( + uri=f'sip:{vonage_number}@sip.nexmo.com;transport=tls', + from_=f'test@vonage.com', + headers={'header_key': 'header_value'}, + auth=SipAuth(username='1485b9e6', password='fL8jvi4W2FmS9som'), + secure=False, + video=False, + observe_force_mute=True, + ), +) +sip_call = vonage_client.video.initiate_sip_call(sip_request_params) +print(sip_call) +``` + +### Play DTMF into a call + +```python +# Play into all connections +session_id = 'your_session_id' +digits = '1234#*p' + +vonage_client.video.play_dtmf(session_id=session_id, digits=digits) + +# Play into one connection +session_id = 'your_session_id' +digits = '1234#*p' +connection_id = 'your_connection_id' + +vonage_client.video.play_dtmf(session_id=session_id, digits=digits, connection_id=connection_id) +``` \ No newline at end of file diff --git a/video/pyproject.toml b/video/pyproject.toml new file mode 100644 index 00000000..5259588c --- /dev/null +++ b/video/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-video' +dynamic = ["version"] +description = 'Vonage video package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_video._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/video/src/vonage_video/BUILD b/video/src/vonage_video/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/video/src/vonage_video/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/video/src/vonage_video/__init__.py b/video/src/vonage_video/__init__.py new file mode 100644 index 00000000..16da3cf3 --- /dev/null +++ b/video/src/vonage_video/__init__.py @@ -0,0 +1,4 @@ +from . import errors, models +from .video import Video + +__all__ = ['Video', 'errors', 'models'] diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py new file mode 100644 index 00000000..a6221b3d --- /dev/null +++ b/video/src/vonage_video/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.2' diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py new file mode 100644 index 00000000..512b6251 --- /dev/null +++ b/video/src/vonage_video/errors.py @@ -0,0 +1,53 @@ +from vonage_utils.errors import VonageError + + +class VideoError(VonageError): + """Indicates an error when using the Vonage Voice API.""" + + +class InvalidRoleError(VideoError): + """The specified role was invalid.""" + + +class TokenExpiryError(VideoError): + """The specified token expiry time was invalid.""" + + +class SipError(VideoError): + """Error related to usage of SIP calls.""" + + +class NoAudioOrVideoError(VideoError): + """Either an audio or video stream must be included.""" + + +class IndividualArchivePropertyError(VideoError): + """The property cannot be set for `archive_mode: 'individual'`.""" + + +class LayoutStylesheetError(VideoError): + """Error with the `stylesheet` property when setting a layout.""" + + +class LayoutScreenshareTypeError(VideoError): + """Error with the `screenshare_type` property when setting a layout.""" + + +class InvalidArchiveStateError(VideoError): + """The archive state was invalid for the specified operation.""" + + +class InvalidHlsOptionsError(VideoError): + """The HLS options were invalid.""" + + +class InvalidOutputOptionsError(VideoError): + """The output options were invalid.""" + + +class InvalidBroadcastStateError(VideoError): + """The broadcast state was invalid for the specified operation.""" + + +class RoutedSessionRequiredError(VideoError): + """The operation requires a session with `media_mode=routed`.""" diff --git a/video/src/vonage_video/models/BUILD b/video/src/vonage_video/models/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/video/src/vonage_video/models/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/video/src/vonage_video/models/__init__.py b/video/src/vonage_video/models/__init__.py new file mode 100644 index 00000000..83697351 --- /dev/null +++ b/video/src/vonage_video/models/__init__.py @@ -0,0 +1,98 @@ +from .archive import Archive, CreateArchiveRequest, ListArchivesFilter, Transcription +from .audio_connector import ( + AudioConnectorData, + AudioConnectorOptions, + AudioConnectorWebSocket, +) +from .broadcast import ( + Broadcast, + BroadcastHls, + BroadcastOutputSettings, + BroadcastRtmp, + BroadcastSettings, + BroadcastUrls, + CreateBroadcastRequest, + HlsSettings, + ListBroadcastsFilter, + RtmpStream, +) +from .captions import CaptionsData, CaptionsOptions +from .common import AddStreamRequest, ComposedLayout, ListVideoFilter, VideoStream +from .enums import ( + ArchiveMode, + ArchiveStatus, + AudioSampleRate, + ExperienceComposerStatus, + LanguageCode, + LayoutType, + MediaMode, + OutputMode, + P2pPreference, + StreamMode, + TokenRole, + VideoResolution, +) +from .experience_composer import ( + ExperienceComposer, + ExperienceComposerOptions, + ExperienceComposerProperties, + ListExperienceComposersFilter, +) +from .session import SessionOptions, VideoSession +from .signal import SignalData +from .sip import InitiateSipRequest, SipAuth, SipCall, SipOptions +from .stream import StreamInfo, StreamLayout, StreamLayoutOptions +from .token import TokenOptions + +__all__ = [ + "AudioConnectorData", + "AudioConnectorOptions", + "AudioConnectorWebSocket", + "Archive", + "ListArchivesFilter", + "Transcription", + "CreateArchiveRequest", + "Broadcast", + "BroadcastSettings", + "BroadcastUrls", + "HlsSettings", + "ListBroadcastsFilter", + "BroadcastHls", + "RtmpStream", + "BroadcastRtmp", + "CreateBroadcastRequest", + "BroadcastOutputSettings", + "CaptionsData", + "CaptionsOptions", + "ComposedLayout", + "ListVideoFilter", + "VideoStream", + "AddStreamRequest", + "ArchiveMode", + "AudioSampleRate", + "LanguageCode", + "MediaMode", + "P2pPreference", + "TokenRole", + "VideoResolution", + "ExperienceComposerStatus", + "OutputMode", + "StreamMode", + "LayoutType", + "ArchiveStatus", + "ExperienceComposer", + "ExperienceComposerOptions", + "ExperienceComposerProperties", + "ListExperienceComposersFilter", + "SessionOptions", + "VideoSession", + "SignalData", + "SipOptions", + "SipAuth", + "SipCall", + "InitiateSipRequest", + "StreamInfo", + "StreamLayout", + "StreamLayoutOptions", + "TokenOptions", +] diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py new file mode 100644 index 00000000..37c39c35 --- /dev/null +++ b/video/src/vonage_video/models/archive.py @@ -0,0 +1,163 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_video.errors import IndividualArchivePropertyError, NoAudioOrVideoError +from vonage_video.models.common import ComposedLayout, ListVideoFilter, VideoStream +from vonage_video.models.enums import ( + ArchiveStatus, + OutputMode, + StreamMode, + VideoResolution, +) + + +class ListArchivesFilter(ListVideoFilter): + """Model with filters for listing archives. + + Args: + offset (int, Optional): The offset. + page_size (int, Optional): The number of archives to return per page. + session_id (str, Optional): The session ID of a Vonage Video session. + """ + + session_id: Optional[str] = None + + +class Transcription(BaseModel): + """Model for transcription options for an archive. + + Args: + status (str, Optional): The status of the transcription. + reason (str, Optional): May give a brief reason for the transcription status. + """ + + status: Optional[str] = None + reason: Optional[str] = None + + +class Archive(BaseModel): + """Model for an archive. + + Args: + id (str, Optional): The unique archive ID. + status (ArchiveStatus, Optional): The status of the archive. + name (str, Optional): The name of the archive. + reason (str, Optional): May give a brief reason for the archive status. + session_id (str, Optional): The session ID of the Vonage Video session. + application_id (str, Optional): The Vonage application ID. + created_at (int, Optional): The timestamp when the archive when the archive + started recording, expressed in milliseconds since the Unix epoch. + size (int, Optional): The size of the archive. + duration (int, Optional): The duration of the archive in seconds. + For archives that have are being recorded, this value is set to 0. + output_mode (OutputMode, Optional): The output mode of the archive. + stream_mode (StreamMode, Optional): Whether streams included in the archive + are selected automatically (`auto`, the default) or manually (`manual`). + has_audio (bool, Optional): Whether the archive will record audio. + has_video (bool, Optional): Whether the archive will record video. + has_transcription (bool, Optional): Whether audio will be transcribed. + sha256_sum (str, Optional): The SHA-256 hash of the archive. + password (str, Optional): The password for the archive. + updated_at (int, Optional): The timestamp when the archive was last updated, + expressed in milliseconds since the Unix epoch. + multi_archive_tag (str, Optional): Set this to support recording multiple + archives for the same session simultaneously. Set this to a unique string + for each simultaneous archive of an ongoing session. + event (str, Optional): The event that triggered the response. + resolution (VideoResolution, Optional): The resolution of the archive. + streams (list[VideoStream], Optional): The streams in the archive. + url (str, Optional): The download URL of the available archive file. + This is only set for an archive with the status set to `available`. + transcription (Transcription, Optional): Transcription options for the archive. + """ + + id: Optional[str] = None + status: Optional[ArchiveStatus] = None + name: Optional[str] = None + reason: Optional[str] = None + session_id: Optional[str] = Field(None, validation_alias='sessionId') + application_id: Optional[str] = Field(None, validation_alias='applicationId') + created_at: Optional[int] = Field(None, validation_alias='createdAt') + size: Optional[int] = None + duration: Optional[int] = None + output_mode: Optional[OutputMode] = Field(None, validation_alias='outputMode') + stream_mode: Optional[StreamMode] = Field(None, validation_alias='streamMode') + has_audio: Optional[bool] = Field(None, validation_alias='hasAudio') + has_video: Optional[bool] = Field(None, validation_alias='hasVideo') + has_transcription: Optional[bool] = Field(None, validation_alias='hasTranscription') + sha256_sum: Optional[str] = Field(None, validation_alias='sha256sum') + password: Optional[str] = None + updated_at: Optional[int] = Field(None, validation_alias='updatedAt') + multi_archive_tag: Optional[str] = Field(None, validation_alias='multiArchiveTag') + event: Optional[str] = None + resolution: Optional[VideoResolution] = None + streams: Optional[list[VideoStream]] = None + url: Optional[str] = None + transcription: Optional[Transcription] = None + + +class CreateArchiveRequest(BaseModel): + """Model for creating an archive. + + Args: + session_id (str): The session ID of a Vonage Video session. + has_audio (bool, Optional): Whether the archive should include audio. + has_video (bool, Optional): Whether the archive should include video. + layout (Layout, Optional): Layout options for the archive. + multi_archive_tag (str, Optional): Set this to support recording multiple archives for the same session simultaneously. + Set this to a unique string for each simultaneous archive of an ongoing session. + You must also set this option when manually starting an archive in a session that is automatically archived. + If you do not specify a unique multiArchiveTag, you can only record one archive at a time for a given session. + name (str, Optional): The name of the archive. + output_mode (OutputMode, Optional): Whether all streams in the archive are recorded to a + single file ("composed", the default) or to individual files ("individual"). + resolution (VideoResolution, Optional): The resolution of the archive. + stream_mode (StreamMode, Optional): Whether streams included in the archive are selected + automatically ("auto", the default) or manually ("manual"). + + Raises: + NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. + IndividualArchivePropertyError: If `resolution` or `layout` is set for individual archives + or if `has_transcription` is set for composed archives. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + has_audio: Optional[bool] = Field(None, serialization_alias='hasAudio') + has_video: Optional[bool] = Field(None, serialization_alias='hasVideo') + has_transcription: Optional[bool] = Field( + None, serialization_alias='hasTranscription' + ) + layout: Optional[ComposedLayout] = None + multi_archive_tag: Optional[str] = Field(None, serialization_alias='multiArchiveTag') + name: Optional[str] = None + output_mode: Optional[OutputMode] = Field(None, serialization_alias='outputMode') + resolution: Optional[VideoResolution] = None + stream_mode: Optional[StreamMode] = Field(None, serialization_alias='streamMode') + + @model_validator(mode='after') + def validate_audio_or_video(self): + if self.has_audio is False and self.has_video is False: + raise NoAudioOrVideoError( + 'One of `has_audio` or `has_video` must be included.' + ) + return self + + @model_validator(mode='after') + def no_layout_or_resolution_for_individual_archives(self): + if self.output_mode == OutputMode.INDIVIDUAL and self.resolution is not None: + raise IndividualArchivePropertyError( + 'The `resolution` property cannot be set for `archive_mode: \'individual\'`.' + ) + if self.output_mode == OutputMode.INDIVIDUAL and self.layout is not None: + raise IndividualArchivePropertyError( + 'The `layout` property cannot be set for `archive_mode: \'individual\'`.' + ) + return self + + @model_validator(mode='after') + def transcription_only_for_individual_archives(self): + if self.output_mode == OutputMode.COMPOSED and self.has_transcription is True: + raise IndividualArchivePropertyError( + 'The `has_transcription` property can only be set for `archive_mode: \'individual\'`.' + ) + return self diff --git a/video/src/vonage_video/models/audio_connector.py b/video/src/vonage_video/models/audio_connector.py new file mode 100644 index 00000000..1c490930 --- /dev/null +++ b/video/src/vonage_video/models/audio_connector.py @@ -0,0 +1,46 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_video.models.enums import AudioSampleRate + + +class AudioConnectorWebSocket(BaseModel): + """The audio connector websocket options. + + Args: + uri (str): The URI. + streams (list[str]): Stream IDs to include. If not provided, all streams are included. + headers (dict): The headers to send to your WebSocket server. + audio_rate (AudioSampleRate): The audio sample rate in Hertz. + """ + + uri: str + streams: Optional[list[str]] = None + headers: Optional[dict] = None + audio_rate: Optional[AudioSampleRate] = Field(None, serialization_alias='audioRate') + + +class AudioConnectorOptions(BaseModel): + """Options for the audio connector. + + Args: + session_id (str): The session ID. + token (str): The token. + websocket (AudioConnectorWebSocket): The audio connector websocket. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + token: str + websocket: AudioConnectorWebSocket + + +class AudioConnectorData(BaseModel): + """Class containing Audio Connector WebSocket ID and connection ID. + + Args: + id (str, Optional): The WebSocket ID. + connection_id (str, Optional): The connection ID. + """ + + id: Optional[str] = None + connection_id: Optional[str] = Field(None, validation_alias='connectionId') diff --git a/video/src/vonage_video/models/broadcast.py b/video/src/vonage_video/models/broadcast.py new file mode 100644 index 00000000..dcedbfe7 --- /dev/null +++ b/video/src/vonage_video/models/broadcast.py @@ -0,0 +1,218 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_video.errors import InvalidHlsOptionsError, InvalidOutputOptionsError +from vonage_video.models.common import ComposedLayout, ListVideoFilter, VideoStream +from vonage_video.models.enums import StreamMode, VideoResolution + + +class ListBroadcastsFilter(ListVideoFilter): + """Model with filters for listing broadcasts. + + Args: + offset (int, Optional): The offset. + page_size (int, Optional): The number of broadcast objects to return per page. + session_id (str, Optional): The session ID of a Vonage Video session. + """ + + session_id: Optional[str] = None + + +class BroadcastHls(BaseModel): + """Model for HLS output settings for a broadcast. + + Args: + dvr (bool, Optional): Whether the broadcast supports DVR. + low_latency (bool, Optional): Whether the broadcast is low latency. + Note: Cannot be True when `dvr=True`. + + Raises: + InvalidHlsOptionsError: If `low_latency=True` and `dvr=True`. + """ + + dvr: Optional[bool] = None + low_latency: Optional[bool] = Field(None, serialization_alias='lowLatency') + + @model_validator(mode='after') + def validate_low_latency(self): + if self.dvr and self.low_latency: + raise InvalidHlsOptionsError('Cannot set `low_latency=True` when `dvr=True`.') + return self + + +class BroadcastRtmp(BaseModel): + """Model for RTMP output settings for a broadcast. + + Args: + id (str, Optional): A unique ID for the stream. + server_url (str): The RTMP server URL. + stream_name (str): The stream name, such as the YouTube Live stream name or the + Facebook stream key. + """ + + id: Optional[str] = None + server_url: str = Field(..., serialization_alias='serverUrl') + stream_name: str = Field(..., serialization_alias='streamName') + + +class RtmpStream(BroadcastRtmp): + """Model for RTMP output settings for a broadcast. + + Args: + id (str, Optional): A unique ID for the stream. + server_url (str): The RTMP server URL. + stream_name (str): The stream name, such as the YouTube Live stream name or the + Facebook stream key. + status (str, Optional): The status of the RTMP stream. + """ + + server_url: Optional[str] = Field(None, validation_alias='serverUrl') + stream_name: Optional[str] = Field(None, validation_alias='streamName') + status: Optional[str] = None + + +class BroadcastUrls(BaseModel): + """Model for URLs for a broadcast. + + Args: + hls (str, Optional): URL for the HLS broadcast. + hls_status (str, Optional): The status of the HLS broadcast. + rtmp (list[str], Optional): An array of objects that include information on each of the RTMP streams. + """ + + hls: Optional[str] = None + hls_status: Optional[str] = Field(None, validation_alias='hlsStatus') + rtmp: Optional[list[RtmpStream]] = None + + +class HlsSettings(BaseModel): + """Model for HLS settings for a broadcast. + + Args: + dvr (bool, Optional): Whether the broadcast supports DVR. + low_latency (bool, Optional): Whether the broadcast is low latency. + """ + + dvr: Optional[bool] = None + low_latency: Optional[bool] = Field(None, validation_alias='lowLatency') + + +class BroadcastSettings(BaseModel): + """Model for settings for a broadcast. + + Args: + hls (HlsSettings, Optional): HLS settings for the broadcast. + """ + + hls: Optional[HlsSettings] = None + + +class Broadcast(BaseModel): + """Model for a broadcast. + + Args: + id (str, Optional): The broadcast ID. + session_id (str, Optional): The video session ID. + multi_broadcast_tag (str, Optional): The unique tag for simultaneous broadcasts + (if one was set). + application_id (str, Optional): The Vonage application ID. + created_at (int, Optional): The timestamp when the broadcast started, expressed + in milliseconds since the Unix epoch. + updated_at (int, Optional): The timestamp when the broadcast was last updated, + expressed in milliseconds since the Unix epoch. + max_duration (int, Optional): The maximum duration of the broadcast in seconds. + max_bitrate (int, Optional): The maximum bitrate of the broadcast. + broadcast_urls (BroadcastUrls, Optional): An object containing details about the + HLS and RTMP broadcasts. + settings (BroadcastHls, Optional): The HLS output settings. + resolution (VideoResolution, Optional): The resolution of the broadcast. + has_audio (bool, Optional): Whether the broadcast includes audio. + has_video (bool, Optional): Whether the broadcast includes video. + stream_mode (StreamMode, Optional): Whether streams included in the broadcast are + selected automatically (`auto`, the default) or manually (`manual`). + status (str, Optional): The status of the broadcast. + streams (list[VideoStream], Optional): An array of objects corresponding to + streams currently being broadcast. This is only set for a broadcast with + the status set to "started" and the `stream_mode` set to "manual". + """ + + id: Optional[str] = None + session_id: Optional[str] = Field(None, validation_alias='sessionId') + multi_broadcast_tag: Optional[str] = Field(None, validation_alias='multiBroadcastTag') + application_id: Optional[str] = Field(None, validation_alias='applicationId') + created_at: Optional[int] = Field(None, validation_alias='createdAt') + updated_at: Optional[int] = Field(None, validation_alias='updatedAt') + max_duration: Optional[int] = Field(None, validation_alias='maxDuration') + max_bitrate: Optional[int] = Field(None, validation_alias='maxBitrate') + broadcast_urls: Optional[BroadcastUrls] = Field( + None, validation_alias='broadcastUrls' + ) + settings: Optional[BroadcastSettings] = None + resolution: Optional[VideoResolution] = None + has_audio: Optional[bool] = Field(None, validation_alias='hasAudio') + has_video: Optional[bool] = Field(None, validation_alias='hasVideo') + stream_mode: Optional[StreamMode] = Field(None, validation_alias='streamMode') + status: Optional[str] = None + streams: Optional[list[VideoStream]] = None + + +class BroadcastOutputSettings(BaseModel): + """Model for output options for a broadcast. You must specify at least one output + option. + + Args: + hls (BroadcastHls, Optional): HLS output settings. + rtmp (list[BroadcastRtmp], Optional): RTMP output settings. + + Raises: + InvalidOutputOptionsError: If neither HLS nor RTMP output options are set. + """ + + hls: Optional[BroadcastHls] = None + rtmp: Optional[list[BroadcastRtmp]] = None + + @model_validator(mode='after') + def validate_outputs(self): + if self.hls is None and self.rtmp is None: + raise InvalidOutputOptionsError( + 'You must specify at least one output option.' + ) + return self + + +class CreateBroadcastRequest(BaseModel): + """Model for creating a broadcast. + + Args: + session_id (str): The session ID of a Vonage Video session. + layout (Layout, Optional): Layout options for the broadcast. + max_duration (int, Optional): The maximum duration of the broadcast in seconds. + outputs (Outputs): Output options for the broadcast. This object defines the types of + broadcast streams you want to start (both HLS and RTMP). You can include HLS, RTMP, + or both as broadcast streams. If you include RTMP streaming, you can specify up + to five target RTMP streams (or just one). Vonage streams the session to each RTMP + URL you specify. Note that Vonage Video live streaming supports RTMP and RTMPS. + resolution (VideoResolution, Optional): The resolution of the broadcast. + stream_mode (StreamMode, Optional): Whether streams included in the broadcast are selected + automatically ("auto", the default) or manually ("manual"). + multi_broadcast_tag (str, Optional): Set this to support recording multiple broadcasts + for the same session simultaneously. Set this to a unique string for each simultaneous + broadcast of an ongoing session. If you do not specify a unique multiBroadcastTag, + you can only record one broadcast at a time for a given session. + max_bitrate (int, Optional): The maximum bitrate of the broadcast, in bits per second. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + layout: Optional[ComposedLayout] = None + max_duration: Optional[int] = Field( + None, ge=60, le=36000, serialization_alias='maxDuration' + ) + outputs: BroadcastOutputSettings + resolution: Optional[VideoResolution] = None + stream_mode: Optional[StreamMode] = Field(None, serialization_alias='streamMode') + multi_broadcast_tag: Optional[str] = Field( + None, serialization_alias='multiBroadcastTag' + ) + max_bitrate: Optional[int] = Field( + None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' + ) diff --git a/video/src/vonage_video/models/captions.py b/video/src/vonage_video/models/captions.py new file mode 100644 index 00000000..483da18b --- /dev/null +++ b/video/src/vonage_video/models/captions.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from .enums import LanguageCode + + +class CaptionsOptions(BaseModel): + """The Options to send captions. + + Args: + session_id (str): The session ID. + token (str): A valid token with moderation privileges. + language_code (LanguageCode, Optional): The language code. + max_duration (int, Optional): The maximum duration. + partial_captions (bool, Optional): The partial captions. + status_callback_url (str, Optional): The status callback URL. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + token: str + language_code: Optional[LanguageCode] = Field( + None, serialization_alias='languageCode' + ) + max_duration: Optional[int] = Field( + None, ge=300, le=14400, serialization_alias='maxDuration' + ) + partial_captions: Optional[bool] = Field(None, serialization_alias='partialCaptions') + status_callback_url: Optional[str] = Field( + None, min_length=15, max_length=2048, serialization_alias='statusCallbackUrl' + ) + + +class CaptionsData(BaseModel): + """Class containing captions ID. + + Args: + captions_id (str): The captions ID. + """ + + captions_id: str = Field(..., serialization_alias='captionsId') diff --git a/video/src/vonage_video/models/common.py b/video/src/vonage_video/models/common.py new file mode 100644 index 00000000..a60784c3 --- /dev/null +++ b/video/src/vonage_video/models/common.py @@ -0,0 +1,91 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_video.errors import LayoutScreenshareTypeError, LayoutStylesheetError +from vonage_video.models.enums import LayoutType + + +class VideoStream(BaseModel): + """Model for a video stream used for archive and broadcast operations. + + Args: + stream_id (str, Optional): The stream ID. + has_audio (bool, Optional): Whether the stream has audio. + has_video (bool, Optional): Whether the stream has video. + """ + + stream_id: Optional[str] = Field(None, validation_alias='streamId') + has_audio: Optional[bool] = Field(None, validation_alias='hasAudio') + has_video: Optional[bool] = Field(None, validation_alias='hasVideo') + + +class AddStreamRequest(BaseModel): + """Model for adding a stream to an archive or broadcast. + + Args: + stream_id (VideoStream): The stream ID to add to the archive/broadcast. + has_audio (bool, Optional): Whether the stream has audio. + has_video (bool, Optional): Whether the stream has video. + """ + + stream_id: str = Field(..., serialization_alias='addStream') + has_audio: Optional[bool] = Field(None, serialization_alias='hasAudio') + has_video: Optional[bool] = Field(None, serialization_alias='hasVideo') + + +class ComposedLayout(BaseModel): + """Model for layout options for a composed archive/broadcast. + + Args: + type (str): Specify this to assign the initial layout type for the archive/broadcast. + This applies only to composed archives. + stylesheet (str, Optional): The stylesheet URL. Used for the custom layout to + define the visual layout. + screenshare_type (str, Optional): The screenshare type. Set the screenshareType + property to the layout type to use when there is a screen-sharing stream in + the session. If you set the screenshareType property, you must set the type + property to "bestFit" and leave the stylesheet property unset. + + Raises: + LayoutStylesheetError: If `stylesheet` is not set for `layout_type: 'custom'` or + if `stylesheet` is set for `layout_type: 'bestFit'`. + LayoutScreenshareTypeError: If `screenshare_type` is set and `type` is not 'bestFit'. + """ + + type: LayoutType + stylesheet: Optional[str] = None + screenshare_type: Optional[LayoutType] = Field( + None, serialization_alias='screenshareType' + ) + + @model_validator(mode='after') + def validate_stylesheet(self): + if self.type == LayoutType.CUSTOM and self.stylesheet is None: + raise LayoutStylesheetError( + 'The `stylesheet` property must be set for `layout_type: \'custom\'`.' + ) + if self.type != LayoutType.CUSTOM and self.stylesheet is not None: + raise LayoutStylesheetError( + 'The `stylesheet` property cannot be set for `layout_type: \'bestFit\'`.' + ) + return self + + @model_validator(mode='after') + def type_and_screenshare_type(self): + if self.screenshare_type is not None and self.type != LayoutType.BEST_FIT: + raise LayoutScreenshareTypeError( + 'If `screenshare_type` is set, `type` must have the value `bestFit`.' + ) + return self + + +class ListVideoFilter(BaseModel): + """Base model to filter when listing archives/broadcasts/Experience Composers. + + Args: + offset (int, Optional): The offset. + page_size (int, Optional): The number of archives to return per page. + """ + + offset: Optional[int] = None + page_size: Optional[int] = Field(100, serialization_alias='count') diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py new file mode 100644 index 00000000..24e594ac --- /dev/null +++ b/video/src/vonage_video/models/enums.py @@ -0,0 +1,109 @@ +from enum import Enum + + +class TokenRole(str, Enum): + """The role assigned to the token.""" + + SUBSCRIBER = 'subscriber' + PUBLISHER = 'publisher' + PUBLISHER_ONLY = 'publisheronly' + MODERATOR = 'moderator' + + +class ArchiveMode(str, Enum): + """Whether the session is archived automatically ("always") or not ("manual").""" + + MANUAL = 'manual' + ALWAYS = 'always' + + +class MediaMode(str, Enum): + """Whether the session uses the Vonage Video media router ("routed") or peers connect + directly (relayed).""" + + ROUTED = 'routed' + RELAYED = 'relayed' + + +class P2pPreference(str, Enum): + """The preference for peer-to-peer connections.""" + + DISABLED = 'disabled' + ALWAYS = 'always' + + +class LanguageCode(str, Enum): + EN_US = 'en-US' + EN_AU = 'en-AU' + EN_GB = 'en-GB' + ZH_CN = 'zh-CN' + FR_FR = 'fr-FR' + FR_CA = 'fr-CA' + DE_DE = 'de-DE' + HI_IN = 'hi-IN' + IT_IT = 'it-IT' + JA_JP = 'ja-JP' + KO_KR = 'ko-KR' + PT_BR = 'pt-BR' + TH_TH = 'th-TH' + + +class AudioSampleRate(int, Enum): + """Audio sample rate, in Hertz.""" + + KHZ_8 = 8000 + KHZ_16 = 16000 + + +class VideoResolution(str, Enum): + """The resolution of the archive or broadcast. + + This property only applies to composed archives. If you set this property and set the + outputMode property to "individual", the call to the REST method results in an error. + """ + + RES_640x480 = '640x480' + RES_480x640 = '480x640' + RES_1280x720 = '1280x720' + RES_720x1280 = '720x1280' + RES_1920x1080 = '1920x1080' + RES_1080x1920 = '1080x1920' + + +class ExperienceComposerStatus(str, Enum): + STARTING = 'starting' + STARTED = 'started' + STOPPED = 'stopped' + FAILED = 'failed' + + +class OutputMode(str, Enum): + COMPOSED = 'composed' + INDIVIDUAL = 'individual' + + +class StreamMode(str, Enum): + """Whether streams included in the archive are selected automatically ("auto", the + default) or manually ("manual").""" + + AUTO = 'auto' + MANUAL = 'manual' + + +class LayoutType(str, Enum): + BEST_FIT = 'bestFit' + CUSTOM = 'custom' + PIP = 'pip' + VERTICAL_PRESENTATION = 'verticalPresentation' + HORIZONTAL_PRESENTATION = 'horizontalPresentation' + + +class ArchiveStatus(str, Enum): + AVAILABLE = 'available' + EXPIRED = 'expired' + FAILED = 'failed' + PAUSED = 'paused' + STARTED = 'started' + STOPPED = 'stopped' + UPLOADED = 'uploaded' + DELETED = 'deleted' diff --git a/video/src/vonage_video/models/experience_composer.py b/video/src/vonage_video/models/experience_composer.py new file mode 100644 index 00000000..03f710d4 --- /dev/null +++ b/video/src/vonage_video/models/experience_composer.py @@ -0,0 +1,81 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_video.models.enums import ExperienceComposerStatus, VideoResolution + + +class ExperienceComposerProperties(BaseModel): + """Model with properties for an Experience Composer session. + + Args: + name (str): The name of the composed output stream which is published to the session. + """ + + name: str = Field(..., min_length=1, max_length=200) + + +class ExperienceComposerOptions(BaseModel): + """The options for the Experience Composer. + + Args: + session_id (str): The session ID of the Vonage Video session you are working with. + token (str): A valid Vonage Video JWT with a Publisher role and (optionally) connection data to be associated with the output stream. + url (str, Optional): A publicly reachable URL controlled by the customer and capable of generating the content to be rendered without user intervention. + max_duration (int, Optional): The maximum duration. + resolution (ExperienceComposerResolution, Optional): The resolution of the Experience Composer stream. + properties (ExperienceComposerProperties, Optional): The initial configuration of Publisher properties for the composed output stream. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + token: str + url: str = Field(..., min_length=15, max_length=2048) + max_duration: Optional[int] = Field( + None, ge=60, le=36000, serialization_alias='maxDuration' + ) + resolution: Optional[VideoResolution] = None + properties: Optional[ExperienceComposerProperties] = None + + +class ExperienceComposer(BaseModel): + """Model with data describing an Experience Composer session. + + Args: + id (str, Optional): The unique ID for the Experience Composer. + session_id (str, Optional): The session ID of the Vonage Video session you are working with. + application_id (str, Optional): The Vonage application ID. + created_at (int, Optional): The time the Experience Composer started, expressed in milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). + callback_url (str, Optional): The callback URL for Experience Composer events (if one was set). + updated_at (int, Optional): The UNIX timestamp when the Experience Composer status was last updated. + name (str, Optional): The name of the composed output stream which is published to the session. + url (str, Optional): A publicly reachable URL controlled by the customer and capable of generating the content to be rendered without user intervention. + resolution (ExperienceComposerResolution, Optional): The resolution of the Experience Composer stream. + status (ExperienceComposerStatus, Optional): The status. + stream_id (str, Optional): The ID of the composed stream being published. + reason (str, Optional): The reason for the status change. + """ + + id: Optional[str] = None + session_id: Optional[str] = Field(None, validation_alias='sessionId') + application_id: Optional[str] = Field(None, validation_alias='applicationId') + created_at: Optional[int] = Field(None, validation_alias='createdAt') + callback_url: Optional[str] = Field(None, validation_alias='callbackUrl') + updated_at: Optional[int] = Field(None, validation_alias='updatedAt') + name: Optional[str] = None + url: Optional[str] = None + resolution: Optional[VideoResolution] = None + status: Optional[ExperienceComposerStatus] = None + stream_id: Optional[str] = Field(None, validation_alias='streamId') + reason: Optional[str] = None + + +class ListExperienceComposersFilter(BaseModel): + """Request object for filtering Experience Composers associated with the specific + Vonage application. + + Args: + offset (int, Optional): The offset. + page_size (int, Optional): The number of Experience Composers to return. + """ + + offset: Optional[int] = None + page_size: Optional[int] = Field(100, serialization_alias='count') diff --git a/video/src/vonage_video/models/session.py b/video/src/vonage_video/models/session.py new file mode 100644 index 00000000..78782f5f --- /dev/null +++ b/video/src/vonage_video/models/session.py @@ -0,0 +1,58 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator + +from .enums import ArchiveMode, MediaMode, P2pPreference + + +class SessionOptions(BaseModel): + """Options for creating a new session. + + Args: + media_mode (MediaMode): The media mode for the session. + archive_mode (ArchiveMode): The archive mode for the session. + location (str): The location of the session. + e2ee (bool): Whether end-to-end encryption is enabled. + p2p_preference (str): The preference for peer-to-peer connections. + This is set automatically by selecting the `media_mode`. + """ + + archive_mode: Optional[ArchiveMode] = Field(None, serialization_alias='archiveMode') + location: Optional[str] = None + media_mode: Optional[MediaMode] = None + e2ee: Optional[bool] = None + p2p_preference: Optional[str] = Field( + P2pPreference.DISABLED, serialization_alias='p2p.preference' + ) + + @model_validator(mode='after') + def set_p2p_preference(self): + if self.media_mode == MediaMode.ROUTED: + self.p2p_preference = P2pPreference.DISABLED + if self.media_mode == MediaMode.RELAYED: + self.p2p_preference = P2pPreference.ALWAYS + return self + + @model_validator(mode='after') + def set_p2p_preference_if_archive_mode_set(self): + if self.archive_mode == ArchiveMode.ALWAYS: + self.p2p_preference = P2pPreference.DISABLED + return self + + +class VideoSession(BaseModel): + """The new session ID and options specified in the request. + + Args: + session_id (str): The session ID. + archive_mode (ArchiveMode, Optional): The archive mode for the session. + media_mode (MediaMode, Optional): The media mode for the session. + location (str, Optional): The location of the session. + e2ee (bool, Optional): Whether end-to-end encryption is enabled for the session. + """ + + session_id: str + archive_mode: Optional[ArchiveMode] = None + media_mode: Optional[MediaMode] = None + location: Optional[str] = None + e2ee: Optional[bool] = None diff --git a/video/src/vonage_video/models/signal.py b/video/src/vonage_video/models/signal.py new file mode 100644 index 00000000..e0e8a4e2 --- /dev/null +++ b/video/src/vonage_video/models/signal.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class SignalData(BaseModel): + """The data to send in a signal. + + Args: + type (str): The type of data being sent to the client. + data (str): Payload to send to the client. + """ + + type: str = Field(..., max_length=128) + data: str = Field(..., max_length=8192) diff --git a/video/src/vonage_video/models/sip.py b/video/src/vonage_video/models/sip.py new file mode 100644 index 00000000..15ec5504 --- /dev/null +++ b/video/src/vonage_video/models/sip.py @@ -0,0 +1,85 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class SipAuth(BaseModel): + """Model representing the authentication details for the SIP INVITE request for HTTP + digest authentication, if it is required by your SIP platform. + + Args: + username (str): The username for HTTP digest authentication. + password (str): The password for HTTP digest authentication. + """ + + username: str + password: str + + +class SipOptions(BaseModel): + """Model representing the SIP options for the call. + + Args: + uri (str): The SIP URI to be used as the destination of the SIP call. + from_ (Optional[str]): The number or string sent to the final SIP number + as the caller. It must be a string in the form of `from@example.com`, where + `from` can be a string or a number. + headers (Optional[dict]): Custom headers to be added to the SIP INVITE request. + auth (Optional[SipAuth]): Authentication details for the SIP INVITE request. + secure (Optional[bool]): Indicates whether the media must be transmitted encrypted. + Default is false. + video (Optional[bool]): Indicates whether the SIP call will include video. + Default is false. + observe_force_mute (Optional[bool]): Indicates whether the SIP endpoint observes + force mute moderation. + """ + + uri: str + from_: Optional[str] = Field(None, serialization_alias='from') + headers: Optional[dict] = None + auth: Optional[SipAuth] = None + secure: Optional[bool] = None + video: Optional[bool] = None + observe_force_mute: Optional[bool] = Field( + None, serialization_alias='observeForceMute' + ) + + +class InitiateSipRequest(BaseModel): + """Model representing the SIP options for joining a Vonage Video session. + + Args: + session_id (str): The Vonage Video session ID for the SIP call to join. + token (str): The Vonage Video token to be used for the participant being called. + sip (Sip): The SIP options for the call. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + token: str + sip: SipOptions + + +class SipCall(BaseModel): + """Model representing the details of a SIP call. + + Args: + id (str): A unique ID for the SIP call. + project_id (str): The Vonage Video project ID for the SIP call. + session_id (str): The Vonage Video session ID for the SIP call. + connection_id (str): The Vonage Video connection ID for the SIP call's connection + in the Vonage Video session. + stream_id (str): The Vonage Video stream ID for the SIP call's stream in the + Vonage Video session. + created_at (int): The timestamp when the SIP call was created,in milliseconds since + the Unix epoch. + updated_at (int): The timestamp when the SIP call was last updated, in milliseconds + since the Unix epoch. + """ + + id: Optional[str] = None + project_id: Optional[str] = Field(None, validation_alias='projectId') + session_id: Optional[str] = Field(None, validation_alias='sessionId') + connection_id: str = Field(None, validation_alias='connectionId') + stream_id: str = Field(None, validation_alias='streamId') + created_at: Optional[int] = Field(None, validation_alias='createdAt') + updated_at: Optional[int] = Field(None, validation_alias='updatedAt') diff --git a/video/src/vonage_video/models/stream.py b/video/src/vonage_video/models/stream.py new file mode 100644 index 00000000..dfe6a666 --- /dev/null +++ b/video/src/vonage_video/models/stream.py @@ -0,0 +1,47 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class StreamInfo(BaseModel): + """The stream information. + + Args: + id (str): The stream ID. + video_type (str): Set to "camera", "screen", or "custom". A "screen" video uses + screen sharing on the publisher as the video source; a "custom" video is + published by a web client using an HTML VideoTrack element as the video + source. + name (str): An array of the layout classes for the stream. + layout_class_list (list[str]): An array of the layout classes for the stream. + """ + + id: Optional[str] = Field(None, validation_alias='id') + video_type: Optional[str] = Field(None, validation_alias='videoType') + name: Optional[str] = Field(None, validation_alias='name') + layout_class_list: Optional[list[str]] = Field( + None, validation_alias='layoutClassList' + ) + + +class StreamLayout(BaseModel): + """The stream layout. + + Args: + id (str): The stream ID. + layout_class_list (list[str]): An array of the layout classes for the stream. + """ + + id: str + layout_class_list: list[str] = Field(..., serialization_alias='layoutClassList') + + +class StreamLayoutOptions(BaseModel): + """The options for the stream layout. + + Args: + items (list[[StreamLayout]]): An array of the stream layout items. Each item is a StreamLayout + object. See StreamLayout. + """ + + items: list[StreamLayout] diff --git a/video/src/vonage_video/models/token.py b/video/src/vonage_video/models/token.py new file mode 100644 index 00000000..350611ee --- /dev/null +++ b/video/src/vonage_video/models/token.py @@ -0,0 +1,62 @@ +from time import time +from typing import Literal, Optional +from uuid import uuid4 + +from pydantic import BaseModel, Field, field_validator, model_validator + +from ..errors import TokenExpiryError +from .enums import TokenRole + + +class TokenOptions(BaseModel): + """Options for generating a token for the Vonage Video API. + + Args: + session_id (str): The session ID. + role (TokenRole): The role of the token. Defaults to 'publisher'. + connection_data (str): The connection data for the token. + initial_layout_class_list (list[str]): The initial layout class list for the token. + exp (int): The expiry date for the token. Defaults to 15 minutes from the current time. + jti (Union[UUID, str]): The JWT ID for the token. Defaults to a new UUID. + iat (float): The time the token was issued. Defaults to the current time. + subject (str): The subject of the token. Defaults to 'video'. + scope (str): The scope of the token. Defaults to 'session.connect'. + acl (dict): The access control list for the token. NOTE: Do not change this value. + + Raises: + TokenExpiryError: If the expiry date is in the past or more than 30 days in the future. + """ + + session_id: str + role: Optional[TokenRole] = TokenRole.PUBLISHER + connection_data: Optional[str] = None + initial_layout_class_list: Optional[list[str]] = None + exp: Optional[int] = None + jti: str = Field(default_factory=lambda: str(uuid4())) + iat: int = Field(default_factory=lambda: int(time())) + subject: Literal['video'] = 'video' + scope: Literal['session.connect'] = 'session.connect' + acl: dict = {'paths': {'/session/**': {}}} + + @field_validator('exp') + @classmethod + def validate_exp(cls, v: int): + now = int(time()) + if v < now: + raise TokenExpiryError('Token expiry date must be in the future.') + if v > now + 3600 * 24 * 30: + raise TokenExpiryError( + 'Token expiry date must be less than 30 days from now.' + ) + return v + + @model_validator(mode='after') + def set_exp(self): + if self.exp is None: + self.exp = self.iat + 15 * 60 + return self + + @model_validator(mode='after') + def enforce_acl_default_value(self): + self.acl = {'paths': {'/session/**': {}}} + return self diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py new file mode 100644 index 00000000..2bb3ca14 --- /dev/null +++ b/video/src/vonage_video/video.py @@ -0,0 +1,757 @@ +from typing import Optional, Type, Union + +from pydantic import validate_call +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_utils.types import Dtmf +from vonage_video.errors import ( + InvalidArchiveStateError, + InvalidBroadcastStateError, + RoutedSessionRequiredError, + VideoError, +) +from vonage_video.models.archive import ( + Archive, + ComposedLayout, + CreateArchiveRequest, + ListArchivesFilter, +) +from vonage_video.models.audio_connector import AudioConnectorData, AudioConnectorOptions +from vonage_video.models.broadcast import ( + Broadcast, + CreateBroadcastRequest, + ListBroadcastsFilter, +) +from vonage_video.models.captions import CaptionsData, CaptionsOptions +from vonage_video.models.common import AddStreamRequest +from vonage_video.models.experience_composer import ( + ExperienceComposer, + ExperienceComposerOptions, + ListExperienceComposersFilter, +) +from vonage_video.models.session import SessionOptions, VideoSession +from vonage_video.models.signal import SignalData +from vonage_video.models.sip import InitiateSipRequest, SipCall +from vonage_video.models.stream import StreamInfo, StreamLayoutOptions +from vonage_video.models.token import TokenOptions + + +class Video: + """Calls Vonage's Video API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Voice API. + + Returns: + HttpClient: The HTTP client used to make requests to the Voice API. + """ + return self._http_client + + @validate_call + def generate_client_token(self, token_options: TokenOptions) -> bytes: + """Generates a client token for the Vonage Video API. + + Args: + token_options (TokenOptions): The options for the token. + + Returns: + str: The client token. + """ + return self._http_client.auth.generate_application_jwt( + token_options.model_dump(exclude_none=True) + ) + + @validate_call + def create_session(self, options: SessionOptions = None) -> VideoSession: + """Creates a new session for the Vonage Video API. + + Args: + options (SessionOptions): The options for the session. + + Returns: + VideoSession: The new session ID, plus the config options specified in `options`. + """ + + response = self._http_client.post( + self._http_client.video_host, + '/session/create', + options.model_dump(by_alias=True, exclude_none=True) if options else None, + sent_data_type='form', + ) + + session_response = { + 'session_id': response[0]['session_id'], + **(options.model_dump(exclude_none=True) if options else {}), + } + + return VideoSession(**session_response) + + @validate_call + def list_streams(self, session_id: str) -> list[StreamInfo]: + """Lists the streams in a session from the Vonage Video API. + + Args: + session_id (str): The session ID. + + Returns: + list[StreamInfo]: Information about the video streams. + """ + + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/stream', + ) + + return [StreamInfo(**stream) for stream in response['items']] + + @validate_call + def get_stream(self, session_id: str, stream_id: str) -> StreamInfo: + """Gets a stream from the Vonage Video API. + + Args: + session_id (str): The session ID. + stream_id (str): The stream ID. + + Returns: + StreamInfo: Information about the video stream. + """ + + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/stream/{stream_id}', + ) + + return StreamInfo(**response) + + @validate_call + def change_stream_layout( + self, session_id: str, stream_layout_options: StreamLayoutOptions + ) -> list[StreamInfo]: + """Changes the layout of a stream in a session in the Vonage Video API. + + Args: + session_id (str): The session ID. + stream_layout_options (StreamLayoutOptions): The options for the stream layout. + + Returns: + list[StreamInfo]: Information about the video streams. + """ + + response = self._http_client.put( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/stream', + stream_layout_options.model_dump(by_alias=True, exclude_none=True), + ) + + return [StreamInfo(**stream) for stream in response['items']] + + @validate_call + def send_signal( + self, session_id: str, data: SignalData, connection_id: str = None + ) -> None: + """Sends a signal to a session in the Vonage Video API. If `connection_id` is not + provided, the signal will be sent to all connections in the session. + + Args: + session_id (str): The session ID. + data (SignalData): The data to send in the signal. + connection_id (str, Optional): The connection ID to send the signal to. If not provided, + the signal will be sent to all connections in the session. + """ + if connection_id is not None: + url = f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/connection/{connection_id}/signal' + else: + url = f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/signal' + + self._http_client.post( + self._http_client.video_host, url, data.model_dump(exclude_none=True) + ) + + @validate_call + def disconnect_client(self, session_id: str, connection_id: str) -> None: + """Disconnects a client from a session in the Vonage Video API. + + Args: + session_id (str): The session ID. + connection_id (str): The connection ID of the client to disconnect. + """ + self._http_client.delete( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/connection/{connection_id}', + ) + + @validate_call + def mute_stream(self, session_id: str, stream_id: str) -> None: + """Mutes a stream in a session using the Vonage Video API. + + Args: + session_id (str): The session ID. + stream_id (str): The stream ID. + """ + self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/stream/{stream_id}/mute', + ) + + @validate_call + def mute_all_streams( + self, session_id: str, excluded_stream_ids: list[str] = None + ) -> None: + """Mutes all streams in a session using the Vonage Video API. + + Args: + session_id (str): The session ID. + excluded_stream_ids (list[str], Optional): The stream IDs to exclude from muting. + """ + params = {'active': True, 'excludedStreamIds': excluded_stream_ids} + self._toggle_mute_all_streams(session_id, params) + + @validate_call + def disable_mute_all_streams(self, session_id: str) -> None: + """Disables muting all streams in a session using the Vonage Video API. + + Args: + session_id (str): The session ID. + """ + self._toggle_mute_all_streams(session_id, {'active': False}) + + @validate_call + def _toggle_mute_all_streams(self, session_id: str, params: dict) -> None: + """Mutes all streams in a session using the Vonage Video API. + + Args: + session_id (str): The session ID. + params (dict): The parameters to send in the request. + """ + self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/mute', + params, + ) + + @validate_call + def start_captions(self, options: CaptionsOptions) -> CaptionsData: + """Enables captions in a session using the Vonage Video API. + + Args: + options (CaptionsOptions): Options for the captions. + + Returns: + CaptionsData: Class containing captions ID. + """ + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/captions', + options.model_dump(exclude_none=True, by_alias=True), + ) + + return CaptionsData(captions_id=response['captionsId']) + + @validate_call + def stop_captions(self, captions: CaptionsData) -> None: + """Disables captions in a session using the Vonage Video API. + + Args: + captions (CaptionsData): The captions data. + """ + self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/captions/{captions.captions_id}/stop', + ) + + @validate_call + def start_audio_connector(self, options: AudioConnectorOptions) -> AudioConnectorData: + """Starts an audio connector in a session using the Vonage Video API. Connects + audio streams to a specified WebSocket URI. + + Args: + options (AudioConnectorOptions): Options for the audio connector. + + Returns: + AudioConnectorData: Class containing audio connector ID. + """ + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/connect', + options.model_dump(exclude_none=True, by_alias=True), + ) + + return AudioConnectorData(**response) + + @validate_call + def start_experience_composer( + self, options: ExperienceComposerOptions + ) -> ExperienceComposer: + """Starts an Experience Composer using the Vonage Video API. + + Args: + options (ExperienceComposerOptions): Options for the Experience Composer. + + Returns: + ExperienceComposer: Class containing Experience Composer data. + """ + + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/render', + options.model_dump(exclude_none=True, by_alias=True), + ) + + return ExperienceComposer(**response) + + @validate_call + def list_experience_composers( + self, filter: ListExperienceComposersFilter = ListExperienceComposersFilter() + ) -> tuple[list[ExperienceComposer], int, Optional[int]]: + """Lists Experience Composers associated with your Vonage application. + + Args: + filter (ListExperienceComposersFilter): Filter for the Experience Composers. + + Returns: + tuple[list[ExperienceComposer], int, Optional[int]]: A tuple containing a list of experience + composer objects, the total count of Experience Composers and the required offset value + for the next page, if applicable. + i.e. + experience_composers: list[ExperienceComposer], count: int, next_page_offset: Optional[int] + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/render', + filter.model_dump(exclude_none=True, by_alias=True), + ) + + return self._list_video_objects(filter, response, ExperienceComposer) + + @validate_call + def get_experience_composer(self, experience_composer_id: str) -> ExperienceComposer: + """Gets an Experience Composer associated with your Vonage application. + + Args: + experience_composer_id (str): The ID of the Experience Composer. + + Returns: + ExperienceComposer: The Experience Composer object. + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/render/{experience_composer_id}', + ) + + return ExperienceComposer(**response) + + @validate_call + def stop_experience_composer(self, experience_composer_id: str) -> None: + """Stops an Experience Composer associated with your Vonage application. + + Args: + experience_composer_id (str): The ID of the Experience Composer. + """ + self._http_client.delete( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/render/{experience_composer_id}', + ) + + @validate_call + def list_archives( + self, filter: ListArchivesFilter + ) -> tuple[list[Archive], int, Optional[int]]: + """Lists archives associated with a Vonage Application. + + Args: + filter (ListArchivesFilter): The filters for the archives. + + Returns: + tuple[list[Archive], int, Optional[int]]: A tuple containing a list of archive objects, + the total count of archives and the required offset value for the next page, if applicable. + i.e. + archives: list[Archive], count: int, next_page_offset: Optional[int] + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive', + filter.model_dump(exclude_none=True, by_alias=True), + ) + + return self._list_video_objects(filter, response, Archive) + + @validate_call + def start_archive(self, options: CreateArchiveRequest) -> Archive: + """Starts an archive in a Vonage Video API session. + + Args: + options (CreateArchiveRequest): The options for the archive. + + Returns: + Archive: The archive object. + """ + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive', + options.model_dump(exclude_none=True, by_alias=True), + ) + + return Archive(**response) + + @validate_call + def get_archive(self, archive_id: str) -> Archive: + """Gets an archive from the Vonage Video API. + + Args: + archive_id (str): The archive ID. + + Returns: + Archive: The archive object. + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}', + ) + + return Archive(**response) + + @validate_call + def delete_archive(self, archive_id: str) -> None: + """Deletes an archive from the Vonage Video API. + + Args: + archive_id (str): The archive ID. + + Raises: + InvalidArchiveStateError: If the archive has a status other than `available`, `uploaded`, or `deleted`. + """ + try: + self._http_client.delete( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}', + ) + except HttpRequestError as e: + conflict_error_message = 'You can only delete an archive that has one of the following statuses: `available` OR `uploaded` OR `deleted`.' + self._check_conflict_error( + e, InvalidArchiveStateError, conflict_error_message + ) + + @validate_call + def add_stream_to_archive(self, archive_id: str, params: AddStreamRequest) -> None: + """Adds a stream to an archive in the Vonage Video API. Use this method to change + the streams included in a composed archive that was started with the streamMode + set to "manual". + + Args: + archive_id (str): The archive ID. + params (AddStreamRequest): Params for adding a stream to an archive. + """ + self._http_client.patch( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}/streams', + params.model_dump(exclude_none=True, by_alias=True), + ) + + @validate_call + def remove_stream_from_archive(self, archive_id: str, stream_id: str) -> None: + """Removes a stream from an archive in the Vonage Video API. + + Args: + archive_id (str): The archive ID. + stream_id (str): ID of the stream to remove. + """ + self._http_client.patch( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}/streams', + params={'removeStream': stream_id}, + ) + + @validate_call + def stop_archive(self, archive_id: str) -> Archive: + """Stops a Vonage Video API archive. + + Args: + archive_id (str): The archive ID. + + Returns: + Archive: The archive object. + + Raises: + InvalidArchiveStateError: If the archive is not being recorded. + """ + try: + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}/stop', + ) + except HttpRequestError as e: + conflict_error_message = ( + 'You can only stop an archive that is being recorded.' + ) + self._check_conflict_error( + e, InvalidArchiveStateError, conflict_error_message + ) + return Archive(**response) + + @validate_call + def change_archive_layout(self, archive_id: str, layout: ComposedLayout) -> Archive: + """Changes the layout of an archive in the Vonage Video API. + + Args: + archive_id (str): The archive ID. + layout (ComposedLayout): The layout to change to. + + Returns: + Archive: The archive object. + """ + response = self._http_client.put( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}/layout', + layout.model_dump(exclude_none=True, by_alias=True), + ) + + return Archive(**response) + + @validate_call + def list_broadcasts( + self, filter: ListBroadcastsFilter + ) -> tuple[list[Broadcast], int, Optional[int]]: + """Lists broadcasts associated with a Vonage Application. + + Args: + filter (ListBroadcastsFilter): The filters for the broadcasts. + + Returns: + tuple[list[Broadcast], int, Optional[int]]: A tuple containing a list of broadcast objects, + the total count of broadcasts and the required offset value for the next page, if applicable. + i.e. + broadcasts: list[Broadcast], count: int, next_page_offset: Optional[int] + # + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast', + filter.model_dump(exclude_none=True, by_alias=True), + ) + + return self._list_video_objects(filter, response, Broadcast) + + @validate_call + def start_broadcast(self, options: CreateBroadcastRequest) -> Broadcast: + """Starts a broadcast in a Vonage Video API session. + + Args: + options (CreateBroadcastRequest): The options for the broadcast. + + Returns: + Broadcast: The broadcast object. + + Raises: + InvalidBroadcastStateError: If the broadcast has already started for the session, + or if you attempt to start a simultaneous broadcast for a session without setting + a unique `multi-broadcast-tag` value. + """ + try: + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast', + options.model_dump(exclude_none=True, by_alias=True), + ) + except HttpRequestError as e: + conflict_error_message = ( + 'Either the broadcast has already started for the session, ' + 'or you attempted to start a simultaneous broadcast for a session ' + 'without setting a unique `multi-broadcast-tag` value.' + ) + self._check_conflict_error( + e, InvalidBroadcastStateError, conflict_error_message + ) + + return Broadcast(**response) + + @validate_call + def get_broadcast(self, broadcast_id: str) -> Broadcast: + """Gets a broadcast from the Vonage Video API. + + Args: + broadcast_id (str): The broadcast ID. + + Returns: + Broadcast: The broadcast object. + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast/{broadcast_id}', + ) + + return Broadcast(**response) + + @validate_call + def stop_broadcast(self, broadcast_id: str) -> Broadcast: + """Stops a Vonage Video API broadcast. + + Args: + broadcast_id (str): The broadcast ID. + + Returns: + Broadcast: The broadcast object. + """ + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast/{broadcast_id}/stop', + ) + return Broadcast(**response) + + @validate_call + def change_broadcast_layout( + self, broadcast_id: str, layout: ComposedLayout + ) -> Broadcast: + """Changes the layout of a broadcast in the Vonage Video API. + + Args: + broadcast_id (str): The broadcast ID. + layout (ComposedLayout): The layout to change to. + + Returns: + Broadcast: The broadcast object. + """ + response = self._http_client.put( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast/{broadcast_id}/layout', + layout.model_dump(exclude_none=True, by_alias=True), + ) + + return Broadcast(**response) + + @validate_call + def add_stream_to_broadcast( + self, broadcast_id: str, params: AddStreamRequest + ) -> None: + """Adds a stream to a broadcast in the Vonage Video API. Use this method to change + the streams included in a composed broadcast that was started with the streamMode + set to "manual". + + Args: + broadcast_id (str): The broadcast ID. + params (AddStreamRequest): The video stream to add to the broadcast. + """ + self._http_client.patch( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast/{broadcast_id}/streams', + params.model_dump(exclude_none=True, by_alias=True), + ) + + @validate_call + def remove_stream_from_broadcast(self, broadcast_id: str, stream_id: str) -> None: + """Removes a stream from a broadcast in the Vonage Video API. + + Args: + broadcast_id (str): The broadcast ID. + stream_id (str): ID of the stream to remove. + """ + self._http_client.patch( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast/{broadcast_id}/streams', + params={'removeStream': stream_id}, + ) + + @validate_call + def initiate_sip_call(self, sip_request_params: InitiateSipRequest) -> SipCall: + """Initiates a SIP call using the Vonage Video API. + + Args: + sip_request_params (SipParams): Model containing the session ID and a valid token, + as well as options for the SIP call. + + Returns: + SipCall: The SIP call object. + """ + try: + response = self._http_client.post( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/dial', + sip_request_params.model_dump(exclude_none=True, by_alias=True), + ) + except HttpRequestError as e: + conflict_error_message = 'SIP calling can only be used in a session with' + ' `media_mode=routed`.' + self._check_conflict_error( + e, RoutedSessionRequiredError, conflict_error_message + ) + + return SipCall(**response) + + @validate_call + def play_dtmf(self, session_id: str, digits: Dtmf, connection_id: str = None) -> None: + """Plays DTMF tones into one or all SIP connections in a session using the Vonage + Video API. + + Args: + session_id (str): The session ID. + digits (Dtmf): The DTMF digits to play. Numbers `0-9`, `*`, `#` and `p` + (500ms pause) are supported. + connection_id (str, Optional): The connection ID to send the DTMF tones to. + If not provided, the DTMF tones will be played on all connections in + the session. + """ + if connection_id is not None: + url = f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/connection/{connection_id}/play-dtmf' + else: + url = f'/v2/project/{self._http_client.auth.application_id}/session/{session_id}/play-dtmf' + + self._http_client.post(self._http_client.video_host, url, {'digits': digits}) + + @validate_call + def _list_video_objects( + self, + request_filter: Union[ + ListArchivesFilter, ListBroadcastsFilter, ListExperienceComposersFilter + ], + response: dict, + model: Union[Type[Archive], Type[Broadcast], Type[ExperienceComposer]], + ) -> tuple[list[object], int, Optional[int]]: + """List objects of a specific model from a response. + + Args: + request_filter (Union[ListArchivesFilter, ListBroadcastsFilter, ListExperienceComposersFilter]): + The filter used to make the request. + response (dict): The response from the API. + model (Union[Type[Archive], Type[Broadcast], Type[ExperienceComposer]]): The type of a pydantic + model to populate the response into. + + Returns: + tuple[list[object], int, Optional[int]]: A tuple containing a list of objects, + the total count of objects and the required offset value for the next page, if applicable. + i.e. + objects: list[object], count: int, next_page_offset: Optional[int] + """ + index = request_filter.offset + 1 or 1 + page_size = request_filter.page_size + objects = [] + + try: + for obj in response['items']: + objects.append(model(**obj)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * index: + return objects, count, index + return objects, count, None + + def _check_conflict_error( + self, + http_error: HttpRequestError, + ConflictError: Type[VideoError], + conflict_error_message: str, + ) -> None: + """Checks if the error is a conflict error and raises the specified error. + + Args: + http_error (HttpRequestError): The error to check. + ConflictError (Type[VideoError]): The error to raise if there is a conflict. + conflict_error_message (str): The error message if there is a conflict. + """ + if http_error.response.status_code == 409: + raise ConflictError(f'{conflict_error_message} {http_error.response.text}') + raise http_error diff --git a/video/tests/BUILD b/video/tests/BUILD new file mode 100644 index 00000000..bde33767 --- /dev/null +++ b/video/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['video', 'testutils']) diff --git a/video/tests/data/archive.json b/video/tests/data/archive.json new file mode 100644 index 00000000..1167780f --- /dev/null +++ b/video/tests/data/archive.json @@ -0,0 +1,23 @@ +{ + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "started", + "name": "first archive test", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "manual", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727870434977, + "multiArchiveTag": "my-multi-archive", + "event": "archive", + "resolution": "1280x720", + "url": null +} \ No newline at end of file diff --git a/video/tests/data/audio_connector.json b/video/tests/data/audio_connector.json new file mode 100644 index 00000000..e0cdc7b5 --- /dev/null +++ b/video/tests/data/audio_connector.json @@ -0,0 +1,4 @@ +{ + "id": "b3cd31f4-020e-4ba3-9a2a-12d98b8a184f", + "connectionId": "1bf530df-97f4-4437-b6c9-2a66200200c8" +} \ No newline at end of file diff --git a/video/tests/data/broadcast.json b/video/tests/data/broadcast.json new file mode 100644 index 00000000..e2f58cc7 --- /dev/null +++ b/video/tests/data/broadcast.json @@ -0,0 +1,33 @@ +{ + "id": "f03fad17-4591-4422-8bd3-00a4df1e616a", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1728039361014, + "broadcastUrls": { + "rtmp": [ + { + "status": "connecting", + "id": "test", + "serverUrl": "rtmp://a.rtmp.youtube.com/live2", + "streamName": "stream-key" + } + ], + "hls": "https://broadcast2-euw1-cdn.media.prod.tokbox.com/broadcast-57d6569497-dj9b2.10293/broadcast-57d6569497-dj9b2.10293_f03fad17-4591-4422-8bd3-00a4df1e616a_29f760f8-7ce1-46c9-ade3-f2dedee4ed5f.2_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3MjgwMzY0MTUzMDd-V2swbzlzeUppaGZIVTFzYUQwamdYM0Ryfn5-.smil/playlist.m3u8?DVR" + }, + "updatedAt": 1728039361511, + "status": "started", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "maxDuration": 3600, + "multiBroadcastTag": "test-broadcast-5", + "maxBitrate": 1000000, + "settings": { + "hls": { + "lowLatency": false, + "dvr": true + } + }, + "event": "broadcast", + "resolution": "1280x720" +} \ No newline at end of file diff --git a/video/tests/data/captions_error_already_enabled.json b/video/tests/data/captions_error_already_enabled.json new file mode 100644 index 00000000..b056d73b --- /dev/null +++ b/video/tests/data/captions_error_already_enabled.json @@ -0,0 +1,5 @@ +{ + "code": 60003, + "message": "Audio captioning is already enabled", + "description": "Audio captioning is already enabled" +} \ No newline at end of file diff --git a/video/tests/data/change_stream_layout.json b/video/tests/data/change_stream_layout.json new file mode 100644 index 00000000..ec1c4244 --- /dev/null +++ b/video/tests/data/change_stream_layout.json @@ -0,0 +1,13 @@ +{ + "count": 1, + "items": [ + { + "id": "e08ff3f4-d04b-4363-bd6c-31bd29648ec8", + "videoType": "camera", + "name": "", + "layoutClassList": [ + "full" + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/video/create_session.json b/video/tests/data/create_session.json similarity index 64% rename from tests/data/video/create_session.json rename to video/tests/data/create_session.json index 7c6f4b35..514ba8ec 100644 --- a/tests/data/video/create_session.json +++ b/video/tests/data/create_session.json @@ -1,9 +1,9 @@ [ { - "session_id": "my_session_id", + "session_id": "1_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3MjY0NjI1ODg2NDd-MTF4TGExYmJoelBlR1FHbVhzbWd4STBrfn5-", "project_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", "partner_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", - "create_dt": "Tue Aug 09 09:10:17 PDT 2022", + "create_dt": "Sun Sep 15 21:56:28 PDT 2024", "session_status": null, "status_invalid": null, "media_server_hostname": null, @@ -12,8 +12,8 @@ "symphony_address": null, "properties": null, "ice_server": null, - "session_segment_id": "b8c32a6d-faf9-4ec4-a648-a6d382cd650b", + "session_segment_id": "35308566-4012-4c1e-90f7-cc15b5a390fe", "ice_servers": null, "ice_credential_expiration": 86100 } -] +] \ No newline at end of file diff --git a/video/tests/data/delete_archive_error.json b/video/tests/data/delete_archive_error.json new file mode 100644 index 00000000..a3c9fe82 --- /dev/null +++ b/video/tests/data/delete_archive_error.json @@ -0,0 +1,5 @@ +{ + "code": 15004, + "message": "You can only delete an archive that has one of the following statuses: available OR uploaded OR deleted", + "description": "You can only delete an archive that has one of the following statuses: available OR uploaded OR deleted" +} \ No newline at end of file diff --git a/video/tests/data/get_experience_composer.json b/video/tests/data/get_experience_composer.json new file mode 100644 index 00000000..46cd6115 --- /dev/null +++ b/video/tests/data/get_experience_composer.json @@ -0,0 +1,13 @@ +{ + "id": "be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6", + "sessionId": "test_session_id", + "createdAt": 1727784741000, + "updatedAt": 1727788344000, + "url": "https://developer.vonage.com", + "status": "stopped", + "streamId": "C1B0E149-8169-4AFD-9397-882516EE9430", + "reason": "Max duration exceeded", + "event": "render", + "applicationId": "test_application_id", + "resolution": "1280x720" +} \ No newline at end of file diff --git a/video/tests/data/get_stream.json b/video/tests/data/get_stream.json new file mode 100644 index 00000000..bbd87a34 --- /dev/null +++ b/video/tests/data/get_stream.json @@ -0,0 +1,6 @@ +{ + "id": "e08ff3f4-d04b-4363-bd6c-31bd29648ec8", + "videoType": "camera", + "name": "", + "layoutClassList": [] +} \ No newline at end of file diff --git a/video/tests/data/initiate_sip_call.json b/video/tests/data/initiate_sip_call.json new file mode 100644 index 00000000..008134ca --- /dev/null +++ b/video/tests/data/initiate_sip_call.json @@ -0,0 +1,9 @@ +{ + "id": "0022f6ba-c3a7-44db-843e-dd5ffa9d0493", + "projectId": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "sessionId": "test_session_id", + "connectionId": "4baf5788-fa5d-4b8d-b344-7315194ebc7d", + "streamId": "de7d4fde-1773-4c7f-a0f8-3e1e2956d739", + "createdAt": 1728383115393, + "updatedAt": 1728383115393 +} \ No newline at end of file diff --git a/video/tests/data/list_archives.json b/video/tests/data/list_archives.json new file mode 100644 index 00000000..e8734d4d --- /dev/null +++ b/video/tests/data/list_archives.json @@ -0,0 +1,51 @@ +{ + "count": 2, + "items": [ + { + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "paused", + "name": "first archive test", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727871263000, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "manual", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727871264000, + "multiArchiveTag": "my-multi-archive", + "event": "archive", + "resolution": "1280x720", + "url": null + }, + { + "id": "a9cdeb69-f6cf-408b-9197-6f99e6eac5aa", + "status": "available", + "name": "first archive test", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870435000, + "size": 0, + "duration": 134, + "outputMode": "composed", + "streamMode": "manual", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "test_sha256_sum", + "password": "", + "updatedAt": 1727870572000, + "multiArchiveTag": "my-multi-archive", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4" + } + ] +} \ No newline at end of file diff --git a/video/tests/data/list_broadcasts.json b/video/tests/data/list_broadcasts.json new file mode 100644 index 00000000..8fb2530a --- /dev/null +++ b/video/tests/data/list_broadcasts.json @@ -0,0 +1,73 @@ +{ + "count": 2, + "items": [ + { + "id": "32cd16ee-715b-4025-bbc6-f314c1459e2f", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1728038157850, + "broadcastUrls": { + "rtmp": [ + { + "status": "offline", + "id": "test", + "serverUrl": "rtmp://a.rtmp.youtube.com/live2", + "streamName": "stream-key" + } + ], + "hlsStatus": "ready", + "hls": "https://example.com/hls.m3u8" + }, + "updatedAt": 1728038163321, + "status": "started", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "maxDuration": 3600, + "multiBroadcastTag": "test-broadcast-1", + "maxBitrate": 1000000, + "settings": { + "hls": { + "lowLatency": false, + "dvr": true + } + }, + "event": "broadcast", + "resolution": "1280x720" + }, + { + "id": "3d740aa2-cece-44df-8383-c720a98f8de3", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1728036518894, + "broadcastUrls": { + "rtmp": [ + { + "status": "offline", + "id": "test", + "serverUrl": "rtmp://a.rtmp.youtube.com/live2", + "streamName": "stream-key" + } + ], + "hlsStatus": "ready", + "hls": "https://broadcast2-euw1-cdn.media.prod.tokbox.com/broadcast-57d6569497-7wg89.10340/broadcast-57d6569497-7wg89.10340_3d740aa2-cece-44df-8383-c720a98f8de3_29f760f8-7ce1-46c9-ade3-f2dedee4ed5f.2_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3MjgwMzY0MTUzMDd-V2swbzlzeUppaGZIVTFzYUQwamdYM0Ryfn5-.smil/playlist.m3u8?DVR" + }, + "updatedAt": 1728036614047, + "status": "started", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "maxDuration": 3600, + "multiBroadcastTag": "test-broadcast", + "maxBitrate": 1000000, + "settings": { + "hls": { + "lowLatency": false, + "dvr": true + } + }, + "event": "broadcast", + "resolution": "1280x720" + } + ] +} \ No newline at end of file diff --git a/video/tests/data/list_broadcasts_next_page.json b/video/tests/data/list_broadcasts_next_page.json new file mode 100644 index 00000000..60012c17 --- /dev/null +++ b/video/tests/data/list_broadcasts_next_page.json @@ -0,0 +1,39 @@ +{ + "count": 2, + "items": [ + { + "id": "32cd16ee-715b-4025-bbc6-f314c1459e2f", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1728038157850, + "broadcastUrls": { + "rtmp": [ + { + "status": "offline", + "id": "test", + "serverUrl": "rtmp://a.rtmp.youtube.com/live2", + "streamName": "stream-key" + } + ], + "hlsStatus": "ready", + "hls": "https://example.com/hls.m3u8" + }, + "updatedAt": 1728038163321, + "status": "started", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "maxDuration": 3600, + "multiBroadcastTag": "test-broadcast-1", + "maxBitrate": 1000000, + "settings": { + "hls": { + "lowLatency": false, + "dvr": true + } + }, + "event": "broadcast", + "resolution": "1280x720" + } + ] +} \ No newline at end of file diff --git a/video/tests/data/list_experience_composers.json b/video/tests/data/list_experience_composers.json new file mode 100644 index 00000000..c19d93eb --- /dev/null +++ b/video/tests/data/list_experience_composers.json @@ -0,0 +1,42 @@ +{ + "count": 3, + "items": [ + { + "id": "be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6", + "sessionId": "test_session_id", + "createdAt": 1727784741000, + "updatedAt": 1727784744000, + "url": "https://developer.vonage.com", + "status": "started", + "streamId": "C1B0E149-8169-4AFD-9397-882516EE9430", + "event": "render", + "applicationId": "test_application_id", + "resolution": "1280x720" + }, + { + "id": "89559e73-0d49-4388-b373-ddef191e4373", + "sessionId": "test_session_id", + "createdAt": 1727784421000, + "updatedAt": 1727784424000, + "url": "https://example.com", + "status": "started", + "streamId": "F9C3BCD5-850F-4DB7-B6C1-97F615CA9E79", + "event": "render", + "applicationId": "test_application_id", + "resolution": "1280x720" + }, + { + "id": "80c3d2d8-0848-41b2-be14-1a5b8936c87d", + "sessionId": "test_session_id", + "createdAt": 1727781191000, + "updatedAt": 1727784793000, + "url": "https://example.com", + "status": "stopped", + "streamId": "95F83A10-D767-4F21-9270-DC6E88067FAC", + "reason": "Max duration exceeded", + "event": "render", + "applicationId": "test_application_id", + "resolution": "1280x720" + } + ] +} \ No newline at end of file diff --git a/video/tests/data/list_streams.json b/video/tests/data/list_streams.json new file mode 100644 index 00000000..9a53a390 --- /dev/null +++ b/video/tests/data/list_streams.json @@ -0,0 +1,11 @@ +{ + "count": 1, + "items": [ + { + "id": "e08ff3f4-d04b-4363-bd6c-31bd29648ec8", + "videoType": "camera", + "name": "", + "layoutClassList": [] + } + ] +} \ No newline at end of file diff --git a/video/tests/data/nothing.json b/video/tests/data/nothing.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/video/tests/data/nothing.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/video/tests/data/start_broadcast_error.json b/video/tests/data/start_broadcast_error.json new file mode 100644 index 00000000..8dcd90b8 --- /dev/null +++ b/video/tests/data/start_broadcast_error.json @@ -0,0 +1,3 @@ +{ + "message": "Session is already composed for given tag with code 409" +} \ No newline at end of file diff --git a/video/tests/data/start_captions.json b/video/tests/data/start_captions.json new file mode 100644 index 00000000..c14cb562 --- /dev/null +++ b/video/tests/data/start_captions.json @@ -0,0 +1,3 @@ +{ + "captionsId": "bc01a6b7-0e8e-4aa0-bb4e-2390f7cb18a1" +} \ No newline at end of file diff --git a/video/tests/data/start_experience_composer.json b/video/tests/data/start_experience_composer.json new file mode 100644 index 00000000..7250feb9 --- /dev/null +++ b/video/tests/data/start_experience_composer.json @@ -0,0 +1,12 @@ +{ + "id": "80c3d2d8-0848-41b2-be14-1a5b8936c87d", + "sessionId": "test_session_id", + "createdAt": 1727781191064, + "updatedAt": 1727781191064, + "url": "https://example.com", + "status": "starting", + "name": "test_experience_composer", + "event": "render", + "applicationId": "test_application_id", + "resolution": "1280x720" +} \ No newline at end of file diff --git a/video/tests/data/stop_archive.json b/video/tests/data/stop_archive.json new file mode 100644 index 00000000..69e4119d --- /dev/null +++ b/video/tests/data/stop_archive.json @@ -0,0 +1,23 @@ +{ + "id": "e05d6f8f-2280-4025-b1d2-defc4f5c8dfa", + "status": "stopped", + "name": "archive test", + "reason": "user initiated", + "sessionId": "2_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3Mjc4NzcyMTYwNzJ-OUJ1WHN0V05vN0NoU044OGthaURwNmpxfn5-", + "applicationId": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "createdAt": 1727887464000, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727887464000, + "multiArchiveTag": "start-to-stop", + "event": "archive", + "resolution": "1280x720", + "url": null +} \ No newline at end of file diff --git a/video/tests/data/stop_archive_error.json b/video/tests/data/stop_archive_error.json new file mode 100644 index 00000000..e40bcd33 --- /dev/null +++ b/video/tests/data/stop_archive_error.json @@ -0,0 +1,5 @@ +{ + "code": 15002, + "message": "You can only stop an archive that has one of the following statuses: started OR paused OR stopped", + "description": "You can only stop an archive that has one of the following statuses: started OR paused OR stopped" +} \ No newline at end of file diff --git a/video/tests/data/stop_broadcast.json b/video/tests/data/stop_broadcast.json new file mode 100644 index 00000000..44f5cad2 --- /dev/null +++ b/video/tests/data/stop_broadcast.json @@ -0,0 +1,23 @@ +{ + "id": "f03fad17-4591-4422-8bd3-00a4df1e616a", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1728060457664, + "broadcastUrls": null, + "updatedAt": 1728060581508, + "status": "stopped", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "maxDuration": 3600, + "multiBroadcastTag": "test-broadcast", + "maxBitrate": 1000000, + "settings": { + "hls": { + "lowLatency": false, + "dvr": true + } + }, + "event": "broadcast", + "resolution": "1280x720" +} \ No newline at end of file diff --git a/video/tests/data/stop_broadcast_timeout_error.json b/video/tests/data/stop_broadcast_timeout_error.json new file mode 100644 index 00000000..bf5422d6 --- /dev/null +++ b/video/tests/data/stop_broadcast_timeout_error.json @@ -0,0 +1,5 @@ +{ + "code": -1, + "message": "Request timed out.", + "description": "Request timed out." +} \ No newline at end of file diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py new file mode 100644 index 00000000..5ef2c4b8 --- /dev/null +++ b/video/tests/test_archive.py @@ -0,0 +1,296 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.http_client import HttpClient +from vonage_video.errors import ( + IndividualArchivePropertyError, + InvalidArchiveStateError, + LayoutScreenshareTypeError, + LayoutStylesheetError, + NoAudioOrVideoError, +) +from vonage_video.models.archive import ( + ComposedLayout, + CreateArchiveRequest, + ListArchivesFilter, +) +from vonage_video.models.common import AddStreamRequest +from vonage_video.models.enums import LayoutType, OutputMode, StreamMode, VideoResolution +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_create_archive_request_valid(): + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + layout=ComposedLayout(type=LayoutType.BEST_FIT), + multi_archive_tag='test_multi_archive_tag', + output_mode=OutputMode.COMPOSED, + resolution=VideoResolution.RES_1280x720, + stream_mode=StreamMode.AUTO, + ) + assert request.session_id == "1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5" + assert request.has_audio is True + assert request.has_video is True + assert request.layout.type == LayoutType.BEST_FIT + assert request.multi_archive_tag == 'test_multi_archive_tag' + assert request.output_mode == OutputMode.COMPOSED + assert request.resolution == VideoResolution.RES_1280x720 + assert request.stream_mode == StreamMode.AUTO + + +def test_create_archive_request_no_audio_or_video(): + with raises(NoAudioOrVideoError) as e: + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=False, + has_video=False, + ) + + +def test_create_archive_request_individual_output_mode_with_resolution(): + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + output_mode=OutputMode.INDIVIDUAL, + resolution=VideoResolution.RES_720x1280, + ) + + +def test_create_archive_request_individual_output_mode_with_layout(): + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + output_mode=OutputMode.INDIVIDUAL, + layout=ComposedLayout(type=LayoutType.BEST_FIT), + ) + + +def test_create_archive_request_composed_output_mode_with_transcription_error(): + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id='test_session_id', + has_audio=True, + output_mode=OutputMode.COMPOSED, + has_transcription=True, + ) + + +def test_layout_custom_without_stylesheet(): + with raises(LayoutStylesheetError): + ComposedLayout(type=LayoutType.CUSTOM) + + +def test_layout_best_fit_with_stylesheet(): + with raises(LayoutStylesheetError): + ComposedLayout( + type=LayoutType.BEST_FIT, stylesheet='http://example.com/stylesheet.css' + ) + + +def test_layout_screenshare_type_without_best_fit(): + with raises(LayoutScreenshareTypeError): + ComposedLayout(type=LayoutType.PIP, screenshare_type=LayoutType.BEST_FIT) + + +@responses.activate +def test_list_archives(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives.json', + ) + + filter = ListArchivesFilter(offset=0, page_size=10, session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + assert count == 2 + assert next_page is None + assert archives[0].id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archives[0].status == 'paused' + assert archives[0].resolution == '1280x720' + assert archives[0].session_id == 'test_session_id' + assert archives[1].id == 'a9cdeb69-f6cf-408b-9197-6f99e6eac5aa' + assert archives[1].status == 'available' + assert archives[1].reason == 'session ended' + assert archives[1].duration == 134 + assert archives[1].sha256_sum == 'test_sha256_sum' + assert archives[1].url == 'https://example.com/archive.mp4' + + +@responses.activate +def test_start_archive(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'archive.json', + ) + + archive_options = CreateArchiveRequest( + session_id='test_session_id', + has_audio=True, + has_video=True, + layout=ComposedLayout( + type=LayoutType.BEST_FIT, screenshare_type=LayoutType.HORIZONTAL_PRESENTATION + ), + multi_archive_tag='my-multi-archive', + name='first archive test', + output_mode=OutputMode.COMPOSED, + resolution=VideoResolution.RES_1280x720, + stream_mode=StreamMode.MANUAL, + ) + + archive = video.start_archive(archive_options) + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.session_id == 'test_session_id' + assert archive.application_id == 'test_application_id' + assert archive.created_at == 1727870434974 + assert archive.updated_at == 1727870434977 + assert archive.status == 'started' + assert archive.name == 'first archive test' + assert archive.resolution == '1280x720' + + +@responses.activate +def test_get_archive(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'archive.json', + ) + + archive = video.get_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.session_id == 'test_session_id' + assert archive.application_id == 'test_application_id' + assert archive.created_at == 1727870434974 + assert archive.updated_at == 1727870434977 + assert archive.status == 'started' + assert archive.name == 'first archive test' + assert archive.resolution == '1280x720' + + +@responses.activate +def test_delete_archive(): + build_response( + path, + 'DELETE', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + status_code=204, + ) + + video.delete_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert video.http_client.last_response.status_code == 204 + + +@responses.activate +def test_delete_archive_error_invalid_status(): + build_response( + path, + 'DELETE', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'delete_archive_error.json', + status_code=409, + ) + + with raises(InvalidArchiveStateError) as e: + video.delete_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert '"code": 15004' in str(e.value) + + +@responses.activate +def test_add_stream_to_archive(): + build_response( + path, + 'PATCH', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/test_archive_id/streams', + status_code=204, + ) + + params = AddStreamRequest( + stream_id='47ce017c-28aa-40d0-b094-2e5dc437746c', has_audio=True, has_video=True + ) + video.add_stream_to_archive(archive_id='test_archive_id', params=params) + + assert video.http_client.last_response.status_code == 204 + + +@responses.activate +def test_remove_stream_from_archive(): + build_response( + path, + 'PATCH', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/test_archive_id/streams', + status_code=204, + ) + + video.remove_stream_from_archive( + archive_id='test_archive_id', stream_id='47ce017c-28aa-40d0-b094-2e5dc437746c' + ) + + assert video.http_client.last_response.status_code == 204 + + +@responses.activate +def test_stop_archive(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/e05d6f8f-2280-4025-b1d2-defc4f5c8dfa/stop', + 'stop_archive.json', + ) + + archive = video.stop_archive('e05d6f8f-2280-4025-b1d2-defc4f5c8dfa') + + assert archive.id == 'e05d6f8f-2280-4025-b1d2-defc4f5c8dfa' + assert archive.status == 'stopped' + assert archive.reason == 'user initiated' + + +@responses.activate +def test_stop_archive_invalid_state_error(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/e05d6f8f-2280-4025-b1d2-defc4f5c8dfa/stop', + 'stop_archive_error.json', + status_code=409, + ) + + with raises(InvalidArchiveStateError) as e: + video.stop_archive('e05d6f8f-2280-4025-b1d2-defc4f5c8dfa') + + assert '"code": 15002' in str(e.value) + + +@responses.activate +def test_change_archive_layout(): + build_response( + path, + 'PUT', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699/layout', + 'archive.json', + ) + + layout = ComposedLayout(type=LayoutType.BEST_FIT, screenshare_type=LayoutType.PIP) + archive = video.change_archive_layout('5b1521e6-115f-4efd-bed9-e527b87f0699', layout) + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert video.http_client.last_response.status_code == 200 diff --git a/video/tests/test_audio_connector.py b/video/tests/test_audio_connector.py new file mode 100644 index 00000000..0c9cce6b --- /dev/null +++ b/video/tests/test_audio_connector.py @@ -0,0 +1,70 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.audio_connector import ( + AudioConnectorOptions, + AudioConnectorWebSocket, +) +from vonage_video.models.enums import AudioSampleRate, TokenRole +from vonage_video.models.token import TokenOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_audio_connector_options_model(): + options = AudioConnectorOptions( + session_id='test_session_id', + token='test_token', + websocket=AudioConnectorWebSocket( + uri='test_uri', + streams=['test_stream_id'], + headers={'test_header': 'test_value'}, + audio_rate=AudioSampleRate.KHZ_16, + ), + ) + + assert options.model_dump(by_alias=True) == { + 'sessionId': 'test_session_id', + 'token': 'test_token', + 'websocket': { + 'uri': 'test_uri', + 'streams': ['test_stream_id'], + 'headers': {'test_header': 'test_value'}, + 'audioRate': 16000, + }, + } + + +@responses.activate +def test_start_audio_connector(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/connect', + 'audio_connector.json', + 200, + ) + + session_id = 'test_session_id' + options = AudioConnectorOptions( + session_id=session_id, + token=video.generate_client_token( + TokenOptions(session_id=session_id, role=TokenRole.MODERATOR) + ), + websocket=AudioConnectorWebSocket( + uri='wss://example.com/ws', + audio_rate=AudioSampleRate.KHZ_16, + ), + ) + + audio_connector = video.start_audio_connector(options) + + assert audio_connector.id == 'b3cd31f4-020e-4ba3-9a2a-12d98b8a184f' + assert audio_connector.connection_id == '1bf530df-97f4-4437-b6c9-2a66200200c8' diff --git a/video/tests/test_broadcast.py b/video/tests/test_broadcast.py new file mode 100644 index 00000000..eb3fcf4f --- /dev/null +++ b/video/tests/test_broadcast.py @@ -0,0 +1,292 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_video.errors import ( + InvalidBroadcastStateError, + InvalidHlsOptionsError, + InvalidOutputOptionsError, +) +from vonage_video.models.broadcast import ( + BroadcastHls, + BroadcastOutputSettings, + BroadcastRtmp, + CreateBroadcastRequest, + ListBroadcastsFilter, +) +from vonage_video.models.common import AddStreamRequest, ComposedLayout +from vonage_video.models.enums import LayoutType, StreamMode, VideoResolution +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_broadcast_hls_invalid(): + with raises(InvalidHlsOptionsError): + BroadcastHls(dvr=True, low_latency=True) + + +def test_broadcast_output_settings_invalid(): + with raises(InvalidOutputOptionsError): + BroadcastOutputSettings() + + +def test_create_broadcast_request_valid(): + request = CreateBroadcastRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + layout=ComposedLayout(type="bestFit"), + max_duration=3600, + outputs=BroadcastOutputSettings(hls=BroadcastHls(dvr=True)), + resolution=VideoResolution.RES_1280x720, + stream_mode=StreamMode.AUTO, + multi_broadcast_tag="test_multi_broadcast_tag", + max_bitrate=2000000, + ) + assert request.session_id == "1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5" + assert request.layout.type == "bestFit" + assert request.max_duration == 3600 + assert request.outputs.hls.dvr is True + assert request.resolution == VideoResolution.RES_1280x720 + assert request.stream_mode == StreamMode.AUTO + assert request.multi_broadcast_tag == "test_multi_broadcast_tag" + assert request.max_bitrate == 2000000 + + +@responses.activate +def test_list_broadcasts(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast', + 'list_broadcasts.json', + ) + + filter = ListBroadcastsFilter(offset=0, page_size=10, session_id='test_session_id') + broadcasts, count, next_page = video.list_broadcasts(filter) + + assert count == 2 + assert next_page is None + assert broadcasts[0].id == '32cd16ee-715b-4025-bbc6-f314c1459e2f' + assert broadcasts[0].status == 'started' + assert broadcasts[0].resolution == '1280x720' + assert ( + broadcasts[0].broadcast_urls.rtmp[0].server_url + == 'rtmp://a.rtmp.youtube.com/live2' + ) + assert broadcasts[0].broadcast_urls.hls == 'https://example.com/hls.m3u8' + assert broadcasts[0].broadcast_urls.hls_status == 'ready' + assert broadcasts[1].multi_broadcast_tag == 'test-broadcast' + assert broadcasts[1].settings.hls.dvr is True + assert broadcasts[1].settings.hls.low_latency is False + + +@responses.activate +def test_list_broadcasts_next_page(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast', + 'list_broadcasts_next_page.json', + ) + + filter = ListBroadcastsFilter(offset=0, page_size=1) + broadcasts, count, next_page = video.list_broadcasts(filter) + + assert count == 2 + assert next_page == 1 + assert broadcasts[0].id == '32cd16ee-715b-4025-bbc6-f314c1459e2f' + + +@responses.activate +def test_list_broadcasts_empty_response(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast', + 'nothing.json', + ) + + filter = ListBroadcastsFilter(offset=0, page_size=10) + broadcasts, count, next_page = video.list_broadcasts(filter) + + assert count == 0 + assert next_page is None + assert broadcasts == [] + + +@responses.activate +def test_start_broadcast(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast', + 'broadcast.json', + ) + + broadcast_options = CreateBroadcastRequest( + session_id='test_session_id', + layout=ComposedLayout( + type=LayoutType.BEST_FIT, screenshare_type=LayoutType.HORIZONTAL_PRESENTATION + ), + max_duration=3600, + outputs=BroadcastOutputSettings( + hls=BroadcastHls(dvr=True, low_latency=False), + rtmp=[ + BroadcastRtmp( + id='test', + server_url='rtmp://a.rtmp.youtube.com/live2', + stream_name='stream-key', + ) + ], + ), + resolution=VideoResolution.RES_1280x720, + stream_mode=StreamMode.AUTO, + multi_broadcast_tag='test-broadcast-5', + max_bitrate=1_000_000, + ) + + broadcast = video.start_broadcast(broadcast_options) + + assert broadcast.id == 'f03fad17-4591-4422-8bd3-00a4df1e616a' + assert broadcast.session_id == 'test_session_id' + assert broadcast.application_id == 'test_application_id' + assert broadcast.updated_at == 1728039361511 + assert broadcast.status == 'started' + assert ( + broadcast.broadcast_urls.rtmp[0].server_url == 'rtmp://a.rtmp.youtube.com/live2' + ) + assert broadcast.resolution == '1280x720' + + +@responses.activate +def test_start_broadcast_conflict_error(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast', + 'start_broadcast_error.json', + status_code=409, + ) + + with raises(InvalidBroadcastStateError) as e: + broadcast_options = CreateBroadcastRequest( + session_id='test_session_id', + outputs=BroadcastOutputSettings(hls=BroadcastHls(dvr=True)), + ) + video.start_broadcast(broadcast_options) + + assert 'broadcast has already started for the session' in str(e.value) + + +@responses.activate +def test_start_broadcast_timeout_error(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast', + 'stop_broadcast_timeout_error.json', + status_code=408, + ) + + with raises(HttpRequestError) as e: + video.start_broadcast( + options=CreateBroadcastRequest( + session_id='test_session_id', + outputs=BroadcastOutputSettings(hls=BroadcastHls()), + ) + ) + + assert 'Request timed out.' in str(e.value) + + +@responses.activate +def test_get_broadcast(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast/f03fad17-4591-4422-8bd3-00a4df1e616a', + 'broadcast.json', + ) + + broadcast = video.get_broadcast('f03fad17-4591-4422-8bd3-00a4df1e616a') + + assert broadcast.id == 'f03fad17-4591-4422-8bd3-00a4df1e616a' + assert broadcast.session_id == 'test_session_id' + assert broadcast.updated_at == 1728039361511 + assert broadcast.status == 'started' + assert ( + broadcast.broadcast_urls.rtmp[0].server_url == 'rtmp://a.rtmp.youtube.com/live2' + ) + assert broadcast.resolution == '1280x720' + + +@responses.activate +def test_stop_broadcast(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast/f03fad17-4591-4422-8bd3-00a4df1e616a/stop', + 'stop_broadcast.json', + ) + + broadcast = video.stop_broadcast('f03fad17-4591-4422-8bd3-00a4df1e616a') + + assert broadcast.id == 'f03fad17-4591-4422-8bd3-00a4df1e616a' + assert broadcast.status == 'stopped' + + +@responses.activate +def test_change_broadcast_layout(): + build_response( + path, + 'PUT', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast/f03fad17-4591-4422-8bd3-00a4df1e616a/layout', + 'broadcast.json', + ) + + layout = ComposedLayout(type=LayoutType.BEST_FIT, screenshare_type=LayoutType.PIP) + broadcast = video.change_broadcast_layout( + 'f03fad17-4591-4422-8bd3-00a4df1e616a', layout + ) + + assert broadcast.id == 'f03fad17-4591-4422-8bd3-00a4df1e616a' + assert video.http_client.last_response.status_code == 200 + + +@responses.activate +def test_add_stream_to_broadcast(): + build_response( + path, + 'PATCH', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast/test_broadcast_id/streams', + status_code=204, + ) + + params = AddStreamRequest( + stream_id='47ce017c-28aa-40d0-b094-2e5dc437746c', has_audio=True, has_video=True + ) + video.add_stream_to_broadcast(broadcast_id='test_broadcast_id', params=params) + + assert video.http_client.last_response.status_code == 204 + + +@responses.activate +def test_remove_stream_from_broadcast(): + build_response( + path, + 'PATCH', + 'https://video.api.vonage.com/v2/project/test_application_id/broadcast/test_broadcast_id/streams', + status_code=204, + ) + + video.remove_stream_from_broadcast( + broadcast_id='test_broadcast_id', stream_id='47ce017c-28aa-40d0-b094-2e5dc437746c' + ) + + assert video.http_client.last_response.status_code == 204 diff --git a/video/tests/test_captions.py b/video/tests/test_captions.py new file mode 100644 index 00000000..875eb5b1 --- /dev/null +++ b/video/tests/test_captions.py @@ -0,0 +1,100 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client import HttpClient +from vonage_http_client.errors import HttpRequestError +from vonage_video.models.captions import CaptionsData, CaptionsOptions +from vonage_video.models.enums import LanguageCode, TokenRole +from vonage_video.models.token import TokenOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_captions_options_model(): + options = CaptionsOptions( + session_id='test_session_id', + token='test_token', + language_code=LanguageCode.EN_GB, + max_duration=300, + partial_captions=True, + status_callback_url='example.com/status', + ) + + assert options.model_dump(by_alias=True) == { + 'sessionId': 'test_session_id', + 'token': 'test_token', + 'languageCode': 'en-GB', + 'maxDuration': 300, + 'partialCaptions': True, + 'statusCallbackUrl': 'example.com/status', + } + + +@responses.activate +def test_start_captions(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/captions', + 'start_captions.json', + 202, + ) + + session_id = 'test_session_id' + options = CaptionsOptions( + session_id=session_id, + token=video.generate_client_token( + TokenOptions(session_id=session_id, role=TokenRole.MODERATOR) + ), + language_code=LanguageCode.EN_GB, + max_duration=300, + partial_captions=True, + status_callback_url='https://example.com/status', + ) + captions = video.start_captions(options) + + assert captions.captions_id == 'bc01a6b7-0e8e-4aa0-bb4e-2390f7cb18a1' + + +@responses.activate +def test_start_captions_error_already_enabled(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/captions', + 'captions_error_already_enabled.json', + 409, + ) + + session_id = 'test_session_id' + options = CaptionsOptions( + session_id=session_id, + token=video.generate_client_token( + TokenOptions(session_id=session_id, role=TokenRole.MODERATOR) + ), + ) + + with raises(HttpRequestError) as e: + video.start_captions(options) + assert 'Audio captioning is already enabled' in e.value.message + + +@responses.activate +def test_stop_captions(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/captions/test_captions_id/stop', + status_code=202, + ) + + video.stop_captions(CaptionsData(captions_id='test_captions_id')) + + assert responses.calls[0].response.status_code == 202 diff --git a/video/tests/test_experience_composer.py b/video/tests/test_experience_composer.py new file mode 100644 index 00000000..0ca081d9 --- /dev/null +++ b/video/tests/test_experience_composer.py @@ -0,0 +1,132 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.enums import TokenRole, VideoResolution +from vonage_video.models.experience_composer import ( + ExperienceComposerOptions, + ExperienceComposerProperties, + ListExperienceComposersFilter, +) +from vonage_video.models.token import TokenOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_experience_composer_model(): + options = ExperienceComposerOptions( + session_id='test_session_id', + token='test_token', + url='https://example.com', + max_duration=3600, + resolution=VideoResolution.RES_1280x720, + properties=ExperienceComposerProperties(name='test_experience_composer'), + ) + + assert options.model_dump(by_alias=True) == { + 'sessionId': 'test_session_id', + 'token': 'test_token', + 'url': 'https://example.com', + 'maxDuration': 3600, + 'resolution': '1280x720', + 'properties': {'name': 'test_experience_composer'}, + } + + +@responses.activate +def test_start_experience_composer(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/render', + 'start_experience_composer.json', + 202, + ) + + session_id = 'test_session_id' + options = ExperienceComposerOptions( + session_id=session_id, + token=video.generate_client_token( + TokenOptions(session_id=session_id, role=TokenRole.MODERATOR) + ), + url='https://example.com', + max_duration=3600, + resolution=VideoResolution.RES_1280x720, + properties=ExperienceComposerProperties(name='test_experience_composer'), + ) + ec = video.start_experience_composer(options) + + assert ec.id == '80c3d2d8-0848-41b2-be14-1a5b8936c87d' + assert ec.session_id == session_id + assert ec.application_id == 'test_application_id' + assert ec.created_at == 1727781191064 + assert ec.url == 'https://example.com' + assert ec.status == 'starting' + assert ec.name == 'test_experience_composer' + assert ec.resolution == '1280x720' + + +@responses.activate +def test_list_experience_composers(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/render', + 'list_experience_composers.json', + ) + + ec_filter = ListExperienceComposersFilter(offset=0, page_size=3) + ec_list, count, next_page_offset = video.list_experience_composers(filter=ec_filter) + + assert len(ec_list) == 3 + assert count == 3 + assert next_page_offset == None + + assert ec_list[0].id == 'be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6' + assert ec_list[0].session_id == 'test_session_id' + assert ec_list[0].created_at == 1727784741000 + assert ec_list[0].url == 'https://developer.vonage.com' + assert ec_list[1].status == 'started' + assert ec_list[1].stream_id == 'F9C3BCD5-850F-4DB7-B6C1-97F615CA9E79' + assert ec_list[1].resolution == '1280x720' + assert ec_list[2].status == 'stopped' + assert ec_list[2].reason == 'Max duration exceeded' + + +@responses.activate +def test_get_experience_composer(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/render/be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6', + 'get_experience_composer.json', + ) + + ec = video.get_experience_composer('be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6') + + assert ec.id == 'be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6' + assert ec.session_id == 'test_session_id' + assert ec.created_at == 1727784741000 + assert ec.url == 'https://developer.vonage.com' + assert ec.status == 'stopped' + assert ec.stream_id == 'C1B0E149-8169-4AFD-9397-882516EE9430' + assert ec.resolution == '1280x720' + + +@responses.activate +def test_stop_experience_composer(): + build_response( + path, + 'DELETE', + 'https://video.api.vonage.com/v2/project/test_application_id/render/be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6', + status_code=204, + ) + video.stop_experience_composer('be7712a4-3a63-4ed7-a2c6-7ffaebefd4a6') + + assert video.http_client.last_response.status_code == 204 diff --git a/video/tests/test_moderation.py b/video/tests/test_moderation.py new file mode 100644 index 00000000..e7748d70 --- /dev/null +++ b/video/tests/test_moderation.py @@ -0,0 +1,70 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_disconnect_client(): + build_response( + path, + 'DELETE', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/connection/test_connection_id', + status_code=204, + ) + + video.disconnect_client( + session_id='test_session_id', connection_id='test_connection_id' + ) + + assert responses.calls[0].response.status_code == 204 + + +@responses.activate +def test_mute_stream(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/stream/test_stream_id/mute', + ) + + video.mute_stream(session_id='test_session_id', stream_id='test_stream_id') + + assert responses.calls[0].response.status_code == 200 + + +@responses.activate +def test_mute_all_streams(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/mute', + ) + + video.mute_all_streams(session_id='test_session_id') + assert responses.calls[0].response.status_code == 200 + + video.disable_mute_all_streams(session_id='test_session_id') + assert responses.calls[1].response.status_code == 200 + + +@responses.activate +def test_mute_all_streams_excluded_stream_ids(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/mute', + ) + + video.mute_all_streams( + session_id='test_session_id', excluded_stream_ids=['test_stream_id'] + ) + assert responses.calls[0].response.status_code == 200 diff --git a/video/tests/test_session.py b/video/tests/test_session.py new file mode 100644 index 00000000..9050379a --- /dev/null +++ b/video/tests/test_session.py @@ -0,0 +1,82 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.enums import ArchiveMode, MediaMode +from vonage_video.models.session import SessionOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_session_options_model(): + session_options = SessionOptions( + media_mode=MediaMode.ROUTED, + archive_mode=ArchiveMode.ALWAYS, + location='192.168.0.1', + e2ee=True, + ) + + assert session_options.media_mode == MediaMode.ROUTED + assert session_options.archive_mode == ArchiveMode.ALWAYS + assert session_options.location == '192.168.0.1' + assert session_options.e2ee is True + assert session_options.p2p_preference == 'disabled' + + +def test_session_options_model_set_params(): + session_options = SessionOptions( + media_mode=MediaMode.RELAYED, + e2ee=True, + ) + + assert session_options.p2p_preference == 'always' + + +@responses.activate +def test_create_session(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/session/create', + 'create_session.json', + ) + + session = video.create_session() + assert ( + session.session_id + == '1_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3MjY0NjI1ODg2NDd-MTF4TGExYmJoelBlR1FHbVhzbWd4STBrfn5-' + ) + assert session.archive_mode is None + assert session.media_mode is None + assert session.location is None + assert session.e2ee is None + + build_response( + path, + 'POST', + 'https://video.api.vonage.com/session/create', + 'create_session.json', + ) + + session_options = SessionOptions( + media_mode=MediaMode.ROUTED, + archive_mode=ArchiveMode.ALWAYS, + location='192.168.0.1', + e2ee=True, + ) + session = video.create_session(session_options) + + assert ( + session.session_id + == '1_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3MjY0NjI1ODg2NDd-MTF4TGExYmJoelBlR1FHbVhzbWd4STBrfn5-' + ) + assert session.archive_mode == ArchiveMode.ALWAYS + assert session.media_mode == MediaMode.ROUTED + assert session.location == '192.168.0.1' + assert session.e2ee is True diff --git a/video/tests/test_signal.py b/video/tests/test_signal.py new file mode 100644 index 00000000..dacdadff --- /dev/null +++ b/video/tests/test_signal.py @@ -0,0 +1,47 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.signal import SignalData +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_send_signal_all(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/signal', + status_code=204, + ) + + video.send_signal( + session_id='test_session_id', data=SignalData(type='msg', data='Hello, World!') + ) + + assert responses.calls[0].response.status_code == 204 + + +@responses.activate +def test_send_signal_to_connection_id(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/connection/test_connection_id/signal', + status_code=204, + ) + + video.send_signal( + session_id='test_session_id', + data=SignalData(type='msg', data='Hello, World!'), + connection_id='test_connection_id', + ) + + assert responses.calls[0].response.status_code == 204 diff --git a/video/tests/test_sip.py b/video/tests/test_sip.py new file mode 100644 index 00000000..26ff9c68 --- /dev/null +++ b/video/tests/test_sip.py @@ -0,0 +1,127 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client import HttpClient +from vonage_video.errors import RoutedSessionRequiredError +from vonage_video.models.sip import InitiateSipRequest, SipAuth, SipOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_sip_params_model(): + sip_request_params = InitiateSipRequest( + session_id='test_session_id', + token='test_token', + sip=SipOptions( + uri='sip:user@sip.partner.com;transport=tls', + from_='example@example.com', + headers={'header_key': 'header_value'}, + auth=SipAuth(username='username', password='password'), + secure=True, + video=True, + observe_force_mute=True, + ), + ) + + assert sip_request_params.model_dump(by_alias=True) == { + 'sessionId': 'test_session_id', + 'token': 'test_token', + 'sip': { + 'uri': 'sip:user@sip.partner.com;transport=tls', + 'from': 'example@example.com', + 'headers': {'header_key': 'header_value'}, + 'auth': {'username': 'username', 'password': 'password'}, + 'secure': True, + 'video': True, + 'observeForceMute': True, + }, + } + + +@responses.activate +def test_initiate_sip_call(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/dial', + 'initiate_sip_call.json', + 200, + ) + + sip_request_params = InitiateSipRequest( + session_id='test_session_id', + token='test_token', + sip=SipOptions( + uri='sip:user@sip.partner.com;transport=tls', + from_='example@example.com', + headers={'header_key': 'header_value'}, + auth=SipAuth(username='username', password='password'), + secure=True, + video=True, + observe_force_mute=True, + ), + ) + + sip_call = video.initiate_sip_call(sip_request_params) + + assert sip_call.id == '0022f6ba-c3a7-44db-843e-dd5ffa9d0493' + assert sip_call.project_id == '29f760f8-7ce1-46c9-ade3-f2dedee4ed5f' + assert sip_call.connection_id == '4baf5788-fa5d-4b8d-b344-7315194ebc7d' + assert sip_call.stream_id == 'de7d4fde-1773-4c7f-a0f8-3e1e2956d739' + + +@responses.activate +def test_initiate_sip_call_error(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/dial', + status_code=409, + ) + + sip_request_params = InitiateSipRequest( + session_id='test_session_id', + token='test_token', + sip=SipOptions(uri='sip:example@example.com;transport=tls'), + ) + + with raises(RoutedSessionRequiredError): + video.initiate_sip_call(sip_request_params) + + assert video.http_client.last_response.status_code == 409 + + +@responses.activate +def test_play_dtmf(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/play-dtmf', + status_code=200, + ) + + video.play_dtmf('test_session_id', '01234#*p') + + assert video.http_client.last_response.status_code == 200 + + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/connection/test_connection_id/play-dtmf', + status_code=200, + ) + + video.play_dtmf( + session_id='test_session_id', + digits='01234#*p', + connection_id='test_connection_id', + ) + + assert video.http_client.last_response.status_code == 200 diff --git a/video/tests/test_stream.py b/video/tests/test_stream.py new file mode 100644 index 00000000..75258c0a --- /dev/null +++ b/video/tests/test_stream.py @@ -0,0 +1,90 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.stream import StreamLayout, StreamLayoutOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_stream_layout_model(): + stream_layout = StreamLayout( + id='e08ff3f4-d04b-4363-bd6c-31bd29648ec8', layout_class_list=['full'] + ) + + stream_layout_options = StreamLayoutOptions(items=[stream_layout]) + + assert stream_layout.id == 'e08ff3f4-d04b-4363-bd6c-31bd29648ec8' + assert stream_layout.layout_class_list == ['full'] + assert stream_layout_options.items == [stream_layout] + + +@responses.activate +def test_list_streams(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/stream', + 'list_streams.json', + ) + + streams = video.list_streams(session_id='test_session_id') + + assert len(streams) == 1 + assert streams[0].id == 'e08ff3f4-d04b-4363-bd6c-31bd29648ec8' + assert streams[0].video_type == 'camera' + assert streams[0].name == '' + assert streams[0].layout_class_list == [] + + +@responses.activate +def test_get_stream(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/stream/e08ff3f4-d04b-4363-bd6c-31bd29648ec8', + 'get_stream.json', + ) + + stream = video.get_stream( + session_id='test_session_id', stream_id='e08ff3f4-d04b-4363-bd6c-31bd29648ec8' + ) + + assert stream.id == 'e08ff3f4-d04b-4363-bd6c-31bd29648ec8' + assert stream.video_type == 'camera' + assert stream.name == '' + assert stream.layout_class_list == [] + + +@responses.activate +def test_change_stream_layout(): + build_response( + path, + 'PUT', + 'https://video.api.vonage.com/v2/project/test_application_id/session/test_session_id/stream', + 'change_stream_layout.json', + ) + + layout = StreamLayoutOptions( + items=[ + StreamLayout( + id='e08ff3f4-d04b-4363-bd6c-31bd29648ec8', layout_class_list=['full'] + ) + ] + ) + + streams = video.change_stream_layout( + session_id='test_session_id', stream_layout_options=layout + ) + + assert len(streams) == 1 + assert streams[0].id == 'e08ff3f4-d04b-4363-bd6c-31bd29648ec8' + assert streams[0].video_type == 'camera' + assert streams[0].name == '' + assert streams[0].layout_class_list == ['full'] diff --git a/video/tests/test_token.py b/video/tests/test_token.py new file mode 100644 index 00000000..56b0f037 --- /dev/null +++ b/video/tests/test_token.py @@ -0,0 +1,66 @@ +from time import time + +from vonage_http_client import HttpClient +from vonage_video.errors import TokenExpiryError +from vonage_video.models.enums import TokenRole +from vonage_video.models.token import TokenOptions +from vonage_video.video import Video + +from testutils import get_mock_jwt_auth + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_token_options_model(): + token_options = TokenOptions( + session_id='session-id', + scope='session.connect', + role=TokenRole.PUBLISHER, + connection_data='connection-data', + initial_layout_class_list=['focus'], + exp=int(time() + 15 * 60), + jti='4cab89ca-b637-41c8-b62f-7b9ce10c3971', + subject='video', + ) + + assert token_options.session_id == 'session-id' + assert token_options.scope == 'session.connect' + assert token_options.role == TokenRole.PUBLISHER + assert token_options.connection_data == 'connection-data' + assert token_options.initial_layout_class_list == ['focus'] + assert token_options.jti == '4cab89ca-b637-41c8-b62f-7b9ce10c3971' + assert token_options.iat is not None + assert token_options.subject == 'video' + assert token_options.acl == {'paths': {'/session/**': {}}} + + +def test_token_options_invalid_expiry(): + try: + TokenOptions(exp=0) + except TokenExpiryError as e: + assert str(e) == 'Token expiry date must be in the future.' + + try: + TokenOptions(exp=99999999999) + except TokenExpiryError as e: + assert str(e) == 'Token expiry date must be less than 30 days from now.' + + +def test_generate_token(): + token = video.generate_client_token( + TokenOptions( + session_id='session-id', + scope='session.connect', + role=TokenRole.PUBLISHER, + connection_data='connection-data', + initial_layout_class_list=['focus'], + jti='4cab89ca-b637-41c8-b62f-7b9ce10c3971', + subject='video', + iat=123456789, + ) + ) + + assert ( + token + == b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoic2Vzc2lvbi1pZCIsInJvbGUiOiJwdWJsaXNoZXIiLCJjb25uZWN0aW9uX2RhdGEiOiJjb25uZWN0aW9uLWRhdGEiLCJpbml0aWFsX2xheW91dF9jbGFzc19saXN0IjpbImZvY3VzIl0sImV4cCI6MTIzNDU3Njg5LCJqdGkiOiI0Y2FiODljYS1iNjM3LTQxYzgtYjYyZi03YjljZTEwYzM5NzEiLCJpYXQiOjEyMzQ1Njc4OSwic3ViamVjdCI6InZpZGVvIiwic2NvcGUiOiJzZXNzaW9uLmNvbm5lY3QiLCJhY2wiOnsicGF0aHMiOnsiL3Nlc3Npb24vKioiOnt9fX0sImFwcGxpY2F0aW9uX2lkIjoidGVzdF9hcHBsaWNhdGlvbl9pZCJ9.DL-b9AJxZIKb0gmc_NGrD8fvIpg_ILX5FBMXpR56CgSdI63wS04VuaAKCTRojSJrqpzENv_GLR2HYY4-d1Qm1pyj1tM1yFRDk8z_vun30DWavYkCFW1T5FenK1VUjg0P9pbdGiPvq0Ku-taMuLyqXzQqHsbEGOovo-JMIag6wD6JPrPIKaYXsqGpXYaJ_BCcuIpg0NquQgJXA004Q415CxguCkQLdv0d7xTyfPw44Sj-_JfRdBdqDjyiDsmYmh7Yt5TrqRqZ1SwxNhNP7MSx8KDake3VqkQB9Iyys43MJBHZtRDrtE6VedLt80RpCz9Yo8F8CIjStwQPOfMjbV-iEA' + ) diff --git a/video/tests/test_video.py b/video/tests/test_video.py new file mode 100644 index 00000000..f256b420 --- /dev/null +++ b/video/tests/test_video.py @@ -0,0 +1,401 @@ +from os.path import abspath + +from vonage_http_client.http_client import HttpClient +from vonage_video.video import Video + +from testutils import get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_http_client_property(): + assert type(video.http_client) == HttpClient + + +### + + +# @responses.activate +# def test_create_call_basic_ncco(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# ncco = [Talk(text='Hello world')] +# call = CreateCallRequest( +# ncco=ncco, +# to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], +# random_from_number=True, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_create_call_ncco_options(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# ncco = [Talk(text='Hello world')] +# call = CreateCallRequest( +# ncco=ncco, +# to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], +# from_={'number': '1234567890', 'type': 'phone'}, +# event_url=['https://example.com/event'], +# event_method='POST', +# machine_detection='hangup', +# length_timer=60, +# ringing_timer=30, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_create_call_basic_answer_url(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# call = CreateCallRequest( +# to=[ +# { +# 'type': 'websocket', +# 'uri': 'wss://example.com/websocket', +# 'content_type': 'audio/l16;rate=8000', +# 'headers': {'key': 'value'}, +# } +# ], +# answer_url=['https://example.com/answer'], +# random_from_number=True, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_create_call_answer_url_options(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# call = CreateCallRequest( +# to=[{'type': 'vbc', 'extension': '1234'}], +# answer_url=['https://example.com/answer'], +# answer_method='GET', +# random_from_number=True, +# event_url=['https://example.com/event'], +# event_method='POST', +# advanced_machine_detection={ +# 'behavior': 'hangup', +# 'mode': 'detect_beep', +# 'beep_timeout': 50, +# }, +# length_timer=60, +# ringing_timer=30, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# def test_create_call_ncco_and_answer_url_error(): +# with raises(VoiceError) as e: +# CreateCallRequest( +# to=[{'type': 'phone', 'number': '1234567890'}], +# random_from_number=True, +# ) +# assert e.match('Either `ncco` or `answer_url` must be set') + +# with raises(VoiceError) as e: +# CreateCallRequest( +# ncco=[Talk(text='Hello world')], +# answer_url=['https://example.com/answer'], +# to=[{'type': 'phone', 'number': '1234567890'}], +# random_from_number=True, +# ) +# assert e.match('`ncco` and `answer_url` cannot be used together') + + +# def test_create_call_from_and_random_from_number_error(): +# with raises(VoiceError) as e: +# CreateCallRequest( +# ncco=[Talk(text='Hello world')], +# to=[{'type': 'phone', 'number': '1234567890'}], +# ) +# assert e.match('Either `from_` or `random_from_number` must be set') + +# with raises(VoiceError) as e: +# CreateCallRequest( +# ncco=[Talk(text='Hello world')], +# to=[{'type': 'phone', 'number': '1234567890'}], +# from_={'number': '9876543210', 'type': 'phone'}, +# random_from_number=True, +# ) +# assert e.match('`from_` and `random_from_number` cannot be used together') + + +# @responses.activate +# def test_list_calls(): +# build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) +# calls, _ = voice.list_calls() +# assert len(calls) == 3 +# assert calls[0].to.number == '1234567890' +# assert calls[0].from_.number == '9876543210' +# assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' +# assert calls[1].direction == 'outbound' +# assert calls[1].status == 'completed' +# assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_list_calls_filter(): +# build_response( +# path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 +# ) +# filter = ListCallsFilter( +# status='completed', +# date_start='2024-03-14T07:45:14Z', +# date_end='2024-04-19T08:45:14Z', +# page_size=10, +# record_index=0, +# order='asc', +# conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +# ) +# filter_dict = { +# 'status': 'completed', +# 'date_start': '2024-03-14T07:45:14Z', +# 'date_end': '2024-04-19T08:45:14Z', +# 'page_size': 10, +# 'record_index': 0, +# 'order': 'asc', +# 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +# } +# assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict + +# calls, next_record_index = voice.list_calls(filter) +# assert len(calls) == 1 +# assert calls[0].to.number == '1234567890' +# assert next_record_index == 2 + + +# @responses.activate +# def test_get_call(): +# build_response( +# path, +# 'GET', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# 'get_call.json', +# 200, +# ) +# call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert call.to.number == '1234567890' +# assert call.from_.number == '9876543210' +# assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' +# assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' + + +# @responses.activate +# def test_transfer_call_ncco(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# ) + +# ncco = [Talk(text='Hello world')] +# voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_transfer_call_answer_url(): +# answer_url = 'https://example.com/answer' +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[ +# json_params_matcher( +# { +# 'action': 'transfer', +# 'destination': {'type': 'ncco', 'url': [answer_url]}, +# }, +# ), +# ], +# ) + +# voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_hangup(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'hangup'})], +# ) + +# voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_mute(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'mute'})], +# ) + +# voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_unmute(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'unmute'})], +# ) + +# voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_earmuff(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'earmuff'})], +# ) + +# voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_unearmuff(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'unearmuff'})], +# ) + +# voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_play_audio_into_call(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'PUT', +# f'https://api.nexmo.com/v1/calls/{uuid}/stream', +# 'play_audio_into_call.json', +# ) + +# options = AudioStreamOptions( +# stream_url=['https://example.com/audio'], loop=2, level=0.5 +# ) +# response = voice.play_audio_into_call(uuid, options) +# assert response.message == 'Stream started' +# assert response.uuid == uuid + + +# @responses.activate +# def test_stop_audio_stream(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'DELETE', +# f'https://api.nexmo.com/v1/calls/{uuid}/stream', +# 'stop_audio_stream.json', +# ) + +# response = voice.stop_audio_stream(uuid) +# assert response.message == 'Stream stopped' +# assert response.uuid == uuid + + +# @responses.activate +# def test_play_tts_into_call(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'PUT', +# f'https://api.nexmo.com/v1/calls/{uuid}/talk', +# 'play_tts_into_call.json', +# ) + +# options = TtsStreamOptions( +# text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +# ) +# response = voice.play_tts_into_call(uuid, options) +# assert response.message == 'Talk started' +# assert response.uuid == uuid + + +# @responses.activate +# def test_stop_tts(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'DELETE', +# f'https://api.nexmo.com/v1/calls/{uuid}/talk', +# 'stop_tts.json', +# ) + +# response = voice.stop_tts(uuid) +# assert response.message == 'Talk stopped' +# assert response.uuid == uuid + + +# @responses.activate +# def test_play_dtmf_into_call(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'PUT', +# f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', +# 'play_dtmf_into_call.json', +# ) + +# response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') +# assert response.message == 'DTMF sent' +# assert response.uuid == uuid diff --git a/voice/BUILD b/voice/BUILD new file mode 100644 index 00000000..a032d635 --- /dev/null +++ b/voice/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-voice', + dependencies=[ + ':pyproject', + ':readme', + 'voice/src/vonage_voice', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/voice/CHANGES.md b/voice/CHANGES.md new file mode 100644 index 00000000..743d3661 --- /dev/null +++ b/voice/CHANGES.md @@ -0,0 +1,20 @@ +# 1.0.6 +- Update dependency versions + +# 1.0.5 +- Support for Python 3.13, drop support for 3.8 + +# 1.0.4 +- Add docstrings to data models + +# 1.0.3 +- Internal refactoring + +# 1.0.2 +- Update minimum dependency version + +# 1.0.1 +- Initial upload + +# 1.0.0 +- This version was skipped due to a technical issue with the package distribution. Please use version 1.0.1 or later. \ No newline at end of file diff --git a/voice/README.md b/voice/README.md new file mode 100644 index 00000000..d68e96d8 --- /dev/null +++ b/voice/README.md @@ -0,0 +1,144 @@ +# Vonage Voice Package + +This package contains the code to use [Vonage's Voice API](https://developer.vonage.com/en/voice/voice-api/overview) in Python. This package includes methods for working with the Voice API. It also contains an NCCO (Call Control Object) builder to help you to control call flow. + +## Structure + +There is a `Voice` class which contains the methods used to call Vonage APIs. To call many of the APIs, you need to pass a Pydantic model with the required options. These can be accessed from the `vonage_voice.models` subpackage. Errors can be accessed from the `vonage_voice.errors` module. + +## 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`, like so: + +```python +from vonage import Vonage, Auth + +vonage_client = Vonage(Auth('MY_AUTH_INFO')) +``` + +### Create a Call + +To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. + +```python +from vonage_voice.models import CreateCallRequest, Talk + +ncco = [Talk(text='Hello world', loop=3, language='en-GB')] + +call = CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + ncco=ncco, + random_from_number=True, +) + +response = vonage_client.voice.create_call(call) +print(response.model_dump()) +``` + +### List Calls + +```python +# Gets the first 100 results and the record_index of the +# next page if there's more than 100 +calls, next_record_index = vonage_client.voice.list_calls() + +# Specify filtering options +from vonage_voice.models import ListCallsFilter + +call_filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +) + +calls, next_record_index = vonage_client.voice.list_calls(call_filter) +``` + +### Get Information About a Specific Call + +```python +call = vonage_client.voice.get_call('CALL_ID') +``` + +### Transfer a Call to a New NCCO + +```python +ncco = [Talk(text='Hello world')] +vonage_client.voice.transfer_call_ncco('UUID', ncco) +``` + +### Transfer a Call to a New Answer URL + +```python +vonage_client.voice.transfer_call_answer_url('UUID', 'ANSWER_URL') +``` + +### Hang Up a Call + +End the call for a specified UUID, removing them from it. + +```python +vonage_client.voice.hangup('UUID') +``` + +### Mute/Unmute a Participant + +```python +vonage_client.voice.mute('UUID') +vonage_client.voice.unmute('UUID') +``` + +### Earmuff/Unearmuff a UUID + +Prevent/allow a specified UUID participant to be able to hear audio. + +```python +vonage_client.voice.earmuff('UUID') +vonage_client.voice.unearmuff('UUID') +``` + +### Play Audio Into a Call + +```python +from vonage_voice.models import AudioStreamOptions + +# Only the `stream_url` option is required +options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 +) +response = vonage_client.voice.play_audio_into_call('UUID', options) +``` + +### Stop Playing Audio Into a Call + +```python +vonage_client.voice.stop_audio_stream('UUID') +``` + +### Play TTS Into a Call + +```python +from vonage_voice.models import TtsStreamOptions + +# Only the `text` field is required +options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +) +response = voice.play_tts_into_call('UUID', options) +``` + +### Stop Playing TTS Into a Call + +```python +vonage_client.voice.stop_tts('UUID') +``` + +### Play DTMF Tones Into a Call + +```python +response = voice.play_dtmf_into_call('UUID', '1234*#') +``` \ No newline at end of file diff --git a/voice/pyproject.toml b/voice/pyproject.toml new file mode 100644 index 00000000..06ea83b3 --- /dev/null +++ b/voice/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-voice' +dynamic = ["version"] +description = 'Vonage voice package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.3", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_voice._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/voice/src/vonage_voice/BUILD b/voice/src/vonage_voice/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/voice/src/vonage_voice/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/voice/src/vonage_voice/__init__.py b/voice/src/vonage_voice/__init__.py new file mode 100644 index 00000000..b73b81f1 --- /dev/null +++ b/voice/src/vonage_voice/__init__.py @@ -0,0 +1,4 @@ +from . import errors, models +from .voice import Voice + +__all__ = ['Voice', 'errors', 'models'] diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py new file mode 100644 index 00000000..da2182f1 --- /dev/null +++ b/voice/src/vonage_voice/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.6' diff --git a/voice/src/vonage_voice/errors.py b/voice/src/vonage_voice/errors.py new file mode 100644 index 00000000..02c4cc68 --- /dev/null +++ b/voice/src/vonage_voice/errors.py @@ -0,0 +1,9 @@ +from vonage_utils.errors import VonageError + + +class VoiceError(VonageError): + """Indicates an error when using the Vonage Voice API.""" + + +class NccoActionError(VoiceError): + """Indicates an error when using an NCCO action.""" diff --git a/voice/src/vonage_voice/models/BUILD b/voice/src/vonage_voice/models/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/voice/src/vonage_voice/models/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py new file mode 100644 index 00000000..aeb86a7b --- /dev/null +++ b/voice/src/vonage_voice/models/__init__.py @@ -0,0 +1,73 @@ +from .common import AdvancedMachineDetection, Phone, Sip, Vbc, Websocket +from .connect_endpoints import ( + AppEndpoint, + OnAnswer, + PhoneEndpoint, + SipEndpoint, + VbcEndpoint, + WebsocketEndpoint, +) +from .enums import ( + CallState, + Channel, + ConnectEndpointType, + NccoActionType, + TtsLanguageCode, +) +from .input_types import Dtmf, Speech +from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk +from .requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + ToPhone, + TtsStreamOptions, +) +from .responses import ( + CallInfo, + CallList, + CallMessage, + CreateCallResponse, + Embedded, + HalLinks, +) + +__all__ = [ + 'AdvancedMachineDetection', + 'AppEndpoint', + 'AudioStreamOptions', + 'CallInfo', + 'CallList', + 'CallMessage', + 'CallState', + 'Channel', + 'Connect', + 'ConnectEndpointType', + 'Conversation', + 'CreateCallRequest', + 'CreateCallResponse', + 'Dtmf', + 'Embedded', + 'Input', + 'ListCallsFilter', + 'HalLinks', + 'NccoAction', + 'NccoActionType', + 'Notify', + 'OnAnswer', + 'Phone', + 'PhoneEndpoint', + 'Record', + 'Sip', + 'SipEndpoint', + 'Speech', + 'Stream', + 'Talk', + 'ToPhone', + 'TtsLanguageCode', + 'TtsStreamOptions', + 'Vbc', + 'VbcEndpoint', + 'Websocket', + 'WebsocketEndpoint', +] diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py new file mode 100644 index 00000000..ef6d75ed --- /dev/null +++ b/voice/src/vonage_voice/models/common.py @@ -0,0 +1,74 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber, SipUri +from vonage_voice.models.enums import Channel + + +class Phone(BaseModel): + """Model for a phone number. + + Args: + number (PhoneNumber): The phone number. + """ + + number: PhoneNumber + type: Channel = Channel.PHONE + + +class Sip(BaseModel): + """Model for a SIP URI. + + Args: + uri (SipUri): The SIP URI. + """ + + uri: SipUri + type: Channel = Channel.SIP + + +class Websocket(BaseModel): + """Model for a WebSocket connection. + + Args: + uri (str): The URI of the WebSocket connection. + content_type (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The content + type of the audio stream. + headers (Optional[dict]): The headers to include with the WebSocket connection. + """ + + uri: str = Field(..., min_length=1, max_length=50) + content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( + 'audio/l16;rate=16000', serialization_alias='content-type' + ) + headers: Optional[dict] = None + type: Channel = Channel.WEBSOCKET + + +class Vbc(BaseModel): + """Model for a VBC connection. + + Args: + extension (str): The extension to call. + """ + + extension: str + type: Channel = Channel.VBC + + +class AdvancedMachineDetection(BaseModel): + """Model for advanced machine detection settings. Configure the behavior of Vonage's + advanced machine detection. Overrides `machine_detection` if both are set. + + Args: + behavior (Optional[Literal['continue', 'hangup']]): The behavior when a machine + is detected. + mode (Optional[Literal['default', 'detect', 'detect_beep']]): Detect if machine + answered and sends a human or machine status in the webhook payload. + beep_timeout (Optional[int]): Maximum time in seconds Vonage should wait for a + machine beep to be detected. + """ + + behavior: Optional[Literal['continue', 'hangup']] = None + mode: Optional[Literal['default', 'detect', 'detect_beep']] = None + beep_timeout: Optional[int] = Field(None, ge=45, le=120) diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py new file mode 100644 index 00000000..dc026e3e --- /dev/null +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -0,0 +1,91 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import Dtmf, PhoneNumber, SipUri + +from .enums import ConnectEndpointType + + +class OnAnswer(BaseModel): + """Settings for what to do when the call is answered. + + Args: + url (str): The URL to fetch the NCCO from. The URL serves an NCCO to execute in the + number being connected to, before that call is joined to your existing conversation. + ringbackTone (Optional[str]): A URL value that points to a `ringbackTone` to be played + back on repeat to the caller, so they do not hear silence. + """ + + url: str + ringbackTone: Optional[str] = None + + +class PhoneEndpoint(BaseModel): + """Model for a phone endpoint. + + Args: + number (PhoneNumber): The phone number to call. + dtmfAnswer (Optional[Dtmf]): The DTMF tones to send when the call is answered. + onAnswer (Optional[OnAnswer]): Settings for what to do when the call is answered. + """ + + number: PhoneNumber + dtmfAnswer: Optional[Dtmf] = None + onAnswer: Optional[OnAnswer] = None + type: ConnectEndpointType = ConnectEndpointType.PHONE + + +class AppEndpoint(BaseModel): + """Model for an RTC capable application endpoint. + + Args: + user (str): The username of the user to connect to. This username must have been + added as a user. + """ + + user: str + type: ConnectEndpointType = ConnectEndpointType.APP + + +class WebsocketEndpoint(BaseModel): + """Model for a WebSocket connection. + + Args: + uri (str): The URI of the WebSocket connection. + contentType (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The internet + media type for the audio you are streaming. + headers (Optional[dict]): The headers to include with the WebSocket connection. + """ + + uri: str + contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] = Field( + None, serialization_alias='content-type' + ) + headers: Optional[dict] = None + type: ConnectEndpointType = ConnectEndpointType.WEBSOCKET + + +class SipEndpoint(BaseModel): + """Model for a SIP endpoint. + + Args: + uri (SipUri): The SIP URI to connect to. + headers (Optional[dict]): The headers to include with the SIP connection. To use + TLS and/or SRTP, include respectively `transport=tls` or `media=srtp` to the URL with + the semicolon `;` as a delimiter. + """ + + uri: SipUri + headers: Optional[dict] = None + type: ConnectEndpointType = ConnectEndpointType.SIP + + +class VbcEndpoint(BaseModel): + """Model for a VBC endpoint. + + Args: + extension (str): The VBC extension to connect the call to. + """ + + extension: str + type: ConnectEndpointType = ConnectEndpointType.VBC diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py new file mode 100644 index 00000000..faa007ca --- /dev/null +++ b/voice/src/vonage_voice/models/enums.py @@ -0,0 +1,89 @@ +from enum import Enum + + +class Channel(str, Enum): + PHONE = 'phone' + SIP = 'sip' + WEBSOCKET = 'websocket' + VBC = 'vbc' + + +class NccoActionType(str, Enum): + RECORD = 'record' + CONVERSATION = 'conversation' + CONNECT = 'connect' + TALK = 'talk' + STREAM = 'stream' + INPUT = 'input' + NOTIFY = 'notify' + + +class ConnectEndpointType(str, Enum): + PHONE = 'phone' + APP = 'app' + WEBSOCKET = 'websocket' + SIP = 'sip' + VBC = 'vbc' + + +class CallState(str, Enum): + STARTED = 'started' + RINGING = 'ringing' + ANSWERED = 'answered' + MACHINE = 'machine' + COMPLETED = 'completed' + BUSY = 'busy' + CANCELLED = 'cancelled' + FAILED = 'failed' + REJECTED = 'rejected' + TIMEOUT = 'timeout' + UNANSWERED = 'unanswered' + + +class TtsLanguageCode(str, Enum): + AR = 'ar' + CA_ES = 'ca-ES' + CMN_CN = 'cmn-CN' + CMN_TW = 'cmn-TW' + CS_CZ = 'cs-CZ' + 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_GB_WLS = 'en-GB-WLS' + EN_IN = 'en-IN' + EN_US = 'en-US' + EN_ZA = 'en-ZA' + ES_ES = 'es-ES' + ES_MX = 'es-MX' + ES_US = 'es-US' + EU_ES = 'eu-ES' + FI_FI = 'fi-FI' + FIL_PH = 'fil-PH' + FR_CA = 'fr-CA' + FR_FR = 'fr-FR' + HE_IL = 'he-IL' + 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' + NO_NO = 'no-NO' + PL_PL = 'pl-PL' + PT_BR = 'pt-BR' + PT_PT = 'pt-PT' + RO_RO = 'ro-RO' + RU_RU = 'ru-RU' + SK_SK = 'sk-SK' + SV_SE = 'sv-SE' + TH_TH = 'th-TH' + TR_TR = 'tr-TR' + UK_UA = 'uk-UA' + VI_VN = 'vi-VN' + YUE_CN = 'yue-CN' diff --git a/voice/src/vonage_voice/models/input_types.py b/voice/src/vonage_voice/models/input_types.py new file mode 100644 index 00000000..a7e1b35b --- /dev/null +++ b/voice/src/vonage_voice/models/input_types.py @@ -0,0 +1,52 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class Dtmf(BaseModel): + """Model for DTMF input options used as part of an NCCO. + + Args: + timeOut (Optional[int]): The result of the callee's activity is sent to the + `eventUrl` webhook endpoint `timeOut` seconds after the last action. + submitOnHash (Optional[bool]): Set to `True` so the callee's activity is sent + to your webhook endpoint at `eventUrl` after they press `#`. If `#` is not + pressed the result is submitted after `timeOut` seconds. + maxDigits (Optional[int]): The number of digits the user can press. + """ + + timeOut: Optional[int] = Field(None, ge=0, le=10) + maxDigits: Optional[int] = Field(None, ge=1, le=20) + submitOnHash: Optional[bool] = None + + +class Speech(BaseModel): + """Model for speech input options used as part of an NCCO. + + Args: + uuid (Optional[list[str]]): The UUID of the speech recognition session. + endOnSilence (Optional[float]): The length of silence in seconds that indicates + the end of the speech. Uses BCP-47 format. + language (Optional[str]): The language used for speech recognition. The default + is `en-US`. + context (Optional[list[str]]):Array of hints (strings) to improve recognition + quality if certain words are expected from the user. + startTimeout (Optional[int]): Controls how long the system will wait for the user + to start speaking. + maxDuration (Optional[int]): Controls maximum speech duration (from the moment + the user starts speaking). + saveAudio (Optional[bool]): If the speech input recording is sent to your webhook + endpoint at `eventUrl`. + sensitivity (Optional[int]): Audio sensitivity used to differentiate noise from + speech. An integer value where `10` represents low sensitivity and `100` + maximum sensitivity. + """ + + uuid: Optional[list[str]] = None + endOnSilence: Optional[float] = Field(None, ge=0.4, le=10.0) + language: Optional[str] = None + context: Optional[list[str]] = None + startTimeout: Optional[int] = Field(None, ge=1, le=60) + maxDuration: Optional[int] = Field(None, ge=1, le=60) + saveAudio: Optional[bool] = False + sensitivity: Optional[int] = Field(None, ge=0, le=100) diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py new file mode 100644 index 00000000..b985e3fd --- /dev/null +++ b/voice/src/vonage_voice/models/ncco.py @@ -0,0 +1,265 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types import PhoneNumber +from vonage_voice.errors import NccoActionError +from vonage_voice.models.common import AdvancedMachineDetection + +from .connect_endpoints import ( + AppEndpoint, + PhoneEndpoint, + SipEndpoint, + VbcEndpoint, + WebsocketEndpoint, +) +from .enums import NccoActionType +from .input_types import Dtmf, Speech + + +class NccoAction(BaseModel): + """The base class for all NCCO actions. + + For more information on NCCO actions, see the Vonage API documentation. + """ + + +class Record(NccoAction): + """Use the Record action to record a call or part of a call. + + Args: + format (Optional[Literal['mp3', 'wav', 'ogg']]): The format of the recording. + split (Optional[Literal['conversation']]): Record the sent and received audio + in separate channels of a stereo recording. Set to `conversation` to enable + this. + channels (Optional[int]): The number of channels to record. If the number of + participants exceeds `channels` any additional participants will be added + to the last channel in file. `split=conversation` must also be set. + endOnSilence (Optional[int]): Stop recording after this many seconds of silence. + endOnKey (Optional[str]): Stop recording when a digit is pressed on the keypad. + Possible values are `[0-9*#]`. + timeOut (Optional[int]): The maximum length of a recording in seconds. Once the + recording is stopped the recording data is sent to `event_url`. + beepStart (Optional[bool]): Play a beep when the recording starts. + eventUrl (Optional[list[str]]): The URL to the webhook endpoint that is called + asynchronously when a recording is finished. If the message recording is + hosted by Vonage, this webhook contains the URL you need to download the + recording and other metadata. + eventMethod (Optional[str]): The HTTP method used to send the recording event to + `eventUrl`. + """ + + format: Optional[Literal['mp3', 'wav', 'ogg']] = None + split: Optional[Literal['conversation']] = None + channels: Optional[int] = Field(None, ge=1, le=32) + endOnSilence: Optional[int] = Field(None, ge=3, le=10) + endOnKey: Optional[str] = Field(None, pattern=r'^[0-9#*]$') + timeOut: Optional[int] = Field(None, ge=3, le=7200) + beepStart: Optional[bool] = None + eventUrl: Optional[list[str]] = None + eventMethod: Optional[str] = None + action: NccoActionType = NccoActionType.RECORD + + @model_validator(mode='after') + def enable_split(self): + if self.channels and not self.split: + self.split = 'conversation' + return self + + +class Conversation(NccoAction): + """You can use the Conversation action to create standard or moderated conferences, + while preserving the communication context. + + Using a conversation with the same name reuses the same persisted conversation. + + Args: + name (str): The name of the conversation room. + musicOnHoldUrl (Optional[list[str]]): The URL to the music that is played to + participants when they are on hold. + startOnEnter (Optional[bool]): The default value of `True` ensures that the + conversation starts when this caller joins conversation `name`. Set to + `False` for attendees in a moderated conversation. + endOnExit (Optional[bool]): End the conversation when the moderator leaves. + record (Optional[bool]): Record the conversation. + canSpeak (Optional[list[str]]): A list of leg UUIDs that this participant can be + heard by. If not provided, the participant can be heard by everyone. If an + empty list is provided, the participant will not be heard by anyone. + canHear (Optional[list[str]]): A list of leg UUIDs that this participant can + hear. If not provided, the participant can hear everyone. If an empty list + is provided, the participant will not hear any other participants. + mute (Optional[bool]): Mute the participant. + + Raises: + NccoActionError: If the `mute` option is used with the `canSpeak` option. + """ + + name: str + musicOnHoldUrl: Optional[list[str]] = None + startOnEnter: Optional[bool] = None + endOnExit: Optional[bool] = None + record: Optional[bool] = None + canSpeak: Optional[list[str]] = None + canHear: Optional[list[str]] = None + mute: Optional[bool] = None + action: NccoActionType = NccoActionType.CONVERSATION + + @model_validator(mode='after') + def can_mute(self): + if self.canSpeak and self.mute: + raise NccoActionError( + 'Cannot use mute option if canSpeak option is specified.' + ) + return self + + +class Connect(NccoAction): + """You can use the Connect action to connect a call to endpoints such as phone numbers + or a VBC extension. + + Args: + endpoint (list[Union[PhoneEndpoint, AppEndpoint, WebsocketEndpoint, SipEndpoint, VbcEndpoint]]): + The endpoint to connect to. + from_ (Optional[PhoneNumber]): The phone number to use when calling. Mutually exclusive + with the `randomFromNumber` property. + randomFromNumber (Optional[bool]): Whether to use a random number as the caller's phone + number. The number will be selected from the list of the numbers assigned to the + current application. Mutually exclusive with the `from_` property. + eventType (Optional[Literal['synchronous']]): The type of event that triggers the `eventUrl` + webhook. The default is `synchronous`. + timeout (Optional[int]): If the call is unanswered, set the number in seconds before + Vonage stops ringing `endpoint`. + limit (Optional[int]): The maximum duration of the call in seconds. The default is `7200`. + machineDetection (Optional[Literal['continue', 'hangup']]): Configure the behavior when Vonage + detects that the call is answered by voicemail. + advancedMachineDetection (Optional[AdvancedMachineDetection]): Configure the behavior of Vonage's + advanced machine detection. Overrides `machineDetection` if both are set. + eventUrl (Optional[list[str]]): Set the webhook endpoint that Vonage calls asynchronously + on each of the possible Call States. If `eventType` is set to `synchronous` the + `eventUrl` can return an NCCO that overrides the current NCCO when a timeout occurs. + eventMethod (Optional[str]): The HTTP method used to send the call events to `eventUrl`. + ringbackTone (Optional[list[str]]):A URL value that points to a `ringbackTone` to be played + back on repeat to the caller, so they don't hear silence. The `ringbackTone` will + automatically stop playing when the call is fully connected. It's not recommended to + use this parameter when connecting to a `phone` endpoint, as the carrier will supply + their own `ringbackTone`. + + Raises: + NccoActionError: If neither `from_` nor `randomFromNumber` is set. + NccoActionError: If both `from_` and `randomFromNumber` are set. + """ + + endpoint: list[ + Union[PhoneEndpoint, AppEndpoint, WebsocketEndpoint, SipEndpoint, VbcEndpoint] + ] + from_: Optional[PhoneNumber] = Field(None, serialization_alias='from') + randomFromNumber: Optional[bool] = None + eventType: Optional[Literal['synchronous']] = None + timeout: Optional[int] = None + limit: Optional[int] = Field(None, le=7200) + machineDetection: Optional[Literal['continue', 'hangup']] = None + advancedMachineDetection: Optional[AdvancedMachineDetection] = None + eventUrl: Optional[list[str]] = None + eventMethod: Optional[str] = None + ringbackTone: Optional[list[str]] = None + action: NccoActionType = NccoActionType.CONNECT + + @model_validator(mode='after') + def validate_from_and_random_from_number(self): + if self.randomFromNumber is None and self.from_ is None: + raise NccoActionError('Either `from_` or `random_from_number` must be set.') + if self.randomFromNumber == True and self.from_ is not None: + raise NccoActionError( + '`from_` and `random_from_number` cannot be used together.' + ) + return self + + +class Talk(NccoAction): + """The Talk action sends synthesized speech to a Conversation. + + For valid languages, see the Vonage API documentation. + https://developer.vonage.com/en/voice/voice-api/concepts/text-to-speech#supported-languages + + Args: + text (str): The text to be spoken. + bargeIn (Optional[bool]): Set to `True` to allow the user to interrupt the audio + stream by speaking or DTMF input. The default is `False`. + loop (Optional[int]): The number of times the audio file is played before the call + is closed. The default is `1`, `0` loops indefinitely. + level (Optional[float]): The volume the speech is played at. The default is `0`. + language (Optional[str]): The language used for the message. The default is `en-US`. + style (Optional[int]): The vocal style of the voice used. + premium (Optional[bool]): Set to `True` to use the premium version of the text-to-speech + voice. The default is `False`. + """ + + text: str = Field(..., max_length=1500) + bargeIn: Optional[bool] = None + loop: Optional[int] = Field(None, ge=0) + level: Optional[float] = Field(None, ge=-1, le=1) + language: Optional[str] = None + style: Optional[int] = None + premium: Optional[bool] = None + action: NccoActionType = NccoActionType.TALK + + +class Stream(NccoAction): + """The stream action allows you to send an audio stream to a Call or Conversation. + + Args: + streamUrl (list[str]): An array containing a single URL to an mp3 or wav (16-bit) + audio file to stream to the Call or Conversation. + level (Optional[float]): The volume level of the audio. The value must be between + -1 and 1. + bargeIn (Optional[bool]): Set to `True` to allow the user to interrupt the audio + stream by speaking or DTMF input. The default is `False`. + loop (Optional[int]): The number of times the audio file is played before the call + is closed. The default is `1`, `0` loops indefinitely. + """ + + streamUrl: list[str] + level: Optional[float] = Field(None, ge=-1, le=1) + bargeIn: Optional[bool] = None + loop: Optional[int] = Field(None, ge=0) + action: NccoActionType = NccoActionType.STREAM + + +class Input(NccoAction): + """Collect digits or speech input by the person you are are calling. + + Args: + type (list[Union[Literal['dtmf'], Literal['speech']]]): The type of input to collect. + dtmf (Optional[Dtmf]): The DTMF options to use. + speech (Optional[Speech]): The speech options to use. + eventUrl (Optional[list[str]]): Vonage sends the digits pressed by the callee to + this URL either 1) after `timeOut` pause in activity or when `#` is pressed for + DTMF input or 2) after the user stops speaking or 30 seconds of speech for + speech input. + eventMethod (Optional[str]): The HTTP method to use when sending the result to + `eventUrl`. + """ + + type: list[Union[Literal['dtmf'], Literal['speech']]] + dtmf: Optional[Dtmf] = None + speech: Optional[Speech] = None + eventUrl: Optional[list[str]] = None + eventMethod: Optional[str] = None + action: NccoActionType = NccoActionType.INPUT + + +class Notify(NccoAction): + """Use the notify action to send a custom payload to your event URL. Your webhook + endpoint can return another NCCO that replaces the existing NCCO or return an empty + payload meaning the existing NCCO will continue to execute. + + Args: + payload (dict): The custom payload to send to your event URL. + eventUrl (list[str]): The URL to send events to. If you return an NCCO when you + receive a notification, it will replace the current NCCO. + eventMethod (Optional[str]): The HTTP method to use when sending the payload. + """ + + payload: dict + eventUrl: list[str] + eventMethod: Optional[str] = None + action: NccoActionType = NccoActionType.NOTIFY diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py new file mode 100644 index 00000000..7d2b1116 --- /dev/null +++ b/voice/src/vonage_voice/models/requests.py @@ -0,0 +1,151 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types import Dtmf + +from ..errors import VoiceError +from .common import AdvancedMachineDetection, Phone, Sip, Vbc, Websocket +from .enums import CallState, TtsLanguageCode +from .ncco import Connect, Conversation, Input, Notify, Record, Stream, Talk + + +class ToPhone(Phone): + """Model for the phone number to call. + + Args: + number (PhoneNumber): The phone number. + dtmf_answer (Optional[Dtmf]): The DTMF tones to send when the call is answered. + """ + + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + + +class CreateCallRequest(BaseModel): + """Request model for creating a call. You must supply either `ncco` or `answer_url`. + + Args: + ncco (Optional[list[Union[Record, Conversation, Connect, Input, Talk, Stream, Notify]]]): + The Nexmo Call Control Object (NCCO) to use for the call. + answer_url (Optional[list[str]]): The URL to fetch the NCCO from. + answer_method (Optional[Literal['POST', 'GET']]): The HTTP method used to send + event information to `answer_url`. + to (list[Union[ToPhone, Sip, Websocket, Vbc]]): The type of connection to call. + from_ (Optional[Phone]): The phone number to use when calling. Mutually exclusive + with the `random_from_number` property. + random_from_number (Optional[bool]): Whether to use a random number as the caller's + phone number. The number will be selected from the list of the numbers assigned + to the current application. Mutually exclusive with the `from_` property. + event_url (Optional[list[str]]): The webhook endpoint where call progress events + are sent. + event_method (Optional[Literal['POST', 'GET']]): The HTTP method used to send the call + events to `event_url`. + machine_detection (Optional[Literal['continue', 'hangup']]): Configure the behavior + when Vonage detects that the call is answered by voicemail. + advanced_machine_detection (Optional[AdvancedMachineDetection]): Configure the + behavior of Vonage's advanced machine detection. Overrides `machine_detection` + if both are set. + length_timer (Optional[int]): Set the number of seconds that elapse before Vonage + hangs up after the call state changes to "answered". + ringing_timer (Optional[int]): Set the number of seconds that elapse before Vonage + hangs up after the call state changes to `ringing`. + + Raises: + VoiceError: If neither `ncco` nor `answer_url` is set. + VoiceError: If both `ncco` and `answer_url` are set. + VoiceError: If neither `from_` nor `random_from_number` is set. + VoiceError: If both `from_` and `random_from_number` are set. + """ + + ncco: list[Union[Record, Conversation, Connect, Input, Talk, Stream, Notify]] = None + answer_url: list[str] = None + answer_method: Optional[Literal['POST', 'GET']] = None + to: list[Union[ToPhone, Sip, Websocket, Vbc]] + + from_: Optional[Phone] = Field(None, serialization_alias='from') + random_from_number: Optional[bool] = None + event_url: Optional[list[str]] = None + event_method: Optional[Literal['POST', 'GET']] = None + machine_detection: Optional[Literal['continue', 'hangup']] = None + advanced_machine_detection: Optional[AdvancedMachineDetection] = None + length_timer: Optional[int] = Field(None, ge=1, le=7200) + ringing_timer: Optional[int] = Field(None, ge=1, le=120) + + @model_validator(mode='after') + def validate_ncco_and_answer_url(self): + if self.ncco is None and self.answer_url is None: + raise VoiceError('Either `ncco` or `answer_url` must be set') + if self.ncco is not None and self.answer_url is not None: + raise VoiceError('`ncco` and `answer_url` cannot be used together') + return self + + @model_validator(mode='after') + def validate_from_and_random_from_number(self): + if self.random_from_number is None and self.from_ is None: + raise VoiceError('Either `from_` or `random_from_number` must be set') + if self.random_from_number == True and self.from_ is not None: + raise VoiceError('`from_` and `random_from_number` cannot be used together') + return self + + +class ListCallsFilter(BaseModel): + """Filter model for listing calls. + + Args: + status (Optional[CallState]): The state of the call. + date_start (Optional[str]): Return the records available after this point in time. + date_end (Optional[str]): Return the records that occurred before this point in + time. + page_size (Optional[int]): Return this amount of records in the response. + record_index (Optional[int]): Return calls from this index in the response. + order (Optional[Literal['asc', 'desc']]): The order in which to return the records. + conversation_uuid (Optional[str]): Return all the records associated with a + specific conversation. + """ + + status: Optional[CallState] = None + date_start: Optional[str] = None + date_end: Optional[str] = None + page_size: Optional[int] = Field(100, ge=1, le=100) + record_index: Optional[int] = None + order: Optional[Literal['asc', 'desc']] = None + conversation_uuid: Optional[str] = None + + +class AudioStreamOptions(BaseModel): + """Options for streaming audio to a call. + + Args: + stream_url (list[str]): The URL to stream audio from. + loop (Optional[int]): The number of times to loop the audio. If set to 0, the audio + will loop indefinitely.` + level (Optional[float]): The volume level of the audio. The value must be between + -1 and 1. + """ + + stream_url: list[str] + loop: Optional[int] = Field(None, ge=0) + level: Optional[float] = Field(None, ge=-1, le=1) + + +class TtsStreamOptions(BaseModel): + """Options for streaming text-to-speech to a call. + + Args: + text (str): The text to stream. + language (Optional[TtsLanguageCode]): The language of the text. + style (Optional[int]): The style of the voice (vocal range, tessitura, and timbre) + to use. + premium (Optional[bool]): Whether to use the premium version of the specified + voice. + loop (Optional[int]): The number of times to loop the audio. If set to 0, the audio + will loop indefinitely. + level (Optional[float]): The volume level of the audio. The value must be between + -1 and 1. + """ + + text: str + language: Optional[TtsLanguageCode] = None + style: Optional[int] = None + premium: Optional[bool] = None + loop: Optional[int] = Field(None, ge=0) + level: Optional[float] = Field(None, ge=-1, le=1) diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py new file mode 100644 index 00000000..10d1497b --- /dev/null +++ b/voice/src/vonage_voice/models/responses.py @@ -0,0 +1,110 @@ +from typing import Optional, Union + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import HalLinks + +from .common import Phone, Sip, Vbc, Websocket + + +class CreateCallResponse(BaseModel): + """Response model for creating a call. + + Args: + uuid (str): The unique identifier for the call. + status (str): The status of the call. + direction (str): The direction of the call. + conversation_uuid (str): The unique identifier for the conversation this call + leg is part of. + """ + + uuid: str + status: str + direction: str + conversation_uuid: str + + +class CallMessage(BaseModel): + """Model for a call message. + + Args: + message (str): Description of the action taken. + uuid (str): The unique identifier for this call leg. + """ + + message: str + uuid: str + + +class CallInfo(BaseModel): + """Model for information about a call. + + Args: + uuid (str): The unique identifier for the call. + conversation_uuid (str): The unique identifier for the conversation this call + leg is part of. + to (Union[Phone, Sip, Websocket, Vbc]): The endpoint that received the call. + from_ (Union[Phone, Sip, Websocket, Vbc]): The phone number that made the call. + status (str): The status of the call. + direction (str): The direction of the call. + rate (Optional[str]): The price per minute for this call. This is only sent + if `status` is `completed`. + price (Optional[str]): The total price charged for this call. This is only + sent if `status` is `completed`. + duration (Optional[str]): The time elapsed for the call to take place in seconds. + This is only sent if `status` is `completed`. + start_time (Optional[str]): The time the call started in the following format: + YYYY-MM-DD HH:MM:SS. + end_time (Optional[str]): The time the call ended in the following format: + YYYY-MM-DD HH:MM:SS. + network (Optional[str]): The Mobile Country Code Mobile Network Code (MCCMNC) for + the carrier network used to make this call. + link (Optional[str]): The URL to this resource. + """ + + uuid: str + conversation_uuid: str + to: Union[Phone, Sip, Websocket, Vbc] + from_: Union[Phone, Sip, Websocket, Vbc] = Field(..., validation_alias='from') + status: str + direction: str + rate: Optional[str] = None + price: Optional[str] = None + duration: Optional[str] = None + start_time: Optional[str] = None + end_time: Optional[str] = None + network: Optional[str] = None + links: HalLinks = Field(..., validation_alias='_links', exclude=True) + link: Optional[str] = None + + @model_validator(mode='after') + def get_link(self): + self.link = self.links.self.href + return self + + +class Embedded(BaseModel): + """Model for calls embedded in a response. + + Args: + calls (list[CallInfo]): The calls in this response. + """ + + calls: list[CallInfo] + + +class CallList(BaseModel): + """Model for a list of calls. + + Args: + count (int): The total number of records. + page_size (int): The number of records in this response. + record_index (int): The index of the first record in this response. + embedded (Embedded): The calls in this response. + links (HalLinks): The links to navigate the list of calls. + """ + + count: int + page_size: int + record_index: int + embedded: Embedded = Field(..., validation_alias='_embedded') + links: HalLinks = Field(..., validation_alias='_links') diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py new file mode 100644 index 00000000..5282b1db --- /dev/null +++ b/voice/src/vonage_voice/voice.py @@ -0,0 +1,263 @@ +from typing import Optional + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_utils.types import Dtmf +from vonage_voice.models.ncco import NccoAction + +from .models.requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + TtsStreamOptions, +) +from .models.responses import CallInfo, CallList, CallMessage, CreateCallResponse + + +class Voice: + """Calls Vonage's Voice API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Voice API. + + Returns: + HttpClient: The HTTP client used to make requests to the Voice API. + """ + return self._http_client + + @validate_call + def create_call(self, params: CreateCallRequest) -> CreateCallResponse: + """Creates a new call using the Vonage Voice API. + + Args: + params (CreateCallRequest): The parameters for the call. + + Returns: + CreateCallResponse: The response object containing information about the created call. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v1/calls', + params.model_dump(by_alias=True, exclude_none=True), + ) + + return CreateCallResponse(**response) + + @validate_call + def list_calls( + self, filter: ListCallsFilter = ListCallsFilter() + ) -> tuple[list[CallInfo], Optional[int]]: + """Lists calls made with the Vonage Voice API. + + Args: + filter (ListCallsFilter): The parameters to filter the list of calls. + + Returns: + tuple[list[CallInfo], Optional[int]] A tuple containing a list of `CallInfo` objects and the + value of the `record_index` attribute to get the next page of results, if there + are more results than the specified `page_size`. + """ + response = self._http_client.get( + self._http_client.api_host, + '/v1/calls', + filter.model_dump(by_alias=True, exclude_none=True), + ) + + list_response = CallList(**response) + if list_response.links.next is None: + return list_response.embedded.calls, None + next_page_index = list_response.record_index + 1 + return list_response.embedded.calls, next_page_index + + @validate_call + def get_call(self, call_id: str) -> CallInfo: + """Gets a call by ID. + + Args: + call_id (str): The ID of the call to retrieve. + + Returns: + CallInfo: Object with information about the call. + """ + response = self._http_client.get( + self._http_client.api_host, f'/v1/calls/{call_id}' + ) + + return CallInfo(**response) + + @validate_call + def transfer_call_ncco(self, uuid: str, ncco: list[NccoAction]) -> None: + """Transfers a call to a new NCCO. + + Args: + uuid (str): The UUID of the call to transfer. + ncco (list[NccoAction]): The new NCCO to transfer the call to. + """ + serializable_ncco = [ + action.model_dump(by_alias=True, exclude_none=True) for action in ncco + ] + self._http_client.put( + self._http_client.api_host, + f'/v1/calls/{uuid}', + { + 'action': 'transfer', + 'destination': {'type': 'ncco', 'ncco': serializable_ncco}, + }, + ) + + @validate_call + def transfer_call_answer_url(self, uuid: str, answer_url: str) -> None: + """Transfers a call to a new answer URL. + + Args: + uuid (str): The UUID of the call to transfer. + answer_url (str): The new answer URL to transfer the call to. + """ + self._http_client.put( + self._http_client.api_host, + f'/v1/calls/{uuid}', + {'action': 'transfer', 'destination': {'type': 'ncco', 'url': [answer_url]}}, + ) + + def hangup(self, uuid: str) -> None: + """Ends the call for the specified UUID, removing them from it. + + Args: + uuid (str): The UUID to end the call for. + """ + self._http_client.put( + self._http_client.api_host, f'/v1/calls/{uuid}', {'action': 'hangup'} + ) + + def mute(self, uuid: str) -> None: + """Mutes a call for the specified UUID. + + Args: + uuid (str): The UUID to mute the call for. + """ + self._http_client.put( + self._http_client.api_host, f'/v1/calls/{uuid}', {'action': 'mute'} + ) + + def unmute(self, uuid: str) -> None: + """Unmutes a call for the specified UUID. + + Args: + uuid (str): The UUID to unmute the call for. + """ + self._http_client.put( + self._http_client.api_host, f'/v1/calls/{uuid}', {'action': 'unmute'} + ) + + def earmuff(self, uuid: str) -> None: + """Earmuffs a call for the specified UUID (prevents them from hearing audio). + + Args: + uuid (str): The UUID you want to prevent from hearing audio. + """ + self._http_client.put( + self._http_client.api_host, f'/v1/calls/{uuid}', {'action': 'earmuff'} + ) + + def unearmuff(self, uuid: str) -> None: + """Allows the specified UUID to hear audio. + + Args: + uuid (str): The UUID you want to to allow to hear audio. + """ + self._http_client.put( + self._http_client.api_host, f'/v1/calls/{uuid}', {'action': 'unearmuff'} + ) + + @validate_call + def play_audio_into_call( + self, uuid: str, audio_stream_options: AudioStreamOptions + ) -> CallMessage: + """Plays an audio stream into a call. + + Args: + uuid (str): The UUID of the call to stream audio into. + stream_audio_options (StreamAudioOptions): The options for streaming audio. + + Returns: + CallMessage: Object with information about the call. + """ + response = self._http_client.put( + self._http_client.api_host, + f'/v1/calls/{uuid}/stream', + audio_stream_options.model_dump(by_alias=True, exclude_none=True), + ) + + return CallMessage(**response) + + def stop_audio_stream(self, uuid: str) -> CallMessage: + """Stops streaming audio into a call. + + Args: + uuid (str): The UUID of the call to stop streaming audio into. + + Returns: + CallMessage: Object with information about the call. + """ + response = self._http_client.delete( + self._http_client.api_host, f'/v1/calls/{uuid}/stream' + ) + + return CallMessage(**response) + + @validate_call + def play_tts_into_call(self, uuid: str, tts_options: TtsStreamOptions) -> CallMessage: + """Plays text-to-speech into a call. + + Args: + uuid (str): The UUID of the call to play text-to-speech into. + tts_options (TtsStreamOptions): The options for playing text-to-speech. + + Returns: + CallMessage: Object with information about the call. + """ + response = self._http_client.put( + self._http_client.api_host, + f'/v1/calls/{uuid}/talk', + tts_options.model_dump(by_alias=True, exclude_none=True), + ) + + return CallMessage(**response) + + def stop_tts(self, uuid: str) -> CallMessage: + """Stops playing text-to-speech into a call. + + Args: + uuid (str): The UUID of the call to stop playing text-to-speech into. + + Returns: + CallMessage: Object with information about the call. + """ + response = self._http_client.delete( + self._http_client.api_host, f'/v1/calls/{uuid}/talk' + ) + + return CallMessage(**response) + + @validate_call + def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: + """Plays DTMF tones into a call. + + Args: + uuid (str): The UUID of the call to play DTMF tones into. + dtmf (Dtmf): The DTMF tones to play. + + Returns: + CallMessage: Object with information about the call. + """ + response = self._http_client.put( + self._http_client.api_host, + f'/v1/calls/{uuid}/dtmf', + {'digits': dtmf}, + ) + + return CallMessage(**response) diff --git a/voice/tests/BUILD b/voice/tests/BUILD new file mode 100644 index 00000000..44127fb3 --- /dev/null +++ b/voice/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['voice', 'testutils']) diff --git a/voice/tests/data/create_call.json b/voice/tests/data/create_call.json new file mode 100644 index 00000000..eae3365a --- /dev/null +++ b/voice/tests/data/create_call.json @@ -0,0 +1,6 @@ +{ + "uuid": "106a581a-34d0-432a-a625-220221fd434f", + "status": "started", + "direction": "outbound", + "conversation_uuid": "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" +} \ No newline at end of file diff --git a/voice/tests/data/get_call.json b/voice/tests/data/get_call.json new file mode 100644 index 00000000..450933d6 --- /dev/null +++ b/voice/tests/data/get_call.json @@ -0,0 +1,25 @@ +{ + "_links": { + "self": { + "href": "/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439" + } + }, + "conversation_uuid": "CON-d4e1389a-b2c8-4621-97eb-c6f3a2b51c72", + "direction": "outbound", + "duration": "2", + "end_time": "2024-04-19T01:34:20.000Z", + "from": { + "number": "9876543210", + "type": "phone" + }, + "network": "23420", + "price": "0.00333333", + "rate": "0.10000000", + "start_time": "2024-04-19T01:34:18.000Z", + "status": "completed", + "to": { + "number": "1234567890", + "type": "phone" + }, + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" +} \ No newline at end of file diff --git a/voice/tests/data/list_calls.json b/voice/tests/data/list_calls.json new file mode 100644 index 00000000..97c3f119 --- /dev/null +++ b/voice/tests/data/list_calls.json @@ -0,0 +1,95 @@ +{ + "_embedded": { + "calls": [ + { + "_links": { + "self": { + "href": "/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439" + } + }, + "conversation_uuid": "CON-d4e1389a-b2c8-4621-97eb-c6f3a2b51c72", + "direction": "outbound", + "duration": "2", + "end_time": "2024-04-19T01:34:20.000Z", + "from": { + "number": "9876543210", + "type": "phone" + }, + "network": "23420", + "price": "0.00333333", + "rate": "0.10000000", + "start_time": "2024-04-19T01:34:18.000Z", + "status": "completed", + "to": { + "number": "1234567890", + "type": "phone" + }, + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" + }, + { + "_links": { + "self": { + "href": "/v1/calls/1acdf499-83ae-4861-a694-9e47a98c505d" + } + }, + "conversation_uuid": "CON-ee83ad86-ee21-4c28-bf7d-4ae67692721e", + "direction": "outbound", + "duration": "2", + "end_time": "2024-04-18T16:01:53.000Z", + "from": { + "number": "9876543210", + "type": "phone" + }, + "network": "23420", + "price": "0.00333333", + "rate": "0.10000000", + "start_time": "2024-04-18T16:01:51.000Z", + "status": "completed", + "to": { + "number": "1234567890", + "type": "phone" + }, + "uuid": "1acdf499-83ae-4861-a694-9e47a98c505d" + }, + { + "_links": { + "self": { + "href": "/v1/calls/106a581a-34d0-432a-a625-220221fd434f" + } + }, + "conversation_uuid": "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044", + "direction": "outbound", + "duration": "2", + "end_time": "2024-04-17T14:30:13.000Z", + "from": { + "number": "9876543210", + "type": "phone" + }, + "network": "23420", + "price": "0.00333333", + "rate": "0.10000000", + "start_time": "2024-04-17T14:30:11.000Z", + "status": "completed", + "to": { + "number": "1234567890", + "type": "phone" + }, + "uuid": "106a581a-34d0-432a-a625-220221fd434f" + } + ] + }, + "_links": { + "first": { + "href": "/v1/calls?page_size=100" + }, + "last": { + "href": "/v1/calls?page_size=100&record_index=0" + }, + "self": { + "href": "/v1/calls?page_size=100&record_index=0" + } + }, + "count": 3, + "page_size": 100, + "record_index": 0 +} \ No newline at end of file diff --git a/voice/tests/data/list_calls_filter.json b/voice/tests/data/list_calls_filter.json new file mode 100644 index 00000000..23f1ab90 --- /dev/null +++ b/voice/tests/data/list_calls_filter.json @@ -0,0 +1,51 @@ +{ + "page_size": 1, + "record_index": 1, + "count": 3, + "_embedded": { + "calls": [ + { + "uuid": "1acdf499-83ae-4861-a694-9e47a98c505d", + "status": "completed", + "direction": "outbound", + "rate": "0.10000000", + "price": "0.00333333", + "duration": "2", + "network": "23420", + "conversation_uuid": "CON-ee83ad86-ee21-4c28-bf7d-4ae67692721e", + "start_time": "2024-04-18T16:01:51.000Z", + "end_time": "2024-04-18T16:01:53.000Z", + "to": { + "type": "phone", + "number": "1234567890" + }, + "from": { + "type": "phone", + "number": "9876543210" + }, + "_links": { + "self": { + "href": "/v1/calls/1acdf499-83ae-4861-a694-9e47a98c505d" + } + } + } + ] + }, + "_links": { + "self": { + "href": "/v1/calls?page_size=1&record_index=1" + }, + "first": { + "href": "/v1/calls?page_size=1" + }, + "last": { + "href": "/v1/calls?page_size=1&record_index=2" + }, + "next": { + "href": "/v1/calls?page_size=1&record_index=2" + }, + "prev": { + "href": "/v1/calls?page_size=1&record_index=0" + } + } +} \ No newline at end of file diff --git a/voice/tests/data/play_audio_into_call.json b/voice/tests/data/play_audio_into_call.json new file mode 100644 index 00000000..e85d9856 --- /dev/null +++ b/voice/tests/data/play_audio_into_call.json @@ -0,0 +1,4 @@ +{ + "message": "Stream started", + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" +} \ No newline at end of file diff --git a/voice/tests/data/play_dtmf_into_call.json b/voice/tests/data/play_dtmf_into_call.json new file mode 100644 index 00000000..4fdf22fb --- /dev/null +++ b/voice/tests/data/play_dtmf_into_call.json @@ -0,0 +1,4 @@ +{ + "message": "DTMF sent", + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" +} \ No newline at end of file diff --git a/voice/tests/data/play_tts_into_call.json b/voice/tests/data/play_tts_into_call.json new file mode 100644 index 00000000..b30449dd --- /dev/null +++ b/voice/tests/data/play_tts_into_call.json @@ -0,0 +1,4 @@ +{ + "message": "Talk started", + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" +} \ No newline at end of file diff --git a/voice/tests/data/stop_audio_stream.json b/voice/tests/data/stop_audio_stream.json new file mode 100644 index 00000000..8741edb2 --- /dev/null +++ b/voice/tests/data/stop_audio_stream.json @@ -0,0 +1,4 @@ +{ + "message": "Stream stopped", + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" +} \ No newline at end of file diff --git a/voice/tests/data/stop_tts.json b/voice/tests/data/stop_tts.json new file mode 100644 index 00000000..c351661e --- /dev/null +++ b/voice/tests/data/stop_tts.json @@ -0,0 +1,4 @@ +{ + "message": "Talk stopped", + "uuid": "e154eb57-2962-41e7-baf4-90f63e25e439" +} \ No newline at end of file diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py new file mode 100644 index 00000000..4b9e9069 --- /dev/null +++ b/voice/tests/test_ncco_actions.py @@ -0,0 +1,324 @@ +from pytest import raises +from vonage_voice.errors import NccoActionError +from vonage_voice.models import connect_endpoints, ncco +from vonage_voice.models.common import AdvancedMachineDetection + + +def test_record_basic(): + record = ncco.Record() + assert record.model_dump(by_alias=True, exclude_none=True) == {'action': 'record'} + + +def test_record_options(): + record = ncco.Record( + format='wav', + split='conversation', + channels=4, + endOnSilence=5, + endOnKey='*', + timeOut=100, + beepStart=True, + eventUrl=['http://example.com'], + eventMethod='PUT', + ) + record_dict = { + 'format': 'wav', + 'split': 'conversation', + 'channels': 4, + 'endOnSilence': 5, + 'endOnKey': '*', + 'timeOut': 100, + 'beepStart': True, + 'eventUrl': ['http://example.com'], + 'eventMethod': 'PUT', + 'action': 'record', + } + assert record.model_dump(by_alias=True, exclude_none=True) == record_dict + + +def test_record_channels_adds_split_parameter(): + record = ncco.Record(channels=4) + assert record.model_dump(by_alias=True, exclude_none=True) == { + 'channels': 4, + 'split': 'conversation', + 'action': 'record', + } + + +def test_conversation_basic(): + conversation = ncco.Conversation(name='my_conversation') + assert conversation.model_dump(by_alias=True, exclude_none=True) == { + 'name': 'my_conversation', + 'action': 'conversation', + } + + +def test_conversation_options(): + conversation = ncco.Conversation( + name='my_conversation', + musicOnHoldUrl=['http://example.com/music.mp3'], + startOnEnter=True, + endOnExit=True, + record=True, + canSpeak=['asdf', 'qwer'], + canHear=['asdf'], + mute=False, + ) + conversation_dict = { + 'name': 'my_conversation', + 'musicOnHoldUrl': ['http://example.com/music.mp3'], + 'startOnEnter': True, + 'endOnExit': True, + 'record': True, + 'canSpeak': ['asdf', 'qwer'], + 'canHear': ['asdf'], + 'mute': False, + 'action': 'conversation', + } + assert conversation.model_dump(by_alias=True, exclude_none=True) == conversation_dict + + +def test_conversation_mute(): + with raises(NccoActionError) as e: + ncco.Conversation(name='my_conversation', canSpeak=['asdf'], mute=True) + assert e.match('Cannot use mute option if canSpeak option is specified.') + + +def test_create_connect_endpoints(): + assert connect_endpoints.PhoneEndpoint( + number='447000000000', + dtmfAnswer='1234', + onAnswer={'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + ).model_dump() == { + 'number': '447000000000', + 'dtmfAnswer': '1234', + 'onAnswer': {'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + 'type': 'phone', + } + + assert connect_endpoints.AppEndpoint(user='my_user').model_dump() == { + 'user': 'my_user', + 'type': 'app', + } + + assert connect_endpoints.WebsocketEndpoint( + uri='wss://example.com', + contentType='audio/l16;rate=8000', + headers={'asdf': 'qwer'}, + ).model_dump(by_alias=True) == { + 'uri': 'wss://example.com', + 'content-type': 'audio/l16;rate=8000', + 'headers': {'asdf': 'qwer'}, + 'type': 'websocket', + } + + assert connect_endpoints.SipEndpoint( + uri='sip:example@sip.example.com', headers={'qwer': 'asdf'} + ).model_dump() == { + 'uri': 'sip:example@sip.example.com', + 'headers': {'qwer': 'asdf'}, + 'type': 'sip', + } + + assert connect_endpoints.VbcEndpoint(extension='1234').model_dump() == { + 'extension': '1234', + 'type': 'vbc', + } + + +def test_connect_basic(): + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + connect = ncco.Connect(endpoint=[endpoint], from_='1234567890') + assert connect.model_dump(by_alias=True, exclude_none=True) == { + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'from': '1234567890', + 'action': 'connect', + } + + +def test_connect_advanced_machine_detection(): + amd = AdvancedMachineDetection(behavior='continue', mode='detect', beep_timeout=60) + + assert amd.model_dump() == { + 'behavior': 'continue', + 'mode': 'detect', + 'beep_timeout': 60, + } + + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + assert ncco.Connect( + endpoint=[endpoint], + from_='1234567890', + advancedMachineDetection=amd, + ).model_dump(by_alias=True, exclude_none=True) == { + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'from': '1234567890', + 'advancedMachineDetection': { + 'behavior': 'continue', + 'mode': 'detect', + 'beep_timeout': 60, + }, + 'action': 'connect', + } + + +def test_connect_options(): + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + connect = ncco.Connect( + endpoint=[endpoint], + randomFromNumber=True, + eventType='synchronous', + timeout=15, + limit=1000, + machineDetection='hangup', + eventUrl=['http://example.com'], + eventMethod='PUT', + ringbackTone=['http://example.com'], + ) + assert connect.model_dump(by_alias=True, exclude_none=True) == { + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'randomFromNumber': True, + 'eventType': 'synchronous', + 'timeout': 15, + 'limit': 1000, + 'machineDetection': 'hangup', + 'eventUrl': ['http://example.com'], + 'eventMethod': 'PUT', + 'ringbackTone': ['http://example.com'], + 'action': 'connect', + } + + +def test_connect_random_from_number_error(): + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + with raises(NccoActionError) as e: + ncco.Connect(endpoint=[endpoint]) + + assert e.match('Either `from_` or `random_from_number` must be set.') + + with raises(NccoActionError) as e: + ncco.Connect(endpoint=[endpoint], from_='1234567890', randomFromNumber=True) + assert e.match('`from_` and `random_from_number` cannot be used together.') + + +def test_talk_basic(): + talk = ncco.Talk(text='hello') + assert talk.model_dump(by_alias=True, exclude_none=True) == { + 'text': 'hello', + 'action': 'talk', + } + + +def test_talk_options(): + talk = ncco.Talk( + text='hello', + bargeIn=True, + loop=3, + level=0.5, + language='en-GB', + style=1, + premium=True, + ) + assert talk.model_dump(by_alias=True, exclude_none=True) == { + 'text': 'hello', + 'bargeIn': True, + 'loop': 3, + 'level': 0.5, + 'language': 'en-GB', + 'style': 1, + 'premium': True, + 'action': 'talk', + } + + +def test_stream_basic(): + stream = ncco.Stream(streamUrl=['https://example.com/stream/music.mp3']) + assert stream.model_dump(by_alias=True, exclude_none=True) == { + 'streamUrl': ['https://example.com/stream/music.mp3'], + 'action': 'stream', + } + + +def test_stream_options(): + stream = ncco.Stream( + streamUrl=['https://example.com/stream/music.mp3'], + level=0.1, + bargeIn=True, + loop=10, + ) + assert stream.model_dump(by_alias=True, exclude_none=True) == { + 'streamUrl': ['https://example.com/stream/music.mp3'], + 'level': 0.1, + 'bargeIn': True, + 'loop': 10, + 'action': 'stream', + } + + +def test_input_basic(): + input = ncco.Input( + type=['dtmf'], + ) + assert input.model_dump(by_alias=True, exclude_none=True) == { + 'type': ['dtmf'], + 'action': 'input', + } + + +def test_input_options(): + input = ncco.Input( + type=['dtmf', 'speech'], + dtmf={'timeOut': 5, 'maxDigits': 12, 'submitOnHash': True}, + speech={ + 'uuid': ['my-uuid'], + 'endOnSilence': 2.5, + 'language': 'en-GB', + 'context': ['sales', 'billing'], + 'startTimeout': 20, + 'maxDuration': 30, + 'saveAudio': True, + 'sensitivity': 50, + }, + eventUrl=['http://example.com/speech'], + eventMethod='PUT', + ) + assert input.model_dump(by_alias=True, exclude_none=True) == { + 'type': ['dtmf', 'speech'], + 'dtmf': {'timeOut': 5, 'maxDigits': 12, 'submitOnHash': True}, + 'speech': { + 'uuid': ['my-uuid'], + 'endOnSilence': 2.5, + 'language': 'en-GB', + 'context': ['sales', 'billing'], + 'startTimeout': 20, + 'maxDuration': 30, + 'saveAudio': True, + 'sensitivity': 50, + }, + 'eventUrl': ['http://example.com/speech'], + 'eventMethod': 'PUT', + 'action': 'input', + } + + +def test_notify_basic(): + notify = ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com']) + assert notify.model_dump(by_alias=True, exclude_none=True) == { + 'payload': {'message': 'hello'}, + 'eventUrl': ['http://example.com'], + 'action': 'notify', + } + + +def test_notify_options(): + notify = ncco.Notify( + payload={'message': 'hello'}, + eventUrl=['http://example.com'], + eventMethod='POST', + ) + assert notify.model_dump(by_alias=True, exclude_none=True) == { + 'payload': {'message': 'hello'}, + 'eventUrl': ['http://example.com'], + 'eventMethod': 'POST', + 'action': 'notify', + } diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py new file mode 100644 index 00000000..313751c8 --- /dev/null +++ b/voice/tests/test_voice.py @@ -0,0 +1,410 @@ +from os.path import abspath + +import responses +from pytest import raises +from responses.matchers import json_params_matcher +from vonage_http_client.http_client import HttpClient +from vonage_voice.errors import VoiceError +from vonage_voice.models.ncco import Talk +from vonage_voice.models.requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + TtsStreamOptions, +) +from vonage_voice.models.responses import CreateCallResponse +from vonage_voice.voice import Voice + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +voice = Voice(HttpClient(get_mock_jwt_auth())) + + +def test_http_client_property(): + assert type(voice.http_client) == HttpClient + + +@responses.activate +def test_create_call_basic_ncco(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], + random_from_number=True, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_ncco_options(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], + from_={'number': '1234567890', 'type': 'phone'}, + event_url=['https://example.com/event'], + event_method='POST', + machine_detection='hangup', + length_timer=60, + ringing_timer=30, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_basic_answer_url(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + call = CreateCallRequest( + to=[ + { + 'type': 'websocket', + 'uri': 'wss://example.com/websocket', + 'content_type': 'audio/l16;rate=8000', + 'headers': {'key': 'value'}, + } + ], + answer_url=['https://example.com/answer'], + random_from_number=True, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_answer_url_options(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + call = CreateCallRequest( + to=[{'type': 'vbc', 'extension': '1234'}], + answer_url=['https://example.com/answer'], + answer_method='GET', + random_from_number=True, + event_url=['https://example.com/event'], + event_method='POST', + advanced_machine_detection={ + 'behavior': 'hangup', + 'mode': 'detect_beep', + 'beep_timeout': 50, + }, + length_timer=60, + ringing_timer=30, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +def test_create_call_ncco_and_answer_url_error(): + with raises(VoiceError) as e: + CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + random_from_number=True, + ) + assert e.match('Either `ncco` or `answer_url` must be set') + + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + answer_url=['https://example.com/answer'], + to=[{'type': 'phone', 'number': '1234567890'}], + random_from_number=True, + ) + assert e.match('`ncco` and `answer_url` cannot be used together') + + +def test_create_call_from_and_random_from_number_error(): + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + to=[{'type': 'phone', 'number': '1234567890'}], + ) + assert e.match('Either `from_` or `random_from_number` must be set') + + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + to=[{'type': 'phone', 'number': '1234567890'}], + from_={'number': '9876543210', 'type': 'phone'}, + random_from_number=True, + ) + assert e.match('`from_` and `random_from_number` cannot be used together') + + +@responses.activate +def test_list_calls(): + build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) + calls, _ = voice.list_calls() + assert len(calls) == 3 + assert calls[0].to.number == '1234567890' + assert calls[0].from_.number == '9876543210' + assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' + assert calls[1].direction == 'outbound' + assert calls[1].status == 'completed' + assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_list_calls_filter(): + build_response( + path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 + ) + filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', + ) + filter_dict = { + 'status': 'completed', + 'date_start': '2024-03-14T07:45:14Z', + 'date_end': '2024-04-19T08:45:14Z', + 'page_size': 10, + 'record_index': 0, + 'order': 'asc', + 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', + } + assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict + + calls, next_record_index = voice.list_calls(filter) + assert len(calls) == 1 + assert calls[0].to.number == '1234567890' + assert next_record_index == 2 + + +@responses.activate +def test_get_call(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + 'get_call.json', + 200, + ) + call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') + assert call.to.number == '1234567890' + assert call.from_.number == '9876543210' + assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' + assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' + + +@responses.activate +def test_transfer_call_ncco(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + ) + + ncco = [Talk(text='Hello world')] + voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_transfer_call_answer_url(): + answer_url = 'https://example.com/answer' + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[ + json_params_matcher( + { + 'action': 'transfer', + 'destination': {'type': 'ncco', 'url': [answer_url]}, + }, + ), + ], + ) + + voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_hangup(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'hangup'})], + ) + + voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_mute(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'mute'})], + ) + + voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_unmute(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'unmute'})], + ) + + voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_earmuff(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'earmuff'})], + ) + + voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_unearmuff(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'unearmuff'})], + ) + + voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_play_audio_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/stream', + 'play_audio_into_call.json', + ) + + options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 + ) + response = voice.play_audio_into_call(uuid, options) + assert response.message == 'Stream started' + assert response.uuid == uuid + + +@responses.activate +def test_stop_audio_stream(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'DELETE', + f'https://api.nexmo.com/v1/calls/{uuid}/stream', + 'stop_audio_stream.json', + ) + + response = voice.stop_audio_stream(uuid) + assert response.message == 'Stream stopped' + assert response.uuid == uuid + + +@responses.activate +def test_play_tts_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/talk', + 'play_tts_into_call.json', + ) + + options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 + ) + response = voice.play_tts_into_call(uuid, options) + assert response.message == 'Talk started' + assert response.uuid == uuid + + +@responses.activate +def test_stop_tts(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'DELETE', + f'https://api.nexmo.com/v1/calls/{uuid}/talk', + 'stop_tts.json', + ) + + response = voice.stop_tts(uuid) + assert response.message == 'Talk stopped' + assert response.uuid == uuid + + +@responses.activate +def test_play_dtmf_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', + 'play_dtmf_into_call.json', + ) + + response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') + assert response.message == 'DTMF sent' + assert response.uuid == uuid diff --git a/vonage/BUILD b/vonage/BUILD new file mode 100644 index 00000000..fc42ca8d --- /dev/null +++ b/vonage/BUILD @@ -0,0 +1,11 @@ +resource(name='pyproject', source='pyproject.toml') + +file(name='readme', source='README.md') + +python_distribution( + name='vonage', + dependencies=[':pyproject', ':readme', 'vonage/src/vonage'], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/CHANGES.md b/vonage/CHANGES.md similarity index 83% rename from CHANGES.md rename to vonage/CHANGES.md index 3cc7a200..2a7abed6 100644 --- a/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,45 @@ +# 4.0.0b2 +A complete, ground-up rewrite of the SDK. +Key changes: +- Monorepo structure, with each API under separate packages +- Targeting Python 3.9+ +- Feature parity with v3 +- Add support for the new network APIs - the [Vonage Sim Swap Network API](https://developer.vonage.com/en/sim-swap/overview) and the [Vonage Number Verification Network API](https://developer.vonage.com/en/number-verification/overview) +- Usage of data models throughout +- Many new custom errors, improved error data models and error messages +- Docstrings for methods and data models across the whole SDK to increase quality-of-life developer experience and make in-IDE development easier +- Use of Pydantic to enforce correct typing throughout +- Add support for all [Vonage Video API](https://developer.vonage.com/en/video/overview) features +- Add `http_client` property to each module that has an HTTP Client, e.g. Voice, Sms, Verify +- Add `last_request` and `last_response` properties to the HTTP Client for easier debugging +- Migrated the Vonage JWT package into the monorepo +- Rename `Verify` -> `VerifyLegacy` and `VerifyV2` -> `Verify` +- With even more enhancements to come! + +# 3.17.1 +- Add "mark WhatsApp message as read" option for Messages API + +# 3.17.0 +- Add RCS message type option for Messages API +- Add "revoke RCS message" option + +# 3.16.1 +- Fix video client token option +- Fix typos in README +- Bump minimum versions for dependencies with fixed vulnerabilities + +# 3.16.0 +- Add support for the [Vonage Number Verification API](https://developer.vonage.com/number-verification/overview) + +# 3.15.0 +- Add support for the [Vonage Sim Swap API](https://developer.vonage.com/en/sim-swap/overview) + +# 3.14.0 +- Add publisher-only as a valid Video API client token role + +# 3.13.1 +- Fix content-type incorrect serialization + # 3.13.0 - Migrating to use Pydantic v2 as a dependency diff --git a/vonage/README.md b/vonage/README.md new file mode 100644 index 00000000..a86bc71f --- /dev/null +++ b/vonage/README.md @@ -0,0 +1,47 @@ +# Vonage Python SDK + +The Vonage Python SDK Package `vonage` provides a streamlined interface for using Vonage APIs in Python projects. This package includes the `Vonage` class, which simplifies API interactions. + +The Vonage class in this package serves as the main entry point for using Vonage APIs. It abstracts away complexities with authentication, HTTP requests and more. + +For full API documentation refer to the [Vonage Developer documentation](https://developer.vonage.com). + +## Installation + +Install the package using pip: + +```bash +pip install vonage +``` + +## Usage + +```python +from vonage import Vonage, Auth, HttpClientOptions + +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') + +# Create HttpClientOptions instance +# (not required unless you want to change options from the defaults) +options = HttpClientOptions(api_host='api.nexmo.com', timeout=30) + +# Create a Vonage instance +vonage = Vonage(auth=auth, http_client_options=options) +``` + +The Vonage class provides access to various Vonage APIs through its properties. For example, to use methods to call the SMS API: + +```python +from vonage_sms import SmsMessage + +message = SmsMessage(to='1234567890', from_='Vonage', text='Hello World') +response = client.sms.send(message) +print(response.model_dump_json(exclude_unset=True)) +``` + +You can also access the underlying `HttpClient` instance through the `http_client` property: + +```python +user_agent = vonage.http_client.user_agent +``` \ No newline at end of file diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml new file mode 100644 index 00000000..5ed9810c --- /dev/null +++ b/vonage/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "vonage" +dynamic = ["version"] +description = "Python Server SDK for using Vonage APIs" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "vonage-utils>=1.1.4", + "vonage-http-client>=1.4.3", + "vonage-account>=1.1.0", + "vonage-application>=2.0.0", + "vonage-messages>=1.2.3", + "vonage-network-auth>=1.0.1", + "vonage-network-sim-swap>=1.1.1", + "vonage-network-number-verification>=1.0.1", + "vonage-number-insight>=1.0.4", + "vonage-numbers>=1.0.3", + "vonage-sms>=1.1.4", + "vonage-subaccounts>=1.0.4", + "vonage-users>=1.2.0", + "vonage-verify>=2.0.0", + "vonage-verify-legacy>=1.0.0", + "vonage-video>=1.0.2", + "vonage-voice>=1.0.6", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] +[[project.authors]] +name = "Vonage" +email = "devrel@vonage.com" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic.version] +attr = "vonage._version.__version__" diff --git a/vonage/src/vonage/BUILD b/vonage/src/vonage/BUILD new file mode 100644 index 00000000..ecc5b776 --- /dev/null +++ b/vonage/src/vonage/BUILD @@ -0,0 +1 @@ +python_sources(name='vonage') diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py new file mode 100644 index 00000000..aa2133db --- /dev/null +++ b/vonage/src/vonage/__init__.py @@ -0,0 +1,42 @@ +from vonage_utils import VonageError + +from .vonage import ( + Account, + Application, + Auth, + HttpClientOptions, + Messages, + NetworkNumberVerification, + NetworkSimSwap, + NumberInsight, + Numbers, + Sms, + Subaccounts, + Users, + Verify, + VerifyLegacy, + Video, + Voice, + Vonage, +) + +__all__ = [ + 'Account', + 'Application', + 'Auth', + 'HttpClientOptions', + 'Messages', + 'NetworkSimSwap', + 'NetworkNumberVerification', + 'NumberInsight', + 'Numbers', + 'Sms', + 'Subaccounts', + 'Users', + 'Verify', + 'VerifyLegacy', + 'Video', + 'Voice', + 'Vonage', + 'VonageError', +] diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py new file mode 100644 index 00000000..8303ea95 --- /dev/null +++ b/vonage/src/vonage/_version.py @@ -0,0 +1 @@ +__version__ = '4.0.0b2' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py new file mode 100644 index 00000000..317613f5 --- /dev/null +++ b/vonage/src/vonage/vonage.py @@ -0,0 +1,57 @@ +from typing import Optional + +from vonage_account.account import Account +from vonage_application.application import Application +from vonage_http_client import Auth, HttpClient, HttpClientOptions +from vonage_messages import Messages +from vonage_network_number_verification import NetworkNumberVerification +from vonage_network_sim_swap import NetworkSimSwap +from vonage_number_insight import NumberInsight +from vonage_numbers import Numbers +from vonage_sms import Sms +from vonage_subaccounts import Subaccounts +from vonage_users import Users +from vonage_verify import Verify +from vonage_verify_legacy import VerifyLegacy +from vonage_video import Video +from vonage_voice import Voice + +from ._version import __version__ + + +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. + """ + + def __init__( + self, auth: Auth, http_client_options: Optional[HttpClientOptions] = None + ): + self._http_client = HttpClient(auth, http_client_options, __version__) + + self.account = Account(self._http_client) + self.application = Application(self._http_client) + self.messages = Messages(self._http_client) + self.network_sim_swap = NetworkSimSwap(self._http_client) + self.network_number_verification = NetworkNumberVerification(self._http_client) + self.number_insight = NumberInsight(self._http_client) + self.numbers = Numbers(self._http_client) + self.sms = Sms(self._http_client) + self.subaccounts = Subaccounts(self._http_client) + self.users = Users(self._http_client) + self.verify = Verify(self._http_client) + self.verify_legacy = VerifyLegacy(self._http_client) + self.video = Video(self._http_client) + self.voice = Voice(self._http_client) + + @property + def http_client(self): + return self._http_client diff --git a/vonage/tests/BUILD b/vonage/tests/BUILD new file mode 100644 index 00000000..dabf212d --- /dev/null +++ b/vonage/tests/BUILD @@ -0,0 +1 @@ +python_tests() diff --git a/vonage/tests/test_vonage.py b/vonage/tests/test_vonage.py new file mode 100644 index 00000000..707eca0d --- /dev/null +++ b/vonage/tests/test_vonage.py @@ -0,0 +1,15 @@ +from vonage_http_client.http_client import HttpClient + +from vonage.vonage import Auth, Vonage, __version__ + + +def test_create_vonage_class_instance(): + vonage = Vonage(Auth(api_key='asdf', api_secret='qwerasdf')) + + assert vonage.http_client.auth.api_key == 'asdf' + assert vonage.http_client.auth.api_secret == 'qwerasdf' + assert ( + vonage.http_client.auth.create_basic_auth_string() == 'Basic YXNkZjpxd2VyYXNkZg==' + ) + assert type(vonage.http_client) == HttpClient + assert f'vonage-python-sdk/{__version__}' in vonage.http_client._user_agent diff --git a/vonage_utils/BUILD b/vonage_utils/BUILD new file mode 100644 index 00000000..8aacf7e5 --- /dev/null +++ b/vonage_utils/BUILD @@ -0,0 +1,11 @@ +resource(name='pyproject', source='pyproject.toml') + +file(name='readme', source='README.md') + +python_distribution( + name='vonage-utils', + dependencies=[':pyproject', ':readme', 'vonage_utils/src/vonage_utils'], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md new file mode 100644 index 00000000..3bb536d8 --- /dev/null +++ b/vonage_utils/CHANGES.md @@ -0,0 +1,22 @@ +# 1.1.4 +- Support for Python 3.13, drop support for 3.8 + +# 1.1.3 +- Add docstrings to data models + +# 1.1.2 +- Refactoring common pydantic models across the monorepo into this package + +# 1.1.1 +- Update minimum dependency version + +# 1.1.0 +- Add `Dtmf` and `SipUri` types +- Add `Link` model +- Internal refactoring + +# 1.0.1 +- Add `PhoneNumber` type + +# 1.0.0 +- Initial upload \ No newline at end of file diff --git a/vonage_utils/README.md b/vonage_utils/README.md new file mode 100644 index 00000000..21555265 --- /dev/null +++ b/vonage_utils/README.md @@ -0,0 +1,25 @@ +# Vonage Utils Package + +This package contains utility code that is used by the Vonage Python SDK and other related packages. + +The utils module provides two utility functions: `format_phone_number` and `remove_none_values`. It also exposes the `VonageError` type that other exceptions related to Vonage SDK inherit from. This can also be accessed via the main SDK module with `vonage.VonageError`. + +## Usage + +```python +from utils import format_phone_number, remove_none_values + +# Use format_phone_number +try: + formatted_number = format_phone_number('123-456-7890') + print(formatted_number) +except (InvalidPhoneNumberError, InvalidPhoneNumberTypeError) as e: + print(e) + +# Use remove_none_values to remove null values from a Vonage API response when converting to a dictionary with the `asdict` method +from dataclasses import asdict + +vonage_api_response = vonage.api.method() +cleaned_dict = asdict(my_dataclass, dict_factory=remove_none_values) +print(cleaned_dict) +``` \ No newline at end of file diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml new file mode 100644 index 00000000..451d5e18 --- /dev/null +++ b/vonage_utils/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = 'vonage-utils' +dynamic = ["version"] +description = 'Utils package containing objects for use with Vonage APIs' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +dependencies = ["pydantic>=2.9.2"] +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_utils._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/vonage_utils/src/vonage_utils/BUILD b/vonage_utils/src/vonage_utils/BUILD new file mode 100644 index 00000000..33118275 --- /dev/null +++ b/vonage_utils/src/vonage_utils/BUILD @@ -0,0 +1 @@ +python_sources(name='vonage_utils') diff --git a/vonage_utils/src/vonage_utils/__init__.py b/vonage_utils/src/vonage_utils/__init__.py new file mode 100644 index 00000000..8c229ce2 --- /dev/null +++ b/vonage_utils/src/vonage_utils/__init__.py @@ -0,0 +1,5 @@ +from . import models, types +from .errors import VonageError +from .utils import format_phone_number, remove_none_values + +__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', 'models', 'types'] diff --git a/vonage_utils/src/vonage_utils/_version.py b/vonage_utils/src/vonage_utils/_version.py new file mode 100644 index 00000000..bc50bee6 --- /dev/null +++ b/vonage_utils/src/vonage_utils/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.4' diff --git a/vonage_utils/src/vonage_utils/errors.py b/vonage_utils/src/vonage_utils/errors.py new file mode 100644 index 00000000..51203de3 --- /dev/null +++ b/vonage_utils/src/vonage_utils/errors.py @@ -0,0 +1,13 @@ +class VonageError(Exception): + """Base Error Class for all Vonage SDK errors.""" + + +class InvalidPhoneNumberError(VonageError): + """An invalid phone number was provided.""" + + +class InvalidPhoneNumberTypeError(VonageError): + """An invalid phone number type was provided. + + Should be a string or an integer. + """ diff --git a/vonage_utils/src/vonage_utils/models.py b/vonage_utils/src/vonage_utils/models.py new file mode 100644 index 00000000..7c5b2951 --- /dev/null +++ b/vonage_utils/src/vonage_utils/models.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Link(BaseModel): + """Model for a link object. + + Args: + href (str): The URL of the link. + """ + + href: str + + +class ResourceLink(BaseModel): + """Model for a resource link object. + + Args: + self (Link): The self link of the resource. + """ + + self: Link + + +class HalLinks(BaseModel): + """Model for links following a version of the HAL standard. + + Args: + self (Link): The self link. + first (Link, Optional): The first link. + last (Link, Optional): The last link. + prev (Link, Optional): The previous link. + next (Link, Optional): The next link. + """ + + self: Link + first: Optional[Link] = None + last: Optional[Link] = None + prev: Optional[Link] = None + next: Optional[Link] = None diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py new file mode 100644 index 00000000..e601c034 --- /dev/null +++ b/vonage_utils/src/vonage_utils/types.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from pydantic import Field + +PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] +"""A phone number, which must be between 7 and 15 digits long and not start with 0. Don't +use a leading `+` or `00` in the number. For example, use `447700900000` instead of +`+447700900000` or `00447700900000`. + +Examples: + - `447700900000` + - `14155552671` +""" + +Dtmf = Annotated[str, Field(pattern=r'^[0-9#*p]+$')] +"""A string of DTMF (Dual-Tone Multi-Frequency) tones. The string can contain the digits +0-9, the symbols `#`, `*`, and `p`. The `p` symbol represents a pause of 500ms. + +Examples: + - `1234#*` + - `1p2p3p4` +""" + +SipUri = Annotated[str, Field(pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)')] +"""A SIP URI, which must start with `sip:` or `sips:` and contain a valid SIP address.""" diff --git a/vonage_utils/src/vonage_utils/utils.py b/vonage_utils/src/vonage_utils/utils.py new file mode 100644 index 00000000..781cdc41 --- /dev/null +++ b/vonage_utils/src/vonage_utils/utils.py @@ -0,0 +1,50 @@ +from re import search +from typing import Union + +from vonage_utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError + + +def format_phone_number(number: Union[str, int]) -> str: + """Formats a phone number by removing all non-numeric characters and leading zeros. + + Args: + number (str, int): The phone number to format. + + Returns: + str: The formatted phone number. + + Raises: + InvalidPhoneNumberError: If the phone number is invalid. + InvalidPhoneNumberTypeError: If the phone number is not a string or an integer. + """ + if type(number) is not str: + if type(number) is int: + number = str(number) + else: + raise InvalidPhoneNumberTypeError( + f'The phone number provided has an invalid type. You provided: "{type(number)}". Must be a string or an integer.' + ) + + # Remove all non-numeric characters and leading zeros + formatted_number = ''.join(filter(str.isdigit, number)).lstrip('0') + + if search(r'^[1-9]\d{6,14}$', formatted_number): + return formatted_number + raise InvalidPhoneNumberError( + f'Invalid phone number provided. You provided: "{number}".\n' + 'Use the E.164 format and start with the country code, e.g. "447700900000".' + ) + + +def remove_none_values(my_dataclass) -> dict: + """A dict_factory that can be passed into the dataclass.asdict() method to remove None + values from a dict serialized from the dataclass my_dataclass. + + Args: + my_dataclass (dataclass): A dataclass instance + + Returns: + A dict based on the dataclass, excluding any key-value pairs where the + value is None. + """ + return {k: v for (k, v) in my_dataclass if v is not None} diff --git a/vonage_utils/tests/BUILD b/vonage_utils/tests/BUILD new file mode 100644 index 00000000..70ea9397 --- /dev/null +++ b/vonage_utils/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['vonage_utils/src/vonage_utils']) diff --git a/vonage_utils/tests/test_format_phone_number.py b/vonage_utils/tests/test_format_phone_number.py new file mode 100644 index 00000000..e0a3328e --- /dev/null +++ b/vonage_utils/tests/test_format_phone_number.py @@ -0,0 +1,31 @@ +from pytest import raises +from vonage_utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError +from vonage_utils.utils import format_phone_number + + +def test_format_phone_numbers(): + number = '1234567890' + assert format_phone_number('1234567890') == number + assert format_phone_number(1234567890) == number + assert format_phone_number('+1234567890') == number + assert format_phone_number('+ 1 234 567 890') == number + assert format_phone_number('00 1 234 567 890') == number + assert format_phone_number('00 1234567890') == number + assert format_phone_number('447700900000') == '447700900000' + assert format_phone_number('1234567') == '1234567' + assert format_phone_number('123456789012345') == '123456789012345' + + +def test_format_phone_number_invalid_type(): + number = ['1234567890'] + with raises(InvalidPhoneNumberTypeError) as e: + format_phone_number(number) + + assert e.match('""') + + +def test_format_phone_number_invalid_format(): + number = 'not a phone number' + with raises(InvalidPhoneNumberError) as e: + format_phone_number(number) + assert e.match('"not a phone number"') diff --git a/vonage_utils/tests/test_remove_none_values.py b/vonage_utils/tests/test_remove_none_values.py new file mode 100644 index 00000000..cc31e656 --- /dev/null +++ b/vonage_utils/tests/test_remove_none_values.py @@ -0,0 +1,16 @@ +from dataclasses import asdict, dataclass + +from vonage_utils.utils import remove_none_values + + +@dataclass +class MyDataClass: + name: str + age: int + address: str = None + + +def test_remove_none_values(): + data = MyDataClass(name='John', age=30) + result = asdict(data, dict_factory=remove_none_values) + assert result == {'name': 'John', 'age': 30} From 72ada834671309381ca783268990ed342de4c4ff Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 11 Nov 2024 14:26:56 +0000 Subject: [PATCH 278/401] fix typo --- video/OPENTOK_TO_VONAGE_MIGRATION.md | 1 - 1 file changed, 1 deletion(-) diff --git a/video/OPENTOK_TO_VONAGE_MIGRATION.md b/video/OPENTOK_TO_VONAGE_MIGRATION.md index efb7dd7d..acf5c74a 100644 --- a/video/OPENTOK_TO_VONAGE_MIGRATION.md +++ b/video/OPENTOK_TO_VONAGE_MIGRATION.md @@ -101,7 +101,6 @@ There are some changes to methods between the `opentok` SDK and the Video API im | `opentok.get_render` | `video.get_experience_composer`| | `opentok.stop_render` | `video.stop_experience_composer`| | `opentok.connect_audio_to_websocket` | `video.start_audio_connector`| -| `opentok.connect_audio_to_websocket` | `video.start_audio_connector`| ## Additional Resources From 6ee22e609778ff6be4bbba4beff09ca1a1a95594 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 14 Nov 2024 11:49:29 +0000 Subject: [PATCH 279/401] updating number insight and video --- number_insight/CHANGES.md | 4 ++++ number_insight/src/vonage_number_insight/_version.py | 2 +- .../src/vonage_number_insight/number_insight.py | 2 +- number_insight/src/vonage_number_insight/requests.py | 2 +- number_insight/tests/test_number_insight.py | 2 +- video/CHANGES.md | 3 +++ video/src/vonage_video/_version.py | 2 +- video/src/vonage_video/video.py | 10 +++++----- 8 files changed, 17 insertions(+), 10 deletions(-) diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index c1abf577..b2d770cb 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,3 +1,7 @@ +# 1.0.5 +- Fix missed method renaming +- Docstring update + # 1.0.4 - Update dependency versions diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py index 8a81504c..858de170 100644 --- a/number_insight/src/vonage_number_insight/_version.py +++ b/number_insight/src/vonage_number_insight/_version.py @@ -1 +1 @@ -__version__ = '1.0.4' +__version__ = '1.0.5' diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py index d71790b1..e5cb0039 100644 --- a/number_insight/src/vonage_number_insight/number_insight.py +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -59,7 +59,7 @@ def get_basic_info(self, options: BasicInsightRequest) -> BasicInsightResponse: return BasicInsightResponse(**response) @validate_call - def standard_number_insight( + def get_standard_info( self, options: StandardInsightRequest ) -> StandardInsightResponse: """Get standard number insight information about a phone number. diff --git a/number_insight/src/vonage_number_insight/requests.py b/number_insight/src/vonage_number_insight/requests.py index 2e57674e..217e65e5 100644 --- a/number_insight/src/vonage_number_insight/requests.py +++ b/number_insight/src/vonage_number_insight/requests.py @@ -33,9 +33,9 @@ class AdvancedAsyncInsightRequest(StandardInsightRequest): Args: number (PhoneNumber): The phone number to get insight information for. + callback (str): The URL to send the asynchronous response to. country (str, Optional): The country code for the phone number. cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. - callback (str): The URL to send the asynchronous response to. """ callback: str diff --git a/number_insight/tests/test_number_insight.py b/number_insight/tests/test_number_insight.py index 02b7ca63..90536e0f 100644 --- a/number_insight/tests/test_number_insight.py +++ b/number_insight/tests/test_number_insight.py @@ -63,7 +63,7 @@ def test_standard_insight(): 'standard_insight.json', ) options = StandardInsightRequest(number='12345678900', country_code='US', cnam=True) - response = number_insight.standard_number_insight(options) + response = number_insight.get_standard_info(options) assert response.status == 0 assert response.status_message == 'Success' assert response.current_carrier.network_code == '23415' diff --git a/video/CHANGES.md b/video/CHANGES.md index ecaf37cf..242f6f58 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Make the filter optional in `Video.list_archives` and `Video.list_broadcasts` + # 1.0.2 - Update dependency versions diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index a6221b3d..3f6fab60 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 2bb3ca14..b3ed287e 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -310,7 +310,7 @@ def list_experience_composers( """Lists Experience Composers associated with your Vonage application. Args: - filter (ListExperienceComposersFilter): Filter for the Experience Composers. + filter (ListExperienceComposersFilter, Optional): Filter for the Experience Composers. Returns: tuple[list[ExperienceComposer], int, Optional[int]]: A tuple containing a list of experience @@ -358,12 +358,12 @@ def stop_experience_composer(self, experience_composer_id: str) -> None: @validate_call def list_archives( - self, filter: ListArchivesFilter + self, filter: ListArchivesFilter = ListArchivesFilter() ) -> tuple[list[Archive], int, Optional[int]]: """Lists archives associated with a Vonage Application. Args: - filter (ListArchivesFilter): The filters for the archives. + filter (ListArchivesFilter, Optional): The filters for the archives. Returns: tuple[list[Archive], int, Optional[int]]: A tuple containing a list of archive objects, @@ -513,12 +513,12 @@ def change_archive_layout(self, archive_id: str, layout: ComposedLayout) -> Arch @validate_call def list_broadcasts( - self, filter: ListBroadcastsFilter + self, filter: ListBroadcastsFilter = ListBroadcastsFilter() ) -> tuple[list[Broadcast], int, Optional[int]]: """Lists broadcasts associated with a Vonage Application. Args: - filter (ListBroadcastsFilter): The filters for the broadcasts. + filter (ListBroadcastsFilter, Optional): The filters for the broadcasts. Returns: tuple[list[Broadcast], int, Optional[int]]: A tuple containing a list of broadcast objects, From bab3c609e89d3520705459b4da8f6c17de9fb9f2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 14 Nov 2024 11:59:47 +0000 Subject: [PATCH 280/401] readying v4 GA --- vonage/CHANGES.md | 5 ++--- vonage/pyproject.toml | 4 ++-- vonage/src/vonage/_version.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 2a7abed6..62d14fa8 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,8 +1,8 @@ -# 4.0.0b2 +# 4.0.0 A complete, ground-up rewrite of the SDK. Key changes: - Monorepo structure, with each API under separate packages -- Targeting Python 3.9+ +- Support for Python 3.9+ - Feature parity with v3 - Add support for the new network APIs - the [Vonage Sim Swap Network API](https://developer.vonage.com/en/sim-swap/overview) and the [Vonage Number Verification Network API](https://developer.vonage.com/en/number-verification/overview) - Usage of data models throughout @@ -14,7 +14,6 @@ Key changes: - Add `last_request` and `last_response` properties to the HTTP Client for easier debugging - Migrated the Vonage JWT package into the monorepo - Rename `Verify` -> `VerifyLegacy` and `VerifyV2` -> `Verify` -- With even more enhancements to come! # 3.17.1 - Add "mark WhatsApp message as read" option for Messages API diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 5ed9810c..6fafde0a 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -13,14 +13,14 @@ dependencies = [ "vonage-network-auth>=1.0.1", "vonage-network-sim-swap>=1.1.1", "vonage-network-number-verification>=1.0.1", - "vonage-number-insight>=1.0.4", + "vonage-number-insight>=1.0.5", "vonage-numbers>=1.0.3", "vonage-sms>=1.1.4", "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.0", "vonage-verify>=2.0.0", "vonage-verify-legacy>=1.0.0", - "vonage-video>=1.0.2", + "vonage-video>=1.0.3", "vonage-voice>=1.0.6", ] classifiers = [ diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 8303ea95..d6497a81 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.0.0b2' +__version__ = '4.0.0' From 0937cc2461da8fe4af92bf6760fd088da39a2a5d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 14 Nov 2024 12:02:41 +0000 Subject: [PATCH 281/401] update changelog --- vonage/CHANGES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 62d14fa8..13b053de 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -15,6 +15,15 @@ Key changes: - Migrated the Vonage JWT package into the monorepo - Rename `Verify` -> `VerifyLegacy` and `VerifyV2` -> `Verify` +# 3.17.4 +- Drop support for Python 3.8, add support for 3.13 + +# 3.17.3 +- Fix bug in JWT generator + +# 3.17.2 +- Update `vonage-jwt` dependency version to fix JWT timeout issue + # 3.17.1 - Add "mark WhatsApp message as read" option for Messages API From bd4fcb099838c4360eb4eeb5f85da9a167657dac Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 20 Nov 2024 18:00:36 +0000 Subject: [PATCH 282/401] add support for basic header auth for messages and verify, move coverage config --- messages/CHANGES.md | 3 + messages/src/vonage_messages/_version.py | 2 +- messages/src/vonage_messages/messages.py | 8 +++ messages/tests/test_messages.py | 36 +++++++++- pants.toml | 26 +------- pyproject.toml | 2 + verify/CHANGES.md | 3 + verify/src/vonage_verify/_version.py | 2 +- verify/src/vonage_verify/verify.py | 17 ++++- verify/tests/test_verify.py | 84 +++++++++++++++++++++++- vonage/CHANGES.md | 3 + vonage/pyproject.toml | 4 +- vonage/src/vonage/_version.py | 2 +- 13 files changed, 158 insertions(+), 34 deletions(-) create mode 100644 pyproject.toml diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 2019adce..4b4c7285 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 1.3.0 +- Add support for API key/secret header authentication + # 1.2.3 - Update dependency versions diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 5a5df3be..19b4f1d6 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.2.3' +__version__ = '1.3.0' diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index c39af991..3fd62f65 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -16,6 +16,10 @@ class Messages: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client + self._auth_type = 'jwt' + + if self._http_client.auth.application_id is None: + self._auth_type = 'basic' @property def http_client(self) -> HttpClient: @@ -42,7 +46,9 @@ def send(self, message: BaseMessage) -> SendMessageResponse: self._http_client.api_host, '/v1/messages', message.model_dump(by_alias=True, exclude_none=True) or message, + self._auth_type, ) + return SendMessageResponse(**response) @validate_call @@ -63,6 +69,7 @@ def mark_whatsapp_message_read(self, message_uuid: str) -> None: self._http_client.api_host, f'/v1/messages/{message_uuid}', {'status': 'read'}, + self._auth_type, ) @validate_call @@ -83,4 +90,5 @@ def revoke_rcs_message(self, message_uuid: str) -> None: self._http_client.api_host, f'/v1/messages/{message_uuid}', {'status': 'revoked'}, + self._auth_type, ) diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index beba2945..9bcf3c64 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -2,6 +2,7 @@ import responses from pytest import raises +from vonage_http_client.auth import Auth from vonage_http_client.errors import HttpRequestError from vonage_http_client.http_client import HttpClient, HttpClientOptions from vonage_messages.messages import Messages @@ -13,7 +14,7 @@ ) from vonage_messages.responses import SendMessageResponse -from testutils import build_response, get_mock_jwt_auth +from testutils import build_response, get_mock_api_key_auth, get_mock_jwt_auth path = abspath(__file__) @@ -21,6 +22,21 @@ messages = Messages(HttpClient(get_mock_jwt_auth())) +@responses.activate +def test_default_auth_type(): + messages = Messages( + HttpClient( + Auth( + api_key='asdf', + api_secret='asdf', + application_id='asdf', + private_key='-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZz9Zz\n-----END PRIVATE-KEY----', + ) + ) + ) + assert messages._auth_type == 'jwt' + + @responses.activate def test_send_message(): build_response( @@ -34,6 +50,24 @@ def test_send_message(): response = messages.send(sms) assert type(response) == SendMessageResponse assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + assert messages._auth_type == 'jwt' + + +@responses.activate +def test_send_message_basic_auth(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/messages', 'send_message.json', 202 + ) + messages = Messages(HttpClient(get_mock_api_key_auth())) + sms = Sms( + from_='Vonage APIs', + to='1234567890', + text='Hello, World!', + ) + response = messages.send(sms) + assert type(response) == SendMessageResponse + assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + assert messages._auth_type == 'basic' @responses.activate diff --git a/pants.toml b/pants.toml index 16761ca5..3b13e505 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.23.0rc1' +pants_version = '2.23.0' backend_packages = [ 'pants.backend.python', @@ -29,30 +29,6 @@ args = ['-vv', '--no-header'] [coverage-py] interpreter_constraints = ['>=3.8'] report = ['html', 'console'] -filter = [ - 'vonage/src', - 'http_client/src', - 'account/src', - 'application/src', - 'jwt/src', - 'messages/src', - 'network_auth/src', - 'network_number_verification/src', - 'network_sim_swap/src', - 'number_insight/src', - 'number_insight_v2/src', - 'number_management/src', - 'sms/src', - 'subaccounts/src', - 'users/src', - 'utils/src', - 'testutils', - 'verify/src', - 'verify_legacy/src', - 'video/src', - 'voice/src', - 'vonage_utils/src', -] [black] args = ['--line-length=90', '--skip-string-normalization'] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..867680d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.coverage.run] +omit = ['**/tests/*', '**/src/**/_version.py'] diff --git a/verify/CHANGES.md b/verify/CHANGES.md index 769d8e2b..55dc5e03 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,3 +1,6 @@ +# 2.1.0 +- Add support for API key/secret header authentication + # 2.0.0 - Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. This package now contains code for the Verify v2 API - Update dependency versions diff --git a/verify/src/vonage_verify/_version.py b/verify/src/vonage_verify/_version.py index afced147..a33997dd 100644 --- a/verify/src/vonage_verify/_version.py +++ b/verify/src/vonage_verify/_version.py @@ -1 +1 @@ -__version__ = '2.0.0' +__version__ = '2.1.0' diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index 20eabc22..d87b4d38 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -10,6 +10,10 @@ class Verify: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client + self._auth_type = 'jwt' + + if self._http_client.auth.application_id is None: + self._auth_type = 'basic' @property def http_client(self) -> HttpClient: @@ -37,6 +41,7 @@ def start_verification( self._http_client.api_host, '/v2/verify', verify_request.model_dump(by_alias=True, exclude_none=True), + self._auth_type, ) return StartVerificationResponse(**response) @@ -53,7 +58,10 @@ def check_code(self, request_id: str, code: str) -> CheckCodeResponse: CheckCodeResponse: The response object containing the verification result. """ response = self._http_client.post( - self._http_client.api_host, f'/v2/verify/{request_id}', {'code': code} + self._http_client.api_host, + f'/v2/verify/{request_id}', + {'code': code}, + self._auth_type, ) return CheckCodeResponse(**response) @@ -64,7 +72,11 @@ def cancel_verification(self, request_id: str) -> None: Args: request_id (str): The request ID. """ - self._http_client.delete(self._http_client.api_host, f'/v2/verify/{request_id}') + self._http_client.delete( + self._http_client.api_host, + f'/v2/verify/{request_id}', + auth_type=self._auth_type, + ) @validate_call def trigger_next_workflow(self, request_id: str) -> None: @@ -77,4 +89,5 @@ def trigger_next_workflow(self, request_id: str) -> None: self._http_client.post( self._http_client.api_host, f'/v2/verify/{request_id}/next_workflow', + auth_type=self._auth_type, ) diff --git a/verify/tests/test_verify.py b/verify/tests/test_verify.py index 40251c6b..b5926137 100644 --- a/verify/tests/test_verify.py +++ b/verify/tests/test_verify.py @@ -2,12 +2,13 @@ import responses from pytest import raises +from vonage_http_client.auth import Auth from vonage_http_client.errors import HttpRequestError from vonage_http_client.http_client import HttpClient from vonage_verify.requests import * from vonage_verify.verify import Verify -from testutils import build_response, get_mock_jwt_auth +from testutils import build_response, get_mock_api_key_auth, get_mock_jwt_auth path = abspath(__file__) @@ -15,6 +16,21 @@ verify = Verify(HttpClient(get_mock_jwt_auth())) +@responses.activate +def test_default_auth_type(): + verify = Verify( + HttpClient( + Auth( + api_key='asdf', + api_secret='asdf', + application_id='asdf', + private_key='-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZz9Zz\n-----END PRIVATE-KEY----', + ) + ) + ) + assert verify._auth_type == 'jwt' + + @responses.activate def test_make_verify_request(): build_response( @@ -37,6 +53,27 @@ def test_make_verify_request(): == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' ) assert verify._http_client.last_response.status_code == 202 + assert verify._auth_type == 'jwt' + + +@responses.activate +def test_make_verify_request_basic_auth(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + request = VerifyRequest(**params) + + verify = Verify(HttpClient(get_mock_api_key_auth())) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert verify._http_client.last_response.status_code == 202 + assert verify._auth_type == 'basic' @responses.activate @@ -108,6 +145,23 @@ def test_check_code(): assert response.status == 'completed' +@responses.activate +def test_check_code_basic_auth(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', + ) + verify = Verify(HttpClient(get_mock_api_key_auth())) + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' + ) + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' + assert verify._auth_type == 'basic' + + @responses.activate def test_check_code_invalid_code_error(): build_response( @@ -153,6 +207,20 @@ def test_cancel_verification(): assert verify._http_client.last_response.status_code == 204 +@responses.activate +def test_cancel_verification_basic_auth(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, + ) + + verify = Verify(HttpClient(get_mock_api_key_auth())) + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 + assert verify._auth_type == 'basic' + + @responses.activate def test_trigger_next_workflow(): responses.add( @@ -164,6 +232,20 @@ def test_trigger_next_workflow(): assert verify._http_client.last_response.status_code == 200 +@responses.activate +def test_trigger_next_workflow_basic_auth(): + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, + ) + + verify = Verify(HttpClient(get_mock_api_key_auth())) + assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 200 + assert verify._auth_type == 'basic' + + @responses.activate def test_trigger_next_event_error(): build_response( diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 13b053de..64057f62 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.1.0 +- Add support for API key/secret header authentication for the Messages and Verify APIs (JWT is the default and recommended method) + # 4.0.0 A complete, ground-up rewrite of the SDK. Key changes: diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 6fafde0a..c546c9c0 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "vonage-http-client>=1.4.3", "vonage-account>=1.1.0", "vonage-application>=2.0.0", - "vonage-messages>=1.2.3", + "vonage-messages>=1.3.0", "vonage-network-auth>=1.0.1", "vonage-network-sim-swap>=1.1.1", "vonage-network-number-verification>=1.0.1", @@ -18,7 +18,7 @@ dependencies = [ "vonage-sms>=1.1.4", "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.0", - "vonage-verify>=2.0.0", + "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.0", "vonage-video>=1.0.3", "vonage-voice>=1.0.6", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index d6497a81..fa721b49 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.0.0' +__version__ = '4.1.0' From da1f990dbf8a348b84f4fbdcbec5a46a53178794 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 22 Nov 2024 17:04:06 +0000 Subject: [PATCH 283/401] add method for streaming an http request to a file, add Voice.download_recording --- http_client/src/vonage_http_client/errors.py | 17 +++++++ .../src/vonage_http_client/http_client.py | 31 ++++++++++++ http_client/tests/data/file_stream.mp3 | Bin 0 -> 17822 bytes http_client/tests/test_http_client.py | 44 +++++++++++++++++- sample-6s.mp3 | 1 + testutils/testutils.py | 8 +++- voice/src/vonage_voice/voice.py | 13 ++++++ 7 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 http_client/tests/data/file_stream.mp3 create mode 100644 sample-6s.mp3 diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index c262f5a3..c8c3584a 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -122,6 +122,23 @@ def __init__(self, response: Response, content_type: str): super().__init__(response, content_type) +class FileStreamingError(HttpRequestError): + """Exception indicating an error occurred while streaming a file in a Vonage SDK + request. + + 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. diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 539b1e99..81175ba2 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -10,6 +10,7 @@ from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, + FileStreamingError, ForbiddenError, HttpRequestError, InvalidHttpClientOptionsError, @@ -250,6 +251,34 @@ def make_request( with self._session.request(**request_params) as response: return self._parse_response(response) + def download_file_stream(self, url: str, file_path: str) -> bytes: + """Download a file from a URL and save it to a local file. This method streams the + file to disk. + + Args: + url (str): The URL of the file to download. + file_path (str): The local path to save the file to. + + Returns: + bytes: The content of the file. + """ + headers = { + 'User-Agent': self.user_agent, + 'Authorization': self.auth.create_jwt_auth_string(), + } + + logger.debug( + f'Downloading file by streaming from {url} to local location: {file_path}, with headers: {self._headers}' + ) + try: + with self._session.get(url, headers=headers, stream=True) as response: + with open(file_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=4096): + f.write(chunk) + except Exception as e: + logger.error(f'Error downloading file from {url}: {e}') + raise FileStreamingError(f'Error downloading file from {url}: {e}') from e + def append_to_user_agent(self, string: str): """Append a string to the User-Agent header. @@ -267,6 +296,8 @@ def _parse_response(self, response: Response) -> Union[dict, None]: try: return response.json() except JSONDecodeError: + if hasattr(response.headers, 'Content-Type'): + return response.content return None if response.status_code >= 400: content_type = response.headers['Content-Type'].split(';', 1)[0] diff --git a/http_client/tests/data/file_stream.mp3 b/http_client/tests/data/file_stream.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f529d1c31b2607a13acf1fb202e3bda9348cb00f GIT binary patch literal 17822 zcma&NWmFtN*EKpwa2ecf7~I_xWN?SUCAbd>?g`FdgS!NG2=0*J8r&f`1SfcmaLMz& z&;4=l_v5RzW_5L)>eW4ao!a}H)78ol0aU@D_h{(oYX5wK|NP?u03dJx4h{hU85uP-2n1qb z;o=ey5Ep;->Xp2_ii)df01Q3kMYs33hJ+<d<3D3v%ZAKuH^E3}nEM zSE=KobogT-NG~Lep0up+yEX6v+XLH0$xnk`p-&&Md*9bk+M@sIzWT|95OlPlI?166&Njr;JNIS?d`;BqFU08(`V6E~1KQrI!$#!iN@XkKi+7#ySE1;+4 z51rl(I?t_5>y7%C*x?$_I3NA)`rbNfF)snK+Vzxe@T@#aQ{WBU2>(Kq!dTb2%;e>H zj3UKfDesw~=Vkcv&QLqlM!hOYK2;-{Np!${MtRxNsc#Tlj+j_Bq)+f;5N%>c;RuFI zaFe#nbwP-aF#0D))DE{^yJewwe*|=2^76~kw|5h_MTD^bILvEr7~7=mBGy^6Yi6^q z&b5dh{K(j7!ua$%eDE58t5!-;p7~aGsjaZ34A0NCB%3}itXKy5$ZQgo6*^!!6>v%6 zk!dAfeJ6t=!?SnHk=}ANuZI{d)=+Kyt6VtlWW+U!b^G3I1J~JbQti4~8)gLx?xebwC;%_jyaJjm5KJ7Ha6>|xh z%59_PAW!;|F-@cT<(?MFjzq!9RKsC|xdzOukk(QjZQ;2XX&b=Hhc%J75{+4&l=lgX z>h#8;v+eGK=(un>vvby~KdW7ls0XCG7;!I^KFlg|ONl@IP91y-Ci{zxT~p$t@=I>V zD-7RAMY|D8+MiN&)ZGnuFT&`*HZlL*gN(muX-=@$nFK7tFNN?>``xc^;6y5rkr&ge zMEDjF&c`uzK4)SDYu%HcTpD!)wE?2>@GyX#!Uk+H9n+N4A&W*kR+<6V^UflzJnfg0 zQdk6Zvcejp{q_nFI7W)9cE0ScXfZKUf~4_gmDLV)}U~B!#GE^ zI&G_8FG1Gp_GVFQNw@kqe0KrcgT0WfcXJG~Xm4AX5YY9c##w+`z2^iLDuiRoFxj&J z7_q%$w_RwWVXRpkVzog!b`-8P`h9a}y%6c=2C~der0jKJ-_PhX&5VIg$-q$e_`M9P zRa;K;)K6z8qDd@=%kTBAbU!_|xTk3H@c$Zvxd@-u^&H$GOt2H{12ysFU-Ea}+e&&) zg_>MIq5V=Wy@G+8^NE%Gju)}2wMv^P)%Hz7G3H9RMHJFnDO&L<2>u}qKk1_C*&GO1 zt5o>{Xo6Jbz7CETqEaq+=a%V&*P>dJ2>4d?vvixK^iE9Vj zD<02YK*TlD74J1jHY2M=d0lI}O>7nKZ{CGtwxd$k`|!G_1*FwcsM6Xe9cp2l+>iHe{G1Wl=h*{^hN~ zH-iCdJnVE#K}=-aHJ4I(eJO~yn`kJRatiaBrcWm$&Cs4c zNPgt+p)}yuH@)!!Cs_%S`|H$R|EDC751139C(bvQ9czqH3Hp)Vk%J(DpHN$;G!hM2 zZFh<{*ia#Wz~0^Gq1s$q8Qj91FAb7aN3kfVP%_flA2KrIZ#2!K#O1E4Wt}z4ij2wA z7bsDB=d7=KcvjNDMjXSS0~72yAlFEwbkW3KWmi-#^s7`)UTyw||79>)Ki#wcd&G=! zFx*8_imeRw2xQFK&9lK7VOZ0TuKRB|+%CNcjl&*~G&UQwr$c*tP2^o{Gvu9xlVg3? z2P`Y+VQoG+bB5EXCLQOvNi1F82#$&ypB`IErT)IAQL#InTm`!_)|!TryvPCo9l=G> z5J5uTTJd>dW-E|?SxcN)q=$&2k>8QQ2Z(2-x+O5|* zzgHED|3_tEjqIJJ}GZ_nSl}?6EU=3#D?pfL44R=I7$FoSjkj^h@i7}XL@Hb z7Yx;?Sei3<`4|>q!0$N_gpgx){V}le{4_&jyVN{&U*cqvs#aMfx%@q!tV$t(&214W zz%Xq1Ku*ilSV&!RUt;=^(;_uf1uYasZgfqV+)vibi4)Qv>Puzjkf|Vrti6EYRVc?) z5~J4l5C5}Za!M-9xRUwtztWa`>3DuJ&1!7#*d_NjlV9+e!6TTpVx>|sza}62b!O2! zZG5p)VGa?l!Ylil2u6~rD>p~=rT0?tw+AxRg5&c=4{pv{ys??zpXlq zu0`Q5#ddY6nZ`KodhUS})9&1&oo!}9>%@6nFh{*~Nn@9hws5vIdLkgCPi8;$wZ$O4 zBT;gXH5v1bMHC1=@uFw4$6b!6 zq7@yfBka;Feo8nZWmNBP(k}kQp^V2`iAm~*Gjsbm9n^1}JuX@wn}E>q8Ef=d{klq4tqRZIvpf+#-31x-@Q!cH@)ViMfWZytN`~uww*=^3U!Xf zo#jt8A=xoOJjuvXoZ=!iW>;=}^O%45?*x~V#9_8kCRem+r-ol0#PUf1T*m4l*=OvVu}yQt3)UCm^znXS_SFb+ z`pLjfydBJNcx6LK!I9j$;RH!iMq0)O7Uo=A0~$i%<;JpI%AGe&4uh`D55oJOTiH$J1L3F(sP0sV_cgf&lFu5=zW?a2j zED*vVluq$eov$D%WKddQ2un!LO{IBHg zY=uNzN_OkIRitMw3$bDLlFZkX^pwHC zVv3lgVG&`b&gFfpG@s_xkSsUg#Ws-1aF7P7aXIt|w8JZEKkLiEB)GKnF|*9o!du&x?D zUSi@VM?i{*rH5v*T(5+on?BgkDibG{5HFK5K|2G)-U#MoZq;L7bwsd_5Vkut-?P_A zMI3DDWv;REMeRoaVu}$>{CQ@kl<*~;d&5~{Zi{yRKGV}$@K5bOh-%S?bN^Z z^#efWgpC;o_832uvvhmrw`DWzp#1#+M(!UGq?>=w-gmBjZ|kmjKU4b#FYU7X?0jy; zHMjNK(#VGH{*GyR;PH~{!q%c{VMRlLO8L=ahd097HLSLcDZZq2BQp8zOxu)rTUJ$U z&9)9?K$;Ioj8P?#JmGZG9l!1`{$~RZZ6FFQ8AuN-D@sB*?N;kpR2_Kk51?( zqgP8DGxT?&_EU|zwe`nR6so{zJ8|*1thg5RK{KA}p;KV$v(*}rzmuytJvSG3pahQb zpvt>9wX29}^j0yYZ%cW07r$Q~JdE?N^ZhbPd}yk5a%2nqR=3!^eoA%G(n&U7BlrG8 z-OQBtsS3B+X=~BW#kevTa9OszPG6ZSx;hnQ}1TsdA5>oWO0ZT5p6cn21}{fFI32k@tB5W>h`2PNnKIHxbX?8~Box*f zsY36mAf<#)KiPV4oVP?n|KWcFAp6CH8P7XAfFW1NSCZ|vm=c3ht}7PxQ8yV)Grs5# ztXJ)&&pNk;&~3MdTkTyA>0Lt0&C9Po??2psG~e?4lDYyBO@ zOfbeY3Kopk=D=3#s3NnH0W~?Y9w$4%fJPJB`WD!fNuiY6 zfYjOn)#5HpUN%JtQ)=gkCkV&-PTy%QYyORS{az?=Xk+GgPAI}e&LL%XA~Pzo2xf~n4A1?L zMaS~W3#HcnOJGyP@?LfYXQ!HMo#?5CK^y(8ea#!IGwfhN_h(J{VM^vc!0rv|m{|84 z!9udE7v!Hp<(rYl9(sdA72#+bf-8$9KWKHm^XJhT8^gijAO7Eh$(I;0YwBkwfCD>x z=?p*hWbc>gN`hMlcqJc$)|fw-VJ%xoL(@;OeS;S^5$3m%YQ+9gWv82I3m>gJ_wL!R zrm`lZla zG)4e(5j7<8N`}y8JkGTriIF_4%QHKjW?_&Ph@n%BmbL z19nj$yBv(@lMr$NB?x9Bl^Axc3j^|JGOB}Q6PA0?4ZU?HvpmkJ@=78t(VeiF8WR)2 zIsf!!IhgGJIlij*7{8abbbHo!ZR6X>5e{8Mkbz9c2B-n(BQ3^jO+wvY*_5UiJQkF1 zhrGwdC%NGLqwaS2=D|nvvLaK#N{i-CxJMmTozc7-QH;!&cwpE9y^& zf(KLxv+F-+0fOD1%-RgzTS*+ktS=u6fB(>=<$sl8_4D$pn|^8Uhm_fslEQ)s2&YMHb^Q3F2ix$Tfi3)C<)KH6SC^OSan{)!H$TPC z0_i0`(xWE+lr&g4yxdw!^_qf5phhpdBaWdOp)%~mWHoEAQXRIS!Txe8MukCtQ^)XZ zHM_3lp!-mIU%HQIpsK9S4~`9wQOy>k9PgB0xO0ayWbkSa4zGyuy%F9L)fiC}I zkcb6prj%4=>rpLPuZIRqGrZNV+uNG~<}&HoQW_?)%bZg%c%s_WvM_tTbN)1TN}hcR zS^SuYNazUlvEe}bx4!-Y$QxNOYk24TZy-B-;Rs({a&h!bEw5X+D#73km10ksL0)~Wr z@OzIk_A&}s9I)7}YE`G2)eXmoIr9>N_DMDSIF^PjgwcAas_92d+3S1I$E{UjDqM5# ze6cE}IbIiTawqRbv`q0NeoQQ-IZ{WVF^rAx#STU?oT+P|PepbAdvm$ws_DYv>vSpSuP=^8v9QiKwN-GVpLw#B|K;!A zy@}RMi3xQP50WOXpe=XLZ}R9<(PItw3PyR^Z-+l=9_Orir|UP>e*Lr9qU}UKX@~UD zCTWaH-OGSpsA-42V2@sD+?hAr*kQs*jqO8O9>DH1#hFzXN4#0$h?!%MbA{GaY_PY5 z@15<$y3`7S4a8k;QCXbjfzOtT&eO0a3Z+ zc|S2{TD2Bu&O|5cJO$mnU-?{ZB~56rrO`Xju5%;5SS7rVL6Qt!IrJ2u|N7=_t-#?f>tDBOt|*XmX84!L*bEb!eBapnHQe-l7v zO!S<;c*Ua0J?jfa2Wi^LyA4NFdK|G#p*dOyaRa>9#kkV4ZP>50)Y4NO7U^S?vO=d{ zy!=k3V{`tkrt8GvtL06Pf-gpfdT-8Fx!&yDyRHQ(`yH)~ zwwRAK_IQ4yRhf)p@HVB@)|3C_04<*K$Y7;%^?GQPd6jssmOZaACR4~go_ye@$jH^N z1EzAe*j8C{8j;cwIxM&^PfX3eK&^9o-++eo&>XS0Rx$7SUTt|Ht~-;PGba}xa`U62 zdtWv@{2in}mYalBHTec#M)~1rjjC3+;BLzx_tjlBt>b+>rRlYQ?Yp~(Tb?0x3E#4X zy2on)G4lYc)YGQ%sS{uwG|5?4O4o~!Hkakj22R=)kLtR}t+MaZp3Rp?+&BPI)?yB6MUT!C z-QMSjl2IoU1Wp4DUc8>7jc9=z zg(?nnY*uL{(*vJC6{&P#BDjOa{e83QvP9n!b9+};Z*naxwmxtMyZ%T{I}2`BcpWX>dT*52If<`?QIMb-aXU-!Y}2&!j&@fiR0tS_kuUq8nmh~r=5Da~!N zpWG_=wT*NcIgZ^E$=LkPA~w67c_Br)e&FJWGSzpfk$SIZkkLQiRdF8Q;iVA86T3cP2$vK#;W-Tjz4Qw`hSPW;D! z&S)Bnsg1kHOFaRcxe5F6&zUEV>*y+4R%qb@0}kJ>+Z2@>4yXqbYb1T;Q5ihJ;ZxF? z18$b-gidJmpR@SFsIm5a$+=&ON*4~EutSEaSv^B6od-d{Mg_?!%JWBxKYiJ=IF=!qy(vFSY& z#`2Qe3h=5T)!DM|3)~Q=-=#Cof4?4FE16Vprg8#ykjE`ZwXI}&Ew$k%vOEn zh+1}rn|jO=XvoZU@Ti7B4MHxKw^Pl?ttt_A8ye~Nf9fXlY9&qU_=o>VFo{2K3=Iq1vzGNEb(=)klua0wx`67u4x#wJq|>4^D^Uu)c4Bj1POE+iC5VyuL>o@^XU`{=x?on960B0b)Y^K z3F;0H$N2E@T4g5@0(o*F~;RBxoL^yh)tGp)=maw14r|GhZoi5Km5M|$p8MU&&*CB4boqwQ|#y}&Ig5e-f`FyF19Cq)QOqBeql?*n?ome zG{;$@&FnU32ePK>sX708WUrDhM9#!j*YU8&@eowuZwD*SUfFo~z`OKQU6bZ+cUO+1 zsQg{j>Sf~Dr&QY<&A_JS;`^;q3c{fEA?ueLr}iI!2LiHNCdId_>7W82b;uYjKIgsy zYr(V(`oUVYiILoH-!9ZhS9FjD39k&BxpOG|)tj7!rPToG_XdXY^S1}G(P2kH@=*su zZFDQNGuz@FN67*QvANomNR@2L-7f0|mlDo*IMuf;Z!k}x-no%UKl0)%->%Mv;Iu=n zXU(+q$78F+KR{}}EQoa#j03o2RBVZjqY7Kx%HUQfR0&^%(N1ehl^4eS9PFpyLhb^V zilC^OnB-=1)_Zojd%b4lXf(;NS}bsd6kg`JWhjY=gHM!~Q!T6!;U*{Yqr>?pg0u#8 z9)u+4tOTw|`|`q=E*AQw`U9bB`rb!@1EPQWvH~Exp!kP>ENcI=zT>&YDPS>@U=myU z3A_C5M{Z<68!bob6W@gk7YQc^Vvn}JCOQVMtqoES5b$_FHb^T!cWdu>l8;(OH!BMZ z3*kEzN9-!>Za~NLszxT1=eB}Yu0h;&i=c@U+RHt66lMQfyc`9VNd<|bk>vQ*Yx{-Dl5Rh*DadO~ehSJ95Lf?gd` zf|HW!8W7gkhcrr{)eqK?X8$spt&rEP6t>AC$BQgDd0_fm9GZVYoZSBq|}u%2vF^MuQC*(i5IQfFM%EjvV~ z8S!F`o^rs{>q5UD6d43wC$ zneJSrfv!d4T%nc>R3cOFDALJDZ0zAe;FH^cdt*oBfA7;D0mSEFWM^Qn-PF3hpmd5q zlN}98*to(n3}5ppR+$=F=Wq_#C$w<7QOPUW>f##H?Mj`H0Ps-|EVr8jm(XM71*4CD z*|=u|jWXwsc;EgnzMu4!Dc~u`Iq($|hj{&^qm8hkv2s&vVqtcEW%Qd1E^7e`ey;#w zi&Opj_a*{U3XKao=C+J;(-I4RCf^HT%QI>6i4O1qn^el0!^C9rpi;srB-?e_>wbtAEecw4|1tS(d0SwC+%P%|B@1=ez zlKWg~I`GzhyqFcCQiPY&g%2wZ9UMh_VH3LT!oLA835w_@ zG&RzHe02*T&3aFAR^qWc%meC4rwn9b>=2&OQY6B8yCYBE-Z07s=Et}aCVEAleWd2E z%4(kcz=HwAV2nsNszCJi)rr~2vrVXzRZX!?1w53aHBGu?$!Rp%C z%Xe0U6JU>h9_qpJnK)&<-0oGkJL?L{nMtm^0&I$-XTWy^_XSs6A^@7OUfc;pkc; z3C|?cppp?1@nhtHUW6@0OUn!+5q^$tc}!rQ}O zv+2}6#?;zhbAmQRV(j6=<@GTzXlal!w#4pT7U2Q#kB`^pA5;fL0e^r|XELI9*e0Qa z62bM0${j^3`cL`LaqOyYrHAmnkh03GNTj!qoe>yT6mCo?`}(7G;Yj+x>%!p5YOl1G zOqD1RgoI7#@j;roPop#;2|*|;T!VrrGFtB?8zzGxX%=fG&d`c8gk_B1Iz`7hKWQJ< ztRZQM1Mw}MAw+w@BNJcOwn|uc%h~`|5rdReVTZ16DF$~?*RYzSrRQ2TDnBluJy{=p{0GH zT~h0j^PqI9XJ)*3VsYThmj)^@&rP^W;{MWg+vaX8vrx-=bkDZ7PafS+Lc!Eee36KZ z9aDl2=8sX9)S-Hvz<@railtht@HrF}cwXjaq~6YW0LLb1Tp+H|J|RwF*sTEa5aZ9i z2#!k5XCVG2oBt*2^^ep5<_PMG_$?Tdt)Rwd&#q+@MO#i0Rr!LU#DQ8d^s*$3iV8_u5iLP2nHQHE4I--R` zg;EAgwwpa7#DFD6!Vb-muL$rahEbajN1aKCC}}mZ4pIh78*ru~bbU14@j0kE4P@Q? zhg`2*M)$inKT(Azlh53Z>HqLaI#LM98Q5dDkO$OIk{#ID7Elw%VHDeTocq_FX4>%nykiWj74x=7 z&Q@m~>aat~Sxn8nQ}W{DI;>^Na1D|dpYZl|lZJw6sl(J7&v4NYO?u6g*|!$NyxhkW zBMYJ={c$^Fa~dr^e!0>#6Lu_1cVP{Fx}RSicP;}FJ|4r7ILvX=sY zQfz59dZuJN-|l2tiSnZ!HNTDV>uY<*E#=3qa@$XA2gDP{A0##}Hr>854V%-v)M+hW zJ2$8dTndsqB)_mH23q|Y@Qen;ar5&IiY5)@3FhGCn+_z+Yy~~FtvbU>&>aXxP?AC%V@JW?*#?+bnGS z1P?FY@0y6<1LOn;z5M$}X-Ph?hx%$Fx{w7m1}d^_qlvmQBwl6)V{U{l(>7*FMtIbT zTT3!TksP0fZ6!)xWT1_2rDS8Fi+J&iEW_>4%e?o!d!(i+Ie6Cy59MNd8{H{$Gcvq=A`Kb`b0D~dxoqhSLyX@Cu(w0t zx6M$uul9(Oab54JDw*~}T$DWnBhmJ`G5U>JE57-fDgmY>x{thsSgxd*vWwiUDl!qN zjQi!MTqh(~u$Bqu+E3xrc+TLNkZRGAc@9G9WF~N;Oo==EdZ&%M_8~Wl@c`=54Yb2c zN-}8mk~w1R(B+;yxj!<;0t>Lfg zJ#YCXMx~DYEK@Qie18E!aM&YNOx6o9elot)p~5$b8cWu#eULP4Tt=AK6ReJl#H?;cbHMnCz-=YNAq8{U)D z)X!T@@>ume$0rWHRP^S8`I1Jx>l(@pVS0si{#hxTF_D?i{~LY0S_yDq z9Kzyn=l0#P32(o14WsoZhQgrI3mKe8 zOg3l`3IQFoZH!BDacm$OPA;j3S~~>7D{r|MTuZ_^HKcOKSEJ)9Swi%Tjz}tN8uzF7 zy=%%0Fk+9)?$}Msn2ea2%D7OK33SR2&fyA3gpR9_t4~*k6I!Qz=7!0;+iTlZ*5|2? zMVV^j-vFIB^b(kb!m9B_gq&`UL&G|ER4};9Ii3hg9qz(bG+BYTJ*f%thu-|Ia>nS>!XQXUY6m}G+-yG*Z2B0 z!&S$}*89}6cmg1gqZMvNu>k&E3J~b@yRl2feZKI8)Vh-l7$iJ_bO zRA}%bRuPYocL+Y2JGizc9+BZGE{NJ35EnKnV54Q{WfRiE#$T-P$2m}kGCn_^bv#}+ z)A|GjiwQFyUIjcW{~gya#oM*s^E-NT1fTr3OT4?Jx7&F> zA5)E`5fxDGdN^T?ga--QrwD#NsOd6D;Q*_f<}i~a)B161aE(|n+pF~@k3V*(hp*>i6#=pNQsGWHIpg?O_%f%0}A3o(2-Sy@(aDW92@?>!)@xhyDf_Dp)*_A!`;p6UG&rXbUxrKv*x_s2Ecy zXK?N6Eer5c>sc=vMEdyH!k#0T1Z;U>Y7o*dhA{6uFLbT9?nYtb*;pEWz?Ac+5YuA? zDaXV!|5tRKKNL;JB)txgUf9$T@WwgHRcT{}SS1RlK-c({;MnvGdP(e*#a=d}M*JC` zz^dBn7z!N-zUa%hA@Z@7i!ND$K$G1yjyDgBe1~zo<2jVI|BFVrcY{(9&6OBR?0k8+B#`z!F<=t&ZY1vgmODF;*-@pTxm4BQmrl9l@4Mb} zFbqtPIooz-i!h<${;91@N2;eFPoFp`xFGm$5$!Z81zk5fQa%ZpRR<-2NcGPvPC5L+ z$o_Db-LI+~n=ZY{zXwJxW-7WUp!e z#g|H<);HcatgT9ipKkLyK6R;1SCn3cQ!>Q%m*uo-PegK6HORkGzxFK0F zjNnQ)luYY+AV3hOsRaig?TfiaNkctUmxz7S-{HTmHb4;HEHo>3w zt0n#nMDAY|LeRRW{(yJf_$c2VwXS8%QkI#Y4qAybw(W_UrhlyAc!gv~TA;Xvj08P8 zZpj$cnwfH0=l(hXy)qqBBww!ap&T1b^N-Je14ugl#WOxu?H`N0A}RhPd{KwWAq|?3oFXz=R#p{bpk*~3 zp0BO^Zpj1AKh2Kr+7eP?r9i7Ul-nIOazglmwMN3sCnMl;^1JT2kttu;*MM5xz?o! z^w7>*#nHFO>gIN0Kx*j#3zL4S2aJbrMMM!J$tNKmb&jm#6x2V^r|fD^T+iA$It zw@rb=OyOnK#*5sOS5Pu2`#qnokf@Khx4w~!dXap;iAAI=pBg%U1stGz@W8Ahyh?%9 z%rEk_cdSUJkwUHqkG~?<3a>nv9YROku8%G3674x0T!vnNq7p~)tA2ivGuIQS-oZL4 z4o`|PiqnWb7ag00&O5C-tHg(enC#9+6RO*R=QZ*K|M~MtFlnI=$yvSEZf*T@e1a#L z-EhgW{Ia5W$8&Ve^LJp6!i|vTjblS21iJM=e6UnkZ8D+nIdmYeuktHG=+IZ5>uhtg zmTNb|}td#b_~AID=6E zAEBv62?41m*in7I2>ZslX4c4C$Q-=9^`ZO91!}?pGIDxe+7=dap)uGP>_sw`Jf?UM zYMR*kU)SsA?$qdiY;cpY3z(C|B!kt0bPouM@iWvqYk1*#3~IT-DlmKAt`i^NO&)k& zn~Sfi#IG#+;;ka{s_AdELHG+M41 zMshn{Ldi&iUw29JvTTzEqlIiTv4Dl@ncRHe(M3}yfAC{tylxS2hZcU6d$Sh{L`6FU z0t&L&M9JfT_|efs7--sGWsp%E>3R2slRjT%X5#=mjkEXS=9K7E}H2-QJl)dGSV@49U{9KI)C2wyZPt_V&iKZ+kYF=4?Z8)8o`i4?XN8KRp z-5awnvQ&|IEPm;-U_J1+lC@|%bz($fvodKaK4j;>`WSQXm(7&$m{Mb2>X$;j1gVJm zmag#K$toi(CAPSl;)$PkOX8tZCwBTgrfHz-y@`XD)vY&rF(N*;_x7)w%JYLtM}xp(lH`YG!_7&Oqx-wPTYA2afTQOf_1+atYn5{ce_KT>6E=G+cjL)EzQ{x8Zw%D1cAe%qZA3dQ zq|y9pYq>_%-b9JA7gxfOnaH0KW)=5JmCV<7hKh`2$dK_!W$Z1vI%2D6md3}kpy)B1*r5bWXs49`y zKwUgGa|Lh6H@=9}=+H_=-su#kGk;dbMKpTH!{mcTSR|ds{C?g)fBqIsO5#Ow#yh{; zQg0O$NfCGx< zy#75Yj@Z*zD?*rn*{8MD5*VQZ4K`_GZ)S^N1JWT}T=+*4HA#nC{N=2XiC7;hND(CatoZdc01 z!_#I!Mlr~@I{^x3=aNaDk`xV}*T9q}h|+S2g@#dZ9Mk8(e6Gb9ZIb~pkrAO;Y<1gZ zDK6TRs28vF90N5&+nV+XF=nY%_$aaV{>-s=>9*%J+^KXzU<4#BUA=NdOr^mfe8- zov)sRQo5wp0#nPoQZFSLV%cC%c47R4$z&9UNJw_(7*_>{v_>{qCYT&vgNuvbIB{r} zZfs|3n6lBRMxcPhzOf2Xd;TJEbtt~FNl!aVUVec=b4~xEG?y7V5=ii!khN@(vcA`a z-!8S?r#=M3?jt8_IRPyFlTlb{;{)iGu7{jSK2mT2`n!Wfj5A3(;$%)P`(G2W3V?!@whGa{qQ3bv6hCOD%iZNd*&ldPRJG z-K$_5_|u$H+t*a?Wkq|>@Y00KQZd)*Km4D+$5DU(93RdXwLRyX;fTNsg2PXgH`T7`T zOh#bPsFo%5Xfd;gI|mDEPqegPq1b&+KXpu=t;zYsIoXVIdu~C<2|_g3C0)W+^SgJL zzo#uZh264mUdQ8?xFsB=zV+6$yqC2fH)~|9ADuWG;aCMX5R$6ptxt1Ln{XZ0cjzUu zL#f-llA>>2RdpOHZaYjC!-6)vKQ9;EyoDD1DP0Ms<&9Nx=>V3@T{)5m#ixR`E9q5QUqq%*N-z z7WPn0`>-ZU()V>nEEG5!(PlMNz~**|(uMZA_M`x($xuz>VjV6N+_`^z{wtWI&zb0q z_seeXb3F50Z!{}vsXWhfo>w&@L+UUfFk&!k`}G8y6P#tKJqN;@gAJL0hVD1Pcs`77 znAm!8+$}#otsmJbJjvVb*@45tY{xV(0 z1yL9!TZNy_E{U!ubgVHKqnEqti%@f{K46pHAfcseK=Fk5l%ir7{EJJ4Ci|m1r4LTK zN%F0+SR5h58%wdMM=#Hv4H7U0ng<{Ci=Xa?B)6hVT>^QvKhMoUE)K!kDR6$}c%-ak zN}GdICO@P|`@~oePrXPxJGN<5aoIkB+>8 zG~_;4v1~$GE`7ZE`$|5M%&%X1`T6$wk1!{4_SE>6CHxgW_Y-t;+|L&rUqlShq_%l1 z;aNBCT^;Q$%+xtdoF=3gU&#JBF9`9(X+SacHXbJa51$8<%0K6u&w4t@x$E^@ztUr{ zLWP4xK|V4{nJ!DUrwl89>Fs>8@)e~`sn`W=DMQk%Mk|`fhp4r|?*?4O&(*`ZyYTiN ze!sa#D5!vGt!z~m@c}o_9OzQXK0(XkMU>bV)-@YJ9k$aFNePg%i-3QXcOq$SB%q5% zeH^xqzsXQhA9mZEKsk+5!xOOUG@zt$v#fUQX=He^h@5iFODuU zQxLHI^k(zY8UGw>$-!lTBiByIJ9m)bL-wy92SN6ZR?UpUsKnCzbH#)q0)`p~D}60m zLafdN&Xd&NQaW~-dLO;#CMaNpei%!`S=^lF^L(mS^{dsy-scT0gve0`rxacCd)g;T z6h#qH{S2ezB=o}E4uY}-oS@_{*My`Yx@cKUA%%_b^d&JbFO`MzZ+P807ID7P{utTW z$J1;SdNmDKwD8Tpd*4>9g|bt+tt76dtjgE`KEJYg*hR{wM&uVJ4;N`;onS8>{^5TO zAbGCep7TD}&pDs-O-k^Z{rQ`^vlUHf<7N&q%p~rEF`ByYAX=ihNrc+OL!tJ3jhwsuM>sANBzL6R-U z(?BnoV_~y(u(rZN9)q8)!v%W>A|BV$#6Wv-;+h>COA5tQVZ+c~LUy4hEpV-U!3QSl z_Ft(T)RPtW&y264&&C#u)`$Cedl|XFisHw{5C9xIurD=|dN4&KCaR#@JEWq}Snglx ztn1u1-&^zNY!SC2Wb{O#dzI)Rp#Ntnnaqm{?kwQis#Ngjl;FlP&EOli9`gup+41W5 zEeEeN;ciSDc$7n<8h%N8x=N>PV*AQv$=2~kE$6{!9shY=a@-o{&8*xNnxizNY>wxO zF~}=Rn5;X>qr5J;<)}kcx{n;VzuzEc39L_n<6RpKltA_AlUGs>*8)~0Xz|=PXnFL0 z!^%%3%6n!SW@m`5yvUW3`fumz-V;GCGX({kTAJNEU3?}59MU^GXTkMKACrQk$D10K zE(<7|7P(a+vixj?75|q_z`es-V)L0T`ghB*FzkN%r7yX|E6OKjLSM?yFssR@SD*3T zmsREw_dDD3S?c6Tx+jDlswyvvd6ZD0*WfncnL?Ol%aw^0Gq>_u1kH=vC|>l5t2w~! z-`1(B@1xC3+^@~-?Z4K}{~cl)$Mvo@XaobXuWo64HcENv4Vcg>J;oTs*B z$+jnYq1?F+mjjmF$=$@-c*Nt+GKWx^JM0CE4>ATPT@~BuFquoI#II^++H6MU2Q5)i zhi@NED!!)nLvrTAmDwvemYKek2)nY_SjLo5?Z!9jSF7&-Hr8MN>$kaN-^zL~5sTwj zmmCLk!;a^z|Axv8GO^?13!Gc!yY7#LWnXFSzR0s!LuNZbGb literal 0 HcmV?d00001 diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index f871fa8c..c233ec23 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -8,6 +8,7 @@ from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, + FileStreamingError, ForbiddenError, HttpRequestError, InvalidHttpClientOptionsError, @@ -16,7 +17,7 @@ ) from vonage_http_client.http_client import HttpClient -from testutils import build_response +from testutils import build_response, get_mock_jwt_auth path = abspath(__file__) @@ -250,3 +251,44 @@ def test_append_to_user_agent(): client = HttpClient(Auth()) client.append_to_user_agent('TestAgent') assert 'TestAgent' in client.user_agent + + +@responses.activate +def test_download_file_stream(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'file_stream.mp3', + ) + + client = HttpClient(get_mock_jwt_auth()) + client.download_file_stream( + url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + file_path='file.mp3', + ) + + with open('file.mp3', 'rb') as file: + file_content = file.read() + assert file_content.startswith(b'ID3') + + +@responses.activate +def test_download_file_stream_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + status_code=400, + ) + + client = HttpClient(get_mock_jwt_auth()) + try: + client.download_file_stream( + url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + file_path='file.mp3', + ) + except FileStreamingError as err: + assert '400 response from' in err.message + assert err.response.status_code == 400 + assert err.response.json()['title'] == 'Bad Request' diff --git a/sample-6s.mp3 b/sample-6s.mp3 new file mode 100644 index 00000000..6d0cead1 --- /dev/null +++ b/sample-6s.mp3 @@ -0,0 +1 @@ +InvalidArgumenttx000006a4510e18f9bfaee-006740722a-12e0a6-fra12e0a6-fra-default \ No newline at end of file diff --git a/testutils/testutils.py b/testutils/testutils.py index f2a547c6..34c04b94 100644 --- a/testutils/testutils.py +++ b/testutils/testutils.py @@ -8,8 +8,12 @@ def _load_mock_data(caller_file_path: str, mock_path: str): """Load mock data from a file.""" - with open(join(dirname(caller_file_path), 'data', mock_path)) as file: - return file.read() + try: + with open(join(dirname(caller_file_path), 'data', mock_path)) as file: + return file.read() + except UnicodeDecodeError: + with open(join(dirname(caller_file_path), 'data', mock_path), 'rb') as file: + return file.read() def _filter_none_values(data: dict) -> dict: diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index 5282b1db..5a75db16 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -261,3 +261,16 @@ def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: ) return CallMessage(**response) + + @validate_call + def download_recording(self, url: str, file_path: str) -> bytes: + """Downloads a call recording from the specified URL and saves it to a local file. + + Args: + url (str): The URL of the recording to get. + file_path (str): The path to save the recording to. + + Returns: + bytes: The recording data. + """ + self._http_client.download_file_stream(url=url, file_path=file_path) From fd8b26836279b1f9f0f4b767b81d103a1a294353 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 28 Nov 2024 15:34:29 +0000 Subject: [PATCH 284/401] add retry on RemoteDisconnect error, add backoff timer, increase testing --- http_client/src/vonage_http_client/errors.py | 22 ++------ .../src/vonage_http_client/http_client.py | 53 ++++++++++++++++-- http_client/tests/test_http_client.py | 51 ++++++++++++++--- jwt/CHANGES.md | 3 + jwt/src/vonage_jwt/_version.py | 2 +- jwt/src/vonage_jwt/verify_jwt.py | 13 ++++- pants.toml | 2 +- requirements.txt | 3 +- sample-6s.mp3 | 1 - voice/src/vonage_voice/voice.py | 18 ++++++ voice/tests/data/file_stream.mp3 | Bin 0 -> 17822 bytes voice/tests/test_voice.py | 26 +++++++++ 12 files changed, 158 insertions(+), 36 deletions(-) delete mode 100644 sample-6s.mp3 create mode 100644 voice/tests/data/file_stream.mp3 diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index c8c3584a..844d188b 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -122,23 +122,6 @@ def __init__(self, response: Response, content_type: str): super().__init__(response, content_type) -class FileStreamingError(HttpRequestError): - """Exception indicating an error occurred while streaming a file in a Vonage SDK - request. - - 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. @@ -156,3 +139,8 @@ class ServerError(HttpRequestError): def __init__(self, response: Response, content_type: str): super().__init__(response, content_type) + + +class FileStreamingError(VonageError): + """Exception indicating an error occurred while streaming a file in a Vonage SDK + request.""" diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 81175ba2..37743a64 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -6,7 +6,9 @@ from pydantic import BaseModel, Field, ValidationError, validate_call from requests import PreparedRequest, Response from requests.adapters import HTTPAdapter +from requests.exceptions import ConnectionError from requests.sessions import Session +from urllib3 import Retry from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, @@ -90,7 +92,9 @@ def __init__( 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, + max_retries=Retry( + total=self._http_client_options.max_retries, backoff_factor=0.1 + ), ) self._session.mount('https://', self._adapter) @@ -216,6 +220,30 @@ def make_request( sent_data_type: Literal['json', 'form', 'query_params'] = 'json', token: Optional[str] = None, ): + """Make an HTTP request to the specified host. This method will automatically + handle retries in the event of a connection error caused by a RemoteDisconnect + exception. + + It will retry the amount of times equal to the maximum number of connections + allowed in a connection pool. I.e., assuming if all connections in a given pool + are in use but the TCP connections to the Vonage host have failed, it will retry + the amount of times equal to the maximum number of connections in the pool. + + Args: + request_type (str): The type of request to make (GET, POST, PATCH, PUT, DELETE). + host (str): The host to make the request to. + request_path (str, optional): The path to make the request to. + params (dict, optional): The parameters to send with the request. + auth_type (str, optional): The type of authentication to use with the request. + sent_data_type (str, optional): The type of data being sent with the request. + token (str, optional): The token to use for OAuth2 authentication. + + Returns: + dict: The response data from the request. + + Raises: + ConnectionError: If the request fails after the maximum number of retries. + """ url = f'https://{host}{request_path}' logger.debug( f'{request_type} request to {url}, with data: {params}; headers: {self._headers}' @@ -248,8 +276,23 @@ def make_request( elif sent_data_type == 'form': request_params['data'] = params - with self._session.request(**request_params) as response: - return self._parse_response(response) + max_retries = self._http_client_options.pool_maxsize or 10 + attempt = 0 + while attempt < max_retries: + try: + with self._session.request(**request_params) as response: + return self._parse_response(response) + except ConnectionError as e: + logger.debug(f'Connection Error: {e}') + if 'RemoteDisconnected' in str(e.args): + attempt += 1 + if attempt >= max_retries: + raise e + logger.debug( + f'ConnectionError caused by RemoteDisconnected exception. Retrying request, attempt {attempt + 1} of {max_retries}' + ) + else: + raise e def download_file_stream(self, url: str, file_path: str) -> bytes: """Download a file from a URL and save it to a local file. This method streams the @@ -272,6 +315,8 @@ def download_file_stream(self, url: str, file_path: str) -> bytes: ) try: with self._session.get(url, headers=headers, stream=True) as response: + if response.status_code >= 400: + self._parse_response(response) with open(file_path, 'wb') as f: for chunk in response.iter_content(chunk_size=4096): f.write(chunk) @@ -296,8 +341,6 @@ def _parse_response(self, response: Response) -> Union[dict, None]: try: return response.json() except JSONDecodeError: - if hasattr(response.headers, 'Content-Type'): - return response.content return None if response.status_code >= 400: content_type = response.headers['Content-Type'].split(';', 1)[0] diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index c233ec23..29d7842b 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -1,9 +1,12 @@ +from http.client import RemoteDisconnected from json import loads from os.path import abspath, dirname, join +from unittest.mock import patch import responses from pytest import raises -from requests import PreparedRequest, Response +from requests import PreparedRequest, Response, Session +from requests.exceptions import ConnectionError from responses import matchers from vonage_http_client.auth import Auth from vonage_http_client.errors import ( @@ -15,7 +18,7 @@ RateLimitedError, ServerError, ) -from vonage_http_client.http_client import HttpClient +from vonage_http_client.http_client import HttpClient, HttpClientOptions from testutils import build_response, get_mock_jwt_auth @@ -265,10 +268,10 @@ def test_download_file_stream(): client = HttpClient(get_mock_jwt_auth()) client.download_file_stream( url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', - file_path='file.mp3', + file_path='http_client/tests/data/file_stream.mp3', ) - with open('file.mp3', 'rb') as file: + with open('http_client/tests/data/file_stream.mp3', 'rb') as file: file_content = file.read() assert file_content.startswith(b'ID3') @@ -280,15 +283,45 @@ def test_download_file_stream_error(): 'GET', 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', status_code=400, + mock_path='400.json', ) client = HttpClient(get_mock_jwt_auth()) - try: + with raises(FileStreamingError) as e: client.download_file_stream( url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', file_path='file.mp3', ) - except FileStreamingError as err: - assert '400 response from' in err.message - assert err.response.status_code == 400 - assert err.response.json()['title'] == 'Bad Request' + assert '400 response from' in e.exconly() + + +@patch.object(Session, 'request') +def test_retry_on_remote_disconnected_connection_error(mock_request): + mock_request.side_effect = ConnectionError( + RemoteDisconnected('Remote end closed connection without response') + ) + client = HttpClient( + Auth(application_id=application_id, private_key=private_key), + http_client_options=HttpClientOptions(), + ) + params = { + 'test': 'post request', + 'testing': 'http client', + } + with raises(ConnectionError) as e: + client.post(host='example.com', request_path='/post_json', params=params) + assert mock_request.call_count == 10 + assert 'Remote end closed connection without response' in str(e.value) + + +@patch.object(Session, 'request') +def test_dont_retry_on_generic_connection_error(mock_request): + mock_request.side_effect = ConnectionError('Error in connection to remote server') + client = HttpClient( + Auth(application_id=application_id, private_key=private_key), + http_client_options=HttpClientOptions(), + ) + with raises(ConnectionError) as e: + client.get(host='example.com', request_path='/get_json') + assert mock_request.call_count == 1 + assert 'Error in connection to remote server' in str(e.value) diff --git a/jwt/CHANGES.md b/jwt/CHANGES.md index f4d4b74e..e023f7ee 100644 --- a/jwt/CHANGES.md +++ b/jwt/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.5 +- Improve `verify_signature` docstring + # 1.1.4 - Fix a bug with generating non-default JWTs diff --git a/jwt/src/vonage_jwt/_version.py b/jwt/src/vonage_jwt/_version.py index bc50bee6..99d2a6fa 100644 --- a/jwt/src/vonage_jwt/_version.py +++ b/jwt/src/vonage_jwt/_version.py @@ -1 +1 @@ -__version__ = '1.1.4' +__version__ = '1.1.5' diff --git a/jwt/src/vonage_jwt/verify_jwt.py b/jwt/src/vonage_jwt/verify_jwt.py index 31de3302..6b9ef216 100644 --- a/jwt/src/vonage_jwt/verify_jwt.py +++ b/jwt/src/vonage_jwt/verify_jwt.py @@ -4,7 +4,18 @@ def verify_signature(token: str, signature_secret: str = None) -> bool: - """Method to verify that an incoming JWT was sent by Vonage.""" + """Method to verify that an incoming JWT was sent by Vonage. + + Args: + token (str): The token to verify. + signature_secret (str, optional): The signature to verify the token against. + + Returns: + bool: True if the token is verified, False otherwise. + + Raises: + VonageVerifyJwtError: The signature could not be verified. + """ try: decode(token, signature_secret, algorithms='HS256') diff --git a/pants.toml b/pants.toml index 3b13e505..5500575c 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.23.0' +pants_version = '2.24.0a0' backend_packages = [ 'pants.backend.python', diff --git a/requirements.txt b/requirements.txt index 06803d26..fb3ae182 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ pytest>=8.0.0 requests>=2.31.0 responses>=0.24.1 -pydantic>=2.7.1 +pydantic>=2.9.2 typing-extensions>=4.9.0 pyjwt[crypto]>=1.6.4 toml>=0.10.2 +urllib3 -e jwt -e http_client diff --git a/sample-6s.mp3 b/sample-6s.mp3 deleted file mode 100644 index 6d0cead1..00000000 --- a/sample-6s.mp3 +++ /dev/null @@ -1 +0,0 @@ -InvalidArgumenttx000006a4510e18f9bfaee-006740722a-12e0a6-fra12e0a6-fra-default \ No newline at end of file diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index 5a75db16..ae7ec8e8 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -2,6 +2,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_jwt.verify_jwt import verify_signature from vonage_utils.types import Dtmf from vonage_voice.models.ncco import NccoAction @@ -274,3 +275,20 @@ def download_recording(self, url: str, file_path: str) -> bytes: bytes: The recording data. """ self._http_client.download_file_stream(url=url, file_path=file_path) + + @validate_call + def verify_signature(self, token: str, signature: str) -> bool: + """Verifies that a token has been signed with the provided signature. Used to + verify that a webhook was sent by Vonage. + + Args: + token (str): The token to verify. + signature (str): The signature to verify the token against. + + Returns: + bool: True if the token was signed with the provided signature, False otherwise. + + Raises: + VonageVerifyJwtError: The signature could not be verified. + """ + return verify_signature(token, signature) diff --git a/voice/tests/data/file_stream.mp3 b/voice/tests/data/file_stream.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f529d1c31b2607a13acf1fb202e3bda9348cb00f GIT binary patch literal 17822 zcma&NWmFtN*EKpwa2ecf7~I_xWN?SUCAbd>?g`FdgS!NG2=0*J8r&f`1SfcmaLMz& z&;4=l_v5RzW_5L)>eW4ao!a}H)78ol0aU@D_h{(oYX5wK|NP?u03dJx4h{hU85uP-2n1qb z;o=ey5Ep;->Xp2_ii)df01Q3kMYs33hJ+<d<3D3v%ZAKuH^E3}nEM zSE=KobogT-NG~Lep0up+yEX6v+XLH0$xnk`p-&&Md*9bk+M@sIzWT|95OlPlI?166&Njr;JNIS?d`;BqFU08(`V6E~1KQrI!$#!iN@XkKi+7#ySE1;+4 z51rl(I?t_5>y7%C*x?$_I3NA)`rbNfF)snK+Vzxe@T@#aQ{WBU2>(Kq!dTb2%;e>H zj3UKfDesw~=Vkcv&QLqlM!hOYK2;-{Np!${MtRxNsc#Tlj+j_Bq)+f;5N%>c;RuFI zaFe#nbwP-aF#0D))DE{^yJewwe*|=2^76~kw|5h_MTD^bILvEr7~7=mBGy^6Yi6^q z&b5dh{K(j7!ua$%eDE58t5!-;p7~aGsjaZ34A0NCB%3}itXKy5$ZQgo6*^!!6>v%6 zk!dAfeJ6t=!?SnHk=}ANuZI{d)=+Kyt6VtlWW+U!b^G3I1J~JbQti4~8)gLx?xebwC;%_jyaJjm5KJ7Ha6>|xh z%59_PAW!;|F-@cT<(?MFjzq!9RKsC|xdzOukk(QjZQ;2XX&b=Hhc%J75{+4&l=lgX z>h#8;v+eGK=(un>vvby~KdW7ls0XCG7;!I^KFlg|ONl@IP91y-Ci{zxT~p$t@=I>V zD-7RAMY|D8+MiN&)ZGnuFT&`*HZlL*gN(muX-=@$nFK7tFNN?>``xc^;6y5rkr&ge zMEDjF&c`uzK4)SDYu%HcTpD!)wE?2>@GyX#!Uk+H9n+N4A&W*kR+<6V^UflzJnfg0 zQdk6Zvcejp{q_nFI7W)9cE0ScXfZKUf~4_gmDLV)}U~B!#GE^ zI&G_8FG1Gp_GVFQNw@kqe0KrcgT0WfcXJG~Xm4AX5YY9c##w+`z2^iLDuiRoFxj&J z7_q%$w_RwWVXRpkVzog!b`-8P`h9a}y%6c=2C~der0jKJ-_PhX&5VIg$-q$e_`M9P zRa;K;)K6z8qDd@=%kTBAbU!_|xTk3H@c$Zvxd@-u^&H$GOt2H{12ysFU-Ea}+e&&) zg_>MIq5V=Wy@G+8^NE%Gju)}2wMv^P)%Hz7G3H9RMHJFnDO&L<2>u}qKk1_C*&GO1 zt5o>{Xo6Jbz7CETqEaq+=a%V&*P>dJ2>4d?vvixK^iE9Vj zD<02YK*TlD74J1jHY2M=d0lI}O>7nKZ{CGtwxd$k`|!G_1*FwcsM6Xe9cp2l+>iHe{G1Wl=h*{^hN~ zH-iCdJnVE#K}=-aHJ4I(eJO~yn`kJRatiaBrcWm$&Cs4c zNPgt+p)}yuH@)!!Cs_%S`|H$R|EDC751139C(bvQ9czqH3Hp)Vk%J(DpHN$;G!hM2 zZFh<{*ia#Wz~0^Gq1s$q8Qj91FAb7aN3kfVP%_flA2KrIZ#2!K#O1E4Wt}z4ij2wA z7bsDB=d7=KcvjNDMjXSS0~72yAlFEwbkW3KWmi-#^s7`)UTyw||79>)Ki#wcd&G=! zFx*8_imeRw2xQFK&9lK7VOZ0TuKRB|+%CNcjl&*~G&UQwr$c*tP2^o{Gvu9xlVg3? z2P`Y+VQoG+bB5EXCLQOvNi1F82#$&ypB`IErT)IAQL#InTm`!_)|!TryvPCo9l=G> z5J5uTTJd>dW-E|?SxcN)q=$&2k>8QQ2Z(2-x+O5|* zzgHED|3_tEjqIJJ}GZ_nSl}?6EU=3#D?pfL44R=I7$FoSjkj^h@i7}XL@Hb z7Yx;?Sei3<`4|>q!0$N_gpgx){V}le{4_&jyVN{&U*cqvs#aMfx%@q!tV$t(&214W zz%Xq1Ku*ilSV&!RUt;=^(;_uf1uYasZgfqV+)vibi4)Qv>Puzjkf|Vrti6EYRVc?) z5~J4l5C5}Za!M-9xRUwtztWa`>3DuJ&1!7#*d_NjlV9+e!6TTpVx>|sza}62b!O2! zZG5p)VGa?l!Ylil2u6~rD>p~=rT0?tw+AxRg5&c=4{pv{ys??zpXlq zu0`Q5#ddY6nZ`KodhUS})9&1&oo!}9>%@6nFh{*~Nn@9hws5vIdLkgCPi8;$wZ$O4 zBT;gXH5v1bMHC1=@uFw4$6b!6 zq7@yfBka;Feo8nZWmNBP(k}kQp^V2`iAm~*Gjsbm9n^1}JuX@wn}E>q8Ef=d{klq4tqRZIvpf+#-31x-@Q!cH@)ViMfWZytN`~uww*=^3U!Xf zo#jt8A=xoOJjuvXoZ=!iW>;=}^O%45?*x~V#9_8kCRem+r-ol0#PUf1T*m4l*=OvVu}yQt3)UCm^znXS_SFb+ z`pLjfydBJNcx6LK!I9j$;RH!iMq0)O7Uo=A0~$i%<;JpI%AGe&4uh`D55oJOTiH$J1L3F(sP0sV_cgf&lFu5=zW?a2j zED*vVluq$eov$D%WKddQ2un!LO{IBHg zY=uNzN_OkIRitMw3$bDLlFZkX^pwHC zVv3lgVG&`b&gFfpG@s_xkSsUg#Ws-1aF7P7aXIt|w8JZEKkLiEB)GKnF|*9o!du&x?D zUSi@VM?i{*rH5v*T(5+on?BgkDibG{5HFK5K|2G)-U#MoZq;L7bwsd_5Vkut-?P_A zMI3DDWv;REMeRoaVu}$>{CQ@kl<*~;d&5~{Zi{yRKGV}$@K5bOh-%S?bN^Z z^#efWgpC;o_832uvvhmrw`DWzp#1#+M(!UGq?>=w-gmBjZ|kmjKU4b#FYU7X?0jy; zHMjNK(#VGH{*GyR;PH~{!q%c{VMRlLO8L=ahd097HLSLcDZZq2BQp8zOxu)rTUJ$U z&9)9?K$;Ioj8P?#JmGZG9l!1`{$~RZZ6FFQ8AuN-D@sB*?N;kpR2_Kk51?( zqgP8DGxT?&_EU|zwe`nR6so{zJ8|*1thg5RK{KA}p;KV$v(*}rzmuytJvSG3pahQb zpvt>9wX29}^j0yYZ%cW07r$Q~JdE?N^ZhbPd}yk5a%2nqR=3!^eoA%G(n&U7BlrG8 z-OQBtsS3B+X=~BW#kevTa9OszPG6ZSx;hnQ}1TsdA5>oWO0ZT5p6cn21}{fFI32k@tB5W>h`2PNnKIHxbX?8~Box*f zsY36mAf<#)KiPV4oVP?n|KWcFAp6CH8P7XAfFW1NSCZ|vm=c3ht}7PxQ8yV)Grs5# ztXJ)&&pNk;&~3MdTkTyA>0Lt0&C9Po??2psG~e?4lDYyBO@ zOfbeY3Kopk=D=3#s3NnH0W~?Y9w$4%fJPJB`WD!fNuiY6 zfYjOn)#5HpUN%JtQ)=gkCkV&-PTy%QYyORS{az?=Xk+GgPAI}e&LL%XA~Pzo2xf~n4A1?L zMaS~W3#HcnOJGyP@?LfYXQ!HMo#?5CK^y(8ea#!IGwfhN_h(J{VM^vc!0rv|m{|84 z!9udE7v!Hp<(rYl9(sdA72#+bf-8$9KWKHm^XJhT8^gijAO7Eh$(I;0YwBkwfCD>x z=?p*hWbc>gN`hMlcqJc$)|fw-VJ%xoL(@;OeS;S^5$3m%YQ+9gWv82I3m>gJ_wL!R zrm`lZla zG)4e(5j7<8N`}y8JkGTriIF_4%QHKjW?_&Ph@n%BmbL z19nj$yBv(@lMr$NB?x9Bl^Axc3j^|JGOB}Q6PA0?4ZU?HvpmkJ@=78t(VeiF8WR)2 zIsf!!IhgGJIlij*7{8abbbHo!ZR6X>5e{8Mkbz9c2B-n(BQ3^jO+wvY*_5UiJQkF1 zhrGwdC%NGLqwaS2=D|nvvLaK#N{i-CxJMmTozc7-QH;!&cwpE9y^& zf(KLxv+F-+0fOD1%-RgzTS*+ktS=u6fB(>=<$sl8_4D$pn|^8Uhm_fslEQ)s2&YMHb^Q3F2ix$Tfi3)C<)KH6SC^OSan{)!H$TPC z0_i0`(xWE+lr&g4yxdw!^_qf5phhpdBaWdOp)%~mWHoEAQXRIS!Txe8MukCtQ^)XZ zHM_3lp!-mIU%HQIpsK9S4~`9wQOy>k9PgB0xO0ayWbkSa4zGyuy%F9L)fiC}I zkcb6prj%4=>rpLPuZIRqGrZNV+uNG~<}&HoQW_?)%bZg%c%s_WvM_tTbN)1TN}hcR zS^SuYNazUlvEe}bx4!-Y$QxNOYk24TZy-B-;Rs({a&h!bEw5X+D#73km10ksL0)~Wr z@OzIk_A&}s9I)7}YE`G2)eXmoIr9>N_DMDSIF^PjgwcAas_92d+3S1I$E{UjDqM5# ze6cE}IbIiTawqRbv`q0NeoQQ-IZ{WVF^rAx#STU?oT+P|PepbAdvm$ws_DYv>vSpSuP=^8v9QiKwN-GVpLw#B|K;!A zy@}RMi3xQP50WOXpe=XLZ}R9<(PItw3PyR^Z-+l=9_Orir|UP>e*Lr9qU}UKX@~UD zCTWaH-OGSpsA-42V2@sD+?hAr*kQs*jqO8O9>DH1#hFzXN4#0$h?!%MbA{GaY_PY5 z@15<$y3`7S4a8k;QCXbjfzOtT&eO0a3Z+ zc|S2{TD2Bu&O|5cJO$mnU-?{ZB~56rrO`Xju5%;5SS7rVL6Qt!IrJ2u|N7=_t-#?f>tDBOt|*XmX84!L*bEb!eBapnHQe-l7v zO!S<;c*Ua0J?jfa2Wi^LyA4NFdK|G#p*dOyaRa>9#kkV4ZP>50)Y4NO7U^S?vO=d{ zy!=k3V{`tkrt8GvtL06Pf-gpfdT-8Fx!&yDyRHQ(`yH)~ zwwRAK_IQ4yRhf)p@HVB@)|3C_04<*K$Y7;%^?GQPd6jssmOZaACR4~go_ye@$jH^N z1EzAe*j8C{8j;cwIxM&^PfX3eK&^9o-++eo&>XS0Rx$7SUTt|Ht~-;PGba}xa`U62 zdtWv@{2in}mYalBHTec#M)~1rjjC3+;BLzx_tjlBt>b+>rRlYQ?Yp~(Tb?0x3E#4X zy2on)G4lYc)YGQ%sS{uwG|5?4O4o~!Hkakj22R=)kLtR}t+MaZp3Rp?+&BPI)?yB6MUT!C z-QMSjl2IoU1Wp4DUc8>7jc9=z zg(?nnY*uL{(*vJC6{&P#BDjOa{e83QvP9n!b9+};Z*naxwmxtMyZ%T{I}2`BcpWX>dT*52If<`?QIMb-aXU-!Y}2&!j&@fiR0tS_kuUq8nmh~r=5Da~!N zpWG_=wT*NcIgZ^E$=LkPA~w67c_Br)e&FJWGSzpfk$SIZkkLQiRdF8Q;iVA86T3cP2$vK#;W-Tjz4Qw`hSPW;D! z&S)Bnsg1kHOFaRcxe5F6&zUEV>*y+4R%qb@0}kJ>+Z2@>4yXqbYb1T;Q5ihJ;ZxF? z18$b-gidJmpR@SFsIm5a$+=&ON*4~EutSEaSv^B6od-d{Mg_?!%JWBxKYiJ=IF=!qy(vFSY& z#`2Qe3h=5T)!DM|3)~Q=-=#Cof4?4FE16Vprg8#ykjE`ZwXI}&Ew$k%vOEn zh+1}rn|jO=XvoZU@Ti7B4MHxKw^Pl?ttt_A8ye~Nf9fXlY9&qU_=o>VFo{2K3=Iq1vzGNEb(=)klua0wx`67u4x#wJq|>4^D^Uu)c4Bj1POE+iC5VyuL>o@^XU`{=x?on960B0b)Y^K z3F;0H$N2E@T4g5@0(o*F~;RBxoL^yh)tGp)=maw14r|GhZoi5Km5M|$p8MU&&*CB4boqwQ|#y}&Ig5e-f`FyF19Cq)QOqBeql?*n?ome zG{;$@&FnU32ePK>sX708WUrDhM9#!j*YU8&@eowuZwD*SUfFo~z`OKQU6bZ+cUO+1 zsQg{j>Sf~Dr&QY<&A_JS;`^;q3c{fEA?ueLr}iI!2LiHNCdId_>7W82b;uYjKIgsy zYr(V(`oUVYiILoH-!9ZhS9FjD39k&BxpOG|)tj7!rPToG_XdXY^S1}G(P2kH@=*su zZFDQNGuz@FN67*QvANomNR@2L-7f0|mlDo*IMuf;Z!k}x-no%UKl0)%->%Mv;Iu=n zXU(+q$78F+KR{}}EQoa#j03o2RBVZjqY7Kx%HUQfR0&^%(N1ehl^4eS9PFpyLhb^V zilC^OnB-=1)_Zojd%b4lXf(;NS}bsd6kg`JWhjY=gHM!~Q!T6!;U*{Yqr>?pg0u#8 z9)u+4tOTw|`|`q=E*AQw`U9bB`rb!@1EPQWvH~Exp!kP>ENcI=zT>&YDPS>@U=myU z3A_C5M{Z<68!bob6W@gk7YQc^Vvn}JCOQVMtqoES5b$_FHb^T!cWdu>l8;(OH!BMZ z3*kEzN9-!>Za~NLszxT1=eB}Yu0h;&i=c@U+RHt66lMQfyc`9VNd<|bk>vQ*Yx{-Dl5Rh*DadO~ehSJ95Lf?gd` zf|HW!8W7gkhcrr{)eqK?X8$spt&rEP6t>AC$BQgDd0_fm9GZVYoZSBq|}u%2vF^MuQC*(i5IQfFM%EjvV~ z8S!F`o^rs{>q5UD6d43wC$ zneJSrfv!d4T%nc>R3cOFDALJDZ0zAe;FH^cdt*oBfA7;D0mSEFWM^Qn-PF3hpmd5q zlN}98*to(n3}5ppR+$=F=Wq_#C$w<7QOPUW>f##H?Mj`H0Ps-|EVr8jm(XM71*4CD z*|=u|jWXwsc;EgnzMu4!Dc~u`Iq($|hj{&^qm8hkv2s&vVqtcEW%Qd1E^7e`ey;#w zi&Opj_a*{U3XKao=C+J;(-I4RCf^HT%QI>6i4O1qn^el0!^C9rpi;srB-?e_>wbtAEecw4|1tS(d0SwC+%P%|B@1=ez zlKWg~I`GzhyqFcCQiPY&g%2wZ9UMh_VH3LT!oLA835w_@ zG&RzHe02*T&3aFAR^qWc%meC4rwn9b>=2&OQY6B8yCYBE-Z07s=Et}aCVEAleWd2E z%4(kcz=HwAV2nsNszCJi)rr~2vrVXzRZX!?1w53aHBGu?$!Rp%C z%Xe0U6JU>h9_qpJnK)&<-0oGkJL?L{nMtm^0&I$-XTWy^_XSs6A^@7OUfc;pkc; z3C|?cppp?1@nhtHUW6@0OUn!+5q^$tc}!rQ}O zv+2}6#?;zhbAmQRV(j6=<@GTzXlal!w#4pT7U2Q#kB`^pA5;fL0e^r|XELI9*e0Qa z62bM0${j^3`cL`LaqOyYrHAmnkh03GNTj!qoe>yT6mCo?`}(7G;Yj+x>%!p5YOl1G zOqD1RgoI7#@j;roPop#;2|*|;T!VrrGFtB?8zzGxX%=fG&d`c8gk_B1Iz`7hKWQJ< ztRZQM1Mw}MAw+w@BNJcOwn|uc%h~`|5rdReVTZ16DF$~?*RYzSrRQ2TDnBluJy{=p{0GH zT~h0j^PqI9XJ)*3VsYThmj)^@&rP^W;{MWg+vaX8vrx-=bkDZ7PafS+Lc!Eee36KZ z9aDl2=8sX9)S-Hvz<@railtht@HrF}cwXjaq~6YW0LLb1Tp+H|J|RwF*sTEa5aZ9i z2#!k5XCVG2oBt*2^^ep5<_PMG_$?Tdt)Rwd&#q+@MO#i0Rr!LU#DQ8d^s*$3iV8_u5iLP2nHQHE4I--R` zg;EAgwwpa7#DFD6!Vb-muL$rahEbajN1aKCC}}mZ4pIh78*ru~bbU14@j0kE4P@Q? zhg`2*M)$inKT(Azlh53Z>HqLaI#LM98Q5dDkO$OIk{#ID7Elw%VHDeTocq_FX4>%nykiWj74x=7 z&Q@m~>aat~Sxn8nQ}W{DI;>^Na1D|dpYZl|lZJw6sl(J7&v4NYO?u6g*|!$NyxhkW zBMYJ={c$^Fa~dr^e!0>#6Lu_1cVP{Fx}RSicP;}FJ|4r7ILvX=sY zQfz59dZuJN-|l2tiSnZ!HNTDV>uY<*E#=3qa@$XA2gDP{A0##}Hr>854V%-v)M+hW zJ2$8dTndsqB)_mH23q|Y@Qen;ar5&IiY5)@3FhGCn+_z+Yy~~FtvbU>&>aXxP?AC%V@JW?*#?+bnGS z1P?FY@0y6<1LOn;z5M$}X-Ph?hx%$Fx{w7m1}d^_qlvmQBwl6)V{U{l(>7*FMtIbT zTT3!TksP0fZ6!)xWT1_2rDS8Fi+J&iEW_>4%e?o!d!(i+Ie6Cy59MNd8{H{$Gcvq=A`Kb`b0D~dxoqhSLyX@Cu(w0t zx6M$uul9(Oab54JDw*~}T$DWnBhmJ`G5U>JE57-fDgmY>x{thsSgxd*vWwiUDl!qN zjQi!MTqh(~u$Bqu+E3xrc+TLNkZRGAc@9G9WF~N;Oo==EdZ&%M_8~Wl@c`=54Yb2c zN-}8mk~w1R(B+;yxj!<;0t>Lfg zJ#YCXMx~DYEK@Qie18E!aM&YNOx6o9elot)p~5$b8cWu#eULP4Tt=AK6ReJl#H?;cbHMnCz-=YNAq8{U)D z)X!T@@>ume$0rWHRP^S8`I1Jx>l(@pVS0si{#hxTF_D?i{~LY0S_yDq z9Kzyn=l0#P32(o14WsoZhQgrI3mKe8 zOg3l`3IQFoZH!BDacm$OPA;j3S~~>7D{r|MTuZ_^HKcOKSEJ)9Swi%Tjz}tN8uzF7 zy=%%0Fk+9)?$}Msn2ea2%D7OK33SR2&fyA3gpR9_t4~*k6I!Qz=7!0;+iTlZ*5|2? zMVV^j-vFIB^b(kb!m9B_gq&`UL&G|ER4};9Ii3hg9qz(bG+BYTJ*f%thu-|Ia>nS>!XQXUY6m}G+-yG*Z2B0 z!&S$}*89}6cmg1gqZMvNu>k&E3J~b@yRl2feZKI8)Vh-l7$iJ_bO zRA}%bRuPYocL+Y2JGizc9+BZGE{NJ35EnKnV54Q{WfRiE#$T-P$2m}kGCn_^bv#}+ z)A|GjiwQFyUIjcW{~gya#oM*s^E-NT1fTr3OT4?Jx7&F> zA5)E`5fxDGdN^T?ga--QrwD#NsOd6D;Q*_f<}i~a)B161aE(|n+pF~@k3V*(hp*>i6#=pNQsGWHIpg?O_%f%0}A3o(2-Sy@(aDW92@?>!)@xhyDf_Dp)*_A!`;p6UG&rXbUxrKv*x_s2Ecy zXK?N6Eer5c>sc=vMEdyH!k#0T1Z;U>Y7o*dhA{6uFLbT9?nYtb*;pEWz?Ac+5YuA? zDaXV!|5tRKKNL;JB)txgUf9$T@WwgHRcT{}SS1RlK-c({;MnvGdP(e*#a=d}M*JC` zz^dBn7z!N-zUa%hA@Z@7i!ND$K$G1yjyDgBe1~zo<2jVI|BFVrcY{(9&6OBR?0k8+B#`z!F<=t&ZY1vgmODF;*-@pTxm4BQmrl9l@4Mb} zFbqtPIooz-i!h<${;91@N2;eFPoFp`xFGm$5$!Z81zk5fQa%ZpRR<-2NcGPvPC5L+ z$o_Db-LI+~n=ZY{zXwJxW-7WUp!e z#g|H<);HcatgT9ipKkLyK6R;1SCn3cQ!>Q%m*uo-PegK6HORkGzxFK0F zjNnQ)luYY+AV3hOsRaig?TfiaNkctUmxz7S-{HTmHb4;HEHo>3w zt0n#nMDAY|LeRRW{(yJf_$c2VwXS8%QkI#Y4qAybw(W_UrhlyAc!gv~TA;Xvj08P8 zZpj$cnwfH0=l(hXy)qqBBww!ap&T1b^N-Je14ugl#WOxu?H`N0A}RhPd{KwWAq|?3oFXz=R#p{bpk*~3 zp0BO^Zpj1AKh2Kr+7eP?r9i7Ul-nIOazglmwMN3sCnMl;^1JT2kttu;*MM5xz?o! z^w7>*#nHFO>gIN0Kx*j#3zL4S2aJbrMMM!J$tNKmb&jm#6x2V^r|fD^T+iA$It zw@rb=OyOnK#*5sOS5Pu2`#qnokf@Khx4w~!dXap;iAAI=pBg%U1stGz@W8Ahyh?%9 z%rEk_cdSUJkwUHqkG~?<3a>nv9YROku8%G3674x0T!vnNq7p~)tA2ivGuIQS-oZL4 z4o`|PiqnWb7ag00&O5C-tHg(enC#9+6RO*R=QZ*K|M~MtFlnI=$yvSEZf*T@e1a#L z-EhgW{Ia5W$8&Ve^LJp6!i|vTjblS21iJM=e6UnkZ8D+nIdmYeuktHG=+IZ5>uhtg zmTNb|}td#b_~AID=6E zAEBv62?41m*in7I2>ZslX4c4C$Q-=9^`ZO91!}?pGIDxe+7=dap)uGP>_sw`Jf?UM zYMR*kU)SsA?$qdiY;cpY3z(C|B!kt0bPouM@iWvqYk1*#3~IT-DlmKAt`i^NO&)k& zn~Sfi#IG#+;;ka{s_AdELHG+M41 zMshn{Ldi&iUw29JvTTzEqlIiTv4Dl@ncRHe(M3}yfAC{tylxS2hZcU6d$Sh{L`6FU z0t&L&M9JfT_|efs7--sGWsp%E>3R2slRjT%X5#=mjkEXS=9K7E}H2-QJl)dGSV@49U{9KI)C2wyZPt_V&iKZ+kYF=4?Z8)8o`i4?XN8KRp z-5awnvQ&|IEPm;-U_J1+lC@|%bz($fvodKaK4j;>`WSQXm(7&$m{Mb2>X$;j1gVJm zmag#K$toi(CAPSl;)$PkOX8tZCwBTgrfHz-y@`XD)vY&rF(N*;_x7)w%JYLtM}xp(lH`YG!_7&Oqx-wPTYA2afTQOf_1+atYn5{ce_KT>6E=G+cjL)EzQ{x8Zw%D1cAe%qZA3dQ zq|y9pYq>_%-b9JA7gxfOnaH0KW)=5JmCV<7hKh`2$dK_!W$Z1vI%2D6md3}kpy)B1*r5bWXs49`y zKwUgGa|Lh6H@=9}=+H_=-su#kGk;dbMKpTH!{mcTSR|ds{C?g)fBqIsO5#Ow#yh{; zQg0O$NfCGx< zy#75Yj@Z*zD?*rn*{8MD5*VQZ4K`_GZ)S^N1JWT}T=+*4HA#nC{N=2XiC7;hND(CatoZdc01 z!_#I!Mlr~@I{^x3=aNaDk`xV}*T9q}h|+S2g@#dZ9Mk8(e6Gb9ZIb~pkrAO;Y<1gZ zDK6TRs28vF90N5&+nV+XF=nY%_$aaV{>-s=>9*%J+^KXzU<4#BUA=NdOr^mfe8- zov)sRQo5wp0#nPoQZFSLV%cC%c47R4$z&9UNJw_(7*_>{v_>{qCYT&vgNuvbIB{r} zZfs|3n6lBRMxcPhzOf2Xd;TJEbtt~FNl!aVUVec=b4~xEG?y7V5=ii!khN@(vcA`a z-!8S?r#=M3?jt8_IRPyFlTlb{;{)iGu7{jSK2mT2`n!Wfj5A3(;$%)P`(G2W3V?!@whGa{qQ3bv6hCOD%iZNd*&ldPRJG z-K$_5_|u$H+t*a?Wkq|>@Y00KQZd)*Km4D+$5DU(93RdXwLRyX;fTNsg2PXgH`T7`T zOh#bPsFo%5Xfd;gI|mDEPqegPq1b&+KXpu=t;zYsIoXVIdu~C<2|_g3C0)W+^SgJL zzo#uZh264mUdQ8?xFsB=zV+6$yqC2fH)~|9ADuWG;aCMX5R$6ptxt1Ln{XZ0cjzUu zL#f-llA>>2RdpOHZaYjC!-6)vKQ9;EyoDD1DP0Ms<&9Nx=>V3@T{)5m#ixR`E9q5QUqq%*N-z z7WPn0`>-ZU()V>nEEG5!(PlMNz~**|(uMZA_M`x($xuz>VjV6N+_`^z{wtWI&zb0q z_seeXb3F50Z!{}vsXWhfo>w&@L+UUfFk&!k`}G8y6P#tKJqN;@gAJL0hVD1Pcs`77 znAm!8+$}#otsmJbJjvVb*@45tY{xV(0 z1yL9!TZNy_E{U!ubgVHKqnEqti%@f{K46pHAfcseK=Fk5l%ir7{EJJ4Ci|m1r4LTK zN%F0+SR5h58%wdMM=#Hv4H7U0ng<{Ci=Xa?B)6hVT>^QvKhMoUE)K!kDR6$}c%-ak zN}GdICO@P|`@~oePrXPxJGN<5aoIkB+>8 zG~_;4v1~$GE`7ZE`$|5M%&%X1`T6$wk1!{4_SE>6CHxgW_Y-t;+|L&rUqlShq_%l1 z;aNBCT^;Q$%+xtdoF=3gU&#JBF9`9(X+SacHXbJa51$8<%0K6u&w4t@x$E^@ztUr{ zLWP4xK|V4{nJ!DUrwl89>Fs>8@)e~`sn`W=DMQk%Mk|`fhp4r|?*?4O&(*`ZyYTiN ze!sa#D5!vGt!z~m@c}o_9OzQXK0(XkMU>bV)-@YJ9k$aFNePg%i-3QXcOq$SB%q5% zeH^xqzsXQhA9mZEKsk+5!xOOUG@zt$v#fUQX=He^h@5iFODuU zQxLHI^k(zY8UGw>$-!lTBiByIJ9m)bL-wy92SN6ZR?UpUsKnCzbH#)q0)`p~D}60m zLafdN&Xd&NQaW~-dLO;#CMaNpei%!`S=^lF^L(mS^{dsy-scT0gve0`rxacCd)g;T z6h#qH{S2ezB=o}E4uY}-oS@_{*My`Yx@cKUA%%_b^d&JbFO`MzZ+P807ID7P{utTW z$J1;SdNmDKwD8Tpd*4>9g|bt+tt76dtjgE`KEJYg*hR{wM&uVJ4;N`;onS8>{^5TO zAbGCep7TD}&pDs-O-k^Z{rQ`^vlUHf<7N&q%p~rEF`ByYAX=ihNrc+OL!tJ3jhwsuM>sANBzL6R-U z(?BnoV_~y(u(rZN9)q8)!v%W>A|BV$#6Wv-;+h>COA5tQVZ+c~LUy4hEpV-U!3QSl z_Ft(T)RPtW&y264&&C#u)`$Cedl|XFisHw{5C9xIurD=|dN4&KCaR#@JEWq}Snglx ztn1u1-&^zNY!SC2Wb{O#dzI)Rp#Ntnnaqm{?kwQis#Ngjl;FlP&EOli9`gup+41W5 zEeEeN;ciSDc$7n<8h%N8x=N>PV*AQv$=2~kE$6{!9shY=a@-o{&8*xNnxizNY>wxO zF~}=Rn5;X>qr5J;<)}kcx{n;VzuzEc39L_n<6RpKltA_AlUGs>*8)~0Xz|=PXnFL0 z!^%%3%6n!SW@m`5yvUW3`fumz-V;GCGX({kTAJNEU3?}59MU^GXTkMKACrQk$D10K zE(<7|7P(a+vixj?75|q_z`es-V)L0T`ghB*FzkN%r7yX|E6OKjLSM?yFssR@SD*3T zmsREw_dDD3S?c6Tx+jDlswyvvd6ZD0*WfncnL?Ol%aw^0Gq>_u1kH=vC|>l5t2w~! z-`1(B@1xC3+^@~-?Z4K}{~cl)$Mvo@XaobXuWo64HcENv4Vcg>J;oTs*B z$+jnYq1?F+mjjmF$=$@-c*Nt+GKWx^JM0CE4>ATPT@~BuFquoI#II^++H6MU2Q5)i zhi@NED!!)nLvrTAmDwvemYKek2)nY_SjLo5?Z!9jSF7&-Hr8MN>$kaN-^zL~5sTwj zmmCLk!;a^z|Axv8GO^?13!Gc!yY7#LWnXFSzR0s!LuNZbGb literal 0 HcmV?d00001 diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index 313751c8..5046cd21 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -408,3 +408,29 @@ def test_play_dtmf_into_call(): response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') assert response.message == 'DTMF sent' assert response.uuid == uuid + + +@responses.activate +def test_download_recording(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'file_stream.mp3', + ) + + voice.download_recording( + url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + file_path='voice/tests/data/file_stream.mp3', + ) + + with open('voice/tests/data/file_stream.mp3', 'rb') as file: + file_content = file.read() + assert file_content.startswith(b'ID3') + + +def test_verify_signature(): + token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM' + valid_signature = "qwertyuiopasdfghjklzxcvbnm123456" + + assert voice.verify_signature(token, valid_signature) is True From ec6dbf7d1126508a845228d49f5cd17373678502 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 28 Nov 2024 18:31:42 +0000 Subject: [PATCH 285/401] prepare for 4.1.0 release, remove unused package --- account/CHANGES.md | 3 + account/pyproject.toml | 2 +- account/src/vonage_account/_version.py | 2 +- application/CHANGES.md | 3 + application/pyproject.toml | 2 +- .../src/vonage_application/_version.py | 2 +- http_client/CHANGES.md | 7 ++ http_client/pyproject.toml | 2 +- .../src/vonage_http_client/__init__.py | 1 + .../src/vonage_http_client/_version.py | 2 +- messages/CHANGES.md | 1 + messages/pyproject.toml | 2 +- network_auth/CHANGES.md | 3 + network_auth/pyproject.toml | 2 +- .../src/vonage_network_auth/_version.py | 2 +- network_number_verification/CHANGES.md | 3 + network_number_verification/pyproject.toml | 4 +- .../_version.py | 2 +- network_sim_swap/CHANGES.md | 3 + network_sim_swap/pyproject.toml | 4 +- .../src/vonage_network_sim_swap/_version.py | 2 +- number_insight/CHANGES.md | 3 + number_insight/pyproject.toml | 2 +- .../src/vonage_number_insight/_version.py | 2 +- number_insight_v2/BUILD | 16 --- number_insight_v2/CHANGES.md | 5 - number_insight_v2/README.md | 23 ----- number_insight_v2/pyproject.toml | 29 ------ .../src/vonage_number_insight_v2/BUILD | 1 - .../src/vonage_number_insight_v2/__init__.py | 7 -- .../number_insight_v2.py | 93 ------------------ number_insight_v2/tests/BUILD | 1 - number_insight_v2/tests/data/default.json | 19 ---- number_insight_v2/tests/data/fraud_score.json | 15 --- number_insight_v2/tests/data/sim_swap.json | 11 --- .../tests/test_number_insight_v2.py | 98 ------------------- number_management/CHANGES.md | 3 + number_management/pyproject.toml | 2 +- .../src/vonage_numbers/_version.py | 2 +- sms/CHANGES.md | 3 + sms/pyproject.toml | 2 +- sms/src/vonage_sms/_version.py | 2 +- subaccounts/CHANGES.md | 3 + subaccounts/pyproject.toml | 2 +- users/CHANGES.md | 3 + users/pyproject.toml | 2 +- users/src/vonage_users/_version.py | 2 +- verify/CHANGES.md | 1 + verify/pyproject.toml | 2 +- verify_legacy/CHANGES.md | 3 + verify_legacy/pyproject.toml | 2 +- .../src/vonage_verify_legacy/_version.py | 2 +- video/CHANGES.md | 3 + video/pyproject.toml | 2 +- video/src/vonage_video/_version.py | 2 +- voice/CHANGES.md | 5 + voice/pyproject.toml | 2 +- voice/src/vonage_voice/_version.py | 2 +- vonage/CHANGES.md | 5 + vonage/pyproject.toml | 2 +- 60 files changed, 88 insertions(+), 350 deletions(-) delete mode 100644 number_insight_v2/BUILD delete mode 100644 number_insight_v2/CHANGES.md delete mode 100644 number_insight_v2/README.md delete mode 100644 number_insight_v2/pyproject.toml delete mode 100644 number_insight_v2/src/vonage_number_insight_v2/BUILD delete mode 100644 number_insight_v2/src/vonage_number_insight_v2/__init__.py delete mode 100644 number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py delete mode 100644 number_insight_v2/tests/BUILD delete mode 100644 number_insight_v2/tests/data/default.json delete mode 100644 number_insight_v2/tests/data/fraud_score.json delete mode 100644 number_insight_v2/tests/data/sim_swap.json delete mode 100644 number_insight_v2/tests/test_number_insight_v2.py diff --git a/account/CHANGES.md b/account/CHANGES.md index 032649f7..e63f3309 100644 --- a/account/CHANGES.md +++ b/account/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update dependency versions + # 1.1.0 - Add support for the [Vonage Pricing API](https://developer.vonage.com/en/api/pricing) - Update dependency versions diff --git a/account/pyproject.toml b/account/pyproject.toml index dc3767b7..96455ec6 100644 --- a/account/pyproject.toml +++ b/account/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/account/src/vonage_account/_version.py b/account/src/vonage_account/_version.py index 1a72d32e..b3ddbc41 100644 --- a/account/src/vonage_account/_version.py +++ b/account/src/vonage_account/_version.py @@ -1 +1 @@ -__version__ = '1.1.0' +__version__ = '1.1.1' diff --git a/application/CHANGES.md b/application/CHANGES.md index 37a93036..528bd200 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,3 +1,6 @@ +# 2.0.1 +- Updated dependency versions + # 2.0.0 - Rename `params` -> `config` in method arguments - Update dependency versions diff --git a/application/pyproject.toml b/application/pyproject.toml index 5ce02df5..1293c7db 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py index afced147..3f390799 100644 --- a/application/src/vonage_application/_version.py +++ b/application/src/vonage_application/_version.py @@ -1 +1 @@ -__version__ = '2.0.0' +__version__ = '2.0.1' diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index b01231cc..23600b0c 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,10 @@ +- Updated dependency versions +# 1.5.0 +- Add new `HttpClient.download_file_stream` method +- Add new `FileStreamingError` exception type +- Add backoff exponential timeout increase for HTTP request retries +- Add retries for `RemoteDisconnected` exceptions + # 1.4.3 - Update JWT dependency version diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index e068f809..3db9c45d 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -7,7 +7,7 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", - "vonage-jwt>=1.1.4", + "vonage-jwt>=1.1.5", "requests>=2.27.0", "typing-extensions>=4.9.0", "pydantic>=2.9.2", diff --git a/http_client/src/vonage_http_client/__init__.py b/http_client/src/vonage_http_client/__init__.py index 88e8a9b7..33add05f 100644 --- a/http_client/src/vonage_http_client/__init__.py +++ b/http_client/src/vonage_http_client/__init__.py @@ -1,6 +1,7 @@ from .auth import Auth from .errors import ( AuthenticationError, + FileStreamingError, ForbiddenError, HttpRequestError, InvalidAuthError, diff --git a/http_client/src/vonage_http_client/_version.py b/http_client/src/vonage_http_client/_version.py index 4e7c72a5..77f1c8e6 100644 --- a/http_client/src/vonage_http_client/_version.py +++ b/http_client/src/vonage_http_client/_version.py @@ -1 +1 @@ -__version__ = '1.4.3' +__version__ = '1.5.0' diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 4b4c7285..ed2fabf3 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,5 +1,6 @@ # 1.3.0 - Add support for API key/secret header authentication +- Updated dependency versions # 1.2.3 - Update dependency versions diff --git a/messages/pyproject.toml b/messages/pyproject.toml index 1f0bb113..c1b529af 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/network_auth/CHANGES.md b/network_auth/CHANGES.md index 72a7fc85..c4feb6f2 100644 --- a/network_auth/CHANGES.md +++ b/network_auth/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Updated dependency versions + # 1.0.1 - Update dependency versions diff --git a/network_auth/pyproject.toml b/network_auth/pyproject.toml index c983dd96..dfb9ad37 100644 --- a/network_auth/pyproject.toml +++ b/network_auth/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/network_auth/src/vonage_network_auth/_version.py b/network_auth/src/vonage_network_auth/_version.py index cd7ca498..a6221b3d 100644 --- a/network_auth/src/vonage_network_auth/_version.py +++ b/network_auth/src/vonage_network_auth/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/network_number_verification/CHANGES.md b/network_number_verification/CHANGES.md index 38ac7bab..a9a90dc1 100644 --- a/network_number_verification/CHANGES.md +++ b/network_number_verification/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Updated dependency versions + # 1.0.1 - Update dependency versions diff --git a/network_number_verification/pyproject.toml b/network_number_verification/pyproject.toml index f270235f..77d3fce7 100644 --- a/network_number_verification/pyproject.toml +++ b/network_number_verification/pyproject.toml @@ -6,8 +6,8 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", - "vonage-network-auth>=1.0.0", + "vonage-http-client>=1.5.0", + "vonage-network-auth>=1.0.2", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/network_number_verification/src/vonage_network_number_verification/_version.py b/network_number_verification/src/vonage_network_number_verification/_version.py index cd7ca498..a6221b3d 100644 --- a/network_number_verification/src/vonage_network_number_verification/_version.py +++ b/network_number_verification/src/vonage_network_number_verification/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/network_sim_swap/CHANGES.md b/network_sim_swap/CHANGES.md index 8d7cd951..8f8fd0b8 100644 --- a/network_sim_swap/CHANGES.md +++ b/network_sim_swap/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Updated dependency versions + # 1.1.1 - Update dependency versions diff --git a/network_sim_swap/pyproject.toml b/network_sim_swap/pyproject.toml index d14bd321..e654b3bc 100644 --- a/network_sim_swap/pyproject.toml +++ b/network_sim_swap/pyproject.toml @@ -6,8 +6,8 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", - "vonage-network-auth>=1.0.0", + "vonage-http-client>=1.5.0", + "vonage-network-auth>=1.0.2", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/network_sim_swap/src/vonage_network_sim_swap/_version.py b/network_sim_swap/src/vonage_network_sim_swap/_version.py index b3ddbc41..7b344eca 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/_version.py +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.1.2' diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index b2d770cb..5d9b12e1 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.6 +- Updated dependency versions + # 1.0.5 - Fix missed method renaming - Docstring update diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml index 8dbd7558..d98d5557 100644 --- a/number_insight/pyproject.toml +++ b/number_insight/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py index 858de170..da2182f1 100644 --- a/number_insight/src/vonage_number_insight/_version.py +++ b/number_insight/src/vonage_number_insight/_version.py @@ -1 +1 @@ -__version__ = '1.0.5' +__version__ = '1.0.6' diff --git a/number_insight_v2/BUILD b/number_insight_v2/BUILD deleted file mode 100644 index 6fa1a4f5..00000000 --- a/number_insight_v2/BUILD +++ /dev/null @@ -1,16 +0,0 @@ -resource(name='pyproject', source='pyproject.toml') -file(name='readme', source='README.md') - -files(sources=['tests/data/*']) - -python_distribution( - name='vonage-number-insight-v2', - dependencies=[ - ':pyproject', - ':readme', - 'number_insight_v2/src/vonage_number_insight_v2', - ], - provides=python_artifact(), - generate_setup=False, - repositories=['@pypi'], -) diff --git a/number_insight_v2/CHANGES.md b/number_insight_v2/CHANGES.md deleted file mode 100644 index 36feaaeb..00000000 --- a/number_insight_v2/CHANGES.md +++ /dev/null @@ -1,5 +0,0 @@ -# 0.1.1b0 -- Update minimum dependency version - -# 0.1.0b0 -- Beta release \ No newline at end of file diff --git a/number_insight_v2/README.md b/number_insight_v2/README.md deleted file mode 100644 index 43b9ac1c..00000000 --- a/number_insight_v2/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Vonage Number Insight Python SDK package - -This package contains the code to use v2 of Vonage's Number Insight API (currently in beta) in Python. - -It includes classes for making fraud check requests and handling the responses. - -## Usage -First, import the necessary classes and create an instance of the `NumberInsightV2` class: - -```python -from vonage_http_client.http_client import HttpClient, Auth -from number_insight_v2 import NumberInsightV2, FraudCheckRequest - -http_client = HttpClient(Auth(api_key='your_api_key', api_secret='your_api_secret')) -number_insight = NumberInsightV2(http_client) -``` - -You can then create a `FraudCheckRequest` object and use the `fraud_check` method to initiate a fraud check request: - -```python -request = FraudCheckRequest(phone='1234567890') -response = number_insight.fraud_check(request) -``` \ No newline at end of file diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml deleted file mode 100644 index 442aeb98..00000000 --- a/number_insight_v2/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[project] -name = 'vonage-number-insight-v2' -version = '0.1.1b0' -description = 'Vonage Number Insight v2 package' -readme = "README.md" -authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" -dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", - "pydantic>=2.7.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/number_insight_v2/src/vonage_number_insight_v2/BUILD b/number_insight_v2/src/vonage_number_insight_v2/BUILD deleted file mode 100644 index db46e8d6..00000000 --- a/number_insight_v2/src/vonage_number_insight_v2/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_sources() diff --git a/number_insight_v2/src/vonage_number_insight_v2/__init__.py b/number_insight_v2/src/vonage_number_insight_v2/__init__.py deleted file mode 100644 index 8998fbc0..00000000 --- a/number_insight_v2/src/vonage_number_insight_v2/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .number_insight_v2 import FraudCheckRequest, FraudCheckResponse, NumberInsightV2 - -__all__ = [ - 'NumberInsightV2', - 'FraudCheckRequest', - 'FraudCheckResponse', -] diff --git a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py deleted file mode 100644 index ad44ce10..00000000 --- a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py +++ /dev/null @@ -1,93 +0,0 @@ -from copy import deepcopy -from dataclasses import dataclass -from typing import Literal, Optional, Union - -from pydantic import BaseModel, field_validator, validate_call -from vonage_http_client.http_client import HttpClient - -from vonage_utils import format_phone_number - - -class FraudCheckRequest(BaseModel): - phone: Union[str, int] - insights: Union[ - Literal['fraud_score', 'sim_swap'], list[Literal['fraud_score', 'sim_swap']] - ] = ['fraud_score', 'sim_swap'] - type: Literal['phone'] = 'phone' - - @field_validator('phone') - @classmethod - def format_phone_number(cls, value): - return format_phone_number(value) - - -@dataclass -class Phone: - phone: str - carrier: Optional[str] = None - type: Optional[str] = None - - -@dataclass -class FraudScore: - risk_score: str - risk_recommendation: str - label: str - status: str - - -@dataclass -class SimSwap: - status: str - swapped: Optional[bool] = None - reason: Optional[str] = None - - -@dataclass -class FraudCheckResponse: - request_id: str - type: str - phone: Phone - fraud_score: Optional[FraudScore] - sim_swap: Optional[SimSwap] - - -class NumberInsightV2: - """Number Insight API V2.""" - - def __init__(self, http_client: HttpClient) -> None: - self._http_client = deepcopy(http_client) - self._auth_type = 'basic' - - @property - def http_client(self) -> HttpClient: - """The HTTP client used to make requests to the Number Insight V2 API. - - Returns: - HttpClient: The HTTP client used to make requests to the Number Insight V2 API. - """ - return self._http_client - - @validate_call - def fraud_check(self, request: FraudCheckRequest) -> FraudCheckResponse: - """Initiate a fraud check request.""" - response = self._http_client.post( - self._http_client.api_host, - '/v2/ni', - request.model_dump(), - self._auth_type, - ) - - phone = Phone(**response['phone']) - fraud_score = ( - FraudScore(**response['fraud_score']) if 'fraud_score' in response else None - ) - sim_swap = SimSwap(**response['sim_swap']) if 'sim_swap' in response else None - - return FraudCheckResponse( - request_id=response['request_id'], - type=response['type'], - phone=phone, - fraud_score=fraud_score, - sim_swap=sim_swap, - ) diff --git a/number_insight_v2/tests/BUILD b/number_insight_v2/tests/BUILD deleted file mode 100644 index 0b73afe7..00000000 --- a/number_insight_v2/tests/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_tests(dependencies=['number_insight_v2', 'testutils']) diff --git a/number_insight_v2/tests/data/default.json b/number_insight_v2/tests/data/default.json deleted file mode 100644 index cb808dd7..00000000 --- a/number_insight_v2/tests/data/default.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", - "type": "phone", - "phone": { - "phone": "1234567890", - "carrier": "Verizon Wireless", - "type": "MOBILE" - }, - "fraud_score": { - "risk_score": "0", - "risk_recommendation": "allow", - "label": "low", - "status": "completed" - }, - "sim_swap": { - "status": "completed", - "swapped": false - } -} \ No newline at end of file diff --git a/number_insight_v2/tests/data/fraud_score.json b/number_insight_v2/tests/data/fraud_score.json deleted file mode 100644 index be7cc267..00000000 --- a/number_insight_v2/tests/data/fraud_score.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", - "type": "phone", - "phone": { - "phone": "1234567890", - "carrier": "Verizon Wireless", - "type": "MOBILE" - }, - "fraud_score": { - "risk_score": "0", - "risk_recommendation": "allow", - "label": "low", - "status": "completed" - } -} \ No newline at end of file diff --git a/number_insight_v2/tests/data/sim_swap.json b/number_insight_v2/tests/data/sim_swap.json deleted file mode 100644 index 594028c8..00000000 --- a/number_insight_v2/tests/data/sim_swap.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "request_id": "db5282b6-8046-4217-9c0e-d9c55d8696e9", - "type": "phone", - "phone": { - "phone": "1234567890" - }, - "sim_swap": { - "status": "completed", - "swapped": false - } -} \ No newline at end of file diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py deleted file mode 100644 index 31a89181..00000000 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ /dev/null @@ -1,98 +0,0 @@ -from dataclasses import asdict -from os.path import abspath - -import responses -from pydantic import ValidationError -from pytest import raises -from vonage_http_client.http_client import HttpClient -from vonage_number_insight_v2.number_insight_v2 import ( - FraudCheckRequest, - FraudCheckResponse, - NumberInsightV2, -) -from vonage_utils.errors import InvalidPhoneNumberError -from vonage_utils.utils import remove_none_values - -from testutils import build_response, get_mock_api_key_auth - -path = abspath(__file__) - -ni2 = NumberInsightV2(HttpClient(get_mock_api_key_auth())) - - -def test_fraud_check_request_defaults(): - request = FraudCheckRequest(phone='1234567890') - assert request.type == 'phone' - assert request.phone == '1234567890' - assert request.insights == ['fraud_score', 'sim_swap'] - - -def test_fraud_check_request_custom_insights(): - request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) - assert request.type == 'phone' - assert request.phone == '1234567890' - assert request.insights == ['fraud_score'] - - -def test_fraud_check_request_invalid_phone(): - with raises(InvalidPhoneNumberError): - FraudCheckRequest(phone='invalid_phone') - with raises(InvalidPhoneNumberError): - FraudCheckRequest(phone='123') - with raises(InvalidPhoneNumberError): - FraudCheckRequest(phone='12345678901234567890') - - -def test_fraud_check_request_invalid_insights(): - with raises(ValidationError): - FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) - - -@responses.activate -def test_ni2_defaults(): - build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') - request = FraudCheckRequest(phone='1234567890') - response = ni2.fraud_check(request) - assert type(response) == FraudCheckResponse - assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' - assert response.phone.carrier == 'Verizon Wireless' - assert response.fraud_score.risk_score == '0' - assert response.sim_swap.status == 'completed' - - -@responses.activate -def test_ni2_fraud_score_only(): - build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') - request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) - response = ni2.fraud_check(request) - assert type(response) == FraudCheckResponse - assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' - assert response.phone.carrier == 'Verizon Wireless' - assert response.fraud_score.risk_score == '0' - assert response.sim_swap is None - - clear_response = asdict(response, dict_factory=remove_none_values) - assert 'fraud_score' in clear_response - assert 'sim_swap' not in clear_response - - -@responses.activate -def test_ni2_sim_swap_only(): - build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') - request = FraudCheckRequest(phone='1234567890', insights='sim_swap') - response = ni2.fraud_check(request) - assert type(response) == FraudCheckResponse - assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' - assert response.phone.phone == '1234567890' - assert response.fraud_score is None - assert response.sim_swap.status == 'completed' - assert response.sim_swap.swapped is False - - clear_response = asdict(response, dict_factory=remove_none_values) - assert 'fraud_score' not in clear_response - assert 'sim_swap' in clear_response - assert 'reason' not in clear_response['sim_swap'] - - -def test_number_insight_v2_http_client(): - assert type(ni2.http_client) == HttpClient diff --git a/number_management/CHANGES.md b/number_management/CHANGES.md index 724aa699..e990cdcb 100644 --- a/number_management/CHANGES.md +++ b/number_management/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.4 +- Updated dependency versions + # 1.0.3 - Update dependency versions diff --git a/number_management/pyproject.toml b/number_management/pyproject.toml index 848bf636..575123a8 100644 --- a/number_management/pyproject.toml +++ b/number_management/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/number_management/src/vonage_numbers/_version.py b/number_management/src/vonage_numbers/_version.py index 3f6fab60..8a81504c 100644 --- a/number_management/src/vonage_numbers/_version.py +++ b/number_management/src/vonage_numbers/_version.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '1.0.4' diff --git a/sms/CHANGES.md b/sms/CHANGES.md index 0937faee..1e8390f3 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.5 +- Updated dependency versions + # 1.1.4 - Update dependency versions diff --git a/sms/pyproject.toml b/sms/pyproject.toml index 1c21cec2..8d88647d 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/sms/src/vonage_sms/_version.py b/sms/src/vonage_sms/_version.py index bc50bee6..99d2a6fa 100644 --- a/sms/src/vonage_sms/_version.py +++ b/sms/src/vonage_sms/_version.py @@ -1 +1 @@ -__version__ = '1.1.4' +__version__ = '1.1.5' diff --git a/subaccounts/CHANGES.md b/subaccounts/CHANGES.md index a9efcbae..bcf4107a 100644 --- a/subaccounts/CHANGES.md +++ b/subaccounts/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.4 +- Updated dependency versions + # 1.0.3 - Update dependency versions diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml index 367e77da..bed193bc 100644 --- a/subaccounts/pyproject.toml +++ b/subaccounts/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/users/CHANGES.md b/users/CHANGES.md index 579ff93d..e8c16f56 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.1 +- Updated dependency versions + # 1.2.0 - Expose more properties in the top-level `vonage_users` scope - Update dependency versions diff --git a/users/pyproject.toml b/users/pyproject.toml index 4aa2f5d4..79280fdc 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/users/src/vonage_users/_version.py b/users/src/vonage_users/_version.py index 58d478ab..3f262a63 100644 --- a/users/src/vonage_users/_version.py +++ b/users/src/vonage_users/_version.py @@ -1 +1 @@ -__version__ = '1.2.0' +__version__ = '1.2.1' diff --git a/verify/CHANGES.md b/verify/CHANGES.md index 55dc5e03..b31dcc40 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,5 +1,6 @@ # 2.1.0 - Add support for API key/secret header authentication +- Updated dependency versions # 2.0.0 - Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. This package now contains code for the Verify v2 API diff --git a/verify/pyproject.toml b/verify/pyproject.toml index 6fa5e68e..8d84878d 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/verify_legacy/CHANGES.md b/verify_legacy/CHANGES.md index 9a43ef2c..df46d595 100644 --- a/verify_legacy/CHANGES.md +++ b/verify_legacy/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Updated dependency versions + # 1.0.0 - Initial upload as `legacy` package diff --git a/verify_legacy/pyproject.toml b/verify_legacy/pyproject.toml index f2811534..e3f105b2 100644 --- a/verify_legacy/pyproject.toml +++ b/verify_legacy/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/verify_legacy/src/vonage_verify_legacy/_version.py b/verify_legacy/src/vonage_verify_legacy/_version.py index 1f356cc5..cd7ca498 100644 --- a/verify_legacy/src/vonage_verify_legacy/_version.py +++ b/verify_legacy/src/vonage_verify_legacy/_version.py @@ -1 +1 @@ -__version__ = '1.0.0' +__version__ = '1.0.1' diff --git a/video/CHANGES.md b/video/CHANGES.md index 242f6f58..ba5c0cad 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.4 +- Updated dependency versions + # 1.0.3 - Make the filter optional in `Video.list_archives` and `Video.list_broadcasts` diff --git a/video/pyproject.toml b/video/pyproject.toml index 5259588c..baed076a 100644 --- a/video/pyproject.toml +++ b/video/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 3f6fab60..8a81504c 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '1.0.4' diff --git a/voice/CHANGES.md b/voice/CHANGES.md index 743d3661..045bb534 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,8 @@ +# 1.1.0 +- Add `Voice.get_recording` method to get call recordings +- Add `Voice.verify_signature` method to expose the verification functionality from `vonage-jwt` +- Updated dependency versions + # 1.0.6 - Update dependency versions diff --git a/voice/pyproject.toml b/voice/pyproject.toml index 06ea83b3..257748eb 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index da2182f1..1a72d32e 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.0.6' +__version__ = '1.1.0' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 64057f62..7f020906 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,10 @@ # 4.1.0 - Add support for API key/secret header authentication for the Messages and Verify APIs (JWT is the default and recommended method) +- Add `Voice.get_recording` method to get call recordings +- Add `Voice.verify_signature` method to expose the verification functionality from `vonage-jwt` +- Add backoff exponential timeout increase for HTTP request retries +- Add automatic retries for `RemoteDisconnected` exceptions +- Add new `http_client.FileStreamingError` exception type # 4.0.0 A complete, ground-up rewrite of the SDK. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index c546c9c0..a4f3e0a1 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", - "vonage-http-client>=1.4.3", + "vonage-http-client>=1.5.0", "vonage-account>=1.1.0", "vonage-application>=2.0.0", "vonage-messages>=1.3.0", From fe6ae91beced2369287f40ac48c4ac527f166cd6 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 28 Nov 2024 18:35:37 +0000 Subject: [PATCH 286/401] add new error --- http_client/src/vonage_http_client/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/http_client/src/vonage_http_client/__init__.py b/http_client/src/vonage_http_client/__init__.py index 33add05f..14ed5207 100644 --- a/http_client/src/vonage_http_client/__init__.py +++ b/http_client/src/vonage_http_client/__init__.py @@ -16,6 +16,7 @@ __all__ = [ 'Auth', 'AuthenticationError', + 'FileStreamingError', 'ForbiddenError', 'HttpRequestError', 'InvalidAuthError', From 02716732fa6256e6e0075ee6506fbf67aba0b6d3 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 29 Nov 2024 15:28:46 +0000 Subject: [PATCH 287/401] roll back to stable version --- pants.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pants.toml b/pants.toml index 5500575c..3b13e505 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.24.0a0' +pants_version = '2.23.0' backend_packages = [ 'pants.backend.python', From 658049e387c09153b71d0007486913a616307d82 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 29 Nov 2024 15:50:13 +0000 Subject: [PATCH 288/401] update package versions --- vonage/CHANGES.md | 3 +++ vonage/pyproject.toml | 24 ++++++++++++------------ vonage/src/vonage/_version.py | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 7f020906..5ab33d35 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.1.1 +- Include new package versions + # 4.1.0 - Add support for API key/secret header authentication for the Messages and Verify APIs (JWT is the default and recommended method) - Add `Voice.get_recording` method to get call recordings diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index a4f3e0a1..f76ceddb 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -7,21 +7,21 @@ requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", "vonage-http-client>=1.5.0", - "vonage-account>=1.1.0", - "vonage-application>=2.0.0", + "vonage-account>=1.1.1", + "vonage-application>=2.0.1", "vonage-messages>=1.3.0", - "vonage-network-auth>=1.0.1", - "vonage-network-sim-swap>=1.1.1", - "vonage-network-number-verification>=1.0.1", - "vonage-number-insight>=1.0.5", - "vonage-numbers>=1.0.3", - "vonage-sms>=1.1.4", + "vonage-network-auth>=1.0.2", + "vonage-network-sim-swap>=1.1.2", + "vonage-network-number-verification>=1.0.2", + "vonage-number-insight>=1.0.6", + "vonage-numbers>=1.0.4", + "vonage-sms>=1.1.5", "vonage-subaccounts>=1.0.4", - "vonage-users>=1.2.0", + "vonage-users>=1.2.1", "vonage-verify>=2.1.0", - "vonage-verify-legacy>=1.0.0", - "vonage-video>=1.0.3", - "vonage-voice>=1.0.6", + "vonage-verify-legacy>=1.0.1", + "vonage-video>=1.0.4", + "vonage-voice>=1.1.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index fa721b49..47cbba72 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.1.0' +__version__ = '4.1.1' From 642401df53aaf6c1834838f1b216d4b7d08e78a2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 5 Dec 2024 15:01:05 +0000 Subject: [PATCH 289/401] remove max length constraint in Voice API for webhook URIs --- voice/CHANGES.md | 3 +++ voice/src/vonage_voice/_version.py | 2 +- voice/src/vonage_voice/models/common.py | 2 +- vonage/CHANGES.md | 3 +++ vonage/pyproject.toml | 2 +- vonage/src/vonage/_version.py | 2 +- 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/voice/CHANGES.md b/voice/CHANGES.md index 045bb534..cd6008ea 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Remove maximum webhook uri length constraint + # 1.1.0 - Add `Voice.get_recording` method to get call recordings - Add `Voice.verify_signature` method to expose the verification functionality from `vonage-jwt` diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 1a72d32e..b3ddbc41 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.1.0' +__version__ = '1.1.1' diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index ef6d75ed..c1f2741f 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -37,7 +37,7 @@ class Websocket(BaseModel): headers (Optional[dict]): The headers to include with the WebSocket connection. """ - uri: str = Field(..., min_length=1, max_length=50) + uri: str = Field(..., min_length=1) content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( 'audio/l16;rate=16000', serialization_alias='content-type' ) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 5ab33d35..b0e35979 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.1.2 +- Remove max length constraint in Voice API for webhook URIs + # 4.1.1 - Include new package versions diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index f76ceddb..11232827 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", "vonage-video>=1.0.4", - "vonage-voice>=1.1.0", + "vonage-voice>=1.1.1", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 47cbba72..96e55858 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.1.1' +__version__ = '4.1.2' From 5b509d341a8bda27d52e888b154569db516371b4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 14 Jan 2025 17:28:25 +0000 Subject: [PATCH 290/401] remove redundant field, add new max_bitrate option to video api --- http_client/CHANGES.md | 4 +- .../src/vonage_http_client/_version.py | 2 +- http_client/src/vonage_http_client/errors.py | 56 ++- .../src/vonage_http_client/http_client.py | 13 +- sms/tests/test_sms.py | 2 +- video/CHANGES.md | 3 + video/src/vonage_video/_version.py | 2 +- video/src/vonage_video/models/archive.py | 9 +- video/tests/data/archive.json | 3 +- video/tests/data/list_archives.json | 3 +- video/tests/test_archive.py | 3 + video/tests/test_video.py | 401 ------------------ voice/CHANGES.md | 3 + voice/src/vonage_voice/_version.py | 2 +- voice/src/vonage_voice/voice.py | 8 +- vonage/CHANGES.md | 5 + vonage/pyproject.toml | 4 +- vonage/src/vonage/_version.py | 2 +- 18 files changed, 71 insertions(+), 454 deletions(-) delete mode 100644 video/tests/test_video.py diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 23600b0c..4f995776 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,4 +1,6 @@ -- Updated dependency versions +# 1.5.1 +- Remove unnecessary `Content-Type` check on error + # 1.5.0 - Add new `HttpClient.download_file_stream` method - Add new `FileStreamingError` exception type diff --git a/http_client/src/vonage_http_client/_version.py b/http_client/src/vonage_http_client/_version.py index 77f1c8e6..51ed7c48 100644 --- a/http_client/src/vonage_http_client/_version.py +++ b/http_client/src/vonage_http_client/_version.py @@ -1 +1 @@ -__version__ = '1.5.0' +__version__ = '1.5.1' diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index 844d188b..b708853b 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -1,4 +1,5 @@ from json import JSONDecodeError, dumps +from typing import Optional from requests import Response from vonage_utils.errors import VonageError @@ -21,32 +22,32 @@ class HttpRequestError(VonageError): 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): + def __init__(self, response: Response): self.response = response - self.set_error_message(self.response, content_type) + self.message = self._format_error_message() super().__init__(self.message) - def set_error_message(self, response: Response, content_type: str): - body = None - if content_type == 'application/json': - try: - body = dumps(response.json(), indent=4) - except JSONDecodeError: - pass - else: - body = response.text + def _format_error_message(self) -> str: + body = self._get_response_body() + base_message = f'{self.response.status_code} response from {self.response.url}' if 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}.' + return f'{base_message}. Error response body: \n{body}' + return base_message + + def _get_response_body(self) -> Optional[str]: + if not self.response.content: + return None + try: + return dumps(self.response.json(), indent=4) + except JSONDecodeError: + return self.response.text class AuthenticationError(HttpRequestError): @@ -56,15 +57,14 @@ class AuthenticationError(HttpRequestError): 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) + def __init__(self, response: Response): + super().__init__(response) class ForbiddenError(HttpRequestError): @@ -74,15 +74,14 @@ class ForbiddenError(HttpRequestError): 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) + def __init__(self, response: Response): + super().__init__(response) class NotFoundError(HttpRequestError): @@ -92,15 +91,14 @@ class NotFoundError(HttpRequestError): 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) + def __init__(self, response: Response): + super().__init__(response) class RateLimitedError(HttpRequestError): @@ -111,15 +109,14 @@ class RateLimitedError(HttpRequestError): 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) + def __init__(self, response: Response): + super().__init__(response) class ServerError(HttpRequestError): @@ -130,15 +127,14 @@ class ServerError(HttpRequestError): 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) + def __init__(self, response: Response): + super().__init__(response) class FileStreamingError(VonageError): diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 37743a64..97c3e8f2 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -343,18 +343,17 @@ def _parse_response(self, response: Response) -> Union[dict, None]: except JSONDecodeError: return None if response.status_code >= 400: - content_type = response.headers['Content-Type'].split(';', 1)[0] 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) + raise AuthenticationError(response) if response.status_code == 403: - raise ForbiddenError(response, content_type) + raise ForbiddenError(response) elif response.status_code == 404: - raise NotFoundError(response, content_type) + raise NotFoundError(response) elif response.status_code == 429: - raise RateLimitedError(response, content_type) + raise RateLimitedError(response) elif response.status_code == 500: - raise ServerError(response, content_type) - raise HttpRequestError(response, content_type) + raise ServerError(response) + raise HttpRequestError(response) diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index 520d5c15..46d5da78 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -170,7 +170,7 @@ def test_submit_sms_conversion_402(): try: sms.submit_sms_conversion('3295d748-4e14-4681-af78-166dca3c5aab') except HttpRequestError as err: - assert err.message == '402 response from https://api.nexmo.com/conversions/sms.' + assert '402 response from https://api.nexmo.com/conversions/sms.' in err.message def test_http_client_property(): diff --git a/video/CHANGES.md b/video/CHANGES.md index ba5c0cad..b30e01a7 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.0 +- Add new `max_bitrate` field for archives + # 1.0.4 - Updated dependency versions diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 8a81504c..1a72d32e 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.0.4' +__version__ = '1.1.0' diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 37c39c35..7745e900 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -69,6 +69,8 @@ class Archive(BaseModel): url (str, Optional): The download URL of the available archive file. This is only set for an archive with the status set to `available`. transcription (Transcription, Optional): Transcription options for the archive. + max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per + second. This is only valid for composed archives. """ id: Optional[str] = None @@ -94,6 +96,7 @@ class Archive(BaseModel): streams: Optional[list[VideoStream]] = None url: Optional[str] = None transcription: Optional[Transcription] = None + max_bitrate: Optional[int] = Field(None, validation_alias='maxBitrate') class CreateArchiveRequest(BaseModel): @@ -114,7 +117,8 @@ class CreateArchiveRequest(BaseModel): resolution (VideoResolution, Optional): The resolution of the archive. stream_mode (StreamMode, Optional): Whether streams included in the archive are selected automatically ("auto", the default) or manually ("manual"). - + max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per + second. This is only valid for composed archives. Raises: NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. IndividualArchivePropertyError: If `resolution` or `layout` is set for individual archives @@ -133,6 +137,9 @@ class CreateArchiveRequest(BaseModel): output_mode: Optional[OutputMode] = Field(None, serialization_alias='outputMode') resolution: Optional[VideoResolution] = None stream_mode: Optional[StreamMode] = Field(None, serialization_alias='streamMode') + max_bitrate: Optional[int] = Field( + None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' + ) @model_validator(mode='after') def validate_audio_or_video(self): diff --git a/video/tests/data/archive.json b/video/tests/data/archive.json index 1167780f..ee1490cc 100644 --- a/video/tests/data/archive.json +++ b/video/tests/data/archive.json @@ -19,5 +19,6 @@ "multiArchiveTag": "my-multi-archive", "event": "archive", "resolution": "1280x720", - "url": null + "url": null, + "maxBitrate": 2000000 } \ No newline at end of file diff --git a/video/tests/data/list_archives.json b/video/tests/data/list_archives.json index e8734d4d..b839ef2f 100644 --- a/video/tests/data/list_archives.json +++ b/video/tests/data/list_archives.json @@ -45,7 +45,8 @@ "multiArchiveTag": "my-multi-archive", "event": "archive", "resolution": "1280x720", - "url": "https://example.com/archive.mp4" + "url": "https://example.com/archive.mp4", + "maxBitrate": 2000000 } ] } \ No newline at end of file diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 5ef2c4b8..d0e58a69 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -127,6 +127,7 @@ def test_list_archives(): assert archives[1].duration == 134 assert archives[1].sha256_sum == 'test_sha256_sum' assert archives[1].url == 'https://example.com/archive.mp4' + assert archives[1].max_bitrate == 2_000_000 @responses.activate @@ -150,6 +151,7 @@ def test_start_archive(): output_mode=OutputMode.COMPOSED, resolution=VideoResolution.RES_1280x720, stream_mode=StreamMode.MANUAL, + max_bitrate=2_000_000, ) archive = video.start_archive(archive_options) @@ -162,6 +164,7 @@ def test_start_archive(): assert archive.status == 'started' assert archive.name == 'first archive test' assert archive.resolution == '1280x720' + assert archive.max_bitrate == 2_000_000 @responses.activate diff --git a/video/tests/test_video.py b/video/tests/test_video.py deleted file mode 100644 index f256b420..00000000 --- a/video/tests/test_video.py +++ /dev/null @@ -1,401 +0,0 @@ -from os.path import abspath - -from vonage_http_client.http_client import HttpClient -from vonage_video.video import Video - -from testutils import get_mock_jwt_auth - -path = abspath(__file__) - - -video = Video(HttpClient(get_mock_jwt_auth())) - - -def test_http_client_property(): - assert type(video.http_client) == HttpClient - - -### - - -# @responses.activate -# def test_create_call_basic_ncco(): -# build_response( -# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 -# ) -# ncco = [Talk(text='Hello world')] -# call = CreateCallRequest( -# ncco=ncco, -# to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], -# random_from_number=True, -# ) -# response = voice.create_call(call) - -# assert type(response) == CreateCallResponse -# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' -# assert response.status == 'started' -# assert response.direction == 'outbound' -# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -# @responses.activate -# def test_create_call_ncco_options(): -# build_response( -# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 -# ) -# ncco = [Talk(text='Hello world')] -# call = CreateCallRequest( -# ncco=ncco, -# to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], -# from_={'number': '1234567890', 'type': 'phone'}, -# event_url=['https://example.com/event'], -# event_method='POST', -# machine_detection='hangup', -# length_timer=60, -# ringing_timer=30, -# ) -# response = voice.create_call(call) - -# assert type(response) == CreateCallResponse -# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' -# assert response.status == 'started' -# assert response.direction == 'outbound' -# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -# @responses.activate -# def test_create_call_basic_answer_url(): -# build_response( -# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 -# ) -# call = CreateCallRequest( -# to=[ -# { -# 'type': 'websocket', -# 'uri': 'wss://example.com/websocket', -# 'content_type': 'audio/l16;rate=8000', -# 'headers': {'key': 'value'}, -# } -# ], -# answer_url=['https://example.com/answer'], -# random_from_number=True, -# ) -# response = voice.create_call(call) - -# assert type(response) == CreateCallResponse -# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' -# assert response.status == 'started' -# assert response.direction == 'outbound' -# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -# @responses.activate -# def test_create_call_answer_url_options(): -# build_response( -# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 -# ) -# call = CreateCallRequest( -# to=[{'type': 'vbc', 'extension': '1234'}], -# answer_url=['https://example.com/answer'], -# answer_method='GET', -# random_from_number=True, -# event_url=['https://example.com/event'], -# event_method='POST', -# advanced_machine_detection={ -# 'behavior': 'hangup', -# 'mode': 'detect_beep', -# 'beep_timeout': 50, -# }, -# length_timer=60, -# ringing_timer=30, -# ) -# response = voice.create_call(call) - -# assert type(response) == CreateCallResponse -# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' -# assert response.status == 'started' -# assert response.direction == 'outbound' -# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -# def test_create_call_ncco_and_answer_url_error(): -# with raises(VoiceError) as e: -# CreateCallRequest( -# to=[{'type': 'phone', 'number': '1234567890'}], -# random_from_number=True, -# ) -# assert e.match('Either `ncco` or `answer_url` must be set') - -# with raises(VoiceError) as e: -# CreateCallRequest( -# ncco=[Talk(text='Hello world')], -# answer_url=['https://example.com/answer'], -# to=[{'type': 'phone', 'number': '1234567890'}], -# random_from_number=True, -# ) -# assert e.match('`ncco` and `answer_url` cannot be used together') - - -# def test_create_call_from_and_random_from_number_error(): -# with raises(VoiceError) as e: -# CreateCallRequest( -# ncco=[Talk(text='Hello world')], -# to=[{'type': 'phone', 'number': '1234567890'}], -# ) -# assert e.match('Either `from_` or `random_from_number` must be set') - -# with raises(VoiceError) as e: -# CreateCallRequest( -# ncco=[Talk(text='Hello world')], -# to=[{'type': 'phone', 'number': '1234567890'}], -# from_={'number': '9876543210', 'type': 'phone'}, -# random_from_number=True, -# ) -# assert e.match('`from_` and `random_from_number` cannot be used together') - - -# @responses.activate -# def test_list_calls(): -# build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) -# calls, _ = voice.list_calls() -# assert len(calls) == 3 -# assert calls[0].to.number == '1234567890' -# assert calls[0].from_.number == '9876543210' -# assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' -# assert calls[1].direction == 'outbound' -# assert calls[1].status == 'completed' -# assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -# @responses.activate -# def test_list_calls_filter(): -# build_response( -# path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 -# ) -# filter = ListCallsFilter( -# status='completed', -# date_start='2024-03-14T07:45:14Z', -# date_end='2024-04-19T08:45:14Z', -# page_size=10, -# record_index=0, -# order='asc', -# conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', -# ) -# filter_dict = { -# 'status': 'completed', -# 'date_start': '2024-03-14T07:45:14Z', -# 'date_end': '2024-04-19T08:45:14Z', -# 'page_size': 10, -# 'record_index': 0, -# 'order': 'asc', -# 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', -# } -# assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict - -# calls, next_record_index = voice.list_calls(filter) -# assert len(calls) == 1 -# assert calls[0].to.number == '1234567890' -# assert next_record_index == 2 - - -# @responses.activate -# def test_get_call(): -# build_response( -# path, -# 'GET', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# 'get_call.json', -# 200, -# ) -# call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') -# assert call.to.number == '1234567890' -# assert call.from_.number == '9876543210' -# assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' -# assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' - - -# @responses.activate -# def test_transfer_call_ncco(): -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# ) - -# ncco = [Talk(text='Hello world')] -# voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_transfer_call_answer_url(): -# answer_url = 'https://example.com/answer' -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# match=[ -# json_params_matcher( -# { -# 'action': 'transfer', -# 'destination': {'type': 'ncco', 'url': [answer_url]}, -# }, -# ), -# ], -# ) - -# voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_hangup(): -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# match=[json_params_matcher({'action': 'hangup'})], -# ) - -# voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_mute(): -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# match=[json_params_matcher({'action': 'mute'})], -# ) - -# voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_unmute(): -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# match=[json_params_matcher({'action': 'unmute'})], -# ) - -# voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_earmuff(): -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# match=[json_params_matcher({'action': 'earmuff'})], -# ) - -# voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_unearmuff(): -# build_response( -# path, -# 'PUT', -# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', -# status_code=204, -# match=[json_params_matcher({'action': 'unearmuff'})], -# ) - -# voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') -# assert voice._http_client.last_response.status_code == 204 - - -# @responses.activate -# def test_play_audio_into_call(): -# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' -# build_response( -# path, -# 'PUT', -# f'https://api.nexmo.com/v1/calls/{uuid}/stream', -# 'play_audio_into_call.json', -# ) - -# options = AudioStreamOptions( -# stream_url=['https://example.com/audio'], loop=2, level=0.5 -# ) -# response = voice.play_audio_into_call(uuid, options) -# assert response.message == 'Stream started' -# assert response.uuid == uuid - - -# @responses.activate -# def test_stop_audio_stream(): -# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' -# build_response( -# path, -# 'DELETE', -# f'https://api.nexmo.com/v1/calls/{uuid}/stream', -# 'stop_audio_stream.json', -# ) - -# response = voice.stop_audio_stream(uuid) -# assert response.message == 'Stream stopped' -# assert response.uuid == uuid - - -# @responses.activate -# def test_play_tts_into_call(): -# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' -# build_response( -# path, -# 'PUT', -# f'https://api.nexmo.com/v1/calls/{uuid}/talk', -# 'play_tts_into_call.json', -# ) - -# options = TtsStreamOptions( -# text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 -# ) -# response = voice.play_tts_into_call(uuid, options) -# assert response.message == 'Talk started' -# assert response.uuid == uuid - - -# @responses.activate -# def test_stop_tts(): -# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' -# build_response( -# path, -# 'DELETE', -# f'https://api.nexmo.com/v1/calls/{uuid}/talk', -# 'stop_tts.json', -# ) - -# response = voice.stop_tts(uuid) -# assert response.message == 'Talk stopped' -# assert response.uuid == uuid - - -# @responses.activate -# def test_play_dtmf_into_call(): -# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' -# build_response( -# path, -# 'PUT', -# f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', -# 'play_dtmf_into_call.json', -# ) - -# response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') -# assert response.message == 'DTMF sent' -# assert response.uuid == uuid diff --git a/voice/CHANGES.md b/voice/CHANGES.md index cd6008ea..f563df38 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Update incorrect return type annotation for `Voice.download_recording` + # 1.1.1 - Remove maximum webhook uri length constraint diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index b3ddbc41..7b344eca 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.1.2' diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index ae7ec8e8..dcbd83d9 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -250,7 +250,8 @@ def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: Args: uuid (str): The UUID of the call to play DTMF tones into. - dtmf (Dtmf): The DTMF tones to play. + dtmf (Dtmf): The DTMF tones to play, as a string of digits. It can include + the characters from 0-9, #, *, and p. Returns: CallMessage: Object with information about the call. @@ -264,15 +265,12 @@ def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: return CallMessage(**response) @validate_call - def download_recording(self, url: str, file_path: str) -> bytes: + def download_recording(self, url: str, file_path: str) -> None: """Downloads a call recording from the specified URL and saves it to a local file. Args: url (str): The URL of the recording to get. file_path (str): The path to save the recording to. - - Returns: - bytes: The recording data. """ self._http_client.download_file_stream(url=url, file_path=file_path) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index b0e35979..9aad0258 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,8 @@ +# 4.2.0 +- Add new `max_bitrate` field for Video API archives +- Fix a bug with error types +- Update an outdated Voice class type hint + # 4.1.2 - Remove max length constraint in Voice API for webhook URIs diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 11232827..c9e467e5 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", - "vonage-http-client>=1.5.0", + "vonage-http-client>=1.5.1", "vonage-account>=1.1.1", "vonage-application>=2.0.1", "vonage-messages>=1.3.0", @@ -20,7 +20,7 @@ dependencies = [ "vonage-users>=1.2.1", "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", - "vonage-video>=1.0.4", + "vonage-video>=1.1.0", "vonage-voice>=1.1.1", ] classifiers = [ diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 96e55858..ea5d65fc 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.1.2' +__version__ = '4.2.0' From 922a7b712a801e43415b9fe6985b0cae7cb6feac Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 14 Jan 2025 17:38:15 +0000 Subject: [PATCH 291/401] use updated version --- vonage/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index c9e467e5..52397140 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", "vonage-video>=1.1.0", - "vonage-voice>=1.1.1", + "vonage-voice>=1.1.2", ] classifiers = [ "Programming Language :: Python", From 7139a4e7ca7b6f71ce0f40c0cd4e4bdb1d7638ab Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 14 Jan 2025 17:41:42 +0000 Subject: [PATCH 292/401] update requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fb3ae182..8c149e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,6 @@ urllib3 -e network_number_verification -e network_sim_swap -e number_insight --e number_insight_v2 -e number_management -e sms -e subaccounts From fb32f46dcd9cd2a9d65010c464bcd4d00dbbd5f5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 30 Jan 2025 18:28:10 +0000 Subject: [PATCH 293/401] make data models accessible from top level --- .github/workflows/build.yml | 2 +- README.md | 55 +++++++++++------------- messages/CHANGES.md | 3 ++ messages/README.md | 8 ++-- messages/src/vonage_messages/__init__.py | 3 +- messages/src/vonage_messages/_version.py | 2 +- messages/tests/test_messages.py | 12 +++--- pants.ci.toml | 2 +- pants.toml | 10 ++--- video/CHANGES.md | 3 ++ video/OPENTOK_TO_VONAGE_MIGRATION.md | 6 +-- video/README.md | 38 ++++++++-------- video/src/vonage_video/__init__.py | 3 +- video/src/vonage_video/_version.py | 2 +- video/tests/test_archive.py | 21 +++++---- video/tests/test_audio_connector.py | 9 ++-- voice/CHANGES.md | 3 ++ voice/README.md | 10 ++--- voice/src/vonage_voice/__init__.py | 3 +- voice/src/vonage_voice/_version.py | 2 +- voice/tests/test_voice.py | 6 +-- vonage/CHANGES.md | 5 +++ vonage/pyproject.toml | 6 +-- vonage/src/vonage/_version.py | 2 +- 24 files changed, 116 insertions(+), 100 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8fe82a53..698497d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python: ["3.9", "3.10", "3.11", "3.12"] os: ["ubuntu-latest"] steps: - name: Clone repo diff --git a/README.md b/README.md index be28ad61..75fe21c5 100644 --- a/README.md +++ b/README.md @@ -405,13 +405,12 @@ verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean ## Messages API - ### How to Construct a Message -In order to send a message, you must construct a message object of the correct type. These are all found under `vonage_messages.models`. +In order to send a message, you must construct a message object of the correct type. ```python -from vonage_messages.models import Sms +from vonage_messages import Sms message = Sms( from_='Vonage APIs', @@ -435,7 +434,7 @@ Some message types have submodels with additional fields. In this case, import t e.g. ```python -from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource +from vonage_messages import MessengerImage, MessengerOptions, MessengerResource messenger = MessengerImage( to='1234567890', @@ -451,7 +450,7 @@ To send a message, access the `Messages.send` method via the main Vonage object, ```python from vonage import Auth, Vonage -from vonage_messages.models import Sms +from vonage_messages import Sms vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) @@ -970,12 +969,10 @@ response = vonage_client.verify_legacy.request_network_unblock('23410') ## Video API -You will use the custom Pydantic data models to make most of the API calls in this package. They are accessed from the `vonage_video.models` package. - ### Generate a Client Token ```python -from vonage_video.models import TokenOptions +from vonage_video import TokenOptions token_options = TokenOptions(session_id='your_session_id', role='publisher') client_token = vonage_client.video.generate_client_token(token_options) @@ -984,7 +981,7 @@ client_token = vonage_client.video.generate_client_token(token_options) ### Create a Session ```python -from vonage_video.models import SessionOptions +from vonage_video import SessionOptions session_options = SessionOptions(media_mode='routed') video_session = vonage_client.video.create_session(session_options) @@ -1005,7 +1002,7 @@ stream_info = vonage_client.video.get_stream(session_id='your_session_id', strea ### Change Stream Layout ```python -from vonage_video.models import StreamLayoutOptions +from vonage_video import StreamLayoutOptions layout_options = StreamLayoutOptions(type='bestFit') updated_streams = vonage_client.video.change_stream_layout(session_id='your_session_id', stream_layout_options=layout_options) @@ -1014,7 +1011,7 @@ updated_streams = vonage_client.video.change_stream_layout(session_id='your_sess ### Send a Signal ```python -from vonage_video.models import SignalData +from vonage_video import SignalData signal_data = SignalData(type='chat', data='Hello, World!') vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) @@ -1047,7 +1044,7 @@ vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ### Start Captions ```python -from vonage_video.models import CaptionsOptions +from vonage_video import CaptionsOptions captions_options = CaptionsOptions(language='en-US') captions_data = vonage_client.video.start_captions(captions_options) @@ -1056,7 +1053,7 @@ captions_data = vonage_client.video.start_captions(captions_options) ### Stop Captions ```python -from vonage_video.models import CaptionsData +from vonage_video import CaptionsData captions_data = CaptionsData(captions_id='your_captions_id') vonage_client.video.stop_captions(captions_data) @@ -1065,7 +1062,7 @@ vonage_client.video.stop_captions(captions_data) ### Start Audio Connector ```python -from vonage_video.models import AudioConnectorOptions +from vonage_video import AudioConnectorOptions audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) @@ -1074,7 +1071,7 @@ audio_connector_data = vonage_client.video.start_audio_connector(audio_connector ### Start Experience Composer ```python -from vonage_video.models import ExperienceComposerOptions +from vonage_video import ExperienceComposerOptions experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) @@ -1083,7 +1080,7 @@ experience_composer = vonage_client.video.start_experience_composer(experience_c ### List Experience Composers ```python -from vonage_video.models import ListExperienceComposersFilter +from vonage_video import ListExperienceComposersFilter filter = ListExperienceComposersFilter(page_size=10) experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) @@ -1105,7 +1102,7 @@ vonage_client.video.stop_experience_composer(experience_composer_id='experience_ ### List Archives ```python -from vonage_video.models import ListArchivesFilter +from vonage_video import ListArchivesFilter filter = ListArchivesFilter(offset=2) archives, count, next_page_offset = vonage_client.video.list_archives(filter) @@ -1115,7 +1112,7 @@ print(archives) ### Start Archive ```python -from vonage_video.models import CreateArchiveRequest +from vonage_video import CreateArchiveRequest archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') archive = vonage_client.video.start_archive(archive_options) @@ -1137,7 +1134,7 @@ vonage_client.video.delete_archive(archive_id='your_archive_id') ### Add Stream to Archive ```python -from vonage_video.models import AddStreamRequest +from vonage_video import AddStreamRequest add_stream_request = AddStreamRequest(stream_id='your_stream_id') vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) @@ -1159,7 +1156,7 @@ print(archive) ### Change Archive Layout ```python -from vonage_video.models import ComposedLayout +from vonage_video import ComposedLayout layout = ComposedLayout(type='bestFit') archive = vonage_client.video.change_archive_layout(archive_id='your_archive_id', layout=layout) @@ -1169,7 +1166,7 @@ print(archive) ### List Broadcasts ```python -from vonage_video.models import ListBroadcastsFilter +from vonage_video import ListBroadcastsFilter filter = ListBroadcastsFilter(page_size=10) broadcasts, count, next_page_offset = vonage_client.video.list_broadcasts(filter) @@ -1179,7 +1176,7 @@ print(broadcasts) ### Start Broadcast ```python -from vonage_video.models import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp +from vonage_video import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp broadcast_options = CreateBroadcastRequest(session_id='your_session_id', outputs=BroadcastOutputSettings( hls=BroadcastHls(dvr=True, low_latency=False), @@ -1213,7 +1210,7 @@ print(broadcast) ### Change Broadcast Layout ```python -from vonage_video.models import ComposedLayout +from vonage_video import ComposedLayout layout = ComposedLayout(type='bestFit') broadcast = vonage_client.video.change_broadcast_layout(broadcast_id='your_broadcast_id', layout=layout) @@ -1223,7 +1220,7 @@ print(broadcast) ### Add Stream to Broadcast ```python -from vonage_video.models import AddStreamRequest +from vonage_video import AddStreamRequest add_stream_request = AddStreamRequest(stream_id='your_stream_id') vonage_client.video.add_stream_to_broadcast(broadcast_id='your_broadcast_id', params=add_stream_request) @@ -1238,7 +1235,7 @@ vonage_client.video.remove_stream_from_broadcast(broadcast_id='your_broadcast_id ### Initiate SIP Call ```python -from vonage_video.models import InitiateSipRequest, SipOptions, SipAuth +from vonage_video import InitiateSipRequest, SipOptions, SipAuth sip_request_params = InitiateSipRequest( session_id='your_session_id', @@ -1281,7 +1278,7 @@ vonage_client.video.play_dtmf(session_id=session_id, digits=digits, connection_i To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. ```python -from vonage_voice.models import CreateCallRequest, Talk +from vonage_voice import CreateCallRequest, Talk ncco = [Talk(text='Hello world', loop=3, language='en-GB')] @@ -1303,7 +1300,7 @@ print(response.model_dump()) calls, next_record_index = vonage_client.voice.list_calls() # Specify filtering options -from vonage_voice.models import ListCallsFilter +from vonage_voice import ListCallsFilter call_filter = ListCallsFilter( status='completed', @@ -1364,7 +1361,7 @@ vonage_client.voice.unearmuff('UUID') ### Play Audio Into a Call ```python -from vonage_voice.models import AudioStreamOptions +from vonage_voice import AudioStreamOptions # Only the `stream_url` option is required options = AudioStreamOptions( @@ -1382,7 +1379,7 @@ vonage_client.voice.stop_audio_stream('UUID') ### Play TTS Into a Call ```python -from vonage_voice.models import TtsStreamOptions +from vonage_voice import TtsStreamOptions # Only the `text` field is required options = TtsStreamOptions( diff --git a/messages/CHANGES.md b/messages/CHANGES.md index ed2fabf3..7183d872 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 1.4.0 +- Make all models originally accessed by `vonage_messages.models.***` available at the top level of the package, i.e. `vonage_messages.***` + # 1.3.0 - Add support for API key/secret header authentication - Updated dependency versions diff --git a/messages/README.md b/messages/README.md index 37f7f957..206d174c 100644 --- a/messages/README.md +++ b/messages/README.md @@ -8,10 +8,10 @@ It is recommended to use this as part of the main `vonage` package. The examples ### How to Construct a Message -In order to send a message, you must construct a message object of the correct type. These are all found under `vonage_messages.models`. +In order to send a message, you must construct a message object of the correct type. ```python -from vonage_messages.models import Sms +from vonage_messages import Sms message = Sms( from_='Vonage APIs', @@ -35,7 +35,7 @@ Some message types have submodels with additional fields. In this case, import t e.g. ```python -from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource +from vonage_messages import MessengerImage, MessengerOptions, MessengerResource messenger = MessengerImage( to='1234567890', @@ -51,7 +51,7 @@ To send a message, access the `Messages.send` method via the main Vonage object, ```python from vonage import Auth, Vonage -from vonage_messages.models import Sms +from vonage_messages import Sms vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) diff --git a/messages/src/vonage_messages/__init__.py b/messages/src/vonage_messages/__init__.py index 11000717..b4ac8969 100644 --- a/messages/src/vonage_messages/__init__.py +++ b/messages/src/vonage_messages/__init__.py @@ -1,5 +1,6 @@ -from . import models +from . import models # Import models to access the module directly from .messages import Messages +from .models import * # Need this to directly expose data models from .responses import SendMessageResponse __all__ = ['models', 'Messages', 'SendMessageResponse'] diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 19b4f1d6..96e3ce8d 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.3.0' +__version__ = '1.4.0' diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index 9bcf3c64..51c8767b 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -2,17 +2,15 @@ import responses from pytest import raises -from vonage_http_client.auth import Auth -from vonage_http_client.errors import HttpRequestError -from vonage_http_client.http_client import HttpClient, HttpClientOptions -from vonage_messages.messages import Messages -from vonage_messages.models import Sms -from vonage_messages.models.messenger import ( +from vonage_http_client import Auth, HttpClient, HttpClientOptions, HttpRequestError +from vonage_messages import ( + Messages, MessengerImage, MessengerOptions, MessengerResource, + SendMessageResponse, + Sms, ) -from vonage_messages.responses import SendMessageResponse from testutils import build_response, get_mock_api_key_auth, get_mock_jwt_auth diff --git a/pants.ci.toml b/pants.ci.toml index a0749d00..299dd51f 100644 --- a/pants.ci.toml +++ b/pants.ci.toml @@ -2,4 +2,4 @@ colors = true [python] -interpreter_constraints = ['>=3.8'] +interpreter_constraints = ['>=3.9'] diff --git a/pants.toml b/pants.toml index 3b13e505..27ab0803 100644 --- a/pants.toml +++ b/pants.toml @@ -27,20 +27,20 @@ interpreter_constraints = ['==3.12.*'] args = ['-vv', '--no-header'] [coverage-py] -interpreter_constraints = ['>=3.8'] +interpreter_constraints = ['>=3.9'] report = ['html', 'console'] [black] args = ['--line-length=90', '--skip-string-normalization'] -interpreter_constraints = ['>=3.8'] +interpreter_constraints = ['>=3.9'] [isort] args = ['--profile=black', '--line-length=90'] -interpreter_constraints = ['>=3.8'] +interpreter_constraints = ['>=3.9'] [docformatter] args = ['--wrap-summaries=90', '--wrap-descriptions=90'] -interpreter_constraints = ['>=3.8'] +interpreter_constraints = ['>=3.9'] [autoflake] -interpreter_constraints = ['>=3.8'] +interpreter_constraints = ['>=3.9'] diff --git a/video/CHANGES.md b/video/CHANGES.md index b30e01a7..bfad17f4 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.0 +- Make all models originally accessed by `vonage_video.models.***` available at the top level of the package, i.e. `vonage_video.***` + # 1.1.0 - Add new `max_bitrate` field for archives diff --git a/video/OPENTOK_TO_VONAGE_MIGRATION.md b/video/OPENTOK_TO_VONAGE_MIGRATION.md index acf5c74a..f7a2b2d9 100644 --- a/video/OPENTOK_TO_VONAGE_MIGRATION.md +++ b/video/OPENTOK_TO_VONAGE_MIGRATION.md @@ -60,10 +60,10 @@ vonage_client.video.video_api_method... ## Accessing Video API Data Models -You can access data models for the Video API, e.g. as arguments to video methods, by importing them from the `vonage_video.models` package, e.g. +You can access data models for the Video API, e.g. as arguments to video methods, by importing them from the `vonage_video` package, e.g. ```python -from vonage_video.models import SessionOptions +from vonage_video import SessionOptions session_options = SessionOptions(...) @@ -78,7 +78,7 @@ vonage_client.video.create_session(session_options) There are some changes to methods between the `opentok` SDK and the Video API implementation in the `vonage-video` SDK. -- Any positional parameters in method signatures have been replaced with data models in the `vonage-video` package, stored at `vonage_video.models`. +- Any positional parameters in method signatures have been replaced with data models in the `vonage-video` package. - Methods now return responses as Pydantic data models. - Some methods have been renamed, for clarity and/or to better reflect what the method does. These are listed below: diff --git a/video/README.md b/video/README.md index d26ac46b..1330f615 100644 --- a/video/README.md +++ b/video/README.md @@ -6,12 +6,12 @@ This package contains the code to use [Vonage's Video API](https://developer.von 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`. -You will use the custom Pydantic data models to make most of the API calls in this package. They are accessed from the `vonage_video.models` package. +You will use the custom Pydantic data models to make most of the API calls in this package. ### Generate a Client Token ```python -from vonage_video.models import TokenOptions +from vonage_video import TokenOptions token_options = TokenOptions(session_id='your_session_id', role='publisher') client_token = vonage_client.video.generate_client_token(token_options) @@ -20,7 +20,7 @@ client_token = vonage_client.video.generate_client_token(token_options) ### Create a Session ```python -from vonage_video.models import SessionOptions +from vonage_video import SessionOptions session_options = SessionOptions(media_mode='routed') video_session = vonage_client.video.create_session(session_options) @@ -41,7 +41,7 @@ stream_info = vonage_client.video.get_stream(session_id='your_session_id', strea ### Change Stream Layout ```python -from vonage_video.models import StreamLayoutOptions +from vonage_video import StreamLayoutOptions layout_options = StreamLayoutOptions(type='bestFit') updated_streams = vonage_client.video.change_stream_layout(session_id='your_session_id', stream_layout_options=layout_options) @@ -50,7 +50,7 @@ updated_streams = vonage_client.video.change_stream_layout(session_id='your_sess ### Send a Signal ```python -from vonage_video.models import SignalData +from vonage_video import SignalData signal_data = SignalData(type='chat', data='Hello, World!') vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) @@ -83,7 +83,7 @@ vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ### Start Captions ```python -from vonage_video.models import CaptionsOptions +from vonage_video import CaptionsOptions captions_options = CaptionsOptions(language='en-US') captions_data = vonage_client.video.start_captions(captions_options) @@ -92,7 +92,7 @@ captions_data = vonage_client.video.start_captions(captions_options) ### Stop Captions ```python -from vonage_video.models import CaptionsData +from vonage_video import CaptionsData captions_data = CaptionsData(captions_id='your_captions_id') vonage_client.video.stop_captions(captions_data) @@ -101,7 +101,7 @@ vonage_client.video.stop_captions(captions_data) ### Start Audio Connector ```python -from vonage_video.models import AudioConnectorOptions +from vonage_video import AudioConnectorOptions audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) @@ -110,7 +110,7 @@ audio_connector_data = vonage_client.video.start_audio_connector(audio_connector ### Start Experience Composer ```python -from vonage_video.models import ExperienceComposerOptions +from vonage_video import ExperienceComposerOptions experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) @@ -119,7 +119,7 @@ experience_composer = vonage_client.video.start_experience_composer(experience_c ### List Experience Composers ```python -from vonage_video.models import ListExperienceComposersFilter +from vonage_video import ListExperienceComposersFilter filter = ListExperienceComposersFilter(page_size=10) experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) @@ -141,7 +141,7 @@ vonage_client.video.stop_experience_composer(experience_composer_id='experience_ ### List Archives ```python -from vonage_video.models import ListArchivesFilter +from vonage_video import ListArchivesFilter filter = ListArchivesFilter(offset=2) archives, count, next_page_offset = vonage_client.video.list_archives(filter) @@ -151,7 +151,7 @@ print(archives) ### Start Archive ```python -from vonage_video.models import CreateArchiveRequest +from vonage_video import CreateArchiveRequest archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') archive = vonage_client.video.start_archive(archive_options) @@ -173,7 +173,7 @@ vonage_client.video.delete_archive(archive_id='your_archive_id') ### Add Stream to Archive ```python -from vonage_video.models import AddStreamRequest +from vonage_video import AddStreamRequest add_stream_request = AddStreamRequest(stream_id='your_stream_id') vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) @@ -195,7 +195,7 @@ print(archive) ### Change Archive Layout ```python -from vonage_video.models import ComposedLayout +from vonage_video import ComposedLayout layout = ComposedLayout(type='bestFit') archive = vonage_client.video.change_archive_layout(archive_id='your_archive_id', layout=layout) @@ -205,7 +205,7 @@ print(archive) ### List Broadcasts ```python -from vonage_video.models import ListBroadcastsFilter +from vonage_video import ListBroadcastsFilter filter = ListBroadcastsFilter(page_size=10) broadcasts, count, next_page_offset = vonage_client.video.list_broadcasts(filter) @@ -215,7 +215,7 @@ print(broadcasts) ### Start Broadcast ```python -from vonage_video.models import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp +from vonage_video import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp broadcast_options = CreateBroadcastRequest(session_id='your_session_id', outputs=BroadcastOutputSettings( hls=BroadcastHls(dvr=True, low_latency=False), @@ -249,7 +249,7 @@ print(broadcast) ### Change Broadcast Layout ```python -from vonage_video.models import ComposedLayout +from vonage_video import ComposedLayout layout = ComposedLayout(type='bestFit') broadcast = vonage_client.video.change_broadcast_layout(broadcast_id='your_broadcast_id', layout=layout) @@ -259,7 +259,7 @@ print(broadcast) ### Add Stream to Broadcast ```python -from vonage_video.models import AddStreamRequest +from vonage_video import AddStreamRequest add_stream_request = AddStreamRequest(stream_id='your_stream_id') vonage_client.video.add_stream_to_broadcast(broadcast_id='your_broadcast_id', params=add_stream_request) @@ -274,7 +274,7 @@ vonage_client.video.remove_stream_from_broadcast(broadcast_id='your_broadcast_id ### Initiate SIP Call ```python -from vonage_video.models import InitiateSipRequest, SipOptions, SipAuth +from vonage_video import InitiateSipRequest, SipOptions, SipAuth sip_request_params = InitiateSipRequest( session_id='your_session_id', diff --git a/video/src/vonage_video/__init__.py b/video/src/vonage_video/__init__.py index 16da3cf3..a91ef7a5 100644 --- a/video/src/vonage_video/__init__.py +++ b/video/src/vonage_video/__init__.py @@ -1,4 +1,5 @@ -from . import errors, models +from . import errors, models # Import models to access the module directly +from .models import * # Need this to directly expose data models from .video import Video __all__ = ['Video', 'errors', 'models'] diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 1a72d32e..58d478ab 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.1.0' +__version__ = '1.2.0' diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index d0e58a69..4ef03d88 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -2,7 +2,18 @@ import responses from pytest import raises -from vonage_http_client.http_client import HttpClient +from vonage_http_client import HttpClient +from vonage_video import ( + AddStreamRequest, + ComposedLayout, + CreateArchiveRequest, + LayoutType, + ListArchivesFilter, + OutputMode, + StreamMode, + Video, + VideoResolution, +) from vonage_video.errors import ( IndividualArchivePropertyError, InvalidArchiveStateError, @@ -10,14 +21,6 @@ LayoutStylesheetError, NoAudioOrVideoError, ) -from vonage_video.models.archive import ( - ComposedLayout, - CreateArchiveRequest, - ListArchivesFilter, -) -from vonage_video.models.common import AddStreamRequest -from vonage_video.models.enums import LayoutType, OutputMode, StreamMode, VideoResolution -from vonage_video.video import Video from testutils import build_response, get_mock_jwt_auth diff --git a/video/tests/test_audio_connector.py b/video/tests/test_audio_connector.py index 0c9cce6b..48d52311 100644 --- a/video/tests/test_audio_connector.py +++ b/video/tests/test_audio_connector.py @@ -2,13 +2,14 @@ import responses from vonage_http_client import HttpClient -from vonage_video.models.audio_connector import ( +from vonage_video import ( AudioConnectorOptions, AudioConnectorWebSocket, + AudioSampleRate, + TokenOptions, + TokenRole, + Video, ) -from vonage_video.models.enums import AudioSampleRate, TokenRole -from vonage_video.models.token import TokenOptions -from vonage_video.video import Video from testutils import build_response, get_mock_jwt_auth diff --git a/voice/CHANGES.md b/voice/CHANGES.md index f563df38..d7bdb746 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.0 +- Make all models originally accessed by `vonage_voice.models.***` available at the top level of the package, i.e. `vonage_voice.***` + # 1.1.2 - Update incorrect return type annotation for `Voice.download_recording` diff --git a/voice/README.md b/voice/README.md index d68e96d8..1c8a3338 100644 --- a/voice/README.md +++ b/voice/README.md @@ -4,7 +4,7 @@ This package contains the code to use [Vonage's Voice API](https://developer.von ## Structure -There is a `Voice` class which contains the methods used to call Vonage APIs. To call many of the APIs, you need to pass a Pydantic model with the required options. These can be accessed from the `vonage_voice.models` subpackage. Errors can be accessed from the `vonage_voice.errors` module. +There is a `Voice` class which contains the methods used to call Vonage APIs. To call many of the APIs, you need to pass a Pydantic model with the required options. Errors can be accessed from the `vonage_voice.errors` module. ## Usage @@ -21,7 +21,7 @@ vonage_client = Vonage(Auth('MY_AUTH_INFO')) To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. ```python -from vonage_voice.models import CreateCallRequest, Talk +from vonage_voice import CreateCallRequest, Talk ncco = [Talk(text='Hello world', loop=3, language='en-GB')] @@ -43,7 +43,7 @@ print(response.model_dump()) calls, next_record_index = vonage_client.voice.list_calls() # Specify filtering options -from vonage_voice.models import ListCallsFilter +from vonage_voice import ListCallsFilter call_filter = ListCallsFilter( status='completed', @@ -104,7 +104,7 @@ vonage_client.voice.unearmuff('UUID') ### Play Audio Into a Call ```python -from vonage_voice.models import AudioStreamOptions +from vonage_voice import AudioStreamOptions # Only the `stream_url` option is required options = AudioStreamOptions( @@ -122,7 +122,7 @@ vonage_client.voice.stop_audio_stream('UUID') ### Play TTS Into a Call ```python -from vonage_voice.models import TtsStreamOptions +from vonage_voice import TtsStreamOptions # Only the `text` field is required options = TtsStreamOptions( diff --git a/voice/src/vonage_voice/__init__.py b/voice/src/vonage_voice/__init__.py index b73b81f1..5f500f22 100644 --- a/voice/src/vonage_voice/__init__.py +++ b/voice/src/vonage_voice/__init__.py @@ -1,4 +1,5 @@ -from . import errors, models +from . import errors, models # Import models to access the module directly +from .models import * # Need this to directly expose data models from .voice import Voice __all__ = ['Voice', 'errors', 'models'] diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 7b344eca..58d478ab 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.1.2' +__version__ = '1.2.0' diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index 5046cd21..d0b8c39c 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -4,14 +4,14 @@ from pytest import raises from responses.matchers import json_params_matcher from vonage_http_client.http_client import HttpClient -from vonage_voice.errors import VoiceError -from vonage_voice.models.ncco import Talk -from vonage_voice.models.requests import ( +from vonage_voice import ( AudioStreamOptions, CreateCallRequest, ListCallsFilter, TtsStreamOptions, ) +from vonage_voice.errors import VoiceError +from vonage_voice.models.ncco import Talk from vonage_voice.models.responses import CreateCallResponse from vonage_voice.voice import Voice diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 9aad0258..af16e675 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,8 @@ +# 4.3.0 +- Make all models originally accessed by `vonage_voice.models.***` available at the top level of the package, i.e. `vonage_voice.***` +- Make all models originally accessed by `vonage_video.models.***` available at the top level of the package, i.e. `vonage_video.***` +- Make all models originally accessed by `vonage_messages.models.***` available at the top level of the package, i.e. `vonage_messages.***` + # 4.2.0 - Add new `max_bitrate` field for Video API archives - Fix a bug with error types diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 52397140..30d79323 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "vonage-http-client>=1.5.1", "vonage-account>=1.1.1", "vonage-application>=2.0.1", - "vonage-messages>=1.3.0", + "vonage-messages>=1.4.0", "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", @@ -20,8 +20,8 @@ dependencies = [ "vonage-users>=1.2.1", "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", - "vonage-video>=1.1.0", - "vonage-voice>=1.1.2", + "vonage-video>=1.2.0", + "vonage-voice>=1.2.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index ea5d65fc..5ee6158c 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.2.0' +__version__ = '4.3.0' From 85a7fc05a825f646afaeef5d36aad510e0711dfc Mon Sep 17 00:00:00 2001 From: Paul Ardeleanu Date: Tue, 4 Feb 2025 15:58:24 +0000 Subject: [PATCH 294/401] Updates to the Contributing section (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updates to the Contributing section - no need to clarify we're using python3 - using `source` to activate the virtual environment in Mac/Linux (as per https://docs.python.org/3/library/venv.html#how-venvs-work) - Since testutils is a local module, we need to ensure it is discoverable when running the tests - we achieve this by setting the PYTHONPATH environment variable * fix spelling * Reverted some changes that can cause incompatibility on some systems --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 75fe21c5..956b6f6c 100644 --- a/README.md +++ b/README.md @@ -1458,7 +1458,20 @@ We don't currently support asyncio in the Python SDK. We :heart: contributions! But if you plan to work on something big or controversial, please contact us by raising an issue first! -We recommend working on `vonage-python-sdk` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: +We recommend working on `vonage-python-sdk` within a virtual environment - below we're using the [venv](https://docs.python.org/3/library/venv.html)] module: + +```bash +# Create the virtual environment +python3 -m venv venv + +# Activate the virtual environment in Mac/Linux +. ./venv/bin/activate + +# Or on Windows Command Prompt +venv\Scripts\activate +``` + +The following command will install all the Python dependencies you need to run the tests: ```bash pip install -r requirements.txt @@ -1467,7 +1480,7 @@ pip install -r requirements.txt The tests are all written with pytest. You run them with: ```bash -pytest -v +PYTHONPATH=. pytest -v ``` We use [Black](https://black.readthedocs.io/en/stable/index.html) for code formatting, with our config in the `pyproject.toml` file. To ensure a PR follows the right format, you can set up and use our pre-commit settings with From f3ce002b9ad9d8bf2905a736ad32ddaf449e1b06 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 13 Feb 2025 13:22:39 +0000 Subject: [PATCH 295/401] edits to voice api models --- voice/CHANGES.md | 6 +++ voice/src/vonage_voice/_version.py | 2 +- voice/src/vonage_voice/models/common.py | 6 +++ .../vonage_voice/models/connect_endpoints.py | 4 ++ voice/src/vonage_voice/models/requests.py | 6 +-- voice/src/vonage_voice/voice.py | 4 ++ voice/tests/test_ncco_actions.py | 5 +- voice/tests/test_voice.py | 46 ++++++++++++++++++- vonage/CHANGES.md | 7 +++ vonage/pyproject.toml | 2 +- 10 files changed, 80 insertions(+), 8 deletions(-) diff --git a/voice/CHANGES.md b/voice/CHANGES.md index d7bdb746..a9d4a3a1 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,9 @@ +# 1.3.0 +- Add new `headers` and `standard_headers` options to the `Sip` data model +- Add new `standardHeaders` option to the `SipEndpoint` NCCO model +- Add check for invalid hostnames when downloading a recording with `Voice.download_recording` +- Allow the `CreateCallRequest` model to accept a SIP URI as well as a phone number in the `from_` field + # 1.2.0 - Make all models originally accessed by `vonage_voice.models.***` available at the top level of the package, i.e. `vonage_voice.***` diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 58d478ab..19b4f1d6 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.2.0' +__version__ = '1.3.0' diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index c1f2741f..577b8e24 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from vonage_utils.types import PhoneNumber, SipUri from vonage_voice.models.enums import Channel +from vonage_voice.models.input_types import Dtmf class Phone(BaseModel): @@ -21,9 +22,14 @@ class Sip(BaseModel): Args: uri (SipUri): The SIP URI. + headers (Optional[dict]): Metadata to include in the request. The headers are transmitted as part of the SIP INVITE as `X-key: value` headers. + standard_headers (Optional[dict]): Standard SIP headers to include in the request. Unlike `headers`, these are not prepended with `X-`. + This should be of the form `{'User-to-User': '342342ef34;encoding=hex'} """ uri: SipUri + headers: Optional[dict] = None + standard_headers: Optional[dict] = None type: Channel = Channel.SIP diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index dc026e3e..d1c15715 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -73,10 +73,14 @@ class SipEndpoint(BaseModel): headers (Optional[dict]): The headers to include with the SIP connection. To use TLS and/or SRTP, include respectively `transport=tls` or `media=srtp` to the URL with the semicolon `;` as a delimiter. + standardHeaders (Optional[dict]): Standard SIP headers to include in the request. Unlike + `headers`, these are not prepended with `X-`. This should be of the form + `{'User-to-User': '342342ef34;encoding=hex'}`. """ uri: SipUri headers: Optional[dict] = None + standardHeaders: Optional[dict] = None type: ConnectEndpointType = ConnectEndpointType.SIP diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index 7d2b1116..550857cb 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -30,8 +30,7 @@ class CreateCallRequest(BaseModel): answer_method (Optional[Literal['POST', 'GET']]): The HTTP method used to send event information to `answer_url`. to (list[Union[ToPhone, Sip, Websocket, Vbc]]): The type of connection to call. - from_ (Optional[Phone]): The phone number to use when calling. Mutually exclusive - with the `random_from_number` property. + from_ (Optional[Union[Phone, str]]): The phone number or SIP URI to use when calling. Mutually exclusive with the `random_from_number` property. random_from_number (Optional[bool]): Whether to use a random number as the caller's phone number. The number will be selected from the list of the numbers assigned to the current application. Mutually exclusive with the `from_` property. @@ -60,8 +59,7 @@ class CreateCallRequest(BaseModel): answer_url: list[str] = None answer_method: Optional[Literal['POST', 'GET']] = None to: list[Union[ToPhone, Sip, Websocket, Vbc]] - - from_: Optional[Phone] = Field(None, serialization_alias='from') + from_: Optional[Union[Phone, str]] = Field(None, serialization_alias='from') random_from_number: Optional[bool] = None event_url: Optional[list[str]] = None event_method: Optional[Literal['POST', 'GET']] = None diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index dcbd83d9..6ebd38e0 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -4,6 +4,7 @@ from vonage_http_client.http_client import HttpClient from vonage_jwt.verify_jwt import verify_signature from vonage_utils.types import Dtmf +from vonage_voice.errors import VoiceError from vonage_voice.models.ncco import NccoAction from .models.requests import ( @@ -272,6 +273,9 @@ def download_recording(self, url: str, file_path: str) -> None: url (str): The URL of the recording to get. file_path (str): The path to save the recording to. """ + if not 'vonage.com' in url and not 'nexmo.com' in url: + raise VoiceError('The recording URL must be from a Vonage or Nexmo hostname.') + self._http_client.download_file_stream(url=url, file_path=file_path) @validate_call diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index 4b9e9069..4e1a3c75 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -113,10 +113,13 @@ def test_create_connect_endpoints(): } assert connect_endpoints.SipEndpoint( - uri='sip:example@sip.example.com', headers={'qwer': 'asdf'} + uri='sip:example@sip.example.com', + headers={'qwer': 'asdf'}, + standardHeaders={'User-to-User': '342342ef34;encoding=hex'}, ).model_dump() == { 'uri': 'sip:example@sip.example.com', 'headers': {'qwer': 'asdf'}, + 'standardHeaders': {'User-to-User': '342342ef34;encoding=hex'}, 'type': 'sip', } diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index d0b8c39c..cfcd459e 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -1,3 +1,4 @@ +import json from os.path import abspath import responses @@ -8,6 +9,7 @@ AudioStreamOptions, CreateCallRequest, ListCallsFilter, + Sip, TtsStreamOptions, ) from vonage_voice.errors import VoiceError @@ -35,9 +37,42 @@ def test_create_call_basic_ncco(): ncco = [Talk(text='Hello world')] call = CreateCallRequest( ncco=ncco, - to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], + to=[ + Sip( + uri='sip:test@example.com', + headers={'location': 'New York City'}, + standard_headers={'User-to-User': '342342ef34;encoding=hex'}, + ) + ], random_from_number=True, ) + + response = voice.create_call(call) + + body = json.loads(voice.http_client.last_request.body) + assert body['to'][0]['headers'] == {'location': 'New York City'} + assert body['to'][0]['standard_headers'] == { + 'User-to-User': '342342ef34;encoding=hex' + } + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_basic_ncco_from_sip(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[Sip(uri='sip:test@example.com')], + from_='sip:from_sip_uri@example.com', + ) + response = voice.create_call(call) assert type(response) == CreateCallResponse @@ -429,6 +464,15 @@ def test_download_recording(): assert file_content.startswith(b'ID3') +def test_download_recording_invalid_url(): + with raises(VoiceError) as e: + voice.download_recording( + url='https://invalid.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + file_path='voice/tests/data/file_stream.mp3', + ) + assert e.match('The recording URL must be from a Vonage or Nexmo hostname.') + + def test_verify_signature(): token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM' valid_signature = "qwertyuiopasdfghjklzxcvbnm123456" diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index af16e675..46c77340 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,10 @@ +# 4.4.0 +Vonage Voice Package: +- Add new `headers` and `standard_headers` options to the `Sip` data model +- Add new `standardHeaders` option to the `SipEndpoint` NCCO model +- Add check for invalid hostnames when downloading a recording with `Voice.download_recording` +- Allow the `CreateCallRequest` model to accept a SIP URI as well as a phone number in the `from_` field + # 4.3.0 - Make all models originally accessed by `vonage_voice.models.***` available at the top level of the package, i.e. `vonage_voice.***` - Make all models originally accessed by `vonage_video.models.***` available at the top level of the package, i.e. `vonage_video.***` diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 30d79323..c52ca065 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", "vonage-video>=1.2.0", - "vonage-voice>=1.2.0", + "vonage-voice>=1.3.0", ] classifiers = [ "Programming Language :: Python", From 1ada40ef9146eac1e6e90adaa48a19d3498ad26e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 13 Feb 2025 13:25:47 +0000 Subject: [PATCH 296/401] update version --- voice/src/vonage_voice/models/common.py | 1 - vonage/src/vonage/_version.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 577b8e24..1a155af2 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -3,7 +3,6 @@ from pydantic import BaseModel, Field from vonage_utils.types import PhoneNumber, SipUri from vonage_voice.models.enums import Channel -from vonage_voice.models.input_types import Dtmf class Phone(BaseModel): diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 5ee6158c..26a6c390 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.3.0' +__version__ = '4.4.0' From 0635b623accc4b56ff09ce462fb0e89c8c3174b0 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 24 Apr 2025 09:48:42 +0200 Subject: [PATCH 297/401] update voice values --- pants.toml | 4 ++-- voice/CHANGES.md | 4 ++++ voice/src/vonage_voice/_version.py | 2 +- voice/src/vonage_voice/models/ncco.py | 4 ++++ voice/src/vonage_voice/models/requests.py | 2 +- vonage/CHANGES.md | 3 +++ vonage/pyproject.toml | 2 +- vonage/src/vonage/_version.py | 2 +- 8 files changed, 17 insertions(+), 6 deletions(-) diff --git a/pants.toml b/pants.toml index 27ab0803..fb8de4e5 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.23.0' +pants_version = '2.24.2' backend_packages = [ 'pants.backend.python', @@ -21,7 +21,7 @@ enabled = false root_patterns = ['/', 'src/', 'tests/'] [python] -interpreter_constraints = ['==3.12.*'] +interpreter_constraints = ['==3.13.*'] [pytest] args = ['-vv', '--no-header'] diff --git a/voice/CHANGES.md b/voice/CHANGES.md index a9d4a3a1..95b0a479 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,7 @@ +# 1.4.0 +- Increase maximum value of call `length_timer` to 86400s +- Add additional fields `eventUrl` and `eventMethod` to NCCO model + # 1.3.0 - Add new `headers` and `standard_headers` options to the `Sip` data model - Add new `standardHeaders` option to the `SipEndpoint` NCCO model diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 19b4f1d6..96e3ce8d 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.3.0' +__version__ = '1.4.0' diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index b985e3fd..72388719 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -88,6 +88,8 @@ class Conversation(NccoAction): hear. If not provided, the participant can hear everyone. If an empty list is provided, the participant will not hear any other participants. mute (Optional[bool]): Mute the participant. + eventUrl (Optional[list[str]]): The URL to send asynchronous events to. + eventMethod (Optional[str]): The HTTP method used to send the event to `eventUrl`. Raises: NccoActionError: If the `mute` option is used with the `canSpeak` option. @@ -101,6 +103,8 @@ class Conversation(NccoAction): canSpeak: Optional[list[str]] = None canHear: Optional[list[str]] = None mute: Optional[bool] = None + eventUrl: Optional[list] = None + eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.CONVERSATION @model_validator(mode='after') diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index 550857cb..25c006a4 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -65,7 +65,7 @@ class CreateCallRequest(BaseModel): event_method: Optional[Literal['POST', 'GET']] = None machine_detection: Optional[Literal['continue', 'hangup']] = None advanced_machine_detection: Optional[AdvancedMachineDetection] = None - length_timer: Optional[int] = Field(None, ge=1, le=7200) + length_timer: Optional[int] = Field(None, ge=1, le=86400) ringing_timer: Optional[int] = Field(None, ge=1, le=120) @model_validator(mode='after') diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 46c77340..a277b84a 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.4.1 +- Update some Voice API parameters + # 4.4.0 Vonage Voice Package: - Add new `headers` and `standard_headers` options to the `Sip` data model diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index c52ca065..30bcd6bf 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", "vonage-video>=1.2.0", - "vonage-voice>=1.3.0", + "vonage-voice>=1.4.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 26a6c390..6dd6cf9b 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.4.0' +__version__ = '4.4.1' From a87a393ad5389f7b6555ebb7266ece21a20c5ad6 Mon Sep 17 00:00:00 2001 From: Chuck Reeves Date: Thu, 24 Apr 2025 04:23:09 -0400 Subject: [PATCH 298/401] build: add slack-notificaitons (#315) --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..610123a6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release Action + +on: + release: + types: + - published + +jobs: + notify-release: + runs-on: ubuntu-latest + name: Notify Release + strategy: + matrix: + url: [SLACK_WEBHOOK_ASK_DEVREL_URL, SLACK_WEBHOOK_DEVREL_TOOLING_URL, SLACK_WEBHOOK_DEVREL_PRIVATE_URL, SLACK_WEBHOOK_COMMUNITY] + steps: + - name: Send to slack channels + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets[matrix.url]}} + webhook-type: incoming-webhook + errors: true + payload: | + blocks: + - type: "header" + text: + type: "plain_text" + text: ":initial_external_notification_sent: :python: Version ${{ github.event.release.name }} of the Python SDK has been released" + - type: "section" + text: + type: "mrkdwn" + text: "${{ github.event.release.body }}" + - type: "divider" + - type: "section" + text: + type: "mrkdwn" + text: "You can view the full change log <${{github.event.release.html_url }}|here>" + + + From 3ac3d1abee38793c672fdbf43c42e69fb8d7fc28 Mon Sep 17 00:00:00 2001 From: Chuck Reeves Date: Thu, 24 Apr 2025 09:59:43 -0400 Subject: [PATCH 299/401] build: remove community slack (#316) --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 610123a6..cf7e4e88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,12 @@ jobs: name: Notify Release strategy: matrix: - url: [SLACK_WEBHOOK_ASK_DEVREL_URL, SLACK_WEBHOOK_DEVREL_TOOLING_URL, SLACK_WEBHOOK_DEVREL_PRIVATE_URL, SLACK_WEBHOOK_COMMUNITY] + url: [SLACK_WEBHOOK_ASK_DEVREL_URL, SLACK_WEBHOOK_DEVREL_TOOLING_URL, SLACK_WEBHOOK_DEVREL_PRIVATE_URL] steps: - name: Send to slack channels uses: slackapi/slack-github-action@v2.0.0 + if: always(); + continue-on-error: true with: webhook: ${{ secrets[matrix.url]}} webhook-type: incoming-webhook From 1909940e8452f24deb7d0f3dec06d1ad53a1a99e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 2 May 2025 12:31:42 +0100 Subject: [PATCH 300/401] update readme --- V3_TO_V4_SDK_MIGRATION_GUIDE.md | 2 ++ video/README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md index 639d3743..3ff48265 100644 --- a/V3_TO_V4_SDK_MIGRATION_GUIDE.md +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -190,6 +190,8 @@ Methods have been added to help you moderate a voice call: - `voice.earmuff` - `voice.unearmuff` +Also, the `voice.get_recording` method from v3 has been replaced by `voice.download_recording` in v4. The behaviour also changes as now the method will download the recording for you by streaming the response into a file. + See the [Voice API samples](voice/README.md) for more information. ### Network Number Verification API diff --git a/video/README.md b/video/README.md index 1330f615..3820f99a 100644 --- a/video/README.md +++ b/video/README.md @@ -283,7 +283,7 @@ sip_request_params = InitiateSipRequest( uri=f'sip:{vonage_number}@sip.nexmo.com;transport=tls', from_=f'test@vonage.com', headers={'header_key': 'header_value'}, - auth=SipAuth(username='1485b9e6', password='fL8jvi4W2FmS9som'), + auth=SipAuth(username='your_username', password='your_password'), secure=False, video=False, observe_force_mute=True, From bd9388c47d7f2565f6ac3e0c04fb07a6d5078e8e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 8 May 2025 15:56:28 +0100 Subject: [PATCH 301/401] make fields optional in sms response --- sms/CHANGES.md | 3 +++ sms/src/vonage_sms/_version.py | 2 +- sms/src/vonage_sms/responses.py | 12 ++++++------ vonage/CHANGES.md | 3 +++ vonage/pyproject.toml | 2 +- vonage/src/vonage/_version.py | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/sms/CHANGES.md b/sms/CHANGES.md index 1e8390f3..16cb22df 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.6 +- Make returned response fields optional + # 1.1.5 - Updated dependency versions diff --git a/sms/src/vonage_sms/_version.py b/sms/src/vonage_sms/_version.py index 99d2a6fa..6ebd335c 100644 --- a/sms/src/vonage_sms/_version.py +++ b/sms/src/vonage_sms/_version.py @@ -1 +1 @@ -__version__ = '1.1.5' +__version__ = '1.1.6' diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py index 04282796..f9bc0053 100644 --- a/sms/src/vonage_sms/responses.py +++ b/sms/src/vonage_sms/responses.py @@ -20,12 +20,12 @@ class MessageResponse(BaseModel): please email support. """ - 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 + to: Optional[str] = None + message_id: Optional[str] = Field(None, validation_alias='message-id') + status: Optional[str] = None + remaining_balance: Optional[str] = Field(None, validation_alias='remaining-balance') + message_price: Optional[str] = Field(None, validation_alias='message-price') + network: Optional[str] = None client_ref: Optional[str] = Field(None, validation_alias='client-ref') account_ref: Optional[str] = Field(None, validation_alias='account-ref') diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index a277b84a..89920536 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.4.2 +- vonage-sms: Make returned response fields optional + # 4.4.1 - Update some Voice API parameters diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 30bcd6bf..2f6c8184 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "vonage-network-number-verification>=1.0.2", "vonage-number-insight>=1.0.6", "vonage-numbers>=1.0.4", - "vonage-sms>=1.1.5", + "vonage-sms>=1.1.6", "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.1", "vonage-verify>=2.1.0", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 6dd6cf9b..31618fca 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.4.1' +__version__ = '4.4.2' From 8a0b832ab28757e221c1f6cc2d51d3b014c1cb65 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 8 May 2025 16:13:31 +0100 Subject: [PATCH 302/401] fix action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf7e4e88..cd27ecff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Send to slack channels uses: slackapi/slack-github-action@v2.0.0 - if: always(); + if: always() continue-on-error: true with: webhook: ${{ secrets[matrix.url]}} From a5f6228434b6b20574c563f71e1ee0c6a6d745bc Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 9 May 2025 16:18:15 +0100 Subject: [PATCH 303/401] update ni v1 to use header authentication --- number_insight/CHANGES.md | 3 +++ number_insight/src/vonage_number_insight/_version.py | 2 +- number_insight/src/vonage_number_insight/number_insight.py | 2 +- vonage/CHANGES.md | 5 ++++- vonage/pyproject.toml | 2 +- vonage/src/vonage/_version.py | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index 5d9b12e1..a33b08fd 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.7 +- Use basic header auth instead of request body auth + # 1.0.6 - Updated dependency versions diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py index da2182f1..887a3427 100644 --- a/number_insight/src/vonage_number_insight/_version.py +++ b/number_insight/src/vonage_number_insight/_version.py @@ -1 +1 @@ -__version__ = '1.0.6' +__version__ = '1.0.7' diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py index e5cb0039..cebc1227 100644 --- a/number_insight/src/vonage_number_insight/number_insight.py +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -25,7 +25,7 @@ class NumberInsight: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client - self._auth_type = 'body' + self._auth_type = 'basic' @property def http_client(self) -> HttpClient: diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 89920536..185e6aa3 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,8 @@ +# 4.4.3 +- vonage-number-insight: use basic header auth instead of request body auth + # 4.4.2 -- vonage-sms: Make returned response fields optional +- vonage-sms: make returned response fields optional # 4.4.1 - Update some Voice API parameters diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 2f6c8184..c04e105f 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", - "vonage-number-insight>=1.0.6", + "vonage-number-insight>=1.0.7", "vonage-numbers>=1.0.4", "vonage-sms>=1.1.6", "vonage-subaccounts>=1.0.4", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 31618fca..77ed0d0b 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.4.2' +__version__ = '4.4.3' From fadcd53c151c3e7de3892cf603af4403ef57cb3b Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 9 May 2025 16:18:15 +0100 Subject: [PATCH 304/401] update release action --- .github/workflows/mutation-test.yml | 4 +- .github/workflows/release.yml | 58 +++++++++++++++++++++-- Makefile | 6 ++- vonage_utils/src/vonage_utils/_version.py | 2 +- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml index 478cf5b4..e02f4cfc 100644 --- a/.github/workflows/mutation-test.yml +++ b/.github/workflows/mutation-test.yml @@ -26,9 +26,9 @@ jobs: continue-on-error: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd27ecff..47b7e90c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,17 +4,70 @@ on: release: types: - published + workflow_dispatch: # for testing jobs: + publish-package: + runs-on: ubuntu-latest + name: Publish to PyPI + + outputs: + published: ${{ steps.set-published-state.outputs.published }} # Define an output for the job + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch history for all tags + + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + + - name: Initialize pants + uses: pantsbuild/actions/init-pants@main + with: + gha-cache-key: cache0-py3.13 + named-caches-hash: ${{ hashFiles('requirements.txt') }} + + - name: Publish to PyPI + run: | + previous_tag=$(git describe --tags --abbrev=0 HEAD^) + echo "Comparing changes since $previous_tag" + + changed_targets=$(pants --changed-since=$previous_tag list | awk -F/ '{print $1}' | sort -u) + + if [ -n "$changed_targets" ]; then + echo "Publishing changed targets: $changed_targets" + for target in $changed_targets; do + pants publish $target:: + done + echo "published=true" >> $GITHUB_ENV + else + echo "No changes detected, skipping publish" + echo "published=false" >> $GITHUB_ENV + fi + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + + - name: Set published state + id: set-published-state + run: echo "published=$published" >> $GITHUB_OUTPUT + notify-release: runs-on: ubuntu-latest name: Notify Release + needs: publish-package + if: needs.publish-package.outputs.published == 'true' strategy: matrix: url: [SLACK_WEBHOOK_ASK_DEVREL_URL, SLACK_WEBHOOK_DEVREL_TOOLING_URL, SLACK_WEBHOOK_DEVREL_PRIVATE_URL] steps: - name: Send to slack channels - uses: slackapi/slack-github-action@v2.0.0 + uses: slackapi/slack-github-action@v2 if: always() continue-on-error: true with: @@ -36,6 +89,3 @@ jobs: text: type: "mrkdwn" text: "You can view the full change log <${{github.event.release.html_url }}|here>" - - - diff --git a/Makefile b/Makefile index 18d342f4..da833391 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -.PHONY: test coverage +.PHONY: format test coverage coverage-report install + +format: + pants lint :: + pants fix :: test: pants test :: diff --git a/vonage_utils/src/vonage_utils/_version.py b/vonage_utils/src/vonage_utils/_version.py index bc50bee6..890b8582 100644 --- a/vonage_utils/src/vonage_utils/_version.py +++ b/vonage_utils/src/vonage_utils/_version.py @@ -1 +1 @@ -__version__ = '1.1.4' +__version__ = '1.1.4dev2' From 2b49fe8ac80780fc043d8c9430a1bd0bc4b0e22a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 12 May 2025 12:53:18 +0100 Subject: [PATCH 305/401] update pants version --- network_auth/src/vonage_network_auth/requests.py | 6 +++--- pants.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/network_auth/src/vonage_network_auth/requests.py b/network_auth/src/vonage_network_auth/requests.py index e7452a11..b4e5f476 100644 --- a/network_auth/src/vonage_network_auth/requests.py +++ b/network_auth/src/vonage_network_auth/requests.py @@ -15,6 +15,6 @@ class CreateOidcUrl(BaseModel): redirect_uri: str state: str login_hint: str - scope: Optional[ - str - ] = 'openid dpv:FraudPreventionAndDetection#number-verification-verify-read' + scope: Optional[str] = ( + 'openid dpv:FraudPreventionAndDetection#number-verification-verify-read' + ) diff --git a/pants.toml b/pants.toml index fb8de4e5..730bfed9 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.24.2' +pants_version = '2.26.0' backend_packages = [ 'pants.backend.python', From 4b3fc6a826d4f57a887627211e14196663eb71c2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 15 May 2025 03:01:05 +0100 Subject: [PATCH 306/401] remove test version number --- vonage_utils/src/vonage_utils/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage_utils/src/vonage_utils/_version.py b/vonage_utils/src/vonage_utils/_version.py index 890b8582..bc50bee6 100644 --- a/vonage_utils/src/vonage_utils/_version.py +++ b/vonage_utils/src/vonage_utils/_version.py @@ -1 +1 @@ -__version__ = '1.1.4dev2' +__version__ = '1.1.4' From 82a02e48b9793a2aa0bce3fa593de8cf97caf839 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 16 May 2025 14:00:21 +0100 Subject: [PATCH 307/401] add update script --- .scripts/update_vonage_versions.py | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .scripts/update_vonage_versions.py diff --git a/.scripts/update_vonage_versions.py b/.scripts/update_vonage_versions.py new file mode 100644 index 00000000..992a808f --- /dev/null +++ b/.scripts/update_vonage_versions.py @@ -0,0 +1,70 @@ +import os +import re + +import toml + +# Define the paths to the vonage packages +packages = [ + "vonage-account", + "vonage-application", + "vonage-http-client", + "vonage-messages", + "vonage-network-auth", + "vonage-network-sim-swap", + "vonage-network-number-verification", + "vonage-number-insight", + "vonage-numbers", + "vonage-sms", + "vonage-subaccounts", + "vonage-users", + "vonage-utils", + "vonage-verify", + "vonage-verify-legacy", + "vonage-video", + "vonage-voice", +] + + +# Function to read the version from _version.py +def get_version(folder_path: str, package_path: str) -> str: + content = None + try: + version_file = os.path.join( + folder_path.replace("-", "_"), + "src", + package_path.replace("-", "_"), + "_version.py", + ) + + with open(version_file, "r") as f: + content = f.read() + except FileNotFoundError: + if folder_path == "vonage-numbers": + return get_version("number-management", "vonage-numbers") + folder_path_fragments = package_path.split("-") + folder_path = "_".join(folder_path_fragments[1:]) + return get_version(folder_path, package_path) + + version_match = re.search(r'__version__\s*=\s*[\'"]([^\'"]+)[\'"]', content) + if version_match: + return version_match.group(1) + raise ValueError(f"Version not found in {version_file}") + + +# Read the existing pyproject.toml +with open("vonage/pyproject.toml", "r") as f: + pyproject = toml.load(f) + +# Update the dependencies with the versions from _version.py +dependencies = [] +for package in packages: + version = get_version(package, package) + dependencies.append(f"{package}>={version}") + +pyproject["project"]["dependencies"] = dependencies + +# Write the updated pyproject.toml +with open("vonage/pyproject.toml", "w") as f: + toml.dump(pyproject, f) + +print("pyproject.toml updated with local package versions.") From 0c89781bf18780a3d35ded75d27d16797e85eda9 Mon Sep 17 00:00:00 2001 From: Chuck MANCHUCK Reeves Date: Mon, 30 Jun 2025 14:40:04 -0400 Subject: [PATCH 308/401] fix: added guard for empty video filter parameters --- video/src/vonage_video/video.py | 2 +- video/tests/test_archive.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index b3ed287e..01413c0f 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -724,7 +724,7 @@ def _list_video_objects( i.e. objects: list[object], count: int, next_page_offset: Optional[int] """ - index = request_filter.offset + 1 or 1 + index = 1 if request_filter is not None else request_filter.offset + 1 page_size = request_filter.page_size objects = [] diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 4ef03d88..65e9f18a 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -132,6 +132,32 @@ def test_list_archives(): assert archives[1].url == 'https://example.com/archive.mp4' assert archives[1].max_bitrate == 2_000_000 + @responses.activate + def test_list_archives_no_parameters(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives.json', + ) + + filter = ListArchivesFilter(session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + assert count == 2 + assert next_page is None + assert archives[0].id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archives[0].status == 'paused' + assert archives[0].resolution == '1280x720' + assert archives[0].session_id == 'test_session_id' + assert archives[1].id == 'a9cdeb69-f6cf-408b-9197-6f99e6eac5aa' + assert archives[1].status == 'available' + assert archives[1].reason == 'session ended' + assert archives[1].duration == 134 + assert archives[1].sha256_sum == 'test_sha256_sum' + assert archives[1].url == 'https://example.com/archive.mp4' + assert archives[1].max_bitrate == 2_000_000 + @responses.activate def test_start_archive(): From e05dd24b25269a57d5bd7047a497419a77c36a72 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Tue, 1 Jul 2025 09:13:23 -0400 Subject: [PATCH 309/401] Add messages failover (#320) * add new response field * add failover parameter * formatting * add failover testing * change payload * fix tests * update messages package for release * prepare for new release --------- Co-authored-by: maxkahan --- messages/CHANGES.md | 3 ++ messages/src/vonage_messages/_version.py | 2 +- messages/src/vonage_messages/messages.py | 18 +++++++- messages/src/vonage_messages/models/rcs.py | 2 +- messages/src/vonage_messages/responses.py | 4 ++ .../data/send_message_with_failover.json | 4 ++ messages/tests/test_messages.py | 44 +++++++++++++++++++ vonage/CHANGES.md | 3 ++ vonage/pyproject.toml | 6 +-- vonage/src/vonage/_version.py | 2 +- 10 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 messages/tests/data/send_message_with_failover.json diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 7183d872..45cf1396 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 1.5.0 +- Add an optional "failover" property to `vonage_messages.Messages.send` + # 1.4.0 - Make all models originally accessed by `vonage_messages.models.***` available at the top level of the package, i.e. `vonage_messages.***` diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 96e3ce8d..77f1c8e6 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.4.0' +__version__ = '1.5.0' diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 3fd62f65..6ba05b98 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -31,21 +31,35 @@ def http_client(self) -> HttpClient: return self._http_client @validate_call - def send(self, message: BaseMessage) -> SendMessageResponse: + def send( + self, message: BaseMessage, failover: list[BaseMessage] = None + ) -> SendMessageResponse: """Send a message using Vonage's Messages API. Args: message (BaseMessage): The message to be sent as a Pydantic model. Use the provided models (in `vonage_messages.models`) to create messages and pass them in to this method. + failover (list[BaseMessage]): A list of failover messages to be attempted if the primary message fails. Returns: SendMessageResponse: Response model containing the unique identifier of the sent message. Access the identifier with the `message_uuid` attribute. """ + body = message.model_dump(by_alias=True, exclude_none=True) or message + + if failover is not None: + failover_body = [ + m.model_dump(by_alias=True, exclude_none=True) or m for m in failover + ] + body = { + **body, + 'failover': failover_body, + } + response = self._http_client.post( self._http_client.api_host, '/v1/messages', - message.model_dump(by_alias=True, exclude_none=True) or message, + body, self._auth_type, ) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index ef856061..b0e98a1a 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -30,7 +30,7 @@ class BaseRcs(BaseMessage): """ to: PhoneNumber - from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9]+$') + from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_]+$') ttl: Optional[int] = Field(None, ge=300, le=259200) channel: ChannelType = ChannelType.RCS diff --git a/messages/src/vonage_messages/responses.py b/messages/src/vonage_messages/responses.py index 59a84b50..99e594ed 100644 --- a/messages/src/vonage_messages/responses.py +++ b/messages/src/vonage_messages/responses.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel @@ -6,6 +8,8 @@ class SendMessageResponse(BaseModel): Attributes: message_uuid (str): The UUID of the sent message. + workflow_id [str]: Workflow ID if the `failover` parameter was used in the request. """ message_uuid: str + workflow_id: Optional[str] = None diff --git a/messages/tests/data/send_message_with_failover.json b/messages/tests/data/send_message_with_failover.json new file mode 100644 index 00000000..7a8a12be --- /dev/null +++ b/messages/tests/data/send_message_with_failover.json @@ -0,0 +1,4 @@ +{ + "message_uuid": "d8f86df1-dec6-442f-870a-2241be27d721", + "workflow_id": "3TcNjguHxr2vcCZ9Ddsnq6tw8yQUpZ9rMHv9QXSxLan5ibMxqSzLdx9" +} \ No newline at end of file diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index 51c8767b..5cde1bc0 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -1,3 +1,4 @@ +from json import loads from os.path import abspath import responses @@ -51,6 +52,49 @@ def test_send_message(): assert messages._auth_type == 'jwt' +@responses.activate +def test_send_message_with_failover(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v1/messages', + 'send_message_with_failover.json', + 202, + ) + sms = Sms( + from_='Vonage APIs', + to='1234567890', + text='Hello, World!', + ) + failover = [ + Sms(from_='Vonage APIs', to='1987654321', text='Failover message'), + ] + + response = messages.send(sms, failover=failover) + print(messages._http_client.last_request.body) + assert loads(messages._http_client.last_request.body) == { + "to": "1234567890", + "from": "Vonage APIs", + "text": "Hello, World!", + "channel": "sms", + "message_type": "text", + "failover": [ + { + "to": "1987654321", + "from": "Vonage APIs", + "text": "Failover message", + "channel": "sms", + "message_type": "text", + } + ], + } + assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + assert ( + response.workflow_id == '3TcNjguHxr2vcCZ9Ddsnq6tw8yQUpZ9rMHv9QXSxLan5ibMxqSzLdx9' + ) + assert messages._auth_type == 'jwt' + + @responses.activate def test_send_message_basic_auth(): build_response( diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 185e6aa3..f75f9a3b 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.5.0 +- vonage-messages: add an optional "failover" property to `vonage_messages.Messages.send` + # 4.4.3 - vonage-number-insight: use basic header auth instead of request body auth diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index c04e105f..3ac25bb2 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -5,11 +5,10 @@ description = "Python Server SDK for using Vonage APIs" readme = "README.md" requires-python = ">=3.9" dependencies = [ - "vonage-utils>=1.1.4", - "vonage-http-client>=1.5.1", "vonage-account>=1.1.1", "vonage-application>=2.0.1", - "vonage-messages>=1.4.0", + "vonage-http-client>=1.5.1", + "vonage-messages>=1.5.0", "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", @@ -18,6 +17,7 @@ dependencies = [ "vonage-sms>=1.1.6", "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.1", + "vonage-utils>=1.1.4", "vonage-verify>=2.1.0", "vonage-verify-legacy>=1.0.1", "vonage-video>=1.2.0", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 77ed0d0b..330025d8 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.4.3' +__version__ = '4.5.0' From 970fc17399eeee6b8ba2fe8ebfa12d9d990dc0b0 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Wed, 30 Jul 2025 19:33:27 -0400 Subject: [PATCH 310/401] fix: Allow Ampersands in RCS From field. (#321) --- messages/src/vonage_messages/models/rcs.py | 2 +- messages/tests/test_rcs_models.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index b0e98a1a..6ed1e7b7 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -30,7 +30,7 @@ class BaseRcs(BaseMessage): """ to: PhoneNumber - from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_]+$') + from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_&]+$') ttl: Optional[int] = Field(None, ge=300, le=259200) channel: ChannelType = ChannelType.RCS diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 4723fa47..4114bcad 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -25,6 +25,27 @@ def test_create_rcs_text(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict +def test_create_rcs_text_with_ampersand(): + """Tests that RCS from fields will allow an ampersand (&) character. + + See also: DEVX-10155 + """ + rcs_model = RcsText( + to='1234567890', + from_='Acme&SonsCo', + text='Hello, World!', + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'Acme&SonsCo', + 'text': 'Hello, World!', + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + def test_create_rcs_text_all_fields(): rcs_model = RcsText( to='1234567890', From 37e7258ac8c72cfb0148d95bfd8d14ac0815f0d4 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Wed, 30 Jul 2025 19:45:44 -0400 Subject: [PATCH 311/401] fix: bumped messages version --- messages/src/vonage_messages/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 77f1c8e6..bb64aa47 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.5.0' +__version__ = '1.6.1' From 7e3eef1fd9b31908c2eab9d4070eeea6e3306b27 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Wed, 30 Jul 2025 19:50:46 -0400 Subject: [PATCH 312/401] fix: bump video version --- video/src/vonage_video/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 58d478ab..19b4f1d6 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.2.0' +__version__ = '1.3.0' From cb7da57168b9852f295ee524aaa37fd36d576d47 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Wed, 30 Jul 2025 19:52:33 -0400 Subject: [PATCH 313/401] fix: messages version bump --- messages/src/vonage_messages/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index bb64aa47..4a9b9788 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.6.1' +__version__ = '1.6.2' From 54fa90ddd0508e52f87c9baa775143afc73cb5d9 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Thu, 31 Jul 2025 10:47:02 -0400 Subject: [PATCH 314/401] fix: bump main package version to 4.5.2 --- vonage/src/vonage/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 330025d8..d04f92aa 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.5.0' +__version__ = '4.5.2' From 36d07c521becfdd7ee70056a5e08f0751b95e610 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Fri, 15 Aug 2025 23:01:28 -0400 Subject: [PATCH 315/401] feat(video): Add bidirectional websocket flag (#324) --- video/src/vonage_video/_version.py | 2 +- .../vonage_video/models/audio_connector.py | 19 +++++++++++++++++++ video/tests/test_audio_connector.py | 18 +++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 19b4f1d6..96e3ce8d 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.3.0' +__version__ = '1.4.0' diff --git a/video/src/vonage_video/models/audio_connector.py b/video/src/vonage_video/models/audio_connector.py index 1c490930..4c8fc060 100644 --- a/video/src/vonage_video/models/audio_connector.py +++ b/video/src/vonage_video/models/audio_connector.py @@ -12,12 +12,25 @@ class AudioConnectorWebSocket(BaseModel): streams (list[str]): Stream IDs to include. If not provided, all streams are included. headers (dict): The headers to send to your WebSocket server. audio_rate (AudioSampleRate): The audio sample rate in Hertz. + bidirectional (bool): Whether the websocket is bidirectional. """ uri: str streams: Optional[list[str]] = None headers: Optional[dict] = None audio_rate: Optional[AudioSampleRate] = Field(None, serialization_alias='audioRate') + bidirectional: Optional[bool] = Field( + None, description="Whether the websocket is bidirectional." + ) + + def model_dump(self, *args, **kwargs): + data = super().model_dump(*args, **kwargs) + if self.bidirectional is not True and 'bidirectional' in data: + del data['bidirectional'] + + if 'audioRate' in data and isinstance(data['audioRate'], AudioSampleRate): + data['audioRate'] = data['audioRate'].value + return data class AudioConnectorOptions(BaseModel): @@ -33,6 +46,12 @@ class AudioConnectorOptions(BaseModel): token: str websocket: AudioConnectorWebSocket + def model_dump(self, *args, **kwargs): + data = super().model_dump(*args, **kwargs) + if isinstance(self.websocket, AudioConnectorWebSocket): + data['websocket'] = self.websocket.model_dump(*args, **kwargs) + return data + class AudioConnectorData(BaseModel): """Class containing Audio Connector WebSocket ID and connection ID. diff --git a/video/tests/test_audio_connector.py b/video/tests/test_audio_connector.py index 48d52311..32ee4ad0 100644 --- a/video/tests/test_audio_connector.py +++ b/video/tests/test_audio_connector.py @@ -20,6 +20,18 @@ def test_audio_connector_options_model(): + options_no_flag = AudioConnectorOptions( + session_id='test_session_id', + token='test_token', + websocket=AudioConnectorWebSocket( + uri='test_uri', + streams=['test_stream_id'], + headers={'test_header': 'test_value'}, + audio_rate=AudioSampleRate.KHZ_16, + ), + ) + websocket_dict = options_no_flag.model_dump(by_alias=True)["websocket"] + assert "bidirectional" not in websocket_dict options = AudioConnectorOptions( session_id='test_session_id', token='test_token', @@ -28,10 +40,12 @@ def test_audio_connector_options_model(): streams=['test_stream_id'], headers={'test_header': 'test_value'}, audio_rate=AudioSampleRate.KHZ_16, + bidirectional=True, ), ) - assert options.model_dump(by_alias=True) == { + actual = options.model_dump(by_alias=True) + expected = { 'sessionId': 'test_session_id', 'token': 'test_token', 'websocket': { @@ -39,8 +53,10 @@ def test_audio_connector_options_model(): 'streams': ['test_stream_id'], 'headers': {'test_header': 'test_value'}, 'audioRate': 16000, + 'bidirectional': True, }, } + assert actual == expected @responses.activate From a8dbf81c818093e91c2ce8619c99ad5de9b6976a Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Fri, 15 Aug 2025 23:02:47 -0400 Subject: [PATCH 316/401] Update _version.py to 4.6.0 --- vonage/src/vonage/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index d04f92aa..52fde385 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.5.2' +__version__ = '4.6.0' From 0826deb631f4c957a2ab7d28573d6da32c862806 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Fri, 19 Sep 2025 11:16:50 -0400 Subject: [PATCH 317/401] feat: Add quantization parameter for video archives --- video/src/vonage_video/_version.py | 2 +- video/src/vonage_video/models/archive.py | 21 +++++- video/tests/data/archive.json | 3 +- video/tests/test_archive.py | 83 ++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 96e3ce8d..77f1c8e6 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.4.0' +__version__ = '1.5.0' diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 7745e900..7efb2dc4 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -71,6 +71,9 @@ class Archive(BaseModel): transcription (Transcription, Optional): Transcription options for the archive. max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. """ id: Optional[str] = None @@ -97,6 +100,9 @@ class Archive(BaseModel): url: Optional[str] = None transcription: Optional[Transcription] = None max_bitrate: Optional[int] = Field(None, validation_alias='maxBitrate') + quantization_parameter: Optional[int] = Field( + None, validation_alias='quantizationParameter' + ) class CreateArchiveRequest(BaseModel): @@ -119,9 +125,12 @@ class CreateArchiveRequest(BaseModel): automatically ("auto", the default) or manually ("manual"). max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. Raises: NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. - IndividualArchivePropertyError: If `resolution` or `layout` is set for individual archives + IndividualArchivePropertyError: If `resolution`, `layout`, or `quantization_parameter` is set for individual archives or if `has_transcription` is set for composed archives. """ @@ -140,6 +149,9 @@ class CreateArchiveRequest(BaseModel): max_bitrate: Optional[int] = Field( None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' ) + quantization_parameter: Optional[int] = Field( + None, ge=15, le=40, serialization_alias='quantizationParameter' + ) @model_validator(mode='after') def validate_audio_or_video(self): @@ -159,6 +171,13 @@ def no_layout_or_resolution_for_individual_archives(self): raise IndividualArchivePropertyError( 'The `layout` property cannot be set for `archive_mode: \'individual\'`.' ) + if ( + self.output_mode == OutputMode.INDIVIDUAL + and self.quantization_parameter is not None + ): + raise IndividualArchivePropertyError( + 'The `quantization_parameter` property cannot be set for `archive_mode: \'individual\'`.' + ) return self @model_validator(mode='after') diff --git a/video/tests/data/archive.json b/video/tests/data/archive.json index ee1490cc..7871653a 100644 --- a/video/tests/data/archive.json +++ b/video/tests/data/archive.json @@ -20,5 +20,6 @@ "event": "archive", "resolution": "1280x720", "url": null, - "maxBitrate": 2000000 + "maxBitrate": 2000000, + "quantizationParameter": 25 } \ No newline at end of file diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 65e9f18a..5baa2a6c 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -89,6 +89,87 @@ def test_create_archive_request_composed_output_mode_with_transcription_error(): ) +def test_create_archive_request_valid_quantization_parameter(): + """Test that quantization_parameter is accepted for composed archives with valid + values.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=25, + ) + assert request.quantization_parameter == 25 + + +def test_create_archive_request_quantization_parameter_boundary_values(): + """Test that quantization_parameter accepts boundary values (15 and 40).""" + # Test minimum value + request_min = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=15, + ) + assert request_min.quantization_parameter == 15 + + # Test maximum value + request_max = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=40, + ) + assert request_max.quantization_parameter == 40 + + +def test_create_archive_request_quantization_parameter_invalid_low(): + """Test that quantization_parameter rejects values below 15.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=14, + ) + + +def test_create_archive_request_quantization_parameter_invalid_high(): + """Test that quantization_parameter rejects values above 40.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=41, + ) + + +def test_create_archive_request_individual_output_mode_with_quantization_parameter(): + """Test that quantization_parameter is rejected for individual archives.""" + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + output_mode=OutputMode.INDIVIDUAL, + quantization_parameter=25, + ) + + +def test_create_archive_request_serialization_with_quantization_parameter(): + """Test that quantization_parameter is properly serialized with the correct alias.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=30, + ) + + serialized = request.model_dump(by_alias=True, exclude_unset=True) + assert 'quantizationParameter' in serialized + assert serialized['quantizationParameter'] == 30 + assert ( + 'quantization_parameter' not in serialized + ) # Ensure Python field name is not used + + def test_layout_custom_without_stylesheet(): with raises(LayoutStylesheetError): ComposedLayout(type=LayoutType.CUSTOM) @@ -194,6 +275,7 @@ def test_start_archive(): assert archive.name == 'first archive test' assert archive.resolution == '1280x720' assert archive.max_bitrate == 2_000_000 + assert archive.quantization_parameter == 25 @responses.activate @@ -215,6 +297,7 @@ def test_get_archive(): assert archive.status == 'started' assert archive.name == 'first archive test' assert archive.resolution == '1280x720' + assert archive.quantization_parameter == 25 @responses.activate From 3c9ab73c4ac7aa7784990871f406d5bdb71c2f88 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Fri, 19 Sep 2025 11:23:52 -0400 Subject: [PATCH 318/401] feat: Add quantization parameter for video archives (#327) --- video/src/vonage_video/_version.py | 2 +- video/src/vonage_video/models/archive.py | 21 +++++- video/tests/data/archive.json | 3 +- video/tests/test_archive.py | 83 ++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 96e3ce8d..77f1c8e6 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.4.0' +__version__ = '1.5.0' diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 7745e900..7efb2dc4 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -71,6 +71,9 @@ class Archive(BaseModel): transcription (Transcription, Optional): Transcription options for the archive. max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. """ id: Optional[str] = None @@ -97,6 +100,9 @@ class Archive(BaseModel): url: Optional[str] = None transcription: Optional[Transcription] = None max_bitrate: Optional[int] = Field(None, validation_alias='maxBitrate') + quantization_parameter: Optional[int] = Field( + None, validation_alias='quantizationParameter' + ) class CreateArchiveRequest(BaseModel): @@ -119,9 +125,12 @@ class CreateArchiveRequest(BaseModel): automatically ("auto", the default) or manually ("manual"). max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. Raises: NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. - IndividualArchivePropertyError: If `resolution` or `layout` is set for individual archives + IndividualArchivePropertyError: If `resolution`, `layout`, or `quantization_parameter` is set for individual archives or if `has_transcription` is set for composed archives. """ @@ -140,6 +149,9 @@ class CreateArchiveRequest(BaseModel): max_bitrate: Optional[int] = Field( None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' ) + quantization_parameter: Optional[int] = Field( + None, ge=15, le=40, serialization_alias='quantizationParameter' + ) @model_validator(mode='after') def validate_audio_or_video(self): @@ -159,6 +171,13 @@ def no_layout_or_resolution_for_individual_archives(self): raise IndividualArchivePropertyError( 'The `layout` property cannot be set for `archive_mode: \'individual\'`.' ) + if ( + self.output_mode == OutputMode.INDIVIDUAL + and self.quantization_parameter is not None + ): + raise IndividualArchivePropertyError( + 'The `quantization_parameter` property cannot be set for `archive_mode: \'individual\'`.' + ) return self @model_validator(mode='after') diff --git a/video/tests/data/archive.json b/video/tests/data/archive.json index ee1490cc..7871653a 100644 --- a/video/tests/data/archive.json +++ b/video/tests/data/archive.json @@ -20,5 +20,6 @@ "event": "archive", "resolution": "1280x720", "url": null, - "maxBitrate": 2000000 + "maxBitrate": 2000000, + "quantizationParameter": 25 } \ No newline at end of file diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 65e9f18a..5baa2a6c 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -89,6 +89,87 @@ def test_create_archive_request_composed_output_mode_with_transcription_error(): ) +def test_create_archive_request_valid_quantization_parameter(): + """Test that quantization_parameter is accepted for composed archives with valid + values.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=25, + ) + assert request.quantization_parameter == 25 + + +def test_create_archive_request_quantization_parameter_boundary_values(): + """Test that quantization_parameter accepts boundary values (15 and 40).""" + # Test minimum value + request_min = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=15, + ) + assert request_min.quantization_parameter == 15 + + # Test maximum value + request_max = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=40, + ) + assert request_max.quantization_parameter == 40 + + +def test_create_archive_request_quantization_parameter_invalid_low(): + """Test that quantization_parameter rejects values below 15.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=14, + ) + + +def test_create_archive_request_quantization_parameter_invalid_high(): + """Test that quantization_parameter rejects values above 40.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=41, + ) + + +def test_create_archive_request_individual_output_mode_with_quantization_parameter(): + """Test that quantization_parameter is rejected for individual archives.""" + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + output_mode=OutputMode.INDIVIDUAL, + quantization_parameter=25, + ) + + +def test_create_archive_request_serialization_with_quantization_parameter(): + """Test that quantization_parameter is properly serialized with the correct alias.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=30, + ) + + serialized = request.model_dump(by_alias=True, exclude_unset=True) + assert 'quantizationParameter' in serialized + assert serialized['quantizationParameter'] == 30 + assert ( + 'quantization_parameter' not in serialized + ) # Ensure Python field name is not used + + def test_layout_custom_without_stylesheet(): with raises(LayoutStylesheetError): ComposedLayout(type=LayoutType.CUSTOM) @@ -194,6 +275,7 @@ def test_start_archive(): assert archive.name == 'first archive test' assert archive.resolution == '1280x720' assert archive.max_bitrate == 2_000_000 + assert archive.quantization_parameter == 25 @responses.activate @@ -215,6 +297,7 @@ def test_get_archive(): assert archive.status == 'started' assert archive.name == 'first archive test' assert archive.resolution == '1280x720' + assert archive.quantization_parameter == 25 @responses.activate From db7d7ba814149a9caf03b7b6f976f1eafbf9920d Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Fri, 26 Sep 2025 10:00:26 -0400 Subject: [PATCH 319/401] fix(video): Add missing transcription fields --- video/src/vonage_video/_version.py | 2 +- video/src/vonage_video/models/archive.py | 6 + .../data/archive_with_transcription.json | 30 +++ .../list_archives_with_transcription.json | 82 ++++++ video/tests/test_archive.py | 242 ++++++++++++++++++ vonage/CHANGES.md | 9 + vonage/src/vonage/_version.py | 2 +- 7 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 video/tests/data/archive_with_transcription.json create mode 100644 video/tests/data/list_archives_with_transcription.json diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 77f1c8e6..51ed7c48 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.5.0' +__version__ = '1.5.1' diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 7efb2dc4..800c7e61 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -29,10 +29,16 @@ class Transcription(BaseModel): Args: status (str, Optional): The status of the transcription. reason (str, Optional): May give a brief reason for the transcription status. + url (str, Optional): The URL of the transcription file. + primaryLanguageCode (str, Optional): The primary language code for transcription. + hasSummary (bool, Optional): Whether the transcription includes a summary. """ status: Optional[str] = None reason: Optional[str] = None + url: Optional[str] = None + primaryLanguageCode: Optional[str] = None + hasSummary: Optional[bool] = None class Archive(BaseModel): diff --git a/video/tests/data/archive_with_transcription.json b/video/tests/data/archive_with_transcription.json new file mode 100644 index 00000000..1e207bb7 --- /dev/null +++ b/video/tests/data/archive_with_transcription.json @@ -0,0 +1,30 @@ +{ + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "available", + "name": "archive with transcription", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 1048576, + "duration": 180, + "outputMode": "individual", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": true, + "sha256sum": "abc123def456", + "password": "", + "updatedAt": 1727870634977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4", + "transcription": { + "status": "completed", + "reason": "transcription completed successfully", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "en-US", + "hasSummary": true + } +} \ No newline at end of file diff --git a/video/tests/data/list_archives_with_transcription.json b/video/tests/data/list_archives_with_transcription.json new file mode 100644 index 00000000..48eb3a07 --- /dev/null +++ b/video/tests/data/list_archives_with_transcription.json @@ -0,0 +1,82 @@ +{ + "count": 3, + "items": [ + { + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "paused", + "name": "archive without transcription", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727870434977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": null + }, + { + "id": "a9cdeb69-f6cf-408b-9197-6f99e6eac5aa", + "status": "available", + "name": "completed archive", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 1024000, + "duration": 134, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "test_sha256_sum", + "password": "", + "updatedAt": 1727870634977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4", + "maxBitrate": 2000000 + }, + { + "id": "c1d2e3f4-g5h6-i7j8-k9l0-m1n2o3p4q5r6", + "status": "available", + "name": "transcribed archive", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 2048000, + "duration": 240, + "outputMode": "individual", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": true, + "sha256sum": "test_transcription_sha256", + "password": "", + "updatedAt": 1727870734977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/transcribed_archive.mp4", + "transcription": { + "status": "completed", + "reason": "transcription completed successfully", + "url": "https://example.com/transcriptions/c1d2e3f4.json", + "primaryLanguageCode": "es-ES", + "hasSummary": true + } + } + ] +} \ No newline at end of file diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 5baa2a6c..50974b9d 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -21,6 +21,7 @@ LayoutStylesheetError, NoAudioOrVideoError, ) +from vonage_video.models.archive import Transcription from testutils import build_response, get_mock_jwt_auth @@ -409,3 +410,244 @@ def test_change_archive_layout(): assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' assert video.http_client.last_response.status_code == 200 + + +# Tests for new Transcription options +def test_transcription_model_with_all_options(): + """Test that the Transcription model can be created with all new options.""" + transcription = Transcription( + status="completed", + reason="transcription completed successfully", + url="https://example.com/transcription.json", + primaryLanguageCode="en-US", + hasSummary=True + ) + + assert transcription.status == "completed" + assert transcription.reason == "transcription completed successfully" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode == "en-US" + assert transcription.hasSummary is True + + +def test_transcription_model_with_partial_options(): + """Test that the Transcription model can be created with only some new options.""" + transcription = Transcription( + status="processing", + url="https://example.com/transcription.json" + ) + + assert transcription.status == "processing" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + assert transcription.reason is None + + +def test_transcription_model_with_url_only(): + """Test that the Transcription model can be created with just the url option.""" + transcription = Transcription( + url="https://example.com/transcription.json" + ) + + assert transcription.url == "https://example.com/transcription.json" + assert transcription.status is None + assert transcription.reason is None + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + + +def test_transcription_model_with_primary_language_code_only(): + """Test that the Transcription model can be created with just the primaryLanguageCode option.""" + transcription = Transcription( + primaryLanguageCode="es-ES" + ) + + assert transcription.primaryLanguageCode == "es-ES" + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.hasSummary is None + + +def test_transcription_model_with_has_summary_only(): + """Test that the Transcription model can be created with just the hasSummary option.""" + transcription = Transcription( + hasSummary=False + ) + + assert transcription.hasSummary is False + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.primaryLanguageCode is None + + +def test_transcription_model_empty(): + """Test that the Transcription model can be created with no options set.""" + transcription = Transcription() + + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + + +def test_transcription_model_serialization(): + """Test that the Transcription model serializes correctly.""" + transcription = Transcription( + status="completed", + reason="success", + url="https://example.com/transcription.json", + primaryLanguageCode="en-US", + hasSummary=True + ) + + serialized = transcription.model_dump() + expected = { + "status": "completed", + "reason": "success", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "en-US", + "hasSummary": True + } + + assert serialized == expected + + +def test_transcription_model_serialization_exclude_unset(): + """Test that the Transcription model serializes correctly excluding unset values.""" + transcription = Transcription( + url="https://example.com/transcription.json", + hasSummary=True + ) + + serialized = transcription.model_dump(exclude_unset=True) + expected = { + "url": "https://example.com/transcription.json", + "hasSummary": True + } + + assert serialized == expected + assert "status" not in serialized + assert "reason" not in serialized + assert "primaryLanguageCode" not in serialized + + +def test_transcription_model_deserialization(): + """Test that the Transcription model can be created from dictionary data.""" + data = { + "status": "completed", + "reason": "transcription finished", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "fr-FR", + "hasSummary": True + } + + transcription = Transcription(**data) + + assert transcription.status == "completed" + assert transcription.reason == "transcription finished" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode == "fr-FR" + assert transcription.hasSummary is True + + +def test_transcription_model_with_various_language_codes(): + """Test that the Transcription model accepts various language codes.""" + test_cases = [ + "en-US", + "es-ES", + "fr-FR", + "de-DE", + "ja-JP", + "zh-CN", + "pt-BR" + ] + + for lang_code in test_cases: + transcription = Transcription(primaryLanguageCode=lang_code) + assert transcription.primaryLanguageCode == lang_code + + +def test_transcription_model_with_various_urls(): + """Test that the Transcription model accepts various URL formats.""" + test_urls = [ + "https://example.com/transcription.json", + "https://storage.googleapis.com/bucket/file.json", + "https://s3.amazonaws.com/bucket/transcription.txt", + "http://example.org/path/to/transcription", + "https://vonage.example.com/transcriptions/12345" + ] + + for url in test_urls: + transcription = Transcription(url=url) + assert transcription.url == url + + +def test_transcription_model_boolean_has_summary(): + """Test that hasSummary properly handles boolean values.""" + # Test True + transcription_true = Transcription(hasSummary=True) + assert transcription_true.hasSummary is True + + # Test False + transcription_false = Transcription(hasSummary=False) + assert transcription_false.hasSummary is False + + # Test None (default) + transcription_none = Transcription() + assert transcription_none.hasSummary is None + + +@responses.activate +def test_archive_with_transcription_options(): + """Test that Archive model properly deserializes with transcription containing new options.""" + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'archive_with_transcription.json', + ) + + archive = video.get_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.has_transcription is True + + # Test transcription object and its new properties + assert archive.transcription is not None + assert archive.transcription.status == "completed" + assert archive.transcription.reason == "transcription completed successfully" + assert archive.transcription.url == "https://example.com/transcription.json" + assert archive.transcription.primaryLanguageCode == "en-US" + assert archive.transcription.hasSummary is True + + +@responses.activate +def test_list_archives_with_transcription_options(): + """Test that listing archives properly handles transcription with new options.""" + # Create a modified list response that includes transcription data + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives_with_transcription.json', + ) + + filter = ListArchivesFilter(session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + # Find the archive with transcription + transcribed_archive = None + for archive in archives: + if archive.has_transcription: + transcribed_archive = archive + break + + assert transcribed_archive is not None + assert transcribed_archive.transcription is not None + assert transcribed_archive.transcription.url is not None + assert transcribed_archive.transcription.primaryLanguageCode is not None + assert transcribed_archive.transcription.hasSummary is not None diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index f75f9a3b..4dbd2f58 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,12 @@ +# 4.7.1 +- vonage-video: Added missing transcription values from archive responses + +# 4.7.0 +- vonage-video: Added quantization parameter for video archives + +# 4.6.0 +- vonage-video: Added bidirectional websocket flag + # 4.5.0 - vonage-messages: add an optional "failover" property to `vonage_messages.Messages.send` diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 52fde385..3c9329b2 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.6.0' +__version__ = '4.7.1' From bb0738f3364a6ec468e16ea85f76097876bf159e Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Fri, 26 Sep 2025 10:09:17 -0400 Subject: [PATCH 320/401] F --- video/tests/test_archive.py | 88 +++++++++++++++---------------------- 1 file changed, 36 insertions(+), 52 deletions(-) diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 50974b9d..5d9f0089 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -420,9 +420,9 @@ def test_transcription_model_with_all_options(): reason="transcription completed successfully", url="https://example.com/transcription.json", primaryLanguageCode="en-US", - hasSummary=True + hasSummary=True, ) - + assert transcription.status == "completed" assert transcription.reason == "transcription completed successfully" assert transcription.url == "https://example.com/transcription.json" @@ -433,10 +433,9 @@ def test_transcription_model_with_all_options(): def test_transcription_model_with_partial_options(): """Test that the Transcription model can be created with only some new options.""" transcription = Transcription( - status="processing", - url="https://example.com/transcription.json" + status="processing", url="https://example.com/transcription.json" ) - + assert transcription.status == "processing" assert transcription.url == "https://example.com/transcription.json" assert transcription.primaryLanguageCode is None @@ -446,10 +445,8 @@ def test_transcription_model_with_partial_options(): def test_transcription_model_with_url_only(): """Test that the Transcription model can be created with just the url option.""" - transcription = Transcription( - url="https://example.com/transcription.json" - ) - + transcription = Transcription(url="https://example.com/transcription.json") + assert transcription.url == "https://example.com/transcription.json" assert transcription.status is None assert transcription.reason is None @@ -458,11 +455,10 @@ def test_transcription_model_with_url_only(): def test_transcription_model_with_primary_language_code_only(): - """Test that the Transcription model can be created with just the primaryLanguageCode option.""" - transcription = Transcription( - primaryLanguageCode="es-ES" - ) - + """Test that the Transcription model can be created with just the primaryLanguageCode + option.""" + transcription = Transcription(primaryLanguageCode="es-ES") + assert transcription.primaryLanguageCode == "es-ES" assert transcription.status is None assert transcription.reason is None @@ -471,11 +467,10 @@ def test_transcription_model_with_primary_language_code_only(): def test_transcription_model_with_has_summary_only(): - """Test that the Transcription model can be created with just the hasSummary option.""" - transcription = Transcription( - hasSummary=False - ) - + """Test that the Transcription model can be created with just the hasSummary + option.""" + transcription = Transcription(hasSummary=False) + assert transcription.hasSummary is False assert transcription.status is None assert transcription.reason is None @@ -486,7 +481,7 @@ def test_transcription_model_with_has_summary_only(): def test_transcription_model_empty(): """Test that the Transcription model can be created with no options set.""" transcription = Transcription() - + assert transcription.status is None assert transcription.reason is None assert transcription.url is None @@ -501,34 +496,30 @@ def test_transcription_model_serialization(): reason="success", url="https://example.com/transcription.json", primaryLanguageCode="en-US", - hasSummary=True + hasSummary=True, ) - + serialized = transcription.model_dump() expected = { "status": "completed", "reason": "success", "url": "https://example.com/transcription.json", "primaryLanguageCode": "en-US", - "hasSummary": True + "hasSummary": True, } - + assert serialized == expected def test_transcription_model_serialization_exclude_unset(): """Test that the Transcription model serializes correctly excluding unset values.""" transcription = Transcription( - url="https://example.com/transcription.json", - hasSummary=True + url="https://example.com/transcription.json", hasSummary=True ) - + serialized = transcription.model_dump(exclude_unset=True) - expected = { - "url": "https://example.com/transcription.json", - "hasSummary": True - } - + expected = {"url": "https://example.com/transcription.json", "hasSummary": True} + assert serialized == expected assert "status" not in serialized assert "reason" not in serialized @@ -542,11 +533,11 @@ def test_transcription_model_deserialization(): "reason": "transcription finished", "url": "https://example.com/transcription.json", "primaryLanguageCode": "fr-FR", - "hasSummary": True + "hasSummary": True, } - + transcription = Transcription(**data) - + assert transcription.status == "completed" assert transcription.reason == "transcription finished" assert transcription.url == "https://example.com/transcription.json" @@ -556,16 +547,8 @@ def test_transcription_model_deserialization(): def test_transcription_model_with_various_language_codes(): """Test that the Transcription model accepts various language codes.""" - test_cases = [ - "en-US", - "es-ES", - "fr-FR", - "de-DE", - "ja-JP", - "zh-CN", - "pt-BR" - ] - + test_cases = ["en-US", "es-ES", "fr-FR", "de-DE", "ja-JP", "zh-CN", "pt-BR"] + for lang_code in test_cases: transcription = Transcription(primaryLanguageCode=lang_code) assert transcription.primaryLanguageCode == lang_code @@ -578,9 +561,9 @@ def test_transcription_model_with_various_urls(): "https://storage.googleapis.com/bucket/file.json", "https://s3.amazonaws.com/bucket/transcription.txt", "http://example.org/path/to/transcription", - "https://vonage.example.com/transcriptions/12345" + "https://vonage.example.com/transcriptions/12345", ] - + for url in test_urls: transcription = Transcription(url=url) assert transcription.url == url @@ -591,11 +574,11 @@ def test_transcription_model_boolean_has_summary(): # Test True transcription_true = Transcription(hasSummary=True) assert transcription_true.hasSummary is True - + # Test False transcription_false = Transcription(hasSummary=False) assert transcription_false.hasSummary is False - + # Test None (default) transcription_none = Transcription() assert transcription_none.hasSummary is None @@ -603,7 +586,8 @@ def test_transcription_model_boolean_has_summary(): @responses.activate def test_archive_with_transcription_options(): - """Test that Archive model properly deserializes with transcription containing new options.""" + """Test that Archive model properly deserializes with transcription containing new + options.""" build_response( path, 'GET', @@ -615,7 +599,7 @@ def test_archive_with_transcription_options(): assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' assert archive.has_transcription is True - + # Test transcription object and its new properties assert archive.transcription is not None assert archive.transcription.status == "completed" @@ -645,7 +629,7 @@ def test_list_archives_with_transcription_options(): if archive.has_transcription: transcribed_archive = archive break - + assert transcribed_archive is not None assert transcribed_archive.transcription is not None assert transcribed_archive.transcription.url is not None From ea251fe04d39cc37feccc88160f393fe3ea817a6 Mon Sep 17 00:00:00 2001 From: Chris Tankersley Date: Thu, 9 Oct 2025 15:30:40 -0400 Subject: [PATCH 321/401] fix(vonage-numbers): Add by_alias=true to update number dump (#330) * fix(numbers_management): Add by_alias=true to update number dump --- number_management/CHANGES.md | 3 + .../src/vonage_numbers/_version.py | 2 +- .../src/vonage_numbers/number_management.py | 2 +- number_management/tests/test_numbers.py | 60 +++++++++++++++++++ vonage/CHANGES.md | 3 + vonage/src/vonage/_version.py | 2 +- 6 files changed, 69 insertions(+), 3 deletions(-) diff --git a/number_management/CHANGES.md b/number_management/CHANGES.md index e990cdcb..813f7da0 100644 --- a/number_management/CHANGES.md +++ b/number_management/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.5 +- Added `by_alias=True` to the numbers update model + # 1.0.4 - Updated dependency versions diff --git a/number_management/src/vonage_numbers/_version.py b/number_management/src/vonage_numbers/_version.py index 8a81504c..858de170 100644 --- a/number_management/src/vonage_numbers/_version.py +++ b/number_management/src/vonage_numbers/_version.py @@ -1 +1 @@ -__version__ = '1.0.4' +__version__ = '1.0.5' diff --git a/number_management/src/vonage_numbers/number_management.py b/number_management/src/vonage_numbers/number_management.py index 7343d07a..2bf05175 100644 --- a/number_management/src/vonage_numbers/number_management.py +++ b/number_management/src/vonage_numbers/number_management.py @@ -167,7 +167,7 @@ def update_number(self, params: UpdateNumberParams) -> NumbersStatus: response = self._http_client.post( self._http_client.rest_host, '/number/update', - params.model_dump(exclude_none=True), + params.model_dump(by_alias=True, exclude_none=True), self._auth_type, self._sent_data_type, ) diff --git a/number_management/tests/test_numbers.py b/number_management/tests/test_numbers.py index 1b31ea0b..3ad6fb2f 100644 --- a/number_management/tests/test_numbers.py +++ b/number_management/tests/test_numbers.py @@ -1,4 +1,5 @@ from os.path import abspath +from urllib.parse import parse_qs import responses from pytest import raises @@ -205,6 +206,19 @@ def test_buy_number(): assert response.error_code == '200' assert response.error_code_label == 'success' + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447000000000', + } + assert parsed_data == expected_data + @responses.activate def test_cancel_number(): @@ -219,6 +233,19 @@ def test_cancel_number(): assert response.error_code == '200' assert response.error_code_label == 'success' + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447000000000', + } + assert parsed_data == expected_data + @responses.activate def test_cancel_number_error_no_number(): @@ -233,6 +260,19 @@ def test_cancel_number_error_no_number(): assert e.match('method failed') + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447000000000', + } + assert parsed_data == expected_data + @responses.activate def test_update_number(): @@ -255,9 +295,29 @@ def test_update_number(): ) ) + # Verify the response assert response.error_code == '200' assert response.error_code_label == 'success' + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447009000000', + 'app_id': '29f769u7-7ce1-46c9-ade3-f2dedee4fr4t', + 'moHttpUrl': 'https://example.com', + 'moSmppSysType': 'inbound', + 'voiceCallbackType': 'tel', + 'voiceCallbackValue': '447009000000', + 'voiceStatusCallback': 'https://example.com', + } + assert parsed_data == expected_data + def test_update_number_options_error(): with raises(NumbersError) as e: diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 4dbd2f58..d8579d7a 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.7.2 +- vonage-numbers: Added `by_alias=True` to the numbers update model to correct issue with incorrect body payload + # 4.7.1 - vonage-video: Added missing transcription values from archive responses diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 3c9329b2..64f6e280 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.7.1' +__version__ = '4.7.2' From 5bc5058a71adaf40459215b881f26e0c675d1200 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 30 Jan 2026 15:52:53 +0100 Subject: [PATCH 322/401] feat: add Identity Insights API (#336) --- README.md | 30 +++++ account/tests/test_account.py | 3 +- application/tests/test_application.py | 3 +- http_client/tests/test_http_client.py | 3 +- identity_insights/BUILD | 16 +++ identity_insights/CHANGES.md | 2 + identity_insights/README.md | 35 +++++ identity_insights/pyproject.toml | 32 +++++ .../src/vonage_identity_insights/BUILD | 1 + .../src/vonage_identity_insights/__init__.py | 20 +++ .../src/vonage_identity_insights/_version.py | 1 + .../src/vonage_identity_insights/errors.py | 9 ++ .../identity_insights.py | 88 ++++++++++++ .../src/vonage_identity_insights/requests.py | 79 +++++++++++ .../src/vonage_identity_insights/responses.py | 126 ++++++++++++++++++ identity_insights/tests/BUILD | 1 + identity_insights/tests/data/format.json | 29 ++++ .../tests/data/insight_error.json | 7 + .../tests/test_identity_insights.py | 76 +++++++++++ requirements.txt | 1 + vonage/src/vonage/vonage.py | 2 + 21 files changed, 558 insertions(+), 6 deletions(-) create mode 100644 identity_insights/BUILD create mode 100644 identity_insights/CHANGES.md create mode 100644 identity_insights/README.md create mode 100644 identity_insights/pyproject.toml create mode 100644 identity_insights/src/vonage_identity_insights/BUILD create mode 100644 identity_insights/src/vonage_identity_insights/__init__.py create mode 100644 identity_insights/src/vonage_identity_insights/_version.py create mode 100644 identity_insights/src/vonage_identity_insights/errors.py create mode 100644 identity_insights/src/vonage_identity_insights/identity_insights.py create mode 100644 identity_insights/src/vonage_identity_insights/requests.py create mode 100644 identity_insights/src/vonage_identity_insights/responses.py create mode 100644 identity_insights/tests/BUILD create mode 100644 identity_insights/tests/data/format.json create mode 100644 identity_insights/tests/data/insight_error.json create mode 100644 identity_insights/tests/test_identity_insights.py diff --git a/README.md b/README.md index 956b6f6c..075430ab 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This is the Python server SDK to help you use Vonage APIs in your Python applica - [Application API](#application-api) - [HTTP Client](#http-client) - [JWT Client](#jwt-client) +- [Identity Insights](#identity-insights) - [Messages API](#messages-api) - [Network Number Verification API](#network-number-verification-api) - [Network Sim Swap API](#network-sim-swap-api) @@ -403,6 +404,34 @@ from vonage_jwt import verify_signature verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean ``` +## Identity Insights + +### Get Insights + +```python +from vonage_identity_insights import ( + IdentityInsightsRequest, + InsightsRequest, + EmptyInsight, + SimSwapInsight, +) + +options = HttpClientOptions(api_host='api-eu.vonage.com', timeout=30) + +client = Vonage(auth=auth, http_client_options=options) + +request = IdentityInsightsRequest( + phone_number='1234567890', + purpose='FraudPreventionAndDetection', + insights=InsightsRequest( + format=EmptyInsight(), + sim_swap=SimSwapInsight(period=240) + ) +) + +response = client.identity_insights.get_insights(request) +``` + ## Messages API ### How to Construct a Message @@ -1436,6 +1465,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | External Accounts API | Beta | ❌ | | Media API | Beta | ❌ | | Messages API | General Availability | ✅ | +| Identity Insights API | General Availability | ✅ | | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | diff --git a/account/tests/test_account.py b/account/tests/test_account.py index 318d72ff..f3043d52 100644 --- a/account/tests/test_account.py +++ b/account/tests/test_account.py @@ -2,6 +2,7 @@ import responses from pytest import raises +from testutils import build_response, get_mock_api_key_auth from vonage_account.account import Account from vonage_account.errors import InvalidSecretError from vonage_account.requests import ( @@ -12,8 +13,6 @@ from vonage_http_client.errors import ForbiddenError from vonage_http_client.http_client import HttpClient -from testutils import build_response, get_mock_api_key_auth - path = abspath(__file__) account = Account(HttpClient(get_mock_api_key_auth())) diff --git a/application/tests/test_application.py b/application/tests/test_application.py index 6c14a4c6..675f61f8 100644 --- a/application/tests/test_application.py +++ b/application/tests/test_application.py @@ -2,6 +2,7 @@ import responses from pytest import raises +from testutils import build_response, get_mock_api_key_auth from vonage_application.application import Application from vonage_application.common import ( ApplicationUrl, @@ -24,8 +25,6 @@ from vonage_application.requests import ApplicationConfig, ListApplicationsFilter from vonage_http_client.http_client import HttpClient -from testutils import build_response, get_mock_api_key_auth - path = abspath(__file__) application = Application(HttpClient(get_mock_api_key_auth())) diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 29d7842b..687a9a1f 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -8,6 +8,7 @@ from requests import PreparedRequest, Response, Session from requests.exceptions import ConnectionError from responses import matchers +from testutils import build_response, get_mock_jwt_auth from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, @@ -20,8 +21,6 @@ ) from vonage_http_client.http_client import HttpClient, HttpClientOptions -from testutils import build_response, get_mock_jwt_auth - path = abspath(__file__) diff --git a/identity_insights/BUILD b/identity_insights/BUILD new file mode 100644 index 00000000..2bd77f26 --- /dev/null +++ b/identity_insights/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-identity-insights', + dependencies=[ + ':pyproject', + ':readme', + 'identity_insights/src/vonage_identity_insights', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/identity_insights/CHANGES.md b/identity_insights/CHANGES.md new file mode 100644 index 00000000..be516a55 --- /dev/null +++ b/identity_insights/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/identity_insights/README.md b/identity_insights/README.md new file mode 100644 index 00000000..8cf71f47 --- /dev/null +++ b/identity_insights/README.md @@ -0,0 +1,35 @@ +# Vonage Identity Insights Package + +This package contains the code to use the [Vonage Identity Insights API](https://developer.vonage.com/en/identity-insights/overview) in Python. The API provides real-time access to a broad range of attributes related to the carrier, subscriber, or device associated with a phone number. To use it you will need a Vonage account. Sign up [for free at vonage.com][signup]. + +## 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 Standard Identity Insights Request + +```python +from vonage import Vonage, Auth, HttpClientOptions +from vonage_identity_insights import ( + IdentityInsightsRequest, + InsightsRequest, + EmptyInsight, + SimSwapInsight, +) + +options = HttpClientOptions(api_host="api-eu.vonage.com", timeout=30) + +client = Vonage(auth=auth, http_client_options=options) + +request = IdentityInsightsRequest( + phone_number="1234567890", + purpose="FraudPreventionAndDetection", + insights=InsightsRequest( + format=EmptyInsight(), sim_swap=SimSwapInsight(period=240) + ), +) + +response = client.identity_insights.requests(request) + +``` + diff --git a/identity_insights/pyproject.toml b/identity_insights/pyproject.toml new file mode 100644 index 00000000..b4cd6a6b --- /dev/null +++ b/identity_insights/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-identity-insights' +dynamic = ["version"] +description = 'Vonage Identity Insights package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_identity_insights._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/identity_insights/src/vonage_identity_insights/BUILD b/identity_insights/src/vonage_identity_insights/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/identity_insights/src/vonage_identity_insights/__init__.py b/identity_insights/src/vonage_identity_insights/__init__.py new file mode 100644 index 00000000..18629588 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/__init__.py @@ -0,0 +1,20 @@ +from . import errors +from .identity_insights import IdentityInsights +from .requests import ( + EmptyInsight, + IdentityInsightsRequest, + InsightsRequest, + SimSwapInsight, +) +from .responses import IdentityInsightsResponse, InsightStatus + +__all__ = [ + "IdentityInsights", + "IdentityInsightsRequest", + "InsightsRequest", + "EmptyInsight", + "SimSwapInsight", + "IdentityInsightsResponse", + "InsightStatus", + "errors", +] diff --git a/identity_insights/src/vonage_identity_insights/_version.py b/identity_insights/src/vonage_identity_insights/_version.py new file mode 100644 index 00000000..5becc17c --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/_version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/identity_insights/src/vonage_identity_insights/errors.py b/identity_insights/src/vonage_identity_insights/errors.py new file mode 100644 index 00000000..3006b318 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/errors.py @@ -0,0 +1,9 @@ +from vonage_utils.errors import VonageError + + +class IdentityInsightsError(VonageError): + """Indicates an error when using the Vonage Identity Insights API.""" + + +class EmptyInsightsRequestException(VonageError): + """At least one insight must be provided.""" diff --git a/identity_insights/src/vonage_identity_insights/identity_insights.py b/identity_insights/src/vonage_identity_insights/identity_insights.py new file mode 100644 index 00000000..b7e12f4d --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/identity_insights.py @@ -0,0 +1,88 @@ +from logging import getLogger + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import EmptyInsightsRequestException, IdentityInsightsError +from .requests import IdentityInsightsRequest +from .responses import IdentityInsightsResponse + +logger = getLogger("vonage_identity_insights") + + +class IdentityInsights: + """Calls Vonage's Identity Insights API.""" + + def __init__(self, http_client: HttpClient) -> None: + """Initialize the IdentityInsights client. + + Args: + http_client (HttpClient): Configured HTTP client used to make + authenticated requests to the Vonage API. + """ + self._http_client = http_client + self._auth_type = "jwt" + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Vonage Indentity Insights API. + + Returns: + HttpClient: The HTTP client used to make requests to the Identity Insights API. + """ + return self._http_client + + @validate_call + def requests( + self, insights_request: IdentityInsightsRequest + ) -> IdentityInsightsResponse: + """Retrieve identity insights for a phone number. + + Sends an aggregated request to the Identity Insights API and returns + the results for each requested insight. + + Args: + insights_request (IdentityInsightsRequest): The request object + containing the phone number and the set of identity insights + to retrieve. + + Returns: + IdentityInsightsResponse: The response object containing the results + and status of each requested insight. + + Raises: + IdentityInsightsError: If the API returns an error response in + `application/problem+json` format. + """ + payload = insights_request.model_dump(exclude_none=True) + + insights = payload.get("insights") + if not insights or not isinstance(insights, dict): + raise EmptyInsightsRequestException() + + response = self._http_client.post( + self._http_client.api_host, + "/identity-insights/v1/requests", + payload, + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return IdentityInsightsResponse(**response) + + def _check_for_error(self, response: dict) -> None: + """Check whether the API response represents an error. + + The Identity Insights API returns errors using the + `application/problem+json` format. If such an error is detected, this + method raises an IdentityInsightsError. + + Args: + response (dict): Raw response returned by the HTTP client. + + Raises: + IdentityInsightsError: If the response contains an error payload. + """ + if "title" in response and "detail" in response: + error_message = f"Error with the following details: {response}" + raise IdentityInsightsError(error_message) diff --git a/identity_insights/src/vonage_identity_insights/requests.py b/identity_insights/src/vonage_identity_insights/requests.py new file mode 100644 index 00000000..b6996533 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/requests.py @@ -0,0 +1,79 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + + +class EmptyInsight(BaseModel): + """Model for an insight request without parameters. + + This model represents insights that must be included as an empty JSON object (`{}`) to + indicate that the insight is requested. + """ + + +class SimSwapInsight(BaseModel): + """Model for a SIM swap insight request. + + This insight checks whether a SIM swap has occurred within a specified + period of time. + + Args: + period (int, Optional): Period in hours to be checked for SIM swap. + Must be between 1 and 2400. Defaults to 240. + """ + + period: Optional[int] = Field( + default=240, + ge=1, + le=2400, + description="Period in hours to be checked for SIM swap", + ) + + +class InsightsRequest(BaseModel): + """Model for a collection of identity insight requests. + + Each field represents an individual insight. Only the insights included + in this object will be processed and returned in the response. + + Args: + format (EmptyInsight, Optional): Request phone number format validation. + sim_swap (SimSwapInsight, Optional): Request SIM swap information. + original_carrier (EmptyInsight, Optional): Request original carrier + information. + current_carrier (EmptyInsight, Optional): Request current carrier + information. + """ + + format: Optional[EmptyInsight] = None + sim_swap: Optional[SimSwapInsight] = None + original_carrier: Optional[EmptyInsight] = None + current_carrier: Optional[EmptyInsight] = None + + def at_least_one_insight(cls, values): + """Validate that at least one insight is provided.""" + if not any(v is not None for v in values.values()): + raise ValueError("At least one insight must be provided") + return values + + +class IdentityInsightsRequest(BaseModel): + """Model for an Identity Insights API request. + + This model represents a single aggregated request for one or more identity + insights related to a phone number. + + Args: + phone_number (PhoneNumber): The phone number to retrieve identity + insights for. + purpose (str, Optional): Purpose of the request. Required for insights + that rely on the Network Registry. + insights (InsightsRequest): Collection of requested insights. + """ + + phone_number: PhoneNumber + purpose: Optional[str] = Field( + None, description="Purpose of the request (required for some insights)" + ) + insights: InsightsRequest diff --git a/identity_insights/src/vonage_identity_insights/responses.py b/identity_insights/src/vonage_identity_insights/responses.py new file mode 100644 index 00000000..4b5c4dfc --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/responses.py @@ -0,0 +1,126 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel + + +class InsightStatus(BaseModel): + """Model for the status of an individual insight response. + + Args: + code (str): Status code of the insight processing. + message (str, Optional): Human-readable description of the status. + """ + + code: str + message: Optional[str] + + +class FormatInsightResponse(BaseModel): + """Model for the response of the `format` insight. + + This insight validates the phone number format and provides information + derived from its numbering plan. + + Args: + country_code (str, Optional): Country code in ISO 3166-1 alpha-2 format. + country_name (str, Optional): Full country name. + country_prefix (str, Optional): Numeric country calling code. + offline_location (str, Optional): Location derived from the number prefix. + time_zones (List[str], Optional): Time zones associated with the number. + number_international (str, Optional): Phone number in E.164 format. + number_national (str, Optional): Phone number in national format. + is_format_valid (bool, Optional): Indicates whether the number format is valid. + status (InsightStatus): Processing status of the insight. + """ + + country_code: Optional[str] + country_name: Optional[str] + country_prefix: Optional[str] + offline_location: Optional[str] + time_zones: Optional[List[str]] + number_international: Optional[str] + number_national: Optional[str] + is_format_valid: Optional[bool] + status: InsightStatus + + +class SimSwapInsightResponse(BaseModel): + """Model for the response of the `sim_swap` insight. + + This insight indicates whether a SIM swap has occurred recently. + + Args: + latest_sim_swap_at (datetime, Optional): Timestamp of the most recent + SIM swap, in UTC. + is_swapped (bool, Optional): Indicates whether a SIM swap occurred + within the requested period. + status (InsightStatus): Processing status of the insight. + """ + + latest_sim_swap_at: Optional[datetime] = None + is_swapped: Optional[bool] = None + status: InsightStatus + + +class CarrierInsightResponse(BaseModel): + """Model for the response of the `original_carrier` and `current_carrier` insights. + + Provides information about the network to which the phone number was + originally assigned. + + Args: + name (str, Optional): Full name of the original carrier. + network_type (str, Optional): Type of the network (e.g. MOBILE, LANDLINE). + country_code (str, Optional): Country code in ISO 3166-1 alpha-2 format. + network_code (str, Optional): MCC + MNC network identifier. + status (InsightStatus): Processing status of the insight. + """ + + name: Optional[str] + network_type: Optional[str] + country_code: Optional[str] + network_code: Optional[str] + status: InsightStatus + + +class InsightsResponse(BaseModel): + """Model for the collection of identity insight responses. + + Each field corresponds to an insight requested in the original request. + Only insights that were requested will be present in the response. + + Args: + format (FormatInsightResponse, Optional): Format validation response. + sim_swap (SimSwapInsightResponse, Optional): SIM swap response. + original_carrier (OriginalCarrierInsightResponse, Optional): Original + carrier response. + current_carrier (CurrentCarrierInsightResponse, Optional): Current + carrier response. + location_verification (LocationVerificationInsightResponse, Optional): + Location verification response. + subscriber_match (SubscriberMatchInsightResponse, Optional): Subscriber + match response. + roaming (RoamingInsightResponse, Optional): Roaming status response. + reachability (ReachabilityInsightResponse, Optional): Reachability response. + """ + + format: Optional[FormatInsightResponse] = None + sim_swap: Optional[SimSwapInsightResponse] = None + original_carrier: Optional[CarrierInsightResponse] = None + current_carrier: Optional[CarrierInsightResponse] = None + + +class IdentityInsightsResponse(BaseModel): + """Model for an Identity Insights API response. + + Represents the aggregated response containing the results of all requested + identity insights. + + Args: + request_id (str, Optional): Unique identifier for the request. + insights (InsightsResponse): Collection of insight responses. + """ + + request_id: Optional[str] = None + insights: InsightsResponse diff --git a/identity_insights/tests/BUILD b/identity_insights/tests/BUILD new file mode 100644 index 00000000..40c70fd4 --- /dev/null +++ b/identity_insights/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['identity_insights', 'testutils']) diff --git a/identity_insights/tests/data/format.json b/identity_insights/tests/data/format.json new file mode 100644 index 00000000..969a17e9 --- /dev/null +++ b/identity_insights/tests/data/format.json @@ -0,0 +1,29 @@ +{ + "request_id": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "insights": { + "format": { + "country_code": "US", + "country_name": "United States", + "country_prefix": "1", + "offline_location": "Georgia", + "time_zones": [ + "America/New_York" + ], + "number_international": "+14040000000", + "number_national": "(404) 000-0000", + "is_format_valid": true, + "status": { + "code": "OK", + "message": "Success" + } + }, + "sim_swap": { + "latest_sim_swap_at": "2024-07-08T09:30:27.504Z", + "is_swapped": true, + "status": { + "code": "OK", + "message": "Success" + } + } + } +} diff --git a/identity_insights/tests/data/insight_error.json b/identity_insights/tests/data/insight_error.json new file mode 100644 index 00000000..1993541c --- /dev/null +++ b/identity_insights/tests/data/insight_error.json @@ -0,0 +1,7 @@ +{ + "title": "Malformed JSON", + "type": "https://developer.vonage.com/api-errors#invalid-json", + "instance": "868cf2e9-bd6d-4bac-96ba-2b08120d8cf9", + "detail": "Malformed JSON payload" +} + diff --git a/identity_insights/tests/test_identity_insights.py b/identity_insights/tests/test_identity_insights.py new file mode 100644 index 00000000..bcc316cb --- /dev/null +++ b/identity_insights/tests/test_identity_insights.py @@ -0,0 +1,76 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.http_client import HttpClient, HttpClientOptions +from vonage_identity_insights.errors import ( + EmptyInsightsRequestException, + IdentityInsightsError, +) +from vonage_identity_insights.identity_insights import IdentityInsights +from vonage_identity_insights.requests import ( + EmptyInsight, + IdentityInsightsRequest, + InsightsRequest, +) + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +options = HttpClientOptions(api_host="api-eu.vonage.com", timeout=30) +identity_insights = IdentityInsights(HttpClient(get_mock_jwt_auth(), options)) + + +def test_http_client_property(): + http_client = identity_insights.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_format_insight(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/identity-insights/v1/requests', + 'format.json', + ) + + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest(format=EmptyInsight()) + ) + + response = identity_insights.requests(options) + + assert response.insights.format.status.code == "OK" + assert response.insights.format.status.message == "Success" + + +@responses.activate +def test_basic_insight_error(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/identity-insights/v1/requests', + 'insight_error.json', + ) + + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest(format=EmptyInsight()) + ) + + with raises(IdentityInsightsError) as e: + identity_insights.requests(options) + + assert "Malformed JSON" in str(e.value) + + +@responses.activate +def test_empty_insights_request_raises_exception(): + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest() + ) + + with raises(EmptyInsightsRequestException): + identity_insights.requests(options) diff --git a/requirements.txt b/requirements.txt index 8c149e7d..3cf935e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ urllib3 -e http_client -e account -e application +-e identity_insights -e messages -e network_auth -e network_number_verification diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 317613f5..cfd2b567 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -3,6 +3,7 @@ from vonage_account.account import Account from vonage_application.application import Application from vonage_http_client import Auth, HttpClient, HttpClientOptions +from vonage_identity_insights import IdentityInsights from vonage_messages import Messages from vonage_network_number_verification import NetworkNumberVerification from vonage_network_sim_swap import NetworkSimSwap @@ -51,6 +52,7 @@ def __init__( self.verify_legacy = VerifyLegacy(self._http_client) self.video = Video(self._http_client) self.voice = Voice(self._http_client) + self.identity_insights = IdentityInsights(self._http_client) @property def http_client(self): From b308cb1e94192b7ae5f5f7256d0ff1dfdd028020 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 30 Jan 2026 15:56:30 +0100 Subject: [PATCH 323/401] fix(sms): add trusted_sender parameter (#334) --- sms/src/vonage_sms/requests.py | 3 +++ sms/tests/test_sms.py | 1 + 2 files changed, 4 insertions(+) diff --git a/sms/src/vonage_sms/requests.py b/sms/src/vonage_sms/requests.py index b06c832a..a1b95a57 100644 --- a/sms/src/vonage_sms/requests.py +++ b/sms/src/vonage_sms/requests.py @@ -41,6 +41,8 @@ class SmsMessage(BaseModel): requirements when sending an SMS to specific countries. content_id (str, Optional): A string parameter that satisfies regulatory requirements when sending an SMS to specific countries. + trusted_sender (bool, Optional): overrides, on a per-message basis, any + protections set up via Fraud Defender """ to: str @@ -67,6 +69,7 @@ class SmsMessage(BaseModel): 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') + trusted_sender: Optional[bool] = Field(None, serialization_alias="trusted_sender") @field_validator('body', 'udh') @classmethod diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index 46d5da78..95b142c0 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -38,6 +38,7 @@ def test_create_valid_SmsMessage(): 'client_ref': 'ref123', 'type': 'binary', 'ttl': 3000000, + 'trusted_sender': True, 'status_report_req': True, 'callback': 'https://example.com/callback', 'message_class': 0, From c82446d69f9f31088bd42b9c80f50103a7dc958e Mon Sep 17 00:00:00 2001 From: Aurelien Favre Date: Thu, 12 Feb 2026 10:22:52 +0000 Subject: [PATCH 324/401] Fix: add None for nullable responses from Identity Insights (#338) --- .../src/vonage_identity_insights/responses.py | 26 +++++++-------- .../tests/data/nullable_response.json | 16 ++++++++++ .../tests/test_identity_insights.py | 32 +++++++++++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 identity_insights/tests/data/nullable_response.json diff --git a/identity_insights/src/vonage_identity_insights/responses.py b/identity_insights/src/vonage_identity_insights/responses.py index 4b5c4dfc..6239ce5c 100644 --- a/identity_insights/src/vonage_identity_insights/responses.py +++ b/identity_insights/src/vonage_identity_insights/responses.py @@ -13,7 +13,7 @@ class InsightStatus(BaseModel): """ code: str - message: Optional[str] + message: Optional[str] = None class FormatInsightResponse(BaseModel): @@ -34,14 +34,14 @@ class FormatInsightResponse(BaseModel): status (InsightStatus): Processing status of the insight. """ - country_code: Optional[str] - country_name: Optional[str] - country_prefix: Optional[str] - offline_location: Optional[str] - time_zones: Optional[List[str]] - number_international: Optional[str] - number_national: Optional[str] - is_format_valid: Optional[bool] + country_code: Optional[str] = None + country_name: Optional[str] = None + country_prefix: Optional[str] = None + offline_location: Optional[str] = None + time_zones: Optional[List[str]] = None + number_international: Optional[str] = None + number_national: Optional[str] = None + is_format_valid: Optional[bool] = None status: InsightStatus @@ -77,10 +77,10 @@ class CarrierInsightResponse(BaseModel): status (InsightStatus): Processing status of the insight. """ - name: Optional[str] - network_type: Optional[str] - country_code: Optional[str] - network_code: Optional[str] + name: Optional[str] = None + network_type: Optional[str] = None + country_code: Optional[str] = None + network_code: Optional[str] = None status: InsightStatus diff --git a/identity_insights/tests/data/nullable_response.json b/identity_insights/tests/data/nullable_response.json new file mode 100644 index 00000000..eb6aa5b8 --- /dev/null +++ b/identity_insights/tests/data/nullable_response.json @@ -0,0 +1,16 @@ +{ + "request_id": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "insights": { + "format": { + "status": { + "code": "OK" + } + }, + "original_carrier": { + "status": { + "code": "OK", + "message": "Success" + } + } + } +} diff --git a/identity_insights/tests/test_identity_insights.py b/identity_insights/tests/test_identity_insights.py index bcc316cb..e7347b62 100644 --- a/identity_insights/tests/test_identity_insights.py +++ b/identity_insights/tests/test_identity_insights.py @@ -74,3 +74,35 @@ def test_empty_insights_request_raises_exception(): with raises(EmptyInsightsRequestException): identity_insights.requests(options) + + +@responses.activate +def test_nullable_response_fields(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/identity-insights/v1/requests', + 'nullable_response.json', + ) + + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest(format=EmptyInsight()) + ) + + response = identity_insights.requests(options) + + # Verify that optional fields with missing values are None + assert response.insights.format.country_code is None + assert response.insights.format.country_name is None + assert response.insights.format.time_zones is None + assert response.insights.format.is_format_valid is None + assert response.insights.format.status.code == "OK" + assert response.insights.format.status.message is None + + # Verify original_carrier fields are None when not provided + assert response.insights.original_carrier.name is None + assert response.insights.original_carrier.network_type is None + assert response.insights.original_carrier.country_code is None + assert response.insights.original_carrier.network_code is None + assert response.insights.original_carrier.status.code == "OK" + assert response.insights.original_carrier.status.message == "Success" From 18c4466fba351a856c2f070141f282281c1fa249 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 10:55:31 +0100 Subject: [PATCH 325/401] feat(voice): add AnswerWebhook support --- voice/src/vonage_voice/models/__init__.py | 2 ++ voice/src/vonage_voice/models/webhooks.py | 33 +++++++++++++++++++++++ voice/tests/test_answer_webhook.py | 25 +++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 voice/src/vonage_voice/models/webhooks.py create mode 100644 voice/tests/test_answer_webhook.py diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index aeb86a7b..7f42cfe1 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -23,6 +23,7 @@ ToPhone, TtsStreamOptions, ) +from .webhooks import AnswerWebhook from .responses import ( CallInfo, CallList, @@ -36,6 +37,7 @@ 'AdvancedMachineDetection', 'AppEndpoint', 'AudioStreamOptions', + 'AnswerWebhook', 'CallInfo', 'CallList', 'CallMessage', diff --git a/voice/src/vonage_voice/models/webhooks.py b/voice/src/vonage_voice/models/webhooks.py new file mode 100644 index 00000000..1b4b7515 --- /dev/null +++ b/voice/src/vonage_voice/models/webhooks.py @@ -0,0 +1,33 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class AnswerWebhook(BaseModel): + """Model for the Voice API Answer webhook payload. + + Args: + to (str, Optional): The number or endpoint that answered the call. + from_ (str, Optional): The number or endpoint that initiated the call. + from_user (str, Optional): The Client SDK user that initiated the call. + endpoint_type (str, Optional): The type of endpoint that answered the call. + uuid (str, Optional): The unique identifier for this call. + conversation_uuid (str, Optional): The unique identifier for this conversation. + region_url (str, Optional): Regional API endpoint to control the call. + custom_data (dict, Optional): Custom data object passed from the Client SDK. + sipheader_user_to_user (str, Optional): Content of the SIP User-to-User header, + received as the `SipHeader_User-to-User` parameter on the webhook. + """ + + to: Optional[str] = None + from_: Optional[str] = Field(None, alias='from') + from_user: Optional[str] = None + endpoint_type: Optional[str] = None + uuid: Optional[str] = None + conversation_uuid: Optional[str] = None + region_url: Optional[str] = None + custom_data: Optional[dict] = None + sipheader_user_to_user: Optional[str] = Field( + None, serialization_alias='SipHeader_User-to-User' + ) + diff --git a/voice/tests/test_answer_webhook.py b/voice/tests/test_answer_webhook.py new file mode 100644 index 00000000..2387db1e --- /dev/null +++ b/voice/tests/test_answer_webhook.py @@ -0,0 +1,25 @@ +from vonage_voice.models import AnswerWebhook + + +def test_answer_webhook_sipheader_user_to_user_alias(): + payload = { + 'to': '442079460000', + 'from': '447700900000', + 'uuid': 'aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'conversation_uuid': 'CON-aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'SipHeader_User-to-User': '1234567890abcdef;encoding=hex', + } + + hook = AnswerWebhook(**payload) + + assert hook.to == '442079460000' + assert hook.from_ == '447700900000' + assert ( + hook.sipheader_user_to_user == '1234567890abcdef;encoding=hex' + ), 'Field should be populated from SipHeader_User-to-User' + + dumped = hook.model_dump(by_alias=True, exclude_none=True) + assert ( + dumped['SipHeader_User-to-User'] == '1234567890abcdef;encoding=hex' + ), 'Field should serialize back with the SipHeader_User-to-User key' + From 77042efb128a06fb1674f7ad38523fd572f562be Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 11:04:51 +0100 Subject: [PATCH 326/401] feat(voice): add wait NCCO action --- voice/src/vonage_voice/models/__init__.py | 13 +++++++++++- voice/src/vonage_voice/models/enums.py | 1 + voice/src/vonage_voice/models/ncco.py | 26 +++++++++++++++++++++++ voice/tests/test_ncco_actions.py | 24 +++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index aeb86a7b..3ad0f38e 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -15,7 +15,17 @@ TtsLanguageCode, ) from .input_types import Dtmf, Speech -from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk +from .ncco import ( + Connect, + Conversation, + Input, + NccoAction, + Notify, + Record, + Stream, + Talk, + Wait, +) from .requests import ( AudioStreamOptions, CreateCallRequest, @@ -63,6 +73,7 @@ 'Speech', 'Stream', 'Talk', + 'Wait', 'ToPhone', 'TtsLanguageCode', 'TtsStreamOptions', diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py index faa007ca..98ecae9b 100644 --- a/voice/src/vonage_voice/models/enums.py +++ b/voice/src/vonage_voice/models/enums.py @@ -16,6 +16,7 @@ class NccoActionType(str, Enum): STREAM = 'stream' INPUT = 'input' NOTIFY = 'notify' + WAIT = 'wait' class ConnectEndpointType(str, Enum): diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index 72388719..b29b7cb9 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -267,3 +267,29 @@ class Notify(NccoAction): eventUrl: list[str] eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.NOTIFY + + +class Wait(NccoAction): + """Use the Wait action to add a pause to an NCCO. + + The wait period starts when the action is executed and ends after the provided + or default timeout value. Execution of the NCCO then resumes with the next action. + + Args: + timeout (Optional[float]): Duration of the wait period in seconds. Valid values + are from 0.1 to 7200. Values below 0.1 are treated as 0.1; values above + 7200 are treated as 7200. If not specified, defaults to 10 seconds. + """ + + timeout: Optional[float] = 10.0 + action: NccoActionType = NccoActionType.WAIT + + @model_validator(mode='after') + def clamp_timeout(self): + if self.timeout is None: + self.timeout = 10.0 + elif self.timeout < 0.1: + self.timeout = 0.1 + elif self.timeout > 7200: + self.timeout = 7200.0 + return self diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index 4e1a3c75..d05f1ca1 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -325,3 +325,27 @@ def test_notify_options(): 'eventMethod': 'POST', 'action': 'notify', } + + +def test_wait_default_timeout(): + wait = ncco.Wait() + assert wait.model_dump(by_alias=True, exclude_none=True) == { + 'timeout': 10.0, + 'action': 'wait', + } + + +def test_wait_custom_timeout(): + wait = ncco.Wait(timeout=0.5) + assert wait.model_dump(by_alias=True, exclude_none=True) == { + 'timeout': 0.5, + 'action': 'wait', + } + + +def test_wait_timeout_clamped_min_max(): + wait_min = ncco.Wait(timeout=0.01) + assert wait_min.timeout == 0.1 + + wait_max = ncco.Wait(timeout=10000) + assert wait_max.timeout == 7200.0 From 425bcd201a8a36a4ae645529bb5a437e2eeb3775 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 11:17:00 +0100 Subject: [PATCH 327/401] feat(voice): add tranfer NCCO action --- voice/src/vonage_voice/models/__init__.py | 13 ++------ voice/src/vonage_voice/models/enums.py | 1 + voice/src/vonage_voice/models/ncco.py | 37 +++++++++++++++++++++++ voice/tests/test_ncco_actions.py | 35 +++++++++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index 3ad0f38e..ef045321 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -15,17 +15,7 @@ TtsLanguageCode, ) from .input_types import Dtmf, Speech -from .ncco import ( - Connect, - Conversation, - Input, - NccoAction, - Notify, - Record, - Stream, - Talk, - Wait, -) +from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk, Transfer, Wait from .requests import ( AudioStreamOptions, CreateCallRequest, @@ -73,6 +63,7 @@ 'Speech', 'Stream', 'Talk', + 'Transfer', 'Wait', 'ToPhone', 'TtsLanguageCode', diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py index 98ecae9b..868abce4 100644 --- a/voice/src/vonage_voice/models/enums.py +++ b/voice/src/vonage_voice/models/enums.py @@ -17,6 +17,7 @@ class NccoActionType(str, Enum): INPUT = 'input' NOTIFY = 'notify' WAIT = 'wait' + TRANSFER = 'transfer' class ConnectEndpointType(str, Enum): diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index b29b7cb9..e82bb084 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -293,3 +293,40 @@ def clamp_timeout(self): elif self.timeout > 7200: self.timeout = 7200.0 return self + + +class Transfer(NccoAction): + """Use the Transfer action to move all legs from the current conversation into + another existing conversation. + + The transfer action is synchronous and terminal for the current conversation. + The target conversation's NCCO continues to control its behaviour. + + Args: + conversationId (str): The target conversation ID. + canHear (Optional[list[str]]): Leg UUIDs this participant can hear. If not + provided, the participant can hear everyone. If an empty list is provided, + the participant will not hear any other participants. + canSpeak (Optional[list[str]]): Leg UUIDs this participant can be heard by. If + not provided, the participant can be heard by everyone. If an empty list is + provided, the participant will not be heard by anyone. + mute (Optional[bool]): Set to `True` to mute the participant. When using + `canSpeak`, the `mute` parameter is not supported. + + Raises: + NccoActionError: If the `mute` option is used with the `canSpeak` option. + """ + + conversationId: str + canHear: Optional[list[str]] = None + canSpeak: Optional[list[str]] = None + mute: Optional[bool] = None + action: NccoActionType = NccoActionType.TRANSFER + + @model_validator(mode='after') + def validate_mute_and_can_speak(self): + if self.canSpeak and self.mute: + raise NccoActionError( + 'Cannot use mute option if canSpeak option is specified.' + ) + return self diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index d05f1ca1..73764591 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -349,3 +349,38 @@ def test_wait_timeout_clamped_min_max(): wait_max = ncco.Wait(timeout=10000) assert wait_max.timeout == 7200.0 + + +def test_transfer_basic(): + transfer = ncco.Transfer(conversationId='CON-1234567890') + assert transfer.model_dump(by_alias=True, exclude_none=True) == { + 'conversationId': 'CON-1234567890', + 'action': 'transfer', + } + + +def test_transfer_options(): + transfer = ncco.Transfer( + conversationId='CON-1234567890', + canHear=['leg-a'], + canSpeak=['leg-b', 'leg-c'], + mute=False, + ) + assert transfer.model_dump(by_alias=True, exclude_none=True) == { + 'conversationId': 'CON-1234567890', + 'canHear': ['leg-a'], + 'canSpeak': ['leg-b', 'leg-c'], + 'mute': False, + 'action': 'transfer', + } + + +def test_transfer_mute_with_canspeak_error(): + with raises(NccoActionError) as e: + ncco.Transfer( + conversationId='CON-1234567890', + canSpeak=['leg-a'], + mute=True, + ) + + assert e.match('Cannot use mute option if canSpeak option is specified.') From b189b683a443a73831a8c44ed76ba1f6791caf66 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 11:40:13 +0100 Subject: [PATCH 328/401] fix: linter --- voice/src/vonage_voice/models/__init__.py | 91 +++++++++++++---------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index ef045321..df81bace 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -15,7 +15,18 @@ TtsLanguageCode, ) from .input_types import Dtmf, Speech -from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk, Transfer, Wait +from .ncco import ( + Connect, + Conversation, + Input, + NccoAction, + Notify, + Record, + Stream, + Talk, + Transfer, + Wait, +) from .requests import ( AudioStreamOptions, CreateCallRequest, @@ -33,43 +44,43 @@ ) __all__ = [ - 'AdvancedMachineDetection', - 'AppEndpoint', - 'AudioStreamOptions', - 'CallInfo', - 'CallList', - 'CallMessage', - 'CallState', - 'Channel', - 'Connect', - 'ConnectEndpointType', - 'Conversation', - 'CreateCallRequest', - 'CreateCallResponse', - 'Dtmf', - 'Embedded', - 'Input', - 'ListCallsFilter', - 'HalLinks', - 'NccoAction', - 'NccoActionType', - 'Notify', - 'OnAnswer', - 'Phone', - 'PhoneEndpoint', - 'Record', - 'Sip', - 'SipEndpoint', - 'Speech', - 'Stream', - 'Talk', - 'Transfer', - 'Wait', - 'ToPhone', - 'TtsLanguageCode', - 'TtsStreamOptions', - 'Vbc', - 'VbcEndpoint', - 'Websocket', - 'WebsocketEndpoint', + "AdvancedMachineDetection", + "AppEndpoint", + "AudioStreamOptions", + "CallInfo", + "CallList", + "CallMessage", + "CallState", + "Channel", + "Connect", + "ConnectEndpointType", + "Conversation", + "CreateCallRequest", + "CreateCallResponse", + "Dtmf", + "Embedded", + "Input", + "ListCallsFilter", + "HalLinks", + "NccoAction", + "NccoActionType", + "Notify", + "OnAnswer", + "Phone", + "PhoneEndpoint", + "Record", + "Sip", + "SipEndpoint", + "Speech", + "Stream", + "Talk", + "Transfer", + "Wait", + "ToPhone", + "TtsLanguageCode", + "TtsStreamOptions", + "Vbc", + "VbcEndpoint", + "Websocket", + "WebsocketEndpoint", ] From 011718da2208052a8c9500cc671e63ac5bb2f6c3 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 16:24:39 +0100 Subject: [PATCH 329/401] feat(voice): add support for 24k audio in Websocket --- users/src/vonage_users/common.py | 2 +- users/tests/test_websocket_channel_24k.py | 8 ++++++++ voice/src/vonage_voice/models/common.py | 14 +++++++------- voice/src/vonage_voice/models/connect_endpoints.py | 10 +++++----- voice/tests/test_ncco_actions.py | 7 +++++++ 5 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 users/tests/test_websocket_channel_24k.py diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 0fb785a8..e213af4b 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -50,7 +50,7 @@ class WebsocketChannel(BaseModel): 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)$' + None, alias='content-type', pattern='^audio/l16;rate=(8000|16000|24000)$' ) headers: Optional[dict] = None diff --git a/users/tests/test_websocket_channel_24k.py b/users/tests/test_websocket_channel_24k.py new file mode 100644 index 00000000..a869a86c --- /dev/null +++ b/users/tests/test_websocket_channel_24k.py @@ -0,0 +1,8 @@ +from vonage_users.common import WebsocketChannel + + +def test_websocket_channel_24k_audio(): + channel = WebsocketChannel( + uri="wss://example.com/socket", content_type="audio/l16;rate=24000" + ) + assert channel.model_dump(by_alias=True)["content-type"] == "audio/l16;rate=24000" diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 1a155af2..77013692 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -37,15 +37,15 @@ class Websocket(BaseModel): Args: uri (str): The URI of the WebSocket connection. - content_type (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The content - type of the audio stream. + content_type (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000', 'audio/l16;rate=24000']): + The content type of the audio stream. headers (Optional[dict]): The headers to include with the WebSocket connection. """ uri: str = Field(..., min_length=1) - content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( - 'audio/l16;rate=16000', serialization_alias='content-type' - ) + content_type: Literal[ + "audio/l16;rate=8000", "audio/l16;rate=16000", "audio/l16;rate=24000" + ] = Field("audio/l16;rate=16000", serialization_alias="content-type") headers: Optional[dict] = None type: Channel = Channel.WEBSOCKET @@ -74,6 +74,6 @@ class AdvancedMachineDetection(BaseModel): machine beep to be detected. """ - behavior: Optional[Literal['continue', 'hangup']] = None - mode: Optional[Literal['default', 'detect', 'detect_beep']] = None + behavior: Optional[Literal["continue", "hangup"]] = None + mode: Optional[Literal["default", "detect", "detect_beep"]] = None beep_timeout: Optional[int] = Field(None, ge=45, le=120) diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index d1c15715..05f7d026 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -52,15 +52,15 @@ class WebsocketEndpoint(BaseModel): Args: uri (str): The URI of the WebSocket connection. - contentType (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The internet - media type for the audio you are streaming. + contentType (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000', 'audio/l16;rate=24000']): + The internet media type for the audio you are streaming. headers (Optional[dict]): The headers to include with the WebSocket connection. """ uri: str - contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] = Field( - None, serialization_alias='content-type' - ) + contentType: Literal[ + 'audio/l16;rate=8000', 'audio/l16;rate=16000', 'audio/l16;rate=24000' + ] = Field(None, serialization_alias='content-type') headers: Optional[dict] = None type: ConnectEndpointType = ConnectEndpointType.WEBSOCKET diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index 4e1a3c75..57a09055 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -112,6 +112,13 @@ def test_create_connect_endpoints(): 'type': 'websocket', } + ws_24k = connect_endpoints.WebsocketEndpoint( + uri='wss://example.com', + contentType='audio/l16;rate=24000', + headers={'asdf': 'qwer'}, + ) + assert ws_24k.model_dump(by_alias=True)['content-type'] == 'audio/l16;rate=24000' + assert connect_endpoints.SipEndpoint( uri='sip:example@sip.example.com', headers={'qwer': 'asdf'}, From c95a37f266a3d28dfdf5f98f43032e655c207195 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 16:58:52 +0100 Subject: [PATCH 330/401] feat(voice): add shaken property to Phone endpoint --- .../vonage_voice/models/connect_endpoints.py | 2 + voice/src/vonage_voice/models/requests.py | 2 + voice/tests/test_ncco_actions.py | 2 + voice/tests/test_voice.py | 352 ++++++++++-------- 4 files changed, 193 insertions(+), 165 deletions(-) diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index d1c15715..cc7659bf 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -27,11 +27,13 @@ class PhoneEndpoint(BaseModel): number (PhoneNumber): The phone number to call. dtmfAnswer (Optional[Dtmf]): The DTMF tones to send when the call is answered. onAnswer (Optional[OnAnswer]): Settings for what to do when the call is answered. + shaken (Optional[str]): STIR/SHAKEN Identity header content to use for this call. """ number: PhoneNumber dtmfAnswer: Optional[Dtmf] = None onAnswer: Optional[OnAnswer] = None + shaken: Optional[str] = None type: ConnectEndpointType = ConnectEndpointType.PHONE diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index 25c006a4..d738710f 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -15,9 +15,11 @@ class ToPhone(Phone): Args: number (PhoneNumber): The phone number. dtmf_answer (Optional[Dtmf]): The DTMF tones to send when the call is answered. + shaken (Optional[str]): STIR/SHAKEN Identity header content to use for this call. """ dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + shaken: Optional[str] = None class CreateCallRequest(BaseModel): diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index 4e1a3c75..e066c10e 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -89,10 +89,12 @@ def test_create_connect_endpoints(): number='447000000000', dtmfAnswer='1234', onAnswer={'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + shaken='shaken-token', ).model_dump() == { 'number': '447000000000', 'dtmfAnswer': '1234', 'onAnswer': {'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + 'shaken': 'shaken-token', 'type': 'phone', } diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index cfcd459e..3dd32eba 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -10,6 +10,7 @@ CreateCallRequest, ListCallsFilter, Sip, + ToPhone, TtsStreamOptions, ) from vonage_voice.errors import VoiceError @@ -32,16 +33,16 @@ def test_http_client_property(): @responses.activate def test_create_call_basic_ncco(): build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 ) - ncco = [Talk(text='Hello world')] + ncco = [Talk(text="Hello world")] call = CreateCallRequest( ncco=ncco, to=[ Sip( - uri='sip:test@example.com', - headers={'location': 'New York City'}, - standard_headers={'User-to-User': '342342ef34;encoding=hex'}, + uri="sip:test@example.com", + headers={"location": "New York City"}, + standard_headers={"User-to-User": "342342ef34;encoding=hex"}, ) ], random_from_number=True, @@ -50,105 +51,126 @@ def test_create_call_basic_ncco(): response = voice.create_call(call) body = json.loads(voice.http_client.last_request.body) - assert body['to'][0]['headers'] == {'location': 'New York City'} - assert body['to'][0]['standard_headers'] == { - 'User-to-User': '342342ef34;encoding=hex' + assert body["to"][0]["headers"] == {"location": "New York City"} + assert body["to"][0]["standard_headers"] == { + "User-to-User": "342342ef34;encoding=hex" } assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + assert response.uuid == "106a581a-34d0-432a-a625-220221fd434f" + assert response.status == "started" + assert response.direction == "outbound" + assert response.conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" @responses.activate def test_create_call_basic_ncco_from_sip(): build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 ) - ncco = [Talk(text='Hello world')] + ncco = [Talk(text="Hello world")] call = CreateCallRequest( ncco=ncco, - to=[Sip(uri='sip:test@example.com')], - from_='sip:from_sip_uri@example.com', + to=[Sip(uri="sip:test@example.com")], + from_="sip:from_sip_uri@example.com", ) response = voice.create_call(call) assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + assert response.uuid == "106a581a-34d0-432a-a625-220221fd434f" + assert response.status == "started" + assert response.direction == "outbound" + assert response.conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" @responses.activate def test_create_call_ncco_options(): build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 ) - ncco = [Talk(text='Hello world')] + ncco = [Talk(text="Hello world")] call = CreateCallRequest( ncco=ncco, - to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], - from_={'number': '1234567890', 'type': 'phone'}, - event_url=['https://example.com/event'], - event_method='POST', - machine_detection='hangup', + to=[{"type": "phone", "number": "1234567890", "dtmf_answer": "1234"}], + from_={"number": "1234567890", "type": "phone"}, + event_url=["https://example.com/event"], + event_method="POST", + machine_detection="hangup", length_timer=60, ringing_timer=30, ) response = voice.create_call(call) assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + assert response.uuid == "106a581a-34d0-432a-a625-220221fd434f" + assert response.status == "started" + assert response.direction == "outbound" + assert response.conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" + + +@responses.activate +def test_create_call_phone_shaken(): + build_response( + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 + ) + ncco = [Talk(text="Hello world")] + to_phone = ToPhone(number="1234567890", dtmf_answer="1234", shaken="shaken-token") + call = CreateCallRequest( + ncco=ncco, + to=[to_phone], + from_={"number": "1234567890", "type": "phone"}, + ) + response = voice.create_call(call) + + body = json.loads(voice.http_client.last_request.body) + assert body["to"][0]["number"] == "1234567890" + assert body["to"][0]["dtmfAnswer"] == "1234" + assert body["to"][0]["shaken"] == "shaken-token" + assert type(response) == CreateCallResponse @responses.activate def test_create_call_basic_answer_url(): build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 ) call = CreateCallRequest( to=[ { - 'type': 'websocket', - 'uri': 'wss://example.com/websocket', - 'content_type': 'audio/l16;rate=8000', - 'headers': {'key': 'value'}, + "type": "websocket", + "uri": "wss://example.com/websocket", + "content_type": "audio/l16;rate=8000", + "headers": {"key": "value"}, } ], - answer_url=['https://example.com/answer'], + answer_url=["https://example.com/answer"], random_from_number=True, ) response = voice.create_call(call) assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + assert response.uuid == "106a581a-34d0-432a-a625-220221fd434f" + assert response.status == "started" + assert response.direction == "outbound" + assert response.conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" @responses.activate def test_create_call_answer_url_options(): build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 ) call = CreateCallRequest( - to=[{'type': 'vbc', 'extension': '1234'}], - answer_url=['https://example.com/answer'], - answer_method='GET', + to=[{"type": "vbc", "extension": "1234"}], + answer_url=["https://example.com/answer"], + answer_method="GET", random_from_number=True, - event_url=['https://example.com/event'], - event_method='POST', + event_url=["https://example.com/event"], + event_method="POST", advanced_machine_detection={ - 'behavior': 'hangup', - 'mode': 'detect_beep', - 'beep_timeout': 50, + "behavior": "hangup", + "mode": "detect_beep", + "beep_timeout": 50, }, length_timer=60, ringing_timer=30, @@ -156,89 +178,89 @@ def test_create_call_answer_url_options(): response = voice.create_call(call) assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + assert response.uuid == "106a581a-34d0-432a-a625-220221fd434f" + assert response.status == "started" + assert response.direction == "outbound" + assert response.conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" def test_create_call_ncco_and_answer_url_error(): with raises(VoiceError) as e: CreateCallRequest( - to=[{'type': 'phone', 'number': '1234567890'}], + to=[{"type": "phone", "number": "1234567890"}], random_from_number=True, ) - assert e.match('Either `ncco` or `answer_url` must be set') + assert e.match("Either `ncco` or `answer_url` must be set") with raises(VoiceError) as e: CreateCallRequest( - ncco=[Talk(text='Hello world')], - answer_url=['https://example.com/answer'], - to=[{'type': 'phone', 'number': '1234567890'}], + ncco=[Talk(text="Hello world")], + answer_url=["https://example.com/answer"], + to=[{"type": "phone", "number": "1234567890"}], random_from_number=True, ) - assert e.match('`ncco` and `answer_url` cannot be used together') + assert e.match("`ncco` and `answer_url` cannot be used together") def test_create_call_from_and_random_from_number_error(): with raises(VoiceError) as e: CreateCallRequest( - ncco=[Talk(text='Hello world')], - to=[{'type': 'phone', 'number': '1234567890'}], + ncco=[Talk(text="Hello world")], + to=[{"type": "phone", "number": "1234567890"}], ) - assert e.match('Either `from_` or `random_from_number` must be set') + assert e.match("Either `from_` or `random_from_number` must be set") with raises(VoiceError) as e: CreateCallRequest( - ncco=[Talk(text='Hello world')], - to=[{'type': 'phone', 'number': '1234567890'}], - from_={'number': '9876543210', 'type': 'phone'}, + ncco=[Talk(text="Hello world")], + to=[{"type": "phone", "number": "1234567890"}], + from_={"number": "9876543210", "type": "phone"}, random_from_number=True, ) - assert e.match('`from_` and `random_from_number` cannot be used together') + assert e.match("`from_` and `random_from_number` cannot be used together") @responses.activate def test_list_calls(): - build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) + build_response(path, "GET", "https://api.nexmo.com/v1/calls", "list_calls.json", 200) calls, _ = voice.list_calls() assert len(calls) == 3 - assert calls[0].to.number == '1234567890' - assert calls[0].from_.number == '9876543210' - assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' - assert calls[1].direction == 'outbound' - assert calls[1].status == 'completed' - assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + assert calls[0].to.number == "1234567890" + assert calls[0].from_.number == "9876543210" + assert calls[0].uuid == "e154eb57-2962-41e7-baf4-90f63e25e439" + assert calls[1].direction == "outbound" + assert calls[1].status == "completed" + assert calls[2].conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" @responses.activate def test_list_calls_filter(): build_response( - path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 + path, "GET", "https://api.nexmo.com/v1/calls", "list_calls_filter.json", 200 ) filter = ListCallsFilter( - status='completed', - date_start='2024-03-14T07:45:14Z', - date_end='2024-04-19T08:45:14Z', + status="completed", + date_start="2024-03-14T07:45:14Z", + date_end="2024-04-19T08:45:14Z", page_size=10, record_index=0, - order='asc', - conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', + order="asc", + conversation_uuid="CON-2be039b2-d0a4-4274-afc8-d7b241c7c044", ) filter_dict = { - 'status': 'completed', - 'date_start': '2024-03-14T07:45:14Z', - 'date_end': '2024-04-19T08:45:14Z', - 'page_size': 10, - 'record_index': 0, - 'order': 'asc', - 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', + "status": "completed", + "date_start": "2024-03-14T07:45:14Z", + "date_end": "2024-04-19T08:45:14Z", + "page_size": 10, + "record_index": 0, + "order": "asc", + "conversation_uuid": "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044", } assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict calls, next_record_index = voice.list_calls(filter) assert len(calls) == 1 - assert calls[0].to.number == '1234567890' + assert calls[0].to.number == "1234567890" assert next_record_index == 2 @@ -246,51 +268,51 @@ def test_list_calls_filter(): def test_get_call(): build_response( path, - 'GET', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - 'get_call.json', + "GET", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", + "get_call.json", 200, ) - call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') - assert call.to.number == '1234567890' - assert call.from_.number == '9876543210' - assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' - assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' + call = voice.get_call("e154eb57-2962-41e7-baf4-90f63e25e439") + assert call.to.number == "1234567890" + assert call.from_.number == "9876543210" + assert call.uuid == "e154eb57-2962-41e7-baf4-90f63e25e439" + assert call.link == "/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439" @responses.activate def test_transfer_call_ncco(): build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, ) - ncco = [Talk(text='Hello world')] - voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) + ncco = [Talk(text="Hello world")] + voice.transfer_call_ncco("e154eb57-2962-41e7-baf4-90f63e25e439", ncco) assert voice._http_client.last_response.status_code == 204 @responses.activate def test_transfer_call_answer_url(): - answer_url = 'https://example.com/answer' + answer_url = "https://example.com/answer" build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, match=[ json_params_matcher( { - 'action': 'transfer', - 'destination': {'type': 'ncco', 'url': [answer_url]}, + "action": "transfer", + "destination": {"type": "ncco", "url": [answer_url]}, }, ), ], ) - voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) + voice.transfer_call_answer_url("e154eb57-2962-41e7-baf4-90f63e25e439", answer_url) assert voice._http_client.last_response.status_code == 204 @@ -298,13 +320,13 @@ def test_transfer_call_answer_url(): def test_hangup(): build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, - match=[json_params_matcher({'action': 'hangup'})], + match=[json_params_matcher({"action": "hangup"})], ) - voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') + voice.hangup("e154eb57-2962-41e7-baf4-90f63e25e439") assert voice._http_client.last_response.status_code == 204 @@ -312,13 +334,13 @@ def test_hangup(): def test_mute(): build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, - match=[json_params_matcher({'action': 'mute'})], + match=[json_params_matcher({"action": "mute"})], ) - voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') + voice.mute("e154eb57-2962-41e7-baf4-90f63e25e439") assert voice._http_client.last_response.status_code == 204 @@ -326,13 +348,13 @@ def test_mute(): def test_unmute(): build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, - match=[json_params_matcher({'action': 'unmute'})], + match=[json_params_matcher({"action": "unmute"})], ) - voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') + voice.unmute("e154eb57-2962-41e7-baf4-90f63e25e439") assert voice._http_client.last_response.status_code == 204 @@ -340,13 +362,13 @@ def test_unmute(): def test_earmuff(): build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, - match=[json_params_matcher({'action': 'earmuff'})], + match=[json_params_matcher({"action": "earmuff"})], ) - voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') + voice.earmuff("e154eb57-2962-41e7-baf4-90f63e25e439") assert voice._http_client.last_response.status_code == 204 @@ -354,94 +376,94 @@ def test_earmuff(): def test_unearmuff(): build_response( path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + "PUT", + "https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439", status_code=204, - match=[json_params_matcher({'action': 'unearmuff'})], + match=[json_params_matcher({"action": "unearmuff"})], ) - voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') + voice.unearmuff("e154eb57-2962-41e7-baf4-90f63e25e439") assert voice._http_client.last_response.status_code == 204 @responses.activate def test_play_audio_into_call(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + uuid = "e154eb57-2962-41e7-baf4-90f63e25e439" build_response( path, - 'PUT', - f'https://api.nexmo.com/v1/calls/{uuid}/stream', - 'play_audio_into_call.json', + "PUT", + f"https://api.nexmo.com/v1/calls/{uuid}/stream", + "play_audio_into_call.json", ) options = AudioStreamOptions( - stream_url=['https://example.com/audio'], loop=2, level=0.5 + stream_url=["https://example.com/audio"], loop=2, level=0.5 ) response = voice.play_audio_into_call(uuid, options) - assert response.message == 'Stream started' + assert response.message == "Stream started" assert response.uuid == uuid @responses.activate def test_stop_audio_stream(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + uuid = "e154eb57-2962-41e7-baf4-90f63e25e439" build_response( path, - 'DELETE', - f'https://api.nexmo.com/v1/calls/{uuid}/stream', - 'stop_audio_stream.json', + "DELETE", + f"https://api.nexmo.com/v1/calls/{uuid}/stream", + "stop_audio_stream.json", ) response = voice.stop_audio_stream(uuid) - assert response.message == 'Stream stopped' + assert response.message == "Stream stopped" assert response.uuid == uuid @responses.activate def test_play_tts_into_call(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + uuid = "e154eb57-2962-41e7-baf4-90f63e25e439" build_response( path, - 'PUT', - f'https://api.nexmo.com/v1/calls/{uuid}/talk', - 'play_tts_into_call.json', + "PUT", + f"https://api.nexmo.com/v1/calls/{uuid}/talk", + "play_tts_into_call.json", ) options = TtsStreamOptions( - text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 + text="Hello world", language="en-ZA", style=1, premium=False, loop=2, level=0.5 ) response = voice.play_tts_into_call(uuid, options) - assert response.message == 'Talk started' + assert response.message == "Talk started" assert response.uuid == uuid @responses.activate def test_stop_tts(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + uuid = "e154eb57-2962-41e7-baf4-90f63e25e439" build_response( path, - 'DELETE', - f'https://api.nexmo.com/v1/calls/{uuid}/talk', - 'stop_tts.json', + "DELETE", + f"https://api.nexmo.com/v1/calls/{uuid}/talk", + "stop_tts.json", ) response = voice.stop_tts(uuid) - assert response.message == 'Talk stopped' + assert response.message == "Talk stopped" assert response.uuid == uuid @responses.activate def test_play_dtmf_into_call(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + uuid = "e154eb57-2962-41e7-baf4-90f63e25e439" build_response( path, - 'PUT', - f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', - 'play_dtmf_into_call.json', + "PUT", + f"https://api.nexmo.com/v1/calls/{uuid}/dtmf", + "play_dtmf_into_call.json", ) - response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') - assert response.message == 'DTMF sent' + response = voice.play_dtmf_into_call(uuid, dtmf="1234*#") + assert response.message == "DTMF sent" assert response.uuid == uuid @@ -449,32 +471,32 @@ def test_play_dtmf_into_call(): def test_download_recording(): build_response( path, - 'GET', - 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', - 'file_stream.mp3', + "GET", + "https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "file_stream.mp3", ) voice.download_recording( - url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', - file_path='voice/tests/data/file_stream.mp3', + url="https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + file_path="voice/tests/data/file_stream.mp3", ) - with open('voice/tests/data/file_stream.mp3', 'rb') as file: + with open("voice/tests/data/file_stream.mp3", "rb") as file: file_content = file.read() - assert file_content.startswith(b'ID3') + assert file_content.startswith(b"ID3") def test_download_recording_invalid_url(): with raises(VoiceError) as e: voice.download_recording( - url='https://invalid.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', - file_path='voice/tests/data/file_stream.mp3', + url="https://invalid.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + file_path="voice/tests/data/file_stream.mp3", ) - assert e.match('The recording URL must be from a Vonage or Nexmo hostname.') + assert e.match("The recording URL must be from a Vonage or Nexmo hostname.") def test_verify_signature(): - token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM' + token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM" valid_signature = "qwertyuiopasdfghjklzxcvbnm123456" assert voice.verify_signature(token, valid_signature) is True From c7d96a1337e5b7afce30b42bf349c00f9ba6bb53 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 17:15:56 +0100 Subject: [PATCH 331/401] fix: unit test --- users/src/vonage_users/common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index e213af4b..b7af86c7 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from vonage_utils.models import ResourceLink from vonage_utils.types import PhoneNumber @@ -48,6 +48,8 @@ class WebsocketChannel(BaseModel): headers (dict, Optional): Headers sent to the WebSocket. """ + model_config = ConfigDict(populate_by_name=True) + 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|24000)$' From 2f79dbf60ce4e4c547c34c1fc83827a02e2be1a6 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 17:16:59 +0100 Subject: [PATCH 332/401] fix: linter --- voice/src/vonage_voice/models/ncco.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index e82bb084..6309dc4c 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -296,8 +296,8 @@ def clamp_timeout(self): class Transfer(NccoAction): - """Use the Transfer action to move all legs from the current conversation into - another existing conversation. + """Use the Transfer action to move all legs from the current conversation into another + existing conversation. The transfer action is synchronous and terminal for the current conversation. The target conversation's NCCO continues to control its behaviour. From 92205689c9f08f2839b07b1f2ebacc2afb427d0d Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 17:22:01 +0100 Subject: [PATCH 333/401] fix(voice): lint and test --- voice/src/vonage_voice/models/__init__.py | 2 +- voice/src/vonage_voice/models/webhooks.py | 9 ++++++--- voice/tests/test_answer_webhook.py | 1 - voice/tests/test_voice.py | 3 +-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index 7f42cfe1..7e03cc52 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -23,7 +23,6 @@ ToPhone, TtsStreamOptions, ) -from .webhooks import AnswerWebhook from .responses import ( CallInfo, CallList, @@ -32,6 +31,7 @@ Embedded, HalLinks, ) +from .webhooks import AnswerWebhook __all__ = [ 'AdvancedMachineDetection', diff --git a/voice/src/vonage_voice/models/webhooks.py b/voice/src/vonage_voice/models/webhooks.py index 1b4b7515..6fb84a1b 100644 --- a/voice/src/vonage_voice/models/webhooks.py +++ b/voice/src/vonage_voice/models/webhooks.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class AnswerWebhook(BaseModel): @@ -19,6 +19,8 @@ class AnswerWebhook(BaseModel): received as the `SipHeader_User-to-User` parameter on the webhook. """ + model_config = ConfigDict(populate_by_name=True) + to: Optional[str] = None from_: Optional[str] = Field(None, alias='from') from_user: Optional[str] = None @@ -28,6 +30,7 @@ class AnswerWebhook(BaseModel): region_url: Optional[str] = None custom_data: Optional[dict] = None sipheader_user_to_user: Optional[str] = Field( - None, serialization_alias='SipHeader_User-to-User' + None, + validation_alias='SipHeader_User-to-User', + serialization_alias='SipHeader_User-to-User', ) - diff --git a/voice/tests/test_answer_webhook.py b/voice/tests/test_answer_webhook.py index 2387db1e..ab56057a 100644 --- a/voice/tests/test_answer_webhook.py +++ b/voice/tests/test_answer_webhook.py @@ -22,4 +22,3 @@ def test_answer_webhook_sipheader_user_to_user_alias(): assert ( dumped['SipHeader_User-to-User'] == '1234567890abcdef;encoding=hex' ), 'Field should serialize back with the SipHeader_User-to-User key' - diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index cfcd459e..b1a7c0de 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -4,6 +4,7 @@ import responses from pytest import raises from responses.matchers import json_params_matcher +from testutils import build_response, get_mock_jwt_auth from vonage_http_client.http_client import HttpClient from vonage_voice import ( AudioStreamOptions, @@ -17,8 +18,6 @@ from vonage_voice.models.responses import CreateCallResponse from vonage_voice.voice import Voice -from testutils import build_response, get_mock_jwt_auth - path = abspath(__file__) From f8907ab033ca958b44e0813a148cf2bb06551ca2 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 27 Feb 2026 17:41:19 +0100 Subject: [PATCH 334/401] feat(voice): add authorization to WebSocket endpoints --- voice/src/vonage_voice/models/common.py | 18 ++++++++ .../vonage_voice/models/connect_endpoints.py | 4 ++ voice/tests/test_ncco_actions.py | 8 ++++ voice/tests/test_voice.py | 42 +++++++++++++++---- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 1a155af2..d7be2791 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -32,6 +32,21 @@ class Sip(BaseModel): type: Channel = Channel.SIP +class WebsocketAuthorization(BaseModel): + """Authorization settings for a WebSocket endpoint. + + Args: + type (Literal['vonage', 'custom']): The authorization mode. Use `vonage` to have + Vonage generate and send a JWT for you, or `custom` to provide your own + Authorization header value. + value (str, Optional): Authorization header value to send when `type` is + `custom`. Ignored for `vonage`. + """ + + type: Literal['vonage', 'custom'] + value: Optional[str] = None + + class Websocket(BaseModel): """Model for a WebSocket connection. @@ -40,6 +55,8 @@ class Websocket(BaseModel): content_type (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The content type of the audio stream. headers (Optional[dict]): The headers to include with the WebSocket connection. + authorization (WebsocketAuthorization, Optional): Authorization configuration for + the WebSocket handshake. """ uri: str = Field(..., min_length=1) @@ -47,6 +64,7 @@ class Websocket(BaseModel): 'audio/l16;rate=16000', serialization_alias='content-type' ) headers: Optional[dict] = None + authorization: Optional[WebsocketAuthorization] = None type: Channel = Channel.WEBSOCKET diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index d1c15715..fd2dba38 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from vonage_utils.types import Dtmf, PhoneNumber, SipUri +from .common import WebsocketAuthorization from .enums import ConnectEndpointType @@ -55,6 +56,8 @@ class WebsocketEndpoint(BaseModel): contentType (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The internet media type for the audio you are streaming. headers (Optional[dict]): The headers to include with the WebSocket connection. + authorization (WebsocketAuthorization, Optional): Authorization configuration for + the WebSocket handshake. """ uri: str @@ -62,6 +65,7 @@ class WebsocketEndpoint(BaseModel): None, serialization_alias='content-type' ) headers: Optional[dict] = None + authorization: Optional[WebsocketAuthorization] = None type: ConnectEndpointType = ConnectEndpointType.WEBSOCKET diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index 4e1a3c75..1d3028f6 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -105,10 +105,18 @@ def test_create_connect_endpoints(): uri='wss://example.com', contentType='audio/l16;rate=8000', headers={'asdf': 'qwer'}, + authorization={ + 'type': 'custom', + 'value': 'Bearer eyJhbGciOi...', + }, ).model_dump(by_alias=True) == { 'uri': 'wss://example.com', 'content-type': 'audio/l16;rate=8000', 'headers': {'asdf': 'qwer'}, + 'authorization': { + 'type': 'custom', + 'value': 'Bearer eyJhbGciOi...', + }, 'type': 'websocket', } diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index cfcd459e..87253fb3 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -11,6 +11,7 @@ ListCallsFilter, Sip, TtsStreamOptions, + Websocket, ) from vonage_voice.errors import VoiceError from vonage_voice.models.ncco import Talk @@ -112,15 +113,13 @@ def test_create_call_basic_answer_url(): build_response( path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 ) + ws = Websocket( + uri='wss://example.com/websocket', + content_type='audio/l16;rate=8000', + headers={'key': 'value'}, + ) call = CreateCallRequest( - to=[ - { - 'type': 'websocket', - 'uri': 'wss://example.com/websocket', - 'content_type': 'audio/l16;rate=8000', - 'headers': {'key': 'value'}, - } - ], + to=[ws], answer_url=['https://example.com/answer'], random_from_number=True, ) @@ -133,6 +132,33 @@ def test_create_call_basic_answer_url(): assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' +@responses.activate +def test_create_call_websocket_authorization_custom(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + ws = Websocket( + uri='wss://example.com/websocket', + content_type='audio/l16;rate=16000', + headers={'key': 'value'}, + authorization={'type': 'custom', 'value': 'Bearer eyJhbGciOi...'}, + ) + call = CreateCallRequest( + ncco=ncco, + to=[ws], + random_from_number=True, + ) + + response = voice.create_call(call) + body = json.loads(voice.http_client.last_request.body) + assert body['to'][0]['authorization'] == { + 'type': 'custom', + 'value': 'Bearer eyJhbGciOi...', + } + assert type(response) == CreateCallResponse + + @responses.activate def test_create_call_answer_url_options(): build_response( From 340291ce360f9d5428a7d412caf960efec4aa1a8 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Mon, 2 Mar 2026 11:19:37 +0100 Subject: [PATCH 335/401] fix(verify): set verify sandbox parameter as deprecated (#341) --- verify/src/vonage_verify/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py index 43d00c6b..2561af56 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -28,8 +28,8 @@ class SilentAuthChannel(Channel): redirect_url (str, Optional): Optional final redirect added at the end of the check_url request/response lifecycle. Will contain the `request_id` and `code` as a url fragment after the URL. - sandbox (bool, Optional): Whether you are using the sandbox to test Silent - Authentication integrations. + sandbox (bool, Optional): [Deprecated] Whether you are using the sandbox to test + Silent Authentication integrations. """ redirect_url: Optional[str] = None From cd21bde7df3dd2c33f229b6a6b0e9ef159159c34 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 11:17:29 +0000 Subject: [PATCH 336/401] DEVX-10006: Adding tests for RCS Suggestion Base Model --- messages/tests/test_rcs_models.py | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 4114bcad..9bd05a5f 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1,3 +1,4 @@ +import pytest from vonage_messages.models import ( RcsCustom, RcsFile, @@ -147,3 +148,50 @@ def test_create_rcs_custom(): } assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + +@pytest.mark.skip(reason="not yet implemented") +def test_rcs_suggestion_base(): + suggestion = RcsSuggestionBase( + text='Reply', + postback_data='postback-data', + ) + suggestion_dict = { + 'text': 'Reply', + 'postback_data': 'postback-data', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + +@pytest.mark.skip(reason="not yet implemented") +def test_rcs_suggestion_base_without_text(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + postback_data='postback-data', + ) + assert "field required" in err.value.errors[0]['msg'] + +@pytest.mark.skip(reason="not yet implemented") +def test_rcs_suggestion_base_without_postback_data(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='Reply', + ) + assert "field required" in err.value.errors[0]['msg'] + +@pytest.mark.skip(reason="not yet implemented") +def test_rcs_suggestion_base_with_text_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='', + postback_data='postback-data', + ) + assert "ensure this value has at least 1 characters" in err.value.errors[0]['msg'] + +@pytest.mark.skip(reason="not yet implemented") +def test_rcs_suggestion_base_with_text_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='A' * 25 + 'B', + postback_data='postback-data', + ) + assert "ensure this value has at most 25 characters" in err.value.errors[0]['msg'] \ No newline at end of file From f1023ab5c6b3a84aff1835cd950c44da767bd91a Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 11:46:17 +0000 Subject: [PATCH 337/401] DEVX-10006: Implementing RCS Suggestion Base model --- .../src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/rcs.py | 12 +++++++++++ messages/tests/test_rcs_models.py | 20 ++++++++++--------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index bbd6d65d..87dce862 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 6ed1e7b7..bd9f2c04 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -17,6 +17,18 @@ class RcsResource(BaseModel): url: str +class RcsSuggestionBase(BaseModel): + """Model for a suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + text: str = Field(..., min_length=1, max_length=25) + postback_data: str + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 9bd05a5f..7501db77 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1,4 +1,5 @@ import pytest +from pydantic import ValidationError from vonage_messages.models import ( RcsCustom, RcsFile, @@ -6,6 +7,7 @@ RcsResource, RcsText, RcsVideo, + RcsSuggestionBase, ) @@ -149,7 +151,7 @@ def test_create_rcs_custom(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict -@pytest.mark.skip(reason="not yet implemented") + def test_rcs_suggestion_base(): suggestion = RcsSuggestionBase( text='Reply', @@ -162,36 +164,36 @@ def test_rcs_suggestion_base(): assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict -@pytest.mark.skip(reason="not yet implemented") + def test_rcs_suggestion_base_without_text(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionBase( postback_data='postback-data', ) - assert "field required" in err.value.errors[0]['msg'] + assert "Field required" in str(err.value) + -@pytest.mark.skip(reason="not yet implemented") def test_rcs_suggestion_base_without_postback_data(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionBase( text='Reply', ) - assert "field required" in err.value.errors[0]['msg'] + assert "Field required" in str(err.value) + -@pytest.mark.skip(reason="not yet implemented") def test_rcs_suggestion_base_with_text_too_short(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionBase( text='', postback_data='postback-data', ) - assert "ensure this value has at least 1 characters" in err.value.errors[0]['msg'] + assert "String should have at least 1 character" in str(err.value) + -@pytest.mark.skip(reason="not yet implemented") def test_rcs_suggestion_base_with_text_too_long(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionBase( text='A' * 25 + 'B', postback_data='postback-data', ) - assert "ensure this value has at most 25 characters" in err.value.errors[0]['msg'] \ No newline at end of file + assert "String should have at most 25 characters" in str(err.value) From 3acc1eb1fbff46b33ff918d52c1f38ad26d98f36 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 11:47:01 +0000 Subject: [PATCH 338/401] DEVX-10006: Adding RCS Suggestion Reply test --- messages/tests/test_rcs_models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 7501db77..5eee8a07 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -197,3 +197,16 @@ def test_rcs_suggestion_base_with_text_too_long(): postback_data='postback-data', ) assert "String should have at most 25 characters" in str(err.value) + +def test_rcs_suggestion_reply(): + suggestion = RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ) + suggestion_dict = { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict \ No newline at end of file From 88e23b114088677af5135c73921d98539ffce8e9 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 11:56:40 +0000 Subject: [PATCH 339/401] DEVX-10006: Implementing RCS Suggested Reply model --- messages/src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/enums.py | 6 ++++++ messages/src/vonage_messages/models/rcs.py | 13 ++++++++++++- messages/tests/test_rcs_models.py | 1 + 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 87dce862..412f7181 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index f0bb501a..4a1e4386 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -37,3 +37,9 @@ class EncodingType(str, Enum): TEXT = 'text' UNICODE = 'unicode' AUTO = 'auto' + + +class SuggestionType(str, Enum): + """The type of RCS suggestion.""" + + REPLY = 'reply' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index bd9f2c04..d6762bbf 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType +from .enums import ChannelType, MessageType, SuggestionType class RcsResource(BaseModel): @@ -29,6 +29,17 @@ class RcsSuggestionBase(BaseModel): postback_data: str +class RcsSuggestionReply(RcsSuggestionBase): + """Model for a reply suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + type_: SuggestionType = Field(SuggestionType.REPLY, serialization_alias='type') + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 5eee8a07..e19b73d4 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -8,6 +8,7 @@ RcsText, RcsVideo, RcsSuggestionBase, + RcsSuggestionReply, ) From 267709ad488a63c972056e263f1157e491d992c1 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 12:46:27 +0000 Subject: [PATCH 340/401] DEVX-10006: Adding tests for RCS suggestion action dial --- messages/tests/test_rcs_models.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index e19b73d4..3c26395c 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -210,4 +210,31 @@ def test_rcs_suggestion_reply(): 'postback_data': 'postback-data', } - assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict \ No newline at end of file + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +@pytest.mark.skip(reason="Not yet implemented.") +def test_rcs_suggestion_dial(): + suggestion = RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ) + suggestion_dict = { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +@pytest.mark.skip(reason="Not yet implemented.") +def test_rcs_suggestion_action_dial_without_phone_number(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + ) + assert "Field required" in str(err.value) From 563cc672b169e88fc4f4e82ecbd6562a851e8483 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 12:56:57 +0000 Subject: [PATCH 341/401] DEVX-10006: Implementing RCS suggestion action dial --- messages/src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/enums.py | 3 ++- messages/src/vonage_messages/models/rcs.py | 12 ++++++++++++ messages/tests/test_rcs_models.py | 3 +-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 412f7181..8209ee61 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 4a1e4386..4f86e179 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -42,4 +42,5 @@ class EncodingType(str, Enum): class SuggestionType(str, Enum): """The type of RCS suggestion.""" - REPLY = 'reply' \ No newline at end of file + REPLY = 'reply' + DIAL = 'dial' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index d6762bbf..3cb33b15 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -40,6 +40,18 @@ class RcsSuggestionReply(RcsSuggestionBase): type_: SuggestionType = Field(SuggestionType.REPLY, serialization_alias='type') +class RcsSuggestionActionDial(RcsSuggestionBase): + """Model for a dial action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + phone_number (str): The phone number to dial when the suggestion is selected. In E.164 format without the leading plus sign. + """ + + type_: SuggestionType = Field(SuggestionType.DIAL, serialization_alias='type') + phone_number: PhoneNumber + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 3c26395c..f1ba6ff9 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -9,6 +9,7 @@ RcsVideo, RcsSuggestionBase, RcsSuggestionReply, + RcsSuggestionActionDial, ) @@ -213,7 +214,6 @@ def test_rcs_suggestion_reply(): assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict -@pytest.mark.skip(reason="Not yet implemented.") def test_rcs_suggestion_dial(): suggestion = RcsSuggestionActionDial( text='Call us', @@ -230,7 +230,6 @@ def test_rcs_suggestion_dial(): assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict -@pytest.mark.skip(reason="Not yet implemented.") def test_rcs_suggestion_action_dial_without_phone_number(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionActionDial( From 1f4ccdb5145e9f9b60281764624edbbc14870c08 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 14:55:27 +0000 Subject: [PATCH 342/401] DEVX-10006: Adding tests and implementation for RCS view location suggestion action --- .../src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/enums.py | 3 +- messages/src/vonage_messages/models/rcs.py | 20 +++++++ messages/tests/test_rcs_models.py | 58 +++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 8209ee61..de815a07 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 4f86e179..710f4436 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -43,4 +43,5 @@ class SuggestionType(str, Enum): """The type of RCS suggestion.""" REPLY = 'reply' - DIAL = 'dial' \ No newline at end of file + DIAL = 'dial' + VIEW_LOCATION = 'view_location' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 3cb33b15..56628f3f 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -52,6 +52,26 @@ class RcsSuggestionActionDial(RcsSuggestionBase): type_: SuggestionType = Field(SuggestionType.DIAL, serialization_alias='type') phone_number: PhoneNumber + +class RcsSuggestionActionViewLocation(RcsSuggestionBase): + """Model for a view location action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + latitude (float): The latitude of the location to view when the suggestion is selected. + longitude (float): The longitude of the location to view when the suggestion is selected. + pin_label (str): The label to display on the location pin. + fallback_url (str, Optional): The URL to open if the device doesn't support the view location action. + """ + + type_: SuggestionType = Field('view_location', serialization_alias='type') + latitude: str + longitude: str + pin_label: str + fallback_url: Optional[str] = None + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index f1ba6ff9..580388e8 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -10,6 +10,7 @@ RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, ) @@ -237,3 +238,60 @@ def test_rcs_suggestion_action_dial_without_phone_number(): postback_data='postback-data', ) assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location(): + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ) + suggestion_dict = { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_view_location_without_latitude(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location_without_longitude(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + pin_label='London', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location_without_pin_label(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) From 9c55ae24d47d9a3cb0b4378e004809d1b2f95c9b Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 15:18:30 +0000 Subject: [PATCH 343/401] DEVX-10006: adding tests and implementation for RCS Share Location suggestion action --- messages/src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/enums.py | 3 ++- messages/src/vonage_messages/models/rcs.py | 13 ++++++++++++- messages/tests/test_rcs_models.py | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index de815a07..c90a03b1 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 710f4436..50138aca 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -44,4 +44,5 @@ class SuggestionType(str, Enum): REPLY = 'reply' DIAL = 'dial' - VIEW_LOCATION = 'view_location' \ No newline at end of file + VIEW_LOCATION = 'view_location' + SHARE_LOCATION = 'share_location' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 56628f3f..b814685b 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -65,13 +65,24 @@ class RcsSuggestionActionViewLocation(RcsSuggestionBase): fallback_url (str, Optional): The URL to open if the device doesn't support the view location action. """ - type_: SuggestionType = Field('view_location', serialization_alias='type') + type_: SuggestionType = Field(SuggestionType.VIEW_LOCATION, serialization_alias='type') latitude: str longitude: str pin_label: str fallback_url: Optional[str] = None +class RcsSuggestionActionShareLocation(RcsSuggestionBase): + """Model for a share location action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + type_: SuggestionType = Field(SuggestionType.SHARE_LOCATION, serialization_alias='type') + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 580388e8..95d71e4b 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -11,6 +11,7 @@ RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, ) @@ -295,3 +296,16 @@ def test_rcs_suggestion_action_view_location_without_pin_label(): fallback_url='https://example.com/location', ) assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_share_location(): + suggestion = RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ) + suggestion_dict = { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict \ No newline at end of file From 02114424a3e3c6be4eb1793b11b177ecf9cfa222 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 15:58:57 +0000 Subject: [PATCH 344/401] DEVX-10006: adding tests and implementation for RCS Open URL suggestion action --- .../src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/enums.py | 3 +- messages/src/vonage_messages/models/rcs.py | 14 +++++ messages/tests/test_rcs_models.py | 62 ++++++++++++++++++- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index c90a03b1..7c05ade8 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 50138aca..4e99e00a 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -45,4 +45,5 @@ class SuggestionType(str, Enum): REPLY = 'reply' DIAL = 'dial' VIEW_LOCATION = 'view_location' - SHARE_LOCATION = 'share_location' \ No newline at end of file + SHARE_LOCATION = 'share_location' + OPEN_URL = 'open_url' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index b814685b..98250a0c 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -83,6 +83,20 @@ class RcsSuggestionActionShareLocation(RcsSuggestionBase): type_: SuggestionType = Field(SuggestionType.SHARE_LOCATION, serialization_alias='type') +class RcsSuggestionActionOpenUrl(RcsSuggestionBase): + """Model for an open URL action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + url (str): The URL to open when the suggestion is selected. + """ + + type_: SuggestionType = Field(SuggestionType.OPEN_URL, serialization_alias='type') + url: str + description: str = Field(..., min_length=1, max_length=500) + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 95d71e4b..530cc9e2 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -12,6 +12,7 @@ RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, ) @@ -308,4 +309,63 @@ def test_rcs_suggestion_action_share_location(): 'text': 'Share location', 'postback_data': 'postback-data', } - assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict \ No newline at end of file + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url(): + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ) + suggestion_dict = { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url_without_url(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + description='Click to open the URL', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_open_url_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) \ No newline at end of file From 352b43d75c2000da38281764c6136c35b72c6410 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 16:29:23 +0000 Subject: [PATCH 345/401] DEVX-10006: Adding tests and implementation for RCS Open URL in Webview suggestion action --- .../src/vonage_messages/models/__init__.py | 2 +- messages/src/vonage_messages/models/enums.py | 11 ++- messages/src/vonage_messages/models/rcs.py | 16 +++- messages/tests/test_rcs_models.py | 80 ++++++++++++++++++- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 7c05ade8..5f72b5da 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 4e99e00a..8d57e9b6 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -46,4 +46,13 @@ class SuggestionType(str, Enum): DIAL = 'dial' VIEW_LOCATION = 'view_location' SHARE_LOCATION = 'share_location' - OPEN_URL = 'open_url' \ No newline at end of file + OPEN_URL = 'open_url' + OPEN_URL_IN_WEBVIEW = 'open_url_in_webview' + + +class UrlWebviewViewMode(str, Enum): + """The view mode for an RCS suggestion that opens a URL in a webview.""" + + FULL = 'FULL' + TALL = 'TALL' + HALF = 'HALF' diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 98250a0c..337760e0 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType, SuggestionType +from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode class RcsResource(BaseModel): @@ -97,6 +97,20 @@ class RcsSuggestionActionOpenUrl(RcsSuggestionBase): description: str = Field(..., min_length=1, max_length=500) +class RcsSuggestionActionOpenUrlWebview(RcsSuggestionActionOpenUrl): + """Model for an open URL in webview action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + url (str): The URL to open in a webview when the suggestion is selected. + view_mode (str, Optional): The view mode for the webview. If not specified, the default view mode will be used. + """ + + type_: SuggestionType = Field(SuggestionType.OPEN_URL_IN_WEBVIEW, serialization_alias='type') + view_mode: Optional[UrlWebviewViewMode] = None + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 530cc9e2..c1742c0c 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -13,6 +13,7 @@ RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, ) @@ -368,4 +369,81 @@ def test_rcs_suggestion_action_open_url_with_description_too_long(): url='https://example.com', description='A' * 500 + 'B', ) - assert "String should have at most 500 characters" in str(err.value) \ No newline at end of file + assert "String should have at most 500 characters" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview(): + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + view_mode='FULL', + ) + suggestion_dict = { + 'type': 'open_url_in_webview', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + 'view_mode': 'FULL', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +# @pytest.mark.skip(reason="Not yet implemented") +def test_rcs_suggestion_action_open_url_in_webview_without_url(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + description='Click to open the URL', + ) + assert "Field required" in str(err.value) + + +# @pytest.mark.skip(reason="Not yet implemented") +def test_rcs_suggestion_action_open_url_in_webview_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + ) + assert "Field required" in str(err.value) + + +# @pytest.mark.skip(reason="Not yet implemented") +def test_rcs_suggestion_action_open_url_in_webview_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +# @pytest.mark.skip(reason="Not yet implemented") +def test_rcs_suggestion_action_open_url_in_webview_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_invalid_view_mode(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + view_mode='INVALID_VIEW_MODE', + ) + assert "Input should be 'FULL', 'TALL' or 'HALF'" in str(err.value) From e8cbd36abb8cd24172fcab19676a595b8b671bea Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 11 Mar 2026 16:58:54 +0000 Subject: [PATCH 346/401] DEVX-10006: addig tests and implementation for RCS Create Calendar Event suggestion action --- .../src/vonage_messages/models/__init__.py | 17 ++- messages/src/vonage_messages/models/enums.py | 1 + messages/src/vonage_messages/models/rcs.py | 20 +++ messages/tests/test_rcs_models.py | 128 +++++++++++++++++- 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 5f72b5da..ce672ac5 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,7 +10,22 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo -from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo, RcsSuggestionBase, RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview +from .rcs import ( + RcsCustom, + RcsFile, + RcsImage, + RcsResource, + RcsText, + RcsVideo, + RcsSuggestionBase, + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, +) from .sms import Sms, SmsOptions from .viber import ( ViberAction, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 8d57e9b6..a38d37b7 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -48,6 +48,7 @@ class SuggestionType(str, Enum): SHARE_LOCATION = 'share_location' OPEN_URL = 'open_url' OPEN_URL_IN_WEBVIEW = 'open_url_in_webview' + CREATE_CALENDAR_EVENT = 'create_calendar_event' class UrlWebviewViewMode(str, Enum): diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 337760e0..802a5c04 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -110,6 +110,26 @@ class RcsSuggestionActionOpenUrlWebview(RcsSuggestionActionOpenUrl): type_: SuggestionType = Field(SuggestionType.OPEN_URL_IN_WEBVIEW, serialization_alias='type') view_mode: Optional[UrlWebviewViewMode] = None +class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): + """Model for a create calendar event action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + start_time (str): The start time of the calendar event in ISO 8601 format. + end_time (str): The end time of the calendar event in ISO 8601 format + title (str): The title of the calendar event. + description (str): The description of the calendar event. + fallback_url (str, Optional): The URL to open if the device doesn't support the create calendar event action. + """ + + type_: SuggestionType = Field(SuggestionType.CREATE_CALENDAR_EVENT, serialization_alias='type') + start_time: str + end_time: str + title: str = Field(..., min_length=1, max_length=100) + description: str = Field(..., min_length=1, max_length=500) + fallback_url: Optional[str] = None + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index c1742c0c..72b9dfb5 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -14,6 +14,7 @@ RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, ) @@ -391,7 +392,6 @@ def test_rcs_suggestion_action_open_url_in_webview(): assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict -# @pytest.mark.skip(reason="Not yet implemented") def test_rcs_suggestion_action_open_url_in_webview_without_url(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionActionOpenUrlWebview( @@ -402,7 +402,6 @@ def test_rcs_suggestion_action_open_url_in_webview_without_url(): assert "Field required" in str(err.value) -# @pytest.mark.skip(reason="Not yet implemented") def test_rcs_suggestion_action_open_url_in_webview_without_description(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionActionOpenUrlWebview( @@ -413,7 +412,6 @@ def test_rcs_suggestion_action_open_url_in_webview_without_description(): assert "Field required" in str(err.value) -# @pytest.mark.skip(reason="Not yet implemented") def test_rcs_suggestion_action_open_url_in_webview_with_description_too_short(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionActionOpenUrlWebview( @@ -425,7 +423,6 @@ def test_rcs_suggestion_action_open_url_in_webview_with_description_too_short(): assert "String should have at least 1 character" in str(err.value) -# @pytest.mark.skip(reason="Not yet implemented") def test_rcs_suggestion_action_open_url_in_webview_with_description_too_long(): with pytest.raises(ValidationError) as err: suggestion = RcsSuggestionActionOpenUrlWebview( @@ -447,3 +444,126 @@ def test_rcs_suggestion_action_open_url_in_webview_with_invalid_view_mode(): view_mode='INVALID_VIEW_MODE', ) assert "Input should be 'FULL', 'TALL' or 'HALF'" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event(): + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ) + suggestion_dict = { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_create_calendar_event_without_start_time(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_end_time(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_title(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_title_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='', + description='Discuss project updates', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_title_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='A' * 100 + 'B', + description='Discuss project updates', + ) + assert "String should have at most 100 characters" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) From efcb0c04c881bfe0174feee1cd558f5f11b90f5d Mon Sep 17 00:00:00 2001 From: superchilled Date: Thu, 12 Mar 2026 17:02:44 +0000 Subject: [PATCH 347/401] DEVX-10006: Adding test and implemention for RCS Category model --- .../src/vonage_messages/models/__init__.py | 1 + messages/src/vonage_messages/models/enums.py | 10 ++++++ messages/src/vonage_messages/models/rcs.py | 13 +++++++- messages/tests/test_rcs_models.py | 31 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index ce672ac5..943c5e9a 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -25,6 +25,7 @@ RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent, + RcsOptions, ) from .sms import Sms, SmsOptions from .viber import ( diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index a38d37b7..90bbbf34 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -57,3 +57,13 @@ class UrlWebviewViewMode(str, Enum): FULL = 'FULL' TALL = 'TALL' HALF = 'HALF' + + +class RcsCategory(str, Enum): + """The category of an RCS message.""" + + ACKNOWLEDGEMENT = 'acknowledgement' + AUTHENTICATION = 'authentication' + PROMOTION = 'promotion' + SERVICE_REQUEST = 'service-request' + TRANSACTION = 'transaction' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 802a5c04..e8fbb735 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode +from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode, RcsCategory class RcsResource(BaseModel): @@ -131,6 +131,17 @@ class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): fallback_url: Optional[str] = None + +class RcsOptions(BaseModel): + """Model for RCS message options. + + Args: + category (str, Optional): The category of the RCS message. + """ + + category: Optional[RcsCategory] = None + + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 72b9dfb5..d8aea557 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -15,6 +15,7 @@ RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent, + RcsOptions, ) @@ -567,3 +568,33 @@ def test_rcs_suggestion_action_create_calendar_event_with_description_too_long() description='A' * 500 + 'B', ) assert "String should have at most 500 characters" in str(err.value) + + +def test_create_rcs_options(): + options = RcsOptions( + category='transaction', + ) + options_dict = { + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_with_each_valid_category(): + valid_options = ['acknowledgement', 'authentication', 'promotion', 'service-request', 'transaction'] + for option in valid_options: + options = RcsOptions( + category=option, + ) + options_dict = { + 'category': option, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_with_invalid_category(): + with pytest.raises(ValidationError) as err: + options = RcsOptions( + category='invalid-category', + ) + assert "Input should be 'acknowledgement', 'authentication', 'promotion', 'service-request' or 'transaction'" in str(err.value) From c9d4e5baf324dd994e9b13ee3c720e3c486e95a1 Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 13 Mar 2026 13:01:32 +0000 Subject: [PATCH 348/401] DEVX-10006: Add tests and implementation for RCS Card Options model --- .../src/vonage_messages/models/__init__.py | 1 + messages/src/vonage_messages/models/enums.py | 16 ++++- messages/src/vonage_messages/models/rcs.py | 14 ++++- messages/tests/test_rcs_models.py | 58 +++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 943c5e9a..2874ad04 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -26,6 +26,7 @@ RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent, RcsOptions, + RcsOptionsCard, ) from .sms import Sms, SmsOptions from .viber import ( diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 90bbbf34..84caa7d2 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -66,4 +66,18 @@ class RcsCategory(str, Enum): AUTHENTICATION = 'authentication' PROMOTION = 'promotion' SERVICE_REQUEST = 'service-request' - TRANSACTION = 'transaction' \ No newline at end of file + TRANSACTION = 'transaction' + + +class RcsCardOrientation(str, Enum): + """The orientation of an RCS card.""" + + VERTICAL = 'VERTICAL' + HORIZONTAL = 'HORIZONTAL' + + +class RcsImageAlignment(str, Enum): + """The alignment of an image on an RCS card.""" + + LEFT = 'LEFT' + RIGHT = 'RIGHT' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index e8fbb735..e9927378 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode, RcsCategory +from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode, RcsCategory, RcsCardOrientation, RcsImageAlignment class RcsResource(BaseModel): @@ -142,6 +142,18 @@ class RcsOptions(BaseModel): category: Optional[RcsCategory] = None +class RcsOptionsCard(RcsOptions): + """Model for an RCS message options card. + + Args: + category (str, Optional): The category of the RCS message. + card_orientation (str): The orientation of the card (HORIZONTAL or VERTICAL). + image_alignment (str): The alignment of the image on the card (LEFT or RIGHT). + """ + + card_orientation: Optional[RcsCardOrientation] = None + image_alignment: Optional[RcsImageAlignment] = None + class BaseRcs(BaseMessage): """Model for a base RCS message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index d8aea557..4e0c67fb 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -16,6 +16,7 @@ RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent, RcsOptions, + RcsOptionsCard, ) @@ -598,3 +599,60 @@ def test_create_rcs_options_with_invalid_category(): category='invalid-category', ) assert "Input should be 'acknowledgement', 'authentication', 'promotion', 'service-request' or 'transaction'" in str(err.value) + + +def test_create_rcs_options_card(): + options = RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment='LEFT' + ) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_card_orientation_with_each_valid_option(): + valid_orientations = ['VERTICAL', 'HORIZONTAL'] + for orientation in valid_orientations: + options = RcsOptionsCard( + card_orientation=orientation, + image_alignment='LEFT' + ) + options_dict = { + 'card_orientation': orientation, + 'image_alignment': 'LEFT', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_image_alignment_with_each_valid_option(): + valid_alignments = ['LEFT', 'RIGHT'] + for alignment in valid_alignments: + options = RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment=alignment + ) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': alignment, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_card_orientation_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard( + card_orientation='INVALID_ORIENTATION', + image_alignment='LEFT' + ) + assert "Input should be 'VERTICAL' or 'HORIZONTAL'" in str(err.value) + +def test_create_rcs_options_card_image_alignment_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment='INVALID_ALIGNMENT' + ) + assert "Input should be 'LEFT' or 'RIGHT'" in str(err.value) From 4f7b5183a4838c4a1cf16bcc83562eb54c7623c9 Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 13 Mar 2026 16:02:56 +0000 Subject: [PATCH 349/401] DEVX-10006: Updating RCS Text model and adding initial RCS Card implementation --- .../src/vonage_messages/models/__init__.py | 2 + messages/src/vonage_messages/models/enums.py | 18 +- messages/src/vonage_messages/models/rcs.py | 72 +++- messages/tests/test_rcs_models.py | 338 ++++++++++++++++++ 4 files changed, 425 insertions(+), 5 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 2874ad04..9dae982c 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -12,6 +12,7 @@ from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo from .rcs import ( RcsCustom, + RcsCard, RcsFile, RcsImage, RcsResource, @@ -27,6 +28,7 @@ RcsSuggestionActionCreateCalendarEvent, RcsOptions, RcsOptionsCard, + RcsOptionsCarousel, ) from .sms import Sms, SmsOptions from .viber import ( diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 84caa7d2..97d09582 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -13,6 +13,7 @@ class MessageType(str, Enum): STICKER = 'sticker' CUSTOM = 'custom' VCARD = 'vcard' + CARD = 'card' class ChannelType(str, Enum): @@ -80,4 +81,19 @@ class RcsImageAlignment(str, Enum): """The alignment of an image on an RCS card.""" LEFT = 'LEFT' - RIGHT = 'RIGHT' \ No newline at end of file + RIGHT = 'RIGHT' + + +class RcsCardWidth(str, Enum): + """The width of a card in an RCS carousel.""" + + SMALL = 'SMALL' + MEDIUM = 'MEDIUM' + + +class RcsMediaHeight(str, Enum): + """The height of media on an RCS card.""" + + SHORT = 'SHORT' + MEDIUM = 'MEDIUM' + TALL = 'TALL' diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index e9927378..5c77cc8f 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -1,10 +1,10 @@ -from typing import Optional +from typing import Optional, List, Union from pydantic import BaseModel, Field from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode, RcsCategory, RcsCardOrientation, RcsImageAlignment +from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode, RcsCategory, RcsCardOrientation, RcsImageAlignment, RcsCardWidth, RcsMediaHeight class RcsResource(BaseModel): @@ -131,7 +131,6 @@ class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): fallback_url: Optional[str] = None - class RcsOptions(BaseModel): """Model for RCS message options. @@ -143,7 +142,7 @@ class RcsOptions(BaseModel): class RcsOptionsCard(RcsOptions): - """Model for an RCS message options card. + """Model for an RCS card message options. Args: category (str, Optional): The category of the RCS message. @@ -154,6 +153,17 @@ class RcsOptionsCard(RcsOptions): card_orientation: Optional[RcsCardOrientation] = None image_alignment: Optional[RcsImageAlignment] = None + +class RcsOptionsCarousel(RcsOptions): + """Model for an RCS carousel message options. + + Args: + card_width (str): The width of each card in the carousel (SMALL or MEDIUM). + """ + + card_width: RcsCardWidth + + class BaseRcs(BaseMessage): """Model for a base RCS message. @@ -187,6 +197,20 @@ class RcsText(BaseRcs): text: str = Field(..., min_length=1, max_length=3072) message_type: MessageType = MessageType.TEXT + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=11) + rcs: Optional[RcsOptions] = None class RcsImage(BaseRcs): @@ -240,6 +264,46 @@ class RcsFile(BaseRcs): message_type: MessageType = MessageType.FILE +class RcsCard(BaseRcs): + """Model for an RCS card message. + + Args: + title (str): The title of the card. + description (str): The description of the card. + media (RcsResource, Optional): The media resource for the card. Can be an image or a video. + suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 8 suggestions. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + title: str = Field(..., min_length=1, max_length=200) + text: str = Field(..., min_length=1, max_length=2000) + media_url: str + media_description: Optional[str] = None + media_height: Optional[RcsMediaHeight] = None + thumbnail_url: Optional[str] = None + media_force_refresh: Optional[bool] = None + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=8) + rcs: Optional[RcsOptionsCard] = None + message_type: MessageType = MessageType.CARD + + class RcsCustom(BaseRcs): """Model for an RCS custom message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 4e0c67fb..e9695ef8 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -2,6 +2,7 @@ from pydantic import ValidationError from vonage_messages.models import ( RcsCustom, + RcsCard, RcsFile, RcsImage, RcsResource, @@ -17,6 +18,7 @@ RcsSuggestionActionCreateCalendarEvent, RcsOptions, RcsOptionsCard, + RcsOptionsCarousel, ) @@ -66,6 +68,9 @@ def test_create_rcs_text_all_fields(): client_ref='client-ref', webhook_url='https://example.com', ttl=600, + rcs=RcsOptions( + category='transaction', + ), ) rcs_dict = { 'to': '1234567890', @@ -76,11 +81,214 @@ def test_create_rcs_text_all_fields(): 'ttl': 600, 'channel': 'rcs', 'message_type': 'text', + 'rcs': { + 'category': 'transaction', + } + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_suggestions(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + 'channel': 'rcs', + 'message_type': 'text', } assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict +def test_create_rcs_text_with_all_suggestion_types(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + ], + 'channel': 'rcs', + 'message_type': 'text', + } + + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_text_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] * 12, + ) + assert "List should have at most 11 items" in str(err.value) + + +def test_create_rcs_text_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + def test_create_rcs_image(): rcs_model = RcsImage( to='1234567890', @@ -144,6 +352,106 @@ def test_create_rcs_file(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict +def test_create_rcs_card(): + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_optional_params(): + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + rcs=RcsOptionsCard( + card_orientation='VERTICAL', + image_alignment='LEFT', + ), + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + 'rcs': { + 'card_orientation': 'VERTICAL', + 'image_alignment': 'LEFT', + }, + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_suggestions(): + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', @@ -656,3 +964,33 @@ def test_create_rcs_options_card_image_alignment_with_invalid_option(): image_alignment='INVALID_ALIGNMENT' ) assert "Input should be 'LEFT' or 'RIGHT'" in str(err.value) + + +def test_create_rcs_options_carousel(): + options = RcsOptionsCarousel( + card_width='MEDIUM', + ) + options_dict = { + 'card_width': 'MEDIUM', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_card_width_with_each_valid_option(): + valid_widths = ['SMALL', 'MEDIUM'] + for width in valid_widths: + options = RcsOptionsCarousel( + card_width=width, + ) + options_dict = { + 'card_width': width, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_card_width_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCarousel( + card_width='INVALID_WIDTH', + ) + assert "Input should be 'SMALL' or 'MEDIUM'" in str(err.value) From 91d3d339644a327219c664be739c735fa93f8f8e Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 16 Mar 2026 10:44:37 +0000 Subject: [PATCH 350/401] DEVX-10006: adding more RCS Card tests --- messages/src/vonage_messages/models/rcs.py | 2 +- messages/tests/test_rcs_models.py | 269 +++++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 5c77cc8f..c421fc0e 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -299,7 +299,7 @@ class RcsCard(BaseRcs): RcsSuggestionActionCreateCalendarEvent, ] ] - ] = Field(None, min_length=1, max_length=8) + ] = Field(None, min_length=1, max_length=4) rcs: Optional[RcsOptionsCard] = None message_type: MessageType = MessageType.CARD diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index e9695ef8..b616e260 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -452,6 +452,275 @@ def test_create_rcs_card_with_suggestions(): assert card.model_dump(by_alias=True, exclude_none=True) == card_dict +def test_create_rcs_cards_with_all_suggestion_types(): + card_1 = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + ], + ) + card_2 = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + card_dict_1 = { + 'to': '1234567890', + 'from': 'asdf1234', + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + } + ], + 'channel': 'rcs', + 'message_type': 'card', + } + card_dict_2 = { + 'to': '1234567890', + 'from': 'asdf1234', + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + ], + 'channel': 'rcs', + 'message_type': 'card', + } + assert card_1.model_dump(by_alias=True, exclude_none=True) == card_dict_1 + assert card_2.model_dump(by_alias=True, exclude_none=True) == card_dict_2 + + +def test_create_rcs_card_without_title(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_without_text(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_without_media_url(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_with_title_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_with_title_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='A' * 200 + 'B', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 200 characters" in str(err.value) + + +def test_create_rcs_card_with_text_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_with_text_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='A' * 2000 + 'B', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 2000 characters" in str(err.value) + + +def test_create_rcs_card_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_card_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] * 5, + ) + assert "List should have at most 4 items" in str(err.value) + + +def test_create_rcs_card_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', From 9b8fc7357b51099b596c68cf6939126110cbd3b9 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 16 Mar 2026 12:29:44 +0000 Subject: [PATCH 351/401] DEVX-10006: Adding tests and implementation for RCS CardContent model --- .../src/vonage_messages/models/__init__.py | 2 + messages/src/vonage_messages/models/enums.py | 1 + messages/src/vonage_messages/models/rcs.py | 53 ++- messages/tests/test_rcs_models.py | 364 ++++++++++++++++++ 4 files changed, 418 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 9dae982c..5f9fae1c 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -12,6 +12,8 @@ from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo from .rcs import ( RcsCustom, + RcsCarousel, + RcsCardContent, RcsCard, RcsFile, RcsImage, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 97d09582..e3baeeaa 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -14,6 +14,7 @@ class MessageType(str, Enum): CUSTOM = 'custom' VCARD = 'vcard' CARD = 'card' + CAROUSEL = 'carousel' class ChannelType(str, Enum): diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index c421fc0e..f798ca30 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -263,6 +263,37 @@ class RcsFile(BaseRcs): file: RcsResource message_type: MessageType = MessageType.FILE +class RcsCardContent(BaseModel): + """Model for the content of an RCS card. + + Args: + title (str): The title of the card. + text (str): The text of the card. + media_url (str): The media URL for the card. Can be an image or a video. + suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 4 suggestions. + """ + + title: str = Field(..., min_length=1, max_length=200) + text: str = Field(..., min_length=1, max_length=2000) + media_url: str + media_description: Optional[str] = None + media_height: Optional[RcsMediaHeight] = None + thumbnail_url: Optional[str] = None + media_force_refresh: Optional[bool] = None + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=4) + class RcsCard(BaseRcs): """Model for an RCS card message. @@ -270,8 +301,8 @@ class RcsCard(BaseRcs): Args: title (str): The title of the card. description (str): The description of the card. - media (RcsResource, Optional): The media resource for the card. Can be an image or a video. - suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 8 suggestions. + media_url (str, Optional): The media URL for the card. Can be an image or a video. + suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 4 suggestions. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. @@ -304,6 +335,24 @@ class RcsCard(BaseRcs): message_type: MessageType = MessageType.CARD +class RcsCarousel(BaseRcs): + """Model for an RCS carousel message. + + Args: + cards (List[RcsCard]): A list of cards to include in the carousel. Can include up to 10 cards. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + cards: List[RcsCard] = Field(..., min_length=1, max_length=10) + rcs: Optional[RcsOptionsCarousel] = None + message_type: MessageType = MessageType.CAROUSEL + + class RcsCustom(BaseRcs): """Model for an RCS custom message. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index b616e260..c57009d3 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -2,6 +2,8 @@ from pydantic import ValidationError from vonage_messages.models import ( RcsCustom, + RcsCarousel, + RcsCardContent, RcsCard, RcsFile, RcsImage, @@ -721,6 +723,368 @@ def test_create_rcs_card_with_inavalid_suggestion_types(): assert "Input should be a valid dictionary or instance" in str(err.value) +def test_create_rcs_card_content(): + card_content = RcsCardContent( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ) + card_content_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + } + assert card_content.model_dump(by_alias=True, exclude_none=True) == card_content_dict + + +def test_create_rcs_card_content_with_optional_params(): + card_content = RcsCardContent( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + card_content_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + assert card_content.model_dump(by_alias=True, exclude_none=True) == card_content_dict + + +def test_create_rcs_card_with_suggestions(): + card_content = RcsCardContent( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + card_content_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + } + assert card_content.model_dump(by_alias=True, exclude_none=True) == card_content_dict + + +def test_create_rcs_cards_with_all_suggestion_types(): + card_content_1 = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + ], + ) + card_content_2 = RcsCardContent( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + card_content_dict_1 = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + } + ] + } + card_content_dict_2 = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + ] + } + assert card_content_1.model_dump(by_alias=True, exclude_none=True) == card_content_dict_1 + assert card_content_2.model_dump(by_alias=True, exclude_none=True) == card_content_dict_2 + + +def test_create_rcs_card_content_without_title(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_content_without_text(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_content_without_media_url(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_content_with_title_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_content_with_title_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='A' * 200 + 'B', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 200 characters" in str(err.value) + + +def test_create_rcs_card_content_with_text_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_content_with_text_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='A' * 2000 + 'B', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 2000 characters" in str(err.value) + + +def test_create_rcs_card_content_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_card_content_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] * 5, + ) + assert "List should have at most 4 items" in str(err.value) + + +def test_create_rcs_card_content_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + card = RcsCardContent( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +@pytest.mark.skip(reason="Not fully implemented yet") +def test_create_rcs_carousel(): + carousel = RcsCarousel( + cards=[ + RcsCard( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ) + ] * 2, + ) + carousel_dict = { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'channel': 'rcs', + 'message_type': 'card', + } + ] * 2, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', From 5a2b335534fa8d595f0e8f59d61d52186b22488a Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 16 Mar 2026 12:37:57 +0000 Subject: [PATCH 352/401] DEVX-10006: Refactoring RCS Card model --- messages/src/vonage_messages/models/rcs.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index f798ca30..5387a7ff 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -295,7 +295,7 @@ class RcsCardContent(BaseModel): ] = Field(None, min_length=1, max_length=4) -class RcsCard(BaseRcs): +class RcsCard(RcsCardContent, BaseRcs): """Model for an RCS card message. Args: @@ -311,26 +311,6 @@ class RcsCard(BaseRcs): webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. """ - title: str = Field(..., min_length=1, max_length=200) - text: str = Field(..., min_length=1, max_length=2000) - media_url: str - media_description: Optional[str] = None - media_height: Optional[RcsMediaHeight] = None - thumbnail_url: Optional[str] = None - media_force_refresh: Optional[bool] = None - suggestions: Optional[ - List[ - Union[ - RcsSuggestionReply, - RcsSuggestionActionDial, - RcsSuggestionActionViewLocation, - RcsSuggestionActionShareLocation, - RcsSuggestionActionOpenUrl, - RcsSuggestionActionOpenUrlWebview, - RcsSuggestionActionCreateCalendarEvent, - ] - ] - ] = Field(None, min_length=1, max_length=4) rcs: Optional[RcsOptionsCard] = None message_type: MessageType = MessageType.CARD From 0f313067b4d5c8fb6672b6d7efb92f9a910370cc Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 16 Mar 2026 12:52:08 +0000 Subject: [PATCH 353/401] DEVX-10006: Adding initial RCS Carousel tests and implementation --- messages/src/vonage_messages/models/rcs.py | 2 +- messages/tests/test_rcs_models.py | 73 ++++++++++++++-------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 5387a7ff..49886ffc 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -328,7 +328,7 @@ class RcsCarousel(BaseRcs): webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. """ - cards: List[RcsCard] = Field(..., min_length=1, max_length=10) + cards: List[RcsCardContent] = Field(..., min_length=1, max_length=10) rcs: Optional[RcsOptionsCarousel] = None message_type: MessageType = MessageType.CAROUSEL diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index c57009d3..712674d1 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -928,8 +928,6 @@ def test_create_rcs_cards_with_all_suggestion_types(): def test_create_rcs_card_content_without_title(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', text='Card description', media_url='https://example.com/image.jpg', ) @@ -939,8 +937,6 @@ def test_create_rcs_card_content_without_title(): def test_create_rcs_card_content_without_text(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', media_url='https://example.com/image.jpg', ) @@ -950,8 +946,6 @@ def test_create_rcs_card_content_without_text(): def test_create_rcs_card_content_without_media_url(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', text='Card description', ) @@ -961,8 +955,6 @@ def test_create_rcs_card_content_without_media_url(): def test_create_rcs_card_content_with_title_too_short(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='', text='Card description', media_url='https://example.com/image.jpg', @@ -973,8 +965,6 @@ def test_create_rcs_card_content_with_title_too_short(): def test_create_rcs_card_content_with_title_too_long(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='A' * 200 + 'B', text='Card description', media_url='https://example.com/image.jpg', @@ -985,8 +975,6 @@ def test_create_rcs_card_content_with_title_too_long(): def test_create_rcs_card_content_with_text_too_short(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', text='', media_url='https://example.com/image.jpg', @@ -997,8 +985,6 @@ def test_create_rcs_card_content_with_text_too_short(): def test_create_rcs_card_content_with_text_too_long(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', text='A' * 2000 + 'B', media_url='https://example.com/image.jpg', @@ -1009,8 +995,6 @@ def test_create_rcs_card_content_with_text_too_long(): def test_create_rcs_card_content_with_insuffient_suggestions(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1022,8 +1006,6 @@ def test_create_rcs_card_content_with_insuffient_suggestions(): def test_create_rcs_card_content_with_too_many_suggestions(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1040,8 +1022,6 @@ def test_create_rcs_card_content_with_too_many_suggestions(): def test_create_rcs_card_content_with_inavalid_suggestion_types(): with pytest.raises(ValidationError) as err: card = RcsCardContent( - to='1234567890', - from_='asdf1234', title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1056,13 +1036,12 @@ def test_create_rcs_card_content_with_inavalid_suggestion_types(): assert "Input should be a valid dictionary or instance" in str(err.value) -@pytest.mark.skip(reason="Not fully implemented yet") def test_create_rcs_carousel(): carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', cards=[ - RcsCard( - to='1234567890', - from_='asdf1234', + RcsCardContent( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1070,13 +1049,13 @@ def test_create_rcs_carousel(): ] * 2, ) carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', 'cards': [ { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', - 'channel': 'rcs', - 'message_type': 'card', } ] * 2, 'channel': 'rcs', @@ -1085,6 +1064,48 @@ def test_create_rcs_carousel(): assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict +def test_create_rcs_carousel_with_optional_params(): + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardContent( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + ] * 2, + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + ] * 2, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', From 9bf8e0efc55e0d17c398819f665089f084285bd1 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 17 Mar 2026 12:45:13 +0000 Subject: [PATCH 354/401] DEVX-10006: Fixing RCS Card models and tests --- .../src/vonage_messages/models/__init__.py | 5 +- messages/src/vonage_messages/models/rcs.py | 33 +- messages/tests/test_rcs_models.py | 696 ++++++++++++------ 3 files changed, 504 insertions(+), 230 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 5f9fae1c..e10e4e52 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -13,8 +13,9 @@ from .rcs import ( RcsCustom, RcsCarousel, - RcsCardContent, - RcsCard, + RcsCardItem, + RcsCardMessage, + RcsCardBase, RcsFile, RcsImage, RcsResource, diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 49886ffc..0256f054 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -263,7 +263,8 @@ class RcsFile(BaseRcs): file: RcsResource message_type: MessageType = MessageType.FILE -class RcsCardContent(BaseModel): + +class RcsCardBase(BaseModel): """Model for the content of an RCS card. Args: @@ -295,7 +296,20 @@ class RcsCardContent(BaseModel): ] = Field(None, min_length=1, max_length=4) -class RcsCard(RcsCardContent, BaseRcs): +class RcsCardItem(RcsCardBase): + """Model for the content of an RCS card. + + Args: + title (str): The title of the card. + text (str): The text of the card. + media_url (str): The media URL for the card. Can be an image or a video. + suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 4 suggestions. + """ + + media_height: RcsMediaHeight + + +class RcsCardMessage(RcsCardBase, BaseRcs): """Model for an RCS card message. Args: @@ -328,7 +342,20 @@ class RcsCarousel(BaseRcs): webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. """ - cards: List[RcsCardContent] = Field(..., min_length=1, max_length=10) + cards: List[RcsCardItem] = Field(..., min_length=1, max_length=10) + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=11) rcs: Optional[RcsOptionsCarousel] = None message_type: MessageType = MessageType.CAROUSEL diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 712674d1..ea365280 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -3,8 +3,9 @@ from vonage_messages.models import ( RcsCustom, RcsCarousel, - RcsCardContent, - RcsCard, + RcsCardItem, + RcsCardMessage, + RcsCardBase, RcsFile, RcsImage, RcsResource, @@ -353,31 +354,22 @@ def test_create_rcs_file(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict - -def test_create_rcs_card(): - card = RcsCard( - to='1234567890', - from_='asdf1234', +def test_create_rcs_card_base(): + card_base = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', ) - card_dict = { - 'to': '1234567890', - 'from': 'asdf1234', + card_base_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', - 'channel': 'rcs', - 'message_type': 'card', } - assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + assert card_base.model_dump(by_alias=True, exclude_none=True) == card_base_dict -def test_create_rcs_card_with_optional_params(): - card = RcsCard( - to='1234567890', - from_='asdf1234', +def test_create_rcs_card_base_with_optional_params(): + card_base = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -385,14 +377,8 @@ def test_create_rcs_card_with_optional_params(): media_height='MEDIUM', thumbnail_url='https://example.com/thumbnail.jpg', media_force_refresh=True, - rcs=RcsOptionsCard( - card_orientation='VERTICAL', - image_alignment='LEFT', - ), ) - card_dict = { - 'to': '1234567890', - 'from': 'asdf1234', + card_base_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -400,20 +386,12 @@ def test_create_rcs_card_with_optional_params(): 'media_height': 'MEDIUM', 'thumbnail_url': 'https://example.com/thumbnail.jpg', 'media_force_refresh': True, - 'rcs': { - 'card_orientation': 'VERTICAL', - 'image_alignment': 'LEFT', - }, - 'channel': 'rcs', - 'message_type': 'card', } - assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + assert card_base.model_dump(by_alias=True, exclude_none=True) == card_base_dict -def test_create_rcs_card_with_suggestions(): - card = RcsCard( - to='1234567890', - from_='asdf1234', +def test_create_rcs_card_base_with_suggestions(): + card_base = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -429,9 +407,7 @@ def test_create_rcs_card_with_suggestions(): ), ], ) - card_dict = { - 'to': '1234567890', - 'from': 'asdf1234', + card_base_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -448,16 +424,12 @@ def test_create_rcs_card_with_suggestions(): 'phone_number': '447900000000', }, ], - 'channel': 'rcs', - 'message_type': 'card', } - assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + assert card_base.model_dump(by_alias=True, exclude_none=True) == card_base_dict -def test_create_rcs_cards_with_all_suggestion_types(): - card_1 = RcsCard( - to='1234567890', - from_='asdf1234', +def test_create_rcs_card_base_with_all_suggestion_types(): + card_base_1 = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -485,9 +457,7 @@ def test_create_rcs_cards_with_all_suggestion_types(): ), ], ) - card_2 = RcsCard( - to='1234567890', - from_='asdf1234', + card_base_2 = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -516,9 +486,7 @@ def test_create_rcs_cards_with_all_suggestion_types(): ), ], ) - card_dict_1 = { - 'to': '1234567890', - 'from': 'asdf1234', + card_base_dict_1 = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -549,12 +517,8 @@ def test_create_rcs_cards_with_all_suggestion_types(): 'postback_data': 'postback-data', } ], - 'channel': 'rcs', - 'message_type': 'card', } - card_dict_2 = { - 'to': '1234567890', - 'from': 'asdf1234', + card_base_dict_2 = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -585,51 +549,41 @@ def test_create_rcs_cards_with_all_suggestion_types(): 'fallback_url': 'https://example.com/calendar-event', } ], - 'channel': 'rcs', - 'message_type': 'card', } - assert card_1.model_dump(by_alias=True, exclude_none=True) == card_dict_1 - assert card_2.model_dump(by_alias=True, exclude_none=True) == card_dict_2 + assert card_base_1.model_dump(by_alias=True, exclude_none=True) == card_base_dict_1 + assert card_base_2.model_dump(by_alias=True, exclude_none=True) == card_base_dict_2 -def test_create_rcs_card_without_title(): +def test_create_rcs_card_base_without_title(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( text='Card description', media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) -def test_create_rcs_card_without_text(): +def test_create_rcs_card_base_without_text(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='Card title', media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) -def test_create_rcs_card_without_media_url(): +def test_create_rcs_card_base_without_media_url(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='Card title', text='Card description', ) assert "Field required" in str(err.value) -def test_create_rcs_card_with_title_too_short(): +def test_create_rcs_card_base_with_title_too_short(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='', text='Card description', media_url='https://example.com/image.jpg', @@ -637,11 +591,9 @@ def test_create_rcs_card_with_title_too_short(): assert "String should have at least 1 character" in str(err.value) -def test_create_rcs_card_with_title_too_long(): +def test_create_rcs_card_base_with_title_too_long(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='A' * 200 + 'B', text='Card description', media_url='https://example.com/image.jpg', @@ -649,9 +601,9 @@ def test_create_rcs_card_with_title_too_long(): assert "String should have at most 200 characters" in str(err.value) -def test_create_rcs_card_with_text_too_short(): +def test_create_rcs_card_base_with_text_too_short(): with pytest.raises(ValidationError) as err: - card = RcsCard( + card_base = RcsCardBase( to='1234567890', from_='asdf1234', title='Card title', @@ -661,9 +613,9 @@ def test_create_rcs_card_with_text_too_short(): assert "String should have at least 1 character" in str(err.value) -def test_create_rcs_card_with_text_too_long(): +def test_create_rcs_card_base_with_text_too_long(): with pytest.raises(ValidationError) as err: - card = RcsCard( + card_base = RcsCardBase( to='1234567890', from_='asdf1234', title='Card title', @@ -673,11 +625,9 @@ def test_create_rcs_card_with_text_too_long(): assert "String should have at most 2000 characters" in str(err.value) -def test_create_rcs_card_with_insuffient_suggestions(): +def test_create_rcs_card_base_with_insuffient_suggestions(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -686,11 +636,9 @@ def test_create_rcs_card_with_insuffient_suggestions(): assert "List should have at least 1 item" in str(err.value) -def test_create_rcs_card_with_too_many_suggestions(): +def test_create_rcs_card_base_with_too_many_suggestions(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -704,11 +652,9 @@ def test_create_rcs_card_with_too_many_suggestions(): assert "List should have at most 4 items" in str(err.value) -def test_create_rcs_card_with_inavalid_suggestion_types(): +def test_create_rcs_card_base_with_inavalid_suggestion_types(): with pytest.raises(ValidationError) as err: - card = RcsCard( - to='1234567890', - from_='asdf1234', + card_base = RcsCardBase( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -723,22 +669,30 @@ def test_create_rcs_card_with_inavalid_suggestion_types(): assert "Input should be a valid dictionary or instance" in str(err.value) -def test_create_rcs_card_content(): - card_content = RcsCardContent( +def test_create_rcs_card_message(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', title='Card title', text='Card description', media_url='https://example.com/image.jpg', ) - card_content_dict = { + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', + 'channel': 'rcs', + 'message_type': 'card', } - assert card_content.model_dump(by_alias=True, exclude_none=True) == card_content_dict + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_card_content_with_optional_params(): - card_content = RcsCardContent( +def test_create_rcs_card_message_with_optional_params(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -746,8 +700,14 @@ def test_create_rcs_card_content_with_optional_params(): media_height='MEDIUM', thumbnail_url='https://example.com/thumbnail.jpg', media_force_refresh=True, + rcs=RcsOptionsCard( + card_orientation='VERTICAL', + image_alignment='LEFT', + ), ) - card_content_dict = { + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -755,12 +715,20 @@ def test_create_rcs_card_content_with_optional_params(): 'media_height': 'MEDIUM', 'thumbnail_url': 'https://example.com/thumbnail.jpg', 'media_force_refresh': True, + 'rcs': { + 'card_orientation': 'VERTICAL', + 'image_alignment': 'LEFT', + }, + 'channel': 'rcs', + 'message_type': 'card', } - assert card_content.model_dump(by_alias=True, exclude_none=True) == card_content_dict + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_card_with_suggestions(): - card_content = RcsCardContent( +def test_create_rcs_card_message_with_suggestions(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -776,7 +744,9 @@ def test_create_rcs_card_with_suggestions(): ), ], ) - card_content_dict = { + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -793,17 +763,187 @@ def test_create_rcs_card_with_suggestions(): 'phone_number': '447900000000', }, ], + 'channel': 'rcs', + 'message_type': 'card', } - assert card_content.model_dump(by_alias=True, exclude_none=True) == card_content_dict + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_cards_with_all_suggestion_types(): - card_content_1 = RcsCardContent( - to='1234567890', - from_='asdf1234', +def test_create_rcs_card_message_without_title(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_message_without_text(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_message_without_media_url(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_message_with_title_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_message_with_title_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='A' * 200 + 'B', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 200 characters" in str(err.value) + + +def test_create_rcs_card_message_with_text_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_message_with_text_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='A' * 2000 + 'B', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 2000 characters" in str(err.value) + + +def test_create_rcs_card_message_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_card_message_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] * 5, + ) + assert "List should have at most 4 items" in str(err.value) + + +def test_create_rcs_card_message_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_card_item(): + card_content = RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + card_item_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + assert card_content.model_dump(by_alias=True, exclude_none=True) == card_item_dict + + +def test_create_rcs_card_item_with_optional_params(): + card_content = RcsCardItem( title='Card title', text='Card description', media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + card_item_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + assert card_content.model_dump(by_alias=True, exclude_none=True) == card_item_dict + + +def test_create_rcs_card_item_with_suggestions(): + card_content = RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', suggestions=[ RcsSuggestionReply( text='Reply', @@ -814,53 +954,13 @@ def test_create_rcs_cards_with_all_suggestion_types(): postback_data='postback-data', phone_number='447900000000', ), - RcsSuggestionActionViewLocation( - text='View location', - postback_data='postback-data', - latitude='51.5074', - longitude='-0.1278', - pin_label='London', - fallback_url='https://example.com/location', - ), - RcsSuggestionActionShareLocation( - text='Share location', - postback_data='postback-data', - ), ], ) - card_content_2 = RcsCardContent( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[ - RcsSuggestionActionOpenUrl( - text='Open URL', - postback_data='postback-data', - url='https://example.com', - description='Click to open the URL', - ), - RcsSuggestionActionOpenUrlWebview( - text='Open URL in webview', - postback_data='postback-data', - url='https://example.com', - description='Click to open the URL in a webview', - view_mode='FULL', - ), - RcsSuggestionActionCreateCalendarEvent( - text='Add to calendar', - postback_data='postback-data', - start_time='2024-01-01T12:00:00Z', - end_time='2024-01-01T13:00:00Z', - title='Meeting with Bob', - description='Discuss project updates', - fallback_url='https://example.com/calendar-event', - ), - ], - ) - card_content_dict_1 = { + card_item_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', 'suggestions': [ { 'type': 'reply', @@ -873,88 +973,51 @@ def test_create_rcs_cards_with_all_suggestion_types(): 'postback_data': 'postback-data', 'phone_number': '447900000000', }, - { - 'type': 'view_location', - 'text': 'View location', - 'postback_data': 'postback-data', - 'latitude': '51.5074', - 'longitude': '-0.1278', - 'pin_label': 'London', - 'fallback_url': 'https://example.com/location', - }, - { - 'type': 'share_location', - 'text': 'Share location', - 'postback_data': 'postback-data', - } - ] - } - card_content_dict_2 = { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'suggestions': [ - { - 'type': 'open_url', - 'text': 'Open URL', - 'postback_data': 'postback-data', - 'url': 'https://example.com', - 'description': 'Click to open the URL', - }, - { - 'type': 'open_url_in_webview', - 'text': 'Open URL in webview', - 'postback_data': 'postback-data', - 'url': 'https://example.com', - 'description': 'Click to open the URL in a webview', - 'view_mode': 'FULL', - }, - { - 'type': 'create_calendar_event', - 'text': 'Add to calendar', - 'postback_data': 'postback-data', - 'start_time': '2024-01-01T12:00:00Z', - 'end_time': '2024-01-01T13:00:00Z', - 'title': 'Meeting with Bob', - 'description': 'Discuss project updates', - 'fallback_url': 'https://example.com/calendar-event', - } - ] + ], } - assert card_content_1.model_dump(by_alias=True, exclude_none=True) == card_content_dict_1 - assert card_content_2.model_dump(by_alias=True, exclude_none=True) == card_content_dict_2 + assert card_content.model_dump(by_alias=True, exclude_none=True) == card_item_dict -def test_create_rcs_card_content_without_title(): +def test_create_rcs_card_item_without_title(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( text='Card description', media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) -def test_create_rcs_card_content_without_text(): +def test_create_rcs_card_item_without_text(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) -def test_create_rcs_card_content_without_media_url(): +def test_create_rcs_card_item_without_media_url(): + with pytest.raises(ValidationError) as err: + card = RcsCardItem( + title='Card title', + text='Card description', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_item_without_media_height(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', text='Card description', + media_url='https://example.com/image.jpg' ) assert "Field required" in str(err.value) -def test_create_rcs_card_content_with_title_too_short(): +def test_create_rcs_card_item_with_title_too_short(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='', text='Card description', media_url='https://example.com/image.jpg', @@ -962,9 +1025,9 @@ def test_create_rcs_card_content_with_title_too_short(): assert "String should have at least 1 character" in str(err.value) -def test_create_rcs_card_content_with_title_too_long(): +def test_create_rcs_card_item_with_title_too_long(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='A' * 200 + 'B', text='Card description', media_url='https://example.com/image.jpg', @@ -972,9 +1035,9 @@ def test_create_rcs_card_content_with_title_too_long(): assert "String should have at most 200 characters" in str(err.value) -def test_create_rcs_card_content_with_text_too_short(): +def test_create_rcs_card_item_with_text_too_short(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', text='', media_url='https://example.com/image.jpg', @@ -982,9 +1045,9 @@ def test_create_rcs_card_content_with_text_too_short(): assert "String should have at least 1 character" in str(err.value) -def test_create_rcs_card_content_with_text_too_long(): +def test_create_rcs_card_item_with_text_too_long(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', text='A' * 2000 + 'B', media_url='https://example.com/image.jpg', @@ -992,9 +1055,9 @@ def test_create_rcs_card_content_with_text_too_long(): assert "String should have at most 2000 characters" in str(err.value) -def test_create_rcs_card_content_with_insuffient_suggestions(): +def test_create_rcs_card_item_with_insuffient_suggestions(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1003,9 +1066,9 @@ def test_create_rcs_card_content_with_insuffient_suggestions(): assert "List should have at least 1 item" in str(err.value) -def test_create_rcs_card_content_with_too_many_suggestions(): +def test_create_rcs_card_item_with_too_many_suggestions(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1019,9 +1082,9 @@ def test_create_rcs_card_content_with_too_many_suggestions(): assert "List should have at most 4 items" in str(err.value) -def test_create_rcs_card_content_with_inavalid_suggestion_types(): +def test_create_rcs_card_item_with_inavalid_suggestion_types(): with pytest.raises(ValidationError) as err: - card = RcsCardContent( + card = RcsCardItem( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1041,10 +1104,11 @@ def test_create_rcs_carousel(): to='1234567890', from_='asdf1234', cards=[ - RcsCardContent( + RcsCardItem( title='Card title', text='Card description', media_url='https://example.com/image.jpg', + media_height='MEDIUM', ) ] * 2, ) @@ -1056,6 +1120,7 @@ def test_create_rcs_carousel(): 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', } ] * 2, 'channel': 'rcs', @@ -1069,7 +1134,7 @@ def test_create_rcs_carousel_with_optional_params(): to='1234567890', from_='asdf1234', cards=[ - RcsCardContent( + RcsCardItem( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -1106,6 +1171,187 @@ def test_create_rcs_carousel_with_optional_params(): assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict +def test_create_rcs_carousel_with_suggestions(): + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] * 2, + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] * 2, + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_with_all_suggestion_types(): + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] * 2, + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] * 2, + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + ], + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', From 0b9be71496b1bb4636288f77d4118c6475af00f6 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 17 Mar 2026 12:51:15 +0000 Subject: [PATCH 355/401] DEVX-10006: fix linting issues --- .../src/vonage_messages/models/__init__.py | 26 ++-- messages/src/vonage_messages/models/rcs.py | 31 ++++- messages/tests/test_rcs_models.py | 112 ++++++++++-------- 3 files changed, 100 insertions(+), 69 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index e10e4e52..dedb747b 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -11,27 +11,27 @@ ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo from .rcs import ( - RcsCustom, - RcsCarousel, + RcsCardBase, RcsCardItem, RcsCardMessage, - RcsCardBase, + RcsCarousel, + RcsCustom, RcsFile, RcsImage, + RcsOptions, + RcsOptionsCard, + RcsOptionsCarousel, RcsResource, - RcsText, - RcsVideo, - RcsSuggestionBase, - RcsSuggestionReply, + RcsSuggestionActionCreateCalendarEvent, RcsSuggestionActionDial, - RcsSuggestionActionViewLocation, - RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, - RcsSuggestionActionCreateCalendarEvent, - RcsOptions, - RcsOptionsCard, - RcsOptionsCarousel, + RcsSuggestionActionShareLocation, + RcsSuggestionActionViewLocation, + RcsSuggestionBase, + RcsSuggestionReply, + RcsText, + RcsVideo, ) from .sms import Sms, SmsOptions from .viber import ( diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 0256f054..e227a6c0 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -1,10 +1,20 @@ -from typing import Optional, List, Union +from typing import List, Optional, Union from pydantic import BaseModel, Field from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType, SuggestionType, UrlWebviewViewMode, RcsCategory, RcsCardOrientation, RcsImageAlignment, RcsCardWidth, RcsMediaHeight +from .enums import ( + ChannelType, + MessageType, + RcsCardOrientation, + RcsCardWidth, + RcsCategory, + RcsImageAlignment, + RcsMediaHeight, + SuggestionType, + UrlWebviewViewMode, +) class RcsResource(BaseModel): @@ -65,7 +75,9 @@ class RcsSuggestionActionViewLocation(RcsSuggestionBase): fallback_url (str, Optional): The URL to open if the device doesn't support the view location action. """ - type_: SuggestionType = Field(SuggestionType.VIEW_LOCATION, serialization_alias='type') + type_: SuggestionType = Field( + SuggestionType.VIEW_LOCATION, serialization_alias='type' + ) latitude: str longitude: str pin_label: str @@ -80,7 +92,9 @@ class RcsSuggestionActionShareLocation(RcsSuggestionBase): postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. """ - type_: SuggestionType = Field(SuggestionType.SHARE_LOCATION, serialization_alias='type') + type_: SuggestionType = Field( + SuggestionType.SHARE_LOCATION, serialization_alias='type' + ) class RcsSuggestionActionOpenUrl(RcsSuggestionBase): @@ -107,9 +121,12 @@ class RcsSuggestionActionOpenUrlWebview(RcsSuggestionActionOpenUrl): view_mode (str, Optional): The view mode for the webview. If not specified, the default view mode will be used. """ - type_: SuggestionType = Field(SuggestionType.OPEN_URL_IN_WEBVIEW, serialization_alias='type') + type_: SuggestionType = Field( + SuggestionType.OPEN_URL_IN_WEBVIEW, serialization_alias='type' + ) view_mode: Optional[UrlWebviewViewMode] = None + class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): """Model for a create calendar event action suggestion in an RCS message. @@ -123,7 +140,9 @@ class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): fallback_url (str, Optional): The URL to open if the device doesn't support the create calendar event action. """ - type_: SuggestionType = Field(SuggestionType.CREATE_CALENDAR_EVENT, serialization_alias='type') + type_: SuggestionType = Field( + SuggestionType.CREATE_CALENDAR_EVENT, serialization_alias='type' + ) start_time: str end_time: str title: str = Field(..., min_length=1, max_length=100) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index ea365280..9ab0aee7 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1,27 +1,27 @@ import pytest from pydantic import ValidationError from vonage_messages.models import ( - RcsCustom, - RcsCarousel, + RcsCardBase, RcsCardItem, RcsCardMessage, - RcsCardBase, + RcsCarousel, + RcsCustom, RcsFile, RcsImage, + RcsOptions, + RcsOptionsCard, + RcsOptionsCarousel, RcsResource, - RcsText, - RcsVideo, - RcsSuggestionBase, - RcsSuggestionReply, + RcsSuggestionActionCreateCalendarEvent, RcsSuggestionActionDial, - RcsSuggestionActionViewLocation, - RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, - RcsSuggestionActionCreateCalendarEvent, - RcsOptions, - RcsOptionsCard, - RcsOptionsCarousel, + RcsSuggestionActionShareLocation, + RcsSuggestionActionViewLocation, + RcsSuggestionBase, + RcsSuggestionReply, + RcsText, + RcsVideo, ) @@ -86,7 +86,7 @@ def test_create_rcs_text_all_fields(): 'message_type': 'text', 'rcs': { 'category': 'transaction', - } + }, } assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict @@ -238,13 +238,12 @@ def test_create_rcs_text_with_all_suggestion_types(): 'title': 'Meeting with Bob', 'description': 'Discuss project updates', 'fallback_url': 'https://example.com/calendar-event', - } + }, ], 'channel': 'rcs', 'message_type': 'text', } - assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict @@ -270,7 +269,8 @@ def test_create_rcs_text_with_too_many_suggestions(): text='Reply', postback_data='postback-data', ), - ] * 12, + ] + * 12, ) assert "List should have at most 11 items" in str(err.value) @@ -354,6 +354,7 @@ def test_create_rcs_file(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + def test_create_rcs_card_base(): card_base = RcsCardBase( title='Card title', @@ -515,7 +516,7 @@ def test_create_rcs_card_base_with_all_suggestion_types(): 'type': 'share_location', 'text': 'Share location', 'postback_data': 'postback-data', - } + }, ], } card_base_dict_2 = { @@ -547,7 +548,7 @@ def test_create_rcs_card_base_with_all_suggestion_types(): 'title': 'Meeting with Bob', 'description': 'Discuss project updates', 'fallback_url': 'https://example.com/calendar-event', - } + }, ], } assert card_base_1.model_dump(by_alias=True, exclude_none=True) == card_base_dict_1 @@ -647,7 +648,8 @@ def test_create_rcs_card_base_with_too_many_suggestions(): text='Reply', postback_data='postback-data', ), - ] * 5, + ] + * 5, ) assert "List should have at most 4 items" in str(err.value) @@ -876,7 +878,8 @@ def test_create_rcs_card_message_with_too_many_suggestions(): text='Reply', postback_data='postback-data', ), - ] * 5, + ] + * 5, ) assert "List should have at most 4 items" in str(err.value) @@ -1010,7 +1013,7 @@ def test_create_rcs_card_item_without_media_height(): card = RcsCardItem( title='Card title', text='Card description', - media_url='https://example.com/image.jpg' + media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) @@ -1077,7 +1080,8 @@ def test_create_rcs_card_item_with_too_many_suggestions(): text='Reply', postback_data='postback-data', ), - ] * 5, + ] + * 5, ) assert "List should have at most 4 items" in str(err.value) @@ -1110,7 +1114,8 @@ def test_create_rcs_carousel(): media_url='https://example.com/image.jpg', media_height='MEDIUM', ) - ] * 2, + ] + * 2, ) carousel_dict = { 'to': '1234567890', @@ -1122,7 +1127,8 @@ def test_create_rcs_carousel(): 'media_url': 'https://example.com/image.jpg', 'media_height': 'MEDIUM', } - ] * 2, + ] + * 2, 'channel': 'rcs', 'message_type': 'carousel', } @@ -1143,7 +1149,8 @@ def test_create_rcs_carousel_with_optional_params(): thumbnail_url='https://example.com/thumbnail.jpg', media_force_refresh=True, ) - ] * 2, + ] + * 2, rcs=RcsOptionsCarousel( card_width='MEDIUM', ), @@ -1161,7 +1168,8 @@ def test_create_rcs_carousel_with_optional_params(): 'thumbnail_url': 'https://example.com/thumbnail.jpg', 'media_force_refresh': True, } - ] * 2, + ] + * 2, 'rcs': { 'card_width': 'MEDIUM', }, @@ -1182,7 +1190,8 @@ def test_create_rcs_carousel_with_suggestions(): media_url='https://example.com/image.jpg', media_height='MEDIUM', ) - ] * 2, + ] + * 2, suggestions=[ RcsSuggestionReply( text='Reply', @@ -1205,7 +1214,8 @@ def test_create_rcs_carousel_with_suggestions(): 'media_url': 'https://example.com/image.jpg', 'media_height': 'MEDIUM', } - ] * 2, + ] + * 2, 'suggestions': [ { 'type': 'reply', @@ -1236,7 +1246,8 @@ def test_create_rcs_carousel_with_all_suggestion_types(): media_url='https://example.com/image.jpg', media_height='MEDIUM', ) - ] * 2, + ] + * 2, suggestions=[ RcsSuggestionReply( text='Reply', @@ -1293,7 +1304,8 @@ def test_create_rcs_carousel_with_all_suggestion_types(): 'media_url': 'https://example.com/image.jpg', 'media_height': 'MEDIUM', } - ] * 2, + ] + * 2, 'suggestions': [ { 'type': 'reply', @@ -1344,7 +1356,7 @@ def test_create_rcs_carousel_with_all_suggestion_types(): 'title': 'Meeting with Bob', 'description': 'Discuss project updates', 'fallback_url': 'https://example.com/calendar-event', - } + }, ], 'channel': 'rcs', 'message_type': 'carousel', @@ -1415,6 +1427,7 @@ def test_rcs_suggestion_base_with_text_too_long(): ) assert "String should have at most 25 characters" in str(err.value) + def test_rcs_suggestion_reply(): suggestion = RcsSuggestionReply( text='Reply', @@ -1790,7 +1803,13 @@ def test_create_rcs_options(): def test_create_rcs_options_with_each_valid_category(): - valid_options = ['acknowledgement', 'authentication', 'promotion', 'service-request', 'transaction'] + valid_options = [ + 'acknowledgement', + 'authentication', + 'promotion', + 'service-request', + 'transaction', + ] for option in valid_options: options = RcsOptions( category=option, @@ -1806,14 +1825,14 @@ def test_create_rcs_options_with_invalid_category(): options = RcsOptions( category='invalid-category', ) - assert "Input should be 'acknowledgement', 'authentication', 'promotion', 'service-request' or 'transaction'" in str(err.value) + assert ( + "Input should be 'acknowledgement', 'authentication', 'promotion', 'service-request' or 'transaction'" + in str(err.value) + ) def test_create_rcs_options_card(): - options = RcsOptionsCard( - card_orientation='HORIZONTAL', - image_alignment='LEFT' - ) + options = RcsOptionsCard(card_orientation='HORIZONTAL', image_alignment='LEFT') options_dict = { 'card_orientation': 'HORIZONTAL', 'image_alignment': 'LEFT', @@ -1824,10 +1843,7 @@ def test_create_rcs_options_card(): def test_create_rcs_options_card_card_orientation_with_each_valid_option(): valid_orientations = ['VERTICAL', 'HORIZONTAL'] for orientation in valid_orientations: - options = RcsOptionsCard( - card_orientation=orientation, - image_alignment='LEFT' - ) + options = RcsOptionsCard(card_orientation=orientation, image_alignment='LEFT') options_dict = { 'card_orientation': orientation, 'image_alignment': 'LEFT', @@ -1838,10 +1854,7 @@ def test_create_rcs_options_card_card_orientation_with_each_valid_option(): def test_create_rcs_options_card_image_alignment_with_each_valid_option(): valid_alignments = ['LEFT', 'RIGHT'] for alignment in valid_alignments: - options = RcsOptionsCard( - card_orientation='HORIZONTAL', - image_alignment=alignment - ) + options = RcsOptionsCard(card_orientation='HORIZONTAL', image_alignment=alignment) options_dict = { 'card_orientation': 'HORIZONTAL', 'image_alignment': alignment, @@ -1852,16 +1865,15 @@ def test_create_rcs_options_card_image_alignment_with_each_valid_option(): def test_create_rcs_options_card_card_orientation_with_invalid_option(): with pytest.raises(ValidationError) as err: options = RcsOptionsCard( - card_orientation='INVALID_ORIENTATION', - image_alignment='LEFT' + card_orientation='INVALID_ORIENTATION', image_alignment='LEFT' ) assert "Input should be 'VERTICAL' or 'HORIZONTAL'" in str(err.value) + def test_create_rcs_options_card_image_alignment_with_invalid_option(): with pytest.raises(ValidationError) as err: options = RcsOptionsCard( - card_orientation='HORIZONTAL', - image_alignment='INVALID_ALIGNMENT' + card_orientation='HORIZONTAL', image_alignment='INVALID_ALIGNMENT' ) assert "Input should be 'LEFT' or 'RIGHT'" in str(err.value) From 5353ffcebcb6f12b4f626552371e941b7216b9a6 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 17 Mar 2026 12:58:35 +0000 Subject: [PATCH 356/401] DEVX-10006: Updating RCS models exports list --- messages/src/vonage_messages/models/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index dedb747b..e5e5b810 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -85,10 +85,25 @@ 'MmsResource', 'MmsVcard', 'MmsVideo', + 'RcsCardBase', + 'RcsCardItem', + 'RcsCardMessage', + 'RcsCarousel', 'RcsCustom', 'RcsFile', 'RcsImage', + 'RcsOptions', + 'RcsOptionsCard', + 'RcsOptionsCarousel', 'RcsResource', + 'RcsSuggestionActionCreateCalendarEvent', + 'RcsSuggestionActionDial', + 'RcsSuggestionActionOpenUrl', + 'RcsSuggestionActionOpenUrlWebview', + 'RcsSuggestionActionShareLocation', + 'RcsSuggestionActionViewLocation', + 'RcsSuggestionBase', + 'RcsSuggestionReply', 'RcsText', 'RcsVideo', 'Sms', From 5ca3b6d5a04f7aacde04a4517dee26d67a13249e Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 17 Mar 2026 16:42:07 +0000 Subject: [PATCH 357/401] DEVX-10006: updating RCS Carousel implementation and tests --- messages/src/vonage_messages/models/rcs.py | 4 +- messages/tests/test_rcs_models.py | 214 +++++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index e227a6c0..6fc64525 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -361,7 +361,7 @@ class RcsCarousel(BaseRcs): webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. """ - cards: List[RcsCardItem] = Field(..., min_length=1, max_length=10) + cards: List[RcsCardItem] = Field(..., min_length=2, max_length=10) suggestions: Optional[ List[ Union[ @@ -375,7 +375,7 @@ class RcsCarousel(BaseRcs): ] ] ] = Field(None, min_length=1, max_length=11) - rcs: Optional[RcsOptionsCarousel] = None + rcs: RcsOptionsCarousel message_type: MessageType = MessageType.CAROUSEL diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 9ab0aee7..5e251638 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1116,6 +1116,9 @@ def test_create_rcs_carousel(): ) ] * 2, + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), ) carousel_dict = { 'to': '1234567890', @@ -1129,6 +1132,9 @@ def test_create_rcs_carousel(): } ] * 2, + 'rcs': { + 'card_width': 'MEDIUM', + }, 'channel': 'rcs', 'message_type': 'carousel', } @@ -1203,6 +1209,9 @@ def test_create_rcs_carousel_with_suggestions(): phone_number='447900000000', ), ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), ) carousel_dict = { 'to': '1234567890', @@ -1229,6 +1238,9 @@ def test_create_rcs_carousel_with_suggestions(): 'phone_number': '447900000000', }, ], + 'rcs': { + 'card_width': 'MEDIUM', + }, 'channel': 'rcs', 'message_type': 'carousel', } @@ -1293,6 +1305,9 @@ def test_create_rcs_carousel_with_all_suggestion_types(): fallback_url='https://example.com/calendar-event', ), ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), ) carousel_dict = { 'to': '1234567890', @@ -1358,12 +1373,177 @@ def test_create_rcs_carousel_with_all_suggestion_types(): 'fallback_url': 'https://example.com/calendar-event', }, ], + 'rcs': { + 'card_width': 'MEDIUM', + }, 'channel': 'rcs', 'message_type': 'carousel', } assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict +def test_create_rcs_carousel_without_rcs_options(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] * 2, + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel_with_insufficient_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at least 2 items" in str(err.value) + + +def test_create_rcs_carousel_with_too_many_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 11, + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at most 10 items" in str(err.value) + + +def test_create_rcs_carousel_with_invalid_card_type(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ), + RcsCardMessage( + to='1234567890', + from_='asdf1234', + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_carousel_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + suggestions=[], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_carousel_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 12, + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at most 11 items" in str(err.value) + + +def test_create_rcs_carousel_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + to='1234567890', + from_='asdf1234', + cards=[ + RcsCardItem( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] * 2, + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + def test_create_rcs_custom(): rcs_model = RcsCustom( to='1234567890', @@ -1840,6 +2020,20 @@ def test_create_rcs_options_card(): assert options.model_dump(by_alias=True, exclude_none=True) == options_dict +def test_create_rcs_options_card_with_all_options(): + options = RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment='LEFT', + category='transaction', + ) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + def test_create_rcs_options_card_card_orientation_with_each_valid_option(): valid_orientations = ['VERTICAL', 'HORIZONTAL'] for orientation in valid_orientations: @@ -1888,6 +2082,26 @@ def test_create_rcs_options_carousel(): assert options.model_dump(by_alias=True, exclude_none=True) == options_dict +def test_create_rcs_options_carousel_with_all_options(): + options = RcsOptionsCarousel( + card_width='MEDIUM', + category='transaction', + ) + options_dict = { + 'card_width': 'MEDIUM', + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_without_card_width(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCarousel( + category='transaction', + ) + assert "Field required" in str(err.value) + + def test_create_rcs_options_carousel_card_width_with_each_valid_option(): valid_widths = ['SMALL', 'MEDIUM'] for width in valid_widths: From f4ed88f57d54987b3dea871cbbcfe273349b104f Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 17 Mar 2026 16:43:57 +0000 Subject: [PATCH 358/401] DEVX-10006: linting --- messages/tests/test_rcs_models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 5e251638..95de7ccd 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1394,7 +1394,8 @@ def test_create_rcs_carousel_without_rcs_options(): media_url='https://example.com/image.jpg', media_height='MEDIUM', ) - ] * 2, + ] + * 2, ) assert "Field required" in str(err.value) @@ -1529,7 +1530,8 @@ def test_create_rcs_carousel_with_inavalid_suggestion_types(): media_url='https://example.com/image.jpg', media_height='MEDIUM', ) - ] * 2, + ] + * 2, suggestions=[ RcsSuggestionReply( text='Reply', From f760a587b3a9c332af937cd1104c0413ac755130 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 18 Mar 2026 13:05:11 +0000 Subject: [PATCH 359/401] DEVX-10006: Updating doc blocks --- messages/src/vonage_messages/models/rcs.py | 79 +++++++++++++++------- messages/tests/test_rcs_models.py | 2 + 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 6fc64525..42c855f4 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -28,7 +28,7 @@ class RcsResource(BaseModel): class RcsSuggestionBase(BaseModel): - """Model for a suggestion in an RCS message. + """Base model for a suggestion in an RCS message. Args: text (str): The text to display on the suggestion chip. @@ -57,10 +57,12 @@ class RcsSuggestionActionDial(RcsSuggestionBase): text (str): The text to display on the suggestion chip. postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. phone_number (str): The phone number to dial when the suggestion is selected. In E.164 format without the leading plus sign. + fallback_url (str, Optional): The URL to open if the device doesn't support the dial action. """ type_: SuggestionType = Field(SuggestionType.DIAL, serialization_alias='type') phone_number: PhoneNumber + fallback_url: Optional[str] = None class RcsSuggestionActionViewLocation(RcsSuggestionBase): @@ -104,6 +106,7 @@ class RcsSuggestionActionOpenUrl(RcsSuggestionBase): text (str): The text to display on the suggestion chip. postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. url (str): The URL to open when the suggestion is selected. + description (str): A short description of the URL for accessibility purposes. """ type_: SuggestionType = Field(SuggestionType.OPEN_URL, serialization_alias='type') @@ -118,7 +121,8 @@ class RcsSuggestionActionOpenUrlWebview(RcsSuggestionActionOpenUrl): text (str): The text to display on the suggestion chip. postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. url (str): The URL to open in a webview when the suggestion is selected. - view_mode (str, Optional): The view mode for the webview. If not specified, the default view mode will be used. + description (str): A short description of the URL for accessibility purposes. + view_mode (str, Optional): The view mode for the webview (FULL, TALL, HALF). If not specified, the default view mode for the device will be used. """ type_: SuggestionType = Field( @@ -151,10 +155,10 @@ class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): class RcsOptions(BaseModel): - """Model for RCS message options. + """Base model for RCS message options. Args: - category (str, Optional): The category of the RCS message. + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). """ category: Optional[RcsCategory] = None @@ -164,7 +168,7 @@ class RcsOptionsCard(RcsOptions): """Model for an RCS card message options. Args: - category (str, Optional): The category of the RCS message. + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). card_orientation (str): The orientation of the card (HORIZONTAL or VERTICAL). image_alignment (str): The alignment of the image on the card (LEFT or RIGHT). """ @@ -177,6 +181,7 @@ class RcsOptionsCarousel(RcsOptions): """Model for an RCS carousel message options. Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). card_width (str): The width of each card in the carousel (SMALL or MEDIUM). """ @@ -184,20 +189,22 @@ class RcsOptionsCarousel(RcsOptions): class BaseRcs(BaseMessage): - """Model for a base RCS message. + """Base model for a base RCS message. Args: to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: RcsOptions, Optional: An optional RcsOptions object to include in the message. """ to: PhoneNumber from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_&]+$') - ttl: Optional[int] = Field(None, ge=300, le=259200) + ttl: Optional[int] = Field(None, ge=20, le=259200) + rcs: Optional[RcsOptions] = None channel: ChannelType = ChannelType.RCS @@ -205,13 +212,15 @@ class RcsText(BaseRcs): """Model for an RCS text message. Args: - text (str): The text of the message. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + text (str): The text of the message. + suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ text: str = Field(..., min_length=1, max_length=3072) @@ -229,20 +238,20 @@ class RcsText(BaseRcs): ] ] ] = Field(None, min_length=1, max_length=11) - rcs: Optional[RcsOptions] = None class RcsImage(BaseRcs): """Model for an RCS image message. Args: - image (RcsResource): The image resource. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + image (RcsResource): The image resource. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ image: RcsResource @@ -253,13 +262,14 @@ class RcsVideo(BaseRcs): """Model for an RCS video message. Args: - video (RcsResource): The video resource. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + video (RcsResource): The video resource. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ video: RcsResource @@ -270,13 +280,14 @@ class RcsFile(BaseRcs): """Model for an RCS file message. Args: - file (RcsResource): The file resource. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + file (RcsResource): The file resource. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ file: RcsResource @@ -284,13 +295,17 @@ class RcsFile(BaseRcs): class RcsCardBase(BaseModel): - """Model for the content of an RCS card. + """Base model for the content of an RCS card. Args: title (str): The title of the card. text (str): The text of the card. media_url (str): The media URL for the card. Can be an image or a video. - suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 4 suggestions. + media_height (str, Optional): The height of the media on the card (SHORT, MEDIUM, TALL). + media_description (str, Optional): A description of the media for accessibility purposes. + thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. + media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. + suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. """ title: str = Field(..., min_length=1, max_length=200) @@ -322,7 +337,11 @@ class RcsCardItem(RcsCardBase): title (str): The title of the card. text (str): The text of the card. media_url (str): The media URL for the card. Can be an image or a video. - suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 4 suggestions. + media_height (str): The height of the media on the card (SHORT, MEDIUM, TALL). + media_description (str, Optional): A description of the media for accessibility purposes. + thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. + media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. + suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. """ media_height: RcsMediaHeight @@ -332,16 +351,21 @@ class RcsCardMessage(RcsCardBase, BaseRcs): """Model for an RCS card message. Args: - title (str): The title of the card. - description (str): The description of the card. - media_url (str, Optional): The media URL for the card. Can be an image or a video. - suggestions (List[Union[RcsSuggestionReply, RcsSuggestionActionDial, RcsSuggestionActionViewLocation, RcsSuggestionActionShareLocation, RcsSuggestionActionOpenUrl, RcsSuggestionActionOpenUrlWebview, RcsSuggestionActionCreateCalendarEvent], Optional): A list of suggestions to include on the card. Can include up to 4 suggestions. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + title (str): The title of the card. + text (str): The text of the card. + media_url (str): The media URL for the card. Can be an image or a video. + media_height (str, Optional): The height of the media on the card (SHORT, MEDIUM, TALL). + media_description (str, Optional): A description of the media for accessibility purposes. + thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. + media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. + suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptionsCard, Optional): An optional RcsOptionsCard object to include in the message. """ rcs: Optional[RcsOptionsCard] = None @@ -352,13 +376,15 @@ class RcsCarousel(BaseRcs): """Model for an RCS carousel message. Args: - cards (List[RcsCard]): A list of cards to include in the carousel. Can include up to 10 cards. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + cards (List[RcsCardItem]): A list of card items to include in the carousel. Can include up to 10 cards. + suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptionsCarousel): An RcsOptionsCarousel object to include in the message. """ cards: List[RcsCardItem] = Field(..., min_length=2, max_length=10) @@ -383,13 +409,14 @@ class RcsCustom(BaseRcs): """Model for an RCS custom message. Args: - custom (dict): The custom message data. to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + custom (dict): The custom message data. ttl (int, Optional): The duration in seconds for which the message is valid. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. """ custom: dict diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 95de7ccd..1df3c348 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1629,12 +1629,14 @@ def test_rcs_suggestion_dial(): text='Call us', postback_data='postback-data', phone_number='447900000000', + fallback_url='https://example.com/dial', ) suggestion_dict = { 'type': 'dial', 'text': 'Call us', 'postback_data': 'postback-data', 'phone_number': '447900000000', + 'fallback_url': 'https://example.com/dial', } assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict From e65be571914d558cc16f975934fc043ca5957eb3 Mon Sep 17 00:00:00 2001 From: Alvaro Navarro Date: Fri, 20 Mar 2026 13:41:03 +0100 Subject: [PATCH 360/401] feat: add whatsapp mode to Verify API --- verify/src/vonage_verify/__init__.py | 3 ++- verify/src/vonage_verify/enums.py | 5 +++++ verify/src/vonage_verify/requests.py | 6 +++++- verify/tests/test_models.py | 21 +++++++++++++++++++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/verify/src/vonage_verify/__init__.py b/verify/src/vonage_verify/__init__.py index 90218321..34b87445 100644 --- a/verify/src/vonage_verify/__init__.py +++ b/verify/src/vonage_verify/__init__.py @@ -1,4 +1,4 @@ -from .enums import ChannelType, Locale +from .enums import ChannelType, Locale, WhatsappMode from .errors import VerifyError from .requests import ( EmailChannel, @@ -21,6 +21,7 @@ 'SilentAuthChannel', 'SmsChannel', 'WhatsappChannel', + 'WhatsappMode', 'VoiceChannel', 'EmailChannel', 'StartVerificationResponse', diff --git a/verify/src/vonage_verify/enums.py b/verify/src/vonage_verify/enums.py index 0871f945..987f0e32 100644 --- a/verify/src/vonage_verify/enums.py +++ b/verify/src/vonage_verify/enums.py @@ -9,6 +9,11 @@ class ChannelType(str, Enum): EMAIL = 'email' +class WhatsappMode(str, Enum): + ZERO_TAP = 'zero_tap' + OTP_CODE = 'otp_code' + + class Locale(str, Enum): EN_US = 'en-us' EN_GB = 'en-gb' diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py index 2561af56..478b1c59 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator from vonage_utils.types import PhoneNumber -from .enums import ChannelType, Locale +from .enums import ChannelType, Locale, WhatsappMode from .errors import VerifyError @@ -88,6 +88,9 @@ class WhatsappChannel(Channel): from_ (Union[PhoneNumber, str]): A WhatsApp Business Account (WABA)-connected sender number, in the E.164 format. Don't use a leading + or 00 when entering a phone number. + mode (WhatsappMode, Optional): Defines the WhatsApp verification experience. Use + `WhatsappMode.ZERO_TAP` for automatic verification on Android apps. Defaults + to `WhatsappMode.OTP_CODE`. Raises: VerifyError: If the `from_` field is not a valid phone number or string of 3-11 @@ -95,6 +98,7 @@ class WhatsappChannel(Channel): """ from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + mode: Optional[WhatsappMode] = None channel: ChannelType = ChannelType.WHATSAPP @field_validator('from_') diff --git a/verify/tests/test_models.py b/verify/tests/test_models.py index 88c075a5..d34358c3 100644 --- a/verify/tests/test_models.py +++ b/verify/tests/test_models.py @@ -1,5 +1,5 @@ from pytest import raises -from vonage_verify.enums import ChannelType, Locale +from vonage_verify.enums import ChannelType, Locale, WhatsappMode from vonage_verify.errors import VerifyError from vonage_verify.requests import * @@ -43,7 +43,7 @@ def test_create_whatsapp_channel(): } channel = WhatsappChannel(**params) - assert channel.model_dump() == params + assert channel.model_dump() == {**params, 'mode': None} assert channel.model_dump(by_alias=True)['from'] == 'Vonage' params['from_'] = 'this.is!invalid' @@ -51,6 +51,23 @@ def test_create_whatsapp_channel(): WhatsappChannel(**params) +def test_create_whatsapp_channel_with_mode(): + params = { + 'channel': ChannelType.WHATSAPP, + 'to': '1234567890', + 'from_': 'Vonage', + 'mode': WhatsappMode.ZERO_TAP, + } + channel = WhatsappChannel(**params) + + assert channel.mode == WhatsappMode.ZERO_TAP + assert channel.model_dump()['mode'] == WhatsappMode.ZERO_TAP + + params['mode'] = WhatsappMode.OTP_CODE + channel = WhatsappChannel(**params) + assert channel.mode == WhatsappMode.OTP_CODE + + def test_create_voice_channel(): params = { 'channel': ChannelType.VOICE, From 7a853a9b6ecf14915d73be6b98a4f52c5fca09ca Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:36:01 +0000 Subject: [PATCH 361/401] DEVX-10043: Updating implementaton and tests for MMS caption max length --- messages/src/vonage_messages/models/mms.py | 2 +- messages/tests/test_mms_models.py | 51 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 6220ed11..748c03bc 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -16,7 +16,7 @@ class MmsResource(BaseModel): """ url: str - caption: Optional[str] = Field(None, min_length=1, max_length=2000) + caption: Optional[str] = Field(None, min_length=1, max_length=3000) class BaseMms(BaseMessage): diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index c74d6994..3a9720d7 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,7 +1,58 @@ +import pytest +from pydantic import ValidationError from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo from vonage_messages.models.enums import WebhookVersion +def test_create_mms_resource(): + mms_resource = MmsResource( + url='https://example.com/resource', + ) + mms_resource_dict = { + 'url': 'https://example.com/resource', + } + + assert mms_resource.model_dump(exclude_none=True) == mms_resource_dict + + +def test_create_mms_resource_with_caption(): + mms_resource = MmsResource( + url='https://example.com/resource', + caption='Resource caption', + ) + mms_resource_dict = { + 'url': 'https://example.com/resource', + 'caption': 'Resource caption', + } + + assert mms_resource.model_dump(exclude_none=True) == mms_resource_dict + + +def test_create_mms_resource_without_url(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + caption='Resource caption', + ) + assert "Field required" in str(err.value) + + +def test_create_mms_resource_with_caption_too_short(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + url='https://example.com/resource', + caption='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_mms_resource_with_caption_too_long(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + url='https://example.com/resource', + caption='a' * 3001, + ) + assert "String should have at most 3000 characters" in str(err.value) + def test_create_mms_image(): mms_model = MmsImage( to='1234567890', From 4c267fcc074169403197308bc1c87c252895964f Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:44:39 +0000 Subject: [PATCH 362/401] DEVX-10815: Adding tests for MMS trusted_recipient param --- messages/tests/test_mms_models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index 3a9720d7..35fa665b 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -86,6 +86,7 @@ def test_create_mms_image_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -98,6 +99,7 @@ def test_create_mms_image_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'image', } @@ -138,6 +140,7 @@ def test_create_mms_vcard_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -150,6 +153,7 @@ def test_create_mms_vcard_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'vcard', } @@ -190,6 +194,7 @@ def test_create_mms_audio_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -202,6 +207,7 @@ def test_create_mms_audio_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'audio', } @@ -242,6 +248,7 @@ def test_create_mms_video_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) mms_dict = { 'to': '1234567890', @@ -254,6 +261,7 @@ def test_create_mms_video_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'mms', 'message_type': 'video', } From e1edd982c9ffbf2e3853395f4d22702fab155f02 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:45:48 +0000 Subject: [PATCH 363/401] DEVX-10815: adding trusted_recipient param to BaseMms model --- messages/src/vonage_messages/models/mms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 748c03bc..3c94cb52 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -34,6 +34,7 @@ class BaseMms(BaseMessage): to: PhoneNumber from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') ttl: Optional[int] = Field(None, ge=300, le=259200) + trusted_recipient: Optional[bool] = None channel: ChannelType = ChannelType.MMS From d86f58f3b27a5d460258dba65d7fc78ca0cf19af Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:47:26 +0000 Subject: [PATCH 364/401] DEVX-10815: Updating SMS tests for trusted_recipient param --- messages/tests/test_sms_models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index 49b19771..2e6ab449 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -33,6 +33,7 @@ def test_create_sms_all_fields(): webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, + trusted_recipient=True, ) sms_dict = { 'to': '1234567890', @@ -47,6 +48,7 @@ def test_create_sms_all_fields(): 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'sms', 'message_type': 'text', } From 6f717b695b411f25ee193bbff730e7b3e1975d1a Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:48:59 +0000 Subject: [PATCH 365/401] DEVX-10815: updating Sms model to add trusted_recipient param --- messages/src/vonage_messages/models/sms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index dd52541f..bf131e76 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -47,6 +47,7 @@ class Sms(BaseMessage): from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') text: str = Field(..., max_length=1000) ttl: Optional[int] = None + trusted_recipient: Optional[bool] = None sms: Optional[SmsOptions] = None channel: ChannelType = ChannelType.SMS message_type: MessageType = MessageType.TEXT From 1499a67b230798c43a4867e1ccd793424b29f7dd Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:51:41 +0000 Subject: [PATCH 366/401] DEVX-10815: updating RCS model tests to include trusted_recipient param --- messages/tests/test_rcs_models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 1df3c348..f5cf1b0f 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -71,6 +71,7 @@ def test_create_rcs_text_all_fields(): client_ref='client-ref', webhook_url='https://example.com', ttl=600, + trusted_recipient=True, rcs=RcsOptions( category='transaction', ), @@ -82,6 +83,7 @@ def test_create_rcs_text_all_fields(): 'client_ref': 'client-ref', 'webhook_url': 'https://example.com', 'ttl': 600, + 'trusted_recipient': True, 'channel': 'rcs', 'message_type': 'text', 'rcs': { From 83534fa16698cdb7aa4915f9fe2d1c5814719f39 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 12:54:40 +0000 Subject: [PATCH 367/401] DEVX-10815: updating BaseRcs model to include trusted_recipient param --- messages/src/vonage_messages/models/rcs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 42c855f4..087832d6 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -204,6 +204,7 @@ class BaseRcs(BaseMessage): to: PhoneNumber from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_&]+$') ttl: Optional[int] = Field(None, ge=20, le=259200) + trusted_recipient: Optional[bool] = None rcs: Optional[RcsOptions] = None channel: ChannelType = ChannelType.RCS From e5dc5251e1ab68f3bde2d149a5676479227f51de Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 13:02:31 +0000 Subject: [PATCH 368/401] DEVX-10815: updating doc blocks --- messages/src/vonage_messages/models/mms.py | 7 ++++++- messages/src/vonage_messages/models/rcs.py | 8 ++++++++ messages/src/vonage_messages/models/sms.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 3c94cb52..e1620863 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -12,7 +12,7 @@ class MmsResource(BaseModel): Args: url (str): The URL of the resource. - caption (str, Optional): Additional text to accompany the resource. + caption (str, Optional): Additional text to accompany the resource, with a maximum length of 3000 characters. """ url: str @@ -26,6 +26,7 @@ class BaseMms(BaseMessage): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -46,6 +47,7 @@ class MmsImage(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -63,6 +65,7 @@ class MmsVcard(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -80,6 +83,7 @@ class MmsAudio(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -97,6 +101,7 @@ class MmsVideo(BaseMms): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 087832d6..96aa2e92 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -195,6 +195,7 @@ class BaseRcs(BaseMessage): to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The RCS Agent ID. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -218,6 +219,7 @@ class RcsText(BaseRcs): text (str): The text of the message. suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -249,6 +251,7 @@ class RcsImage(BaseRcs): from_ (str): The RCS Agent ID. image (RcsResource): The image resource. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -267,6 +270,7 @@ class RcsVideo(BaseRcs): from_ (str): The RCS Agent ID. video (RcsResource): The video resource. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -285,6 +289,7 @@ class RcsFile(BaseRcs): from_ (str): The RCS Agent ID. file (RcsResource): The file resource. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -363,6 +368,7 @@ class RcsCardMessage(RcsCardBase, BaseRcs): media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -382,6 +388,7 @@ class RcsCarousel(BaseRcs): cards (List[RcsCardItem]): A list of card items to include in the carousel. Can include up to 10 cards. suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. @@ -414,6 +421,7 @@ class RcsCustom(BaseRcs): from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. custom (dict): The custom message data. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index bf131e76..ff559713 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -38,6 +38,7 @@ class Sms(BaseMessage): Don't use a leading plus sign. text (str): The text of the message. ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. sms (SmsOptions, Optional): SMS options. client_ref (str, Optional): An optional client reference. webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. From fcfc16d96879e2fdf15c926d73897e699758a209 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 14:12:19 +0000 Subject: [PATCH 369/401] DEVX-10013: Updating SMS tests to include pool_id param --- messages/tests/test_sms_models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index 2e6ab449..19e64203 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -34,6 +34,7 @@ def test_create_sms_all_fields(): webhook_version=WebhookVersion.V1, ttl=600, trusted_recipient=True, + pool_id='abc123', ) sms_dict = { 'to': '1234567890', @@ -49,6 +50,7 @@ def test_create_sms_all_fields(): 'webhook_version': 'v1', 'ttl': 600, 'trusted_recipient': True, + 'pool_id': 'abc123', 'channel': 'sms', 'message_type': 'text', } From 0c8556702624b8b343e1b7ed1b008adffec3f6d0 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 14:14:23 +0000 Subject: [PATCH 370/401] DEVX-10013: implemeting pool_id param in Sms model --- messages/src/vonage_messages/models/sms.py | 1 + messages/tests/test_sms_models.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index ff559713..cd5a5a14 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -26,6 +26,7 @@ class SmsOptions(BaseModel): encoding_type: Optional[EncodingType] = None content_id: Optional[str] = None entity_id: Optional[str] = None + pool_id: Optional[str] = None class Sms(BaseMessage): diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index 19e64203..800bef52 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -28,13 +28,13 @@ def test_create_sms_all_fields(): encoding_type=EncodingType.TEXT, content_id='content-id', entity_id='entity-id', + pool_id='abc123', ), client_ref='client-ref', webhook_url='https://example.com', webhook_version=WebhookVersion.V1, ttl=600, trusted_recipient=True, - pool_id='abc123', ) sms_dict = { 'to': '1234567890', @@ -44,13 +44,13 @@ def test_create_sms_all_fields(): 'encoding_type': 'text', 'content_id': 'content-id', 'entity_id': 'entity-id', + 'pool_id': 'abc123', }, 'client_ref': 'client-ref', 'webhook_url': 'https://example.com', 'webhook_version': 'v1', 'ttl': 600, 'trusted_recipient': True, - 'pool_id': 'abc123', 'channel': 'sms', 'message_type': 'text', } From 123150fd57b617510ebd99b357f5c0cbefd822cd Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 14:17:58 +0000 Subject: [PATCH 371/401] DEVX-10013: updating doc block for pool_id param in SmsOptions model --- messages/src/vonage_messages/models/sms.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index cd5a5a14..a37139db 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -21,6 +21,10 @@ class SmsOptions(BaseModel): entity_id (str, Optional): A string parameter that satisfies regulatory requirements when sending an SMS to specific countries. Not needed unless sending SMS in a country that requires a specific entity ID. + pool_id (str, Optional): The ID of the Number Pool to use as the sender of this message. + If specified, a number from the pool will be used as the from number. + The from number is still required even when specifying a pool_id and will be used as a fall-back if the number pool cannot be used. + See the Number Pools documentation for more information: https://developer.vonage.com/numbers/number-pools-api/overview. """ encoding_type: Optional[EncodingType] = None From ec27bc83c1eee0c6acef6d113a42e29c1563b200 Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 14:21:59 +0000 Subject: [PATCH 372/401] DEVX-10043: linting --- messages/tests/test_mms_models.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index 35fa665b..eb3387a9 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -31,28 +31,29 @@ def test_create_mms_resource_with_caption(): def test_create_mms_resource_without_url(): with pytest.raises(ValidationError) as err: mms_resource = MmsResource( - caption='Resource caption', - ) + caption='Resource caption', + ) assert "Field required" in str(err.value) def test_create_mms_resource_with_caption_too_short(): with pytest.raises(ValidationError) as err: mms_resource = MmsResource( - url='https://example.com/resource', - caption='', - ) + url='https://example.com/resource', + caption='', + ) assert "String should have at least 1 character" in str(err.value) def test_create_mms_resource_with_caption_too_long(): with pytest.raises(ValidationError) as err: mms_resource = MmsResource( - url='https://example.com/resource', - caption='a' * 3001, - ) + url='https://example.com/resource', + caption='a' * 3001, + ) assert "String should have at most 3000 characters" in str(err.value) + def test_create_mms_image(): mms_model = MmsImage( to='1234567890', From e8f1b5be79e472738fed69231a47bea137a7a09e Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 14:36:02 +0000 Subject: [PATCH 373/401] DEVX-9451: Adding tests for new MmsText model --- messages/tests/test_mms_models.py | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index eb3387a9..1bd3660e 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -54,6 +54,50 @@ def test_create_mms_resource_with_caption_too_long(): assert "String should have at most 3000 characters" in str(err.value) +def test_create_mms_text(): + mms_model = MmsText( + to='1234567890', + from_='1234567890', + text='Hello, world!', + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, world!', + 'channel': 'mms', + 'message_type': 'text', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_text_all_fields(): + mms_model = MmsText( + to='1234567890', + from_='1234567890', + text='Hello, world!', + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, world!', + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'text', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + def test_create_mms_image(): mms_model = MmsImage( to='1234567890', From 5dbe7bef594f7dba5a4f3912ccad3674f9188c7a Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 24 Mar 2026 14:41:14 +0000 Subject: [PATCH 374/401] DEVX-9451: Implementing MmsText Model --- messages/src/vonage_messages/models/__init__.py | 3 ++- messages/src/vonage_messages/models/mms.py | 17 +++++++++++++++++ messages/tests/test_mms_models.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index e5e5b810..b8372248 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -9,7 +9,7 @@ MessengerText, MessengerVideo, ) -from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from .mms import MmsAudio, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo from .rcs import ( RcsCardBase, RcsCardItem, @@ -83,6 +83,7 @@ 'MmsAudio', 'MmsImage', 'MmsResource', + 'MmsText', 'MmsVcard', 'MmsVideo', 'RcsCardBase', diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index e1620863..f1c1d5ea 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -39,6 +39,23 @@ class BaseMms(BaseMessage): channel: ChannelType = ChannelType.MMS +class MmsText(BaseMms): + """Model for an MMS text message. + + Args: + text (str): The text of the message. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + text: str + message_type: MessageType = MessageType.TEXT + + class MmsImage(BaseMms): """Model for an MMS image message. diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index 1bd3660e..7be035cc 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo from vonage_messages.models.enums import WebhookVersion From cbb55dde98b1de044416589e7db9a0999b7c493c Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 25 Mar 2026 11:20:28 +0000 Subject: [PATCH 375/401] DEVX-9451: Adding tests and implementation for MmsFile model --- .../src/vonage_messages/models/__init__.py | 3 +- messages/src/vonage_messages/models/mms.py | 18 ++++++ messages/tests/test_mms_models.py | 56 ++++++++++++++++++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index b8372248..88ce97e6 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -9,7 +9,7 @@ MessengerText, MessengerVideo, ) -from .mms import MmsAudio, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo +from .mms import MmsAudio, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo from .rcs import ( RcsCardBase, RcsCardItem, @@ -81,6 +81,7 @@ 'MessengerText', 'MessengerVideo', 'MmsAudio', + 'MmsFile', 'MmsImage', 'MmsResource', 'MmsText', diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index f1c1d5ea..0a1ed414 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -126,3 +126,21 @@ class MmsVideo(BaseMms): video: MmsResource message_type: MessageType = MessageType.VIDEO + + +class MmsFile(BaseMms): + """Model for an MMS file message. + + Args: + file (MmsResource): The file resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + file: MmsResource + message_type: MessageType = MessageType.FILE diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index 7be035cc..f52e2a0d 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo +from vonage_messages.models import MmsAudio, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo from vonage_messages.models.enums import WebhookVersion @@ -312,3 +312,57 @@ def test_create_mms_video_all_fields(): } assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_file(): + mms_model = MmsFile( + to='1234567890', + from_='1234567890', + file=MmsResource( + url='https://example.com/file.pdf', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + }, + 'channel': 'mms', + 'message_type': 'file', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_file_all_fields(): + mms_model = MmsFile( + to='1234567890', + from_='1234567890', + file=MmsResource( + url='https://example.com/file.pdf', + caption='File caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'file', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict From c4df5804ce0381b76f27b68682d0ad516d7f85aa Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 25 Mar 2026 15:21:31 +0000 Subject: [PATCH 376/401] DEVX-9451: adding tests and implementation for MmsContent model --- .../src/vonage_messages/models/__init__.py | 10 +- messages/src/vonage_messages/models/enums.py | 11 ++ messages/src/vonage_messages/models/mms.py | 83 +++++++++- messages/tests/test_mms_models.py | 144 +++++++++++++++++- 4 files changed, 243 insertions(+), 5 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 88ce97e6..82bbd963 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -1,5 +1,5 @@ from .base_message import BaseMessage -from .enums import ChannelType, EncodingType, MessageType, WebhookVersion +from .enums import ChannelType, EncodingType, MessageType, WebhookVersion, SuggestionType, UrlWebviewViewMode, MmsContentItemType from .messenger import ( MessengerAudio, MessengerFile, @@ -9,7 +9,7 @@ MessengerText, MessengerVideo, ) -from .mms import MmsAudio, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo +from .mms import MmsAudio, MmsContent, MmsContentItemImage, MmsContentItemAudio, MmsContentItemVideo, MmsContentItemFile, MmsContentItemVcard, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo from .rcs import ( RcsCardBase, RcsCardItem, @@ -81,6 +81,12 @@ 'MessengerText', 'MessengerVideo', 'MmsAudio', + 'MmsContent', + 'MmsContentItemImage', + 'MmsContentItemAudio', + 'MmsContentItemVideo', + 'MmsContentItemFile', + 'MmsContentItemVcard', 'MmsFile', 'MmsImage', 'MmsResource', diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index e3baeeaa..f1146b68 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -15,6 +15,7 @@ class MessageType(str, Enum): VCARD = 'vcard' CARD = 'card' CAROUSEL = 'carousel' + CONTENT = 'content' class ChannelType(str, Enum): @@ -98,3 +99,13 @@ class RcsMediaHeight(str, Enum): SHORT = 'SHORT' MEDIUM = 'MEDIUM' TALL = 'TALL' + + +class MmsContentItemType(str, Enum): + """The type of a content item in an MMS Content message.""" + + IMAGE = 'image' + AUDIO = 'audio' + VIDEO = 'video' + FILE = 'file' + VCARD = 'vcard' \ No newline at end of file diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 0a1ed414..a07d74f6 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType +from .enums import ChannelType, MessageType, MmsContentItemType class MmsResource(BaseModel): @@ -144,3 +144,84 @@ class MmsFile(BaseMms): file: MmsResource message_type: MessageType = MessageType.FILE + + +class MmsContent(BaseMms): + """Model for an MMS message with content that can be of various types. + + Args: + content (list[MmsContentItem]): A list of content items for the message (images, audio, video, files, or vCards). + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + content: list[ + Union[ + MmsContentItemImage, + MmsContentItemAudio, + MmsContentItemVideo, + MmsContentItemFile, + MmsContentItemVcard + ] + ] + message_type: MessageType = MessageType.CONTENT + + +class MmsContentItemImage(MmsResource): + """Model for an image content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.IMAGE, serialization_alias='type') + + +class MmsContentItemAudio(MmsResource): + """Model for an audio content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.AUDIO, serialization_alias='type') + + +class MmsContentItemVideo(MmsResource): + """Model for a video content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.VIDEO, serialization_alias='type') + + +class MmsContentItemFile(MmsResource): + """Model for a file content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.FILE, serialization_alias='type') + + +class MmsContentItemVcard(MmsResource): + """Model for a vCard content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.VCARD, serialization_alias='type') diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index f52e2a0d..e7d263ae 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from vonage_messages.models import MmsAudio, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo +from vonage_messages.models import MmsAudio, MmsContent, MmsContentItemImage, MmsContentItemAudio, MmsContentItemVideo, MmsContentItemFile, MmsContentItemVcard, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo from vonage_messages.models.enums import WebhookVersion @@ -365,4 +365,144 @@ def test_create_mms_file_all_fields(): 'message_type': 'file', } - assert mms_model.model_dump(by_alias=True) == mms_dict + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_content(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + ], + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + ], + 'channel': 'mms', + 'message_type': 'content', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_content_all_fields(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + ], + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + ], + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'content', + } + + +def test_create_mms_content_all_content_types(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + MmsContentItemAudio( + url='https://example.com/audio.mp3', + caption='Audio caption', + ), + MmsContentItemVideo( + url='https://example.com/video.mp4', + caption='Video caption', + ), + MmsContentItemFile( + url='https://example.com/file.pdf', + caption='File caption', + ), + MmsContentItemVcard( + url='https://example.com/vcard.vcf', + caption='Vcard caption', + ), + ], + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + { + 'type': 'audio', + 'url': 'https://example.com/audio.mp3', + 'caption': 'Audio caption', + }, + { + 'type': 'video', + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + { + 'type': 'file', + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + }, + { + 'type': 'vcard', + 'url': 'https://example.com/vcard.vcf', + 'caption': 'Vcard caption', + }, + ], + 'channel': 'mms', + 'message_type': 'content', + } + + +def test_create_mms_content_with_invalid_content_item(): + with pytest.raises(ValidationError) as err: + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsResource( + url='https://example.com/resource', + ), + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) From 95648e2f56c36b7d6e9fea882013bd0e8026bb99 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 25 Mar 2026 15:26:39 +0000 Subject: [PATCH 377/401] DEVX-9451: updating imports lists --- .../src/vonage_messages/models/__init__.py | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 82bbd963..43284300 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -1,5 +1,18 @@ from .base_message import BaseMessage -from .enums import ChannelType, EncodingType, MessageType, WebhookVersion, SuggestionType, UrlWebviewViewMode, MmsContentItemType +from .enums import ( + ChannelType, + EncodingType, + MessageType, + WebhookVersion, + SuggestionType, + UrlWebviewViewMode, + MmsContentItemType, + RcsCategory, + RcsCardOrientation, + RcsImageAlignment, + RcsCardWidth, + RcsMediaHeight, +) from .messenger import ( MessengerAudio, MessengerFile, @@ -9,7 +22,21 @@ MessengerText, MessengerVideo, ) -from .mms import MmsAudio, MmsContent, MmsContentItemImage, MmsContentItemAudio, MmsContentItemVideo, MmsContentItemFile, MmsContentItemVcard, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo +from .mms import ( + MmsAudio, + MmsContent, + MmsContentItemImage, + MmsContentItemAudio, + MmsContentItemVideo, + MmsContentItemFile, + MmsContentItemVcard, + MmsFile, + MmsImage, + MmsResource, + MmsText, + MmsVcard, + MmsVideo, +) from .rcs import ( RcsCardBase, RcsCardItem, @@ -87,6 +114,7 @@ 'MmsContentItemVideo', 'MmsContentItemFile', 'MmsContentItemVcard', + 'MmsContentItemType', 'MmsFile', 'MmsImage', 'MmsResource', @@ -114,6 +142,11 @@ 'RcsSuggestionReply', 'RcsText', 'RcsVideo', + 'RcsCategory', + 'RcsCardOrientation', + 'RcsImageAlignment', + 'RcsCardWidth', + 'RcsMediaHeight', 'Sms', 'SmsOptions', 'ViberAction', From 819475d26603e08b0945f8c27cb3fdf937d16935 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 25 Mar 2026 15:28:24 +0000 Subject: [PATCH 378/401] DEVX-9451: linting --- .../src/vonage_messages/models/__init__.py | 12 +++++------- messages/src/vonage_messages/models/enums.py | 2 +- messages/src/vonage_messages/models/mms.py | 19 ++++++++++++++----- messages/tests/test_mms_models.py | 16 +++++++++++++++- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 43284300..ba920ac2 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -3,15 +3,13 @@ ChannelType, EncodingType, MessageType, - WebhookVersion, - SuggestionType, - UrlWebviewViewMode, MmsContentItemType, - RcsCategory, RcsCardOrientation, - RcsImageAlignment, RcsCardWidth, + RcsCategory, + RcsImageAlignment, RcsMediaHeight, + WebhookVersion, ) from .messenger import ( MessengerAudio, @@ -25,11 +23,11 @@ from .mms import ( MmsAudio, MmsContent, - MmsContentItemImage, MmsContentItemAudio, - MmsContentItemVideo, MmsContentItemFile, + MmsContentItemImage, MmsContentItemVcard, + MmsContentItemVideo, MmsFile, MmsImage, MmsResource, diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index f1146b68..80fc9877 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -108,4 +108,4 @@ class MmsContentItemType(str, Enum): AUDIO = 'audio' VIDEO = 'video' FILE = 'file' - VCARD = 'vcard' \ No newline at end of file + VCARD = 'vcard' diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index a07d74f6..9f85a16d 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -52,6 +52,7 @@ class MmsText(BaseMms): webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. """ + text: str message_type: MessageType = MessageType.TEXT @@ -166,7 +167,7 @@ class MmsContent(BaseMms): MmsContentItemAudio, MmsContentItemVideo, MmsContentItemFile, - MmsContentItemVcard + MmsContentItemVcard, ] ] message_type: MessageType = MessageType.CONTENT @@ -180,7 +181,9 @@ class MmsContentItemImage(MmsResource): caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. """ - type_: MmsContentItemType = Field(MmsContentItemType.IMAGE, serialization_alias='type') + type_: MmsContentItemType = Field( + MmsContentItemType.IMAGE, serialization_alias='type' + ) class MmsContentItemAudio(MmsResource): @@ -191,7 +194,9 @@ class MmsContentItemAudio(MmsResource): caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. """ - type_: MmsContentItemType = Field(MmsContentItemType.AUDIO, serialization_alias='type') + type_: MmsContentItemType = Field( + MmsContentItemType.AUDIO, serialization_alias='type' + ) class MmsContentItemVideo(MmsResource): @@ -202,7 +207,9 @@ class MmsContentItemVideo(MmsResource): caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. """ - type_: MmsContentItemType = Field(MmsContentItemType.VIDEO, serialization_alias='type') + type_: MmsContentItemType = Field( + MmsContentItemType.VIDEO, serialization_alias='type' + ) class MmsContentItemFile(MmsResource): @@ -224,4 +231,6 @@ class MmsContentItemVcard(MmsResource): caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. """ - type_: MmsContentItemType = Field(MmsContentItemType.VCARD, serialization_alias='type') + type_: MmsContentItemType = Field( + MmsContentItemType.VCARD, serialization_alias='type' + ) diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index e7d263ae..09b0b11e 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,6 +1,20 @@ import pytest from pydantic import ValidationError -from vonage_messages.models import MmsAudio, MmsContent, MmsContentItemImage, MmsContentItemAudio, MmsContentItemVideo, MmsContentItemFile, MmsContentItemVcard, MmsFile, MmsImage, MmsResource, MmsText, MmsVcard, MmsVideo +from vonage_messages.models import ( + MmsAudio, + MmsContent, + MmsContentItemAudio, + MmsContentItemFile, + MmsContentItemImage, + MmsContentItemVcard, + MmsContentItemVideo, + MmsFile, + MmsImage, + MmsResource, + MmsText, + MmsVcard, + MmsVideo, +) from vonage_messages.models.enums import WebhookVersion From 3eed3e6b527cacdc326fe7ab53fd0b5e0ac26723 Mon Sep 17 00:00:00 2001 From: superchilled Date: Wed, 25 Mar 2026 15:41:02 +0000 Subject: [PATCH 379/401] DEVX-9451: class ordering --- messages/src/vonage_messages/models/mms.py | 52 +++++++++++----------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 9f85a16d..bdc54be5 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -147,32 +147,6 @@ class MmsFile(BaseMms): message_type: MessageType = MessageType.FILE -class MmsContent(BaseMms): - """Model for an MMS message with content that can be of various types. - - Args: - content (list[MmsContentItem]): A list of content items for the message (images, audio, video, files, or vCards). - to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. - from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. - ttl (int, Optional): The duration in seconds for which the message is valid. - trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. - client_ref (str, Optional): An optional client reference. - webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. - webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. - """ - - content: list[ - Union[ - MmsContentItemImage, - MmsContentItemAudio, - MmsContentItemVideo, - MmsContentItemFile, - MmsContentItemVcard, - ] - ] - message_type: MessageType = MessageType.CONTENT - - class MmsContentItemImage(MmsResource): """Model for an image content item in an MMS Content message. @@ -234,3 +208,29 @@ class MmsContentItemVcard(MmsResource): type_: MmsContentItemType = Field( MmsContentItemType.VCARD, serialization_alias='type' ) + + +class MmsContent(BaseMms): + """Model for an MMS message with content that can be of various types. + + Args: + content (list[MmsContentItem]): A list of content items for the message (images, audio, video, files, or vCards). + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + content: list[ + Union[ + MmsContentItemImage, + MmsContentItemAudio, + MmsContentItemVideo, + MmsContentItemFile, + MmsContentItemVcard, + ] + ] + message_type: MessageType = MessageType.CONTENT From 2e3a263c403dac2ed7f5cd71ecdc05b4901d50ee Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 27 Mar 2026 12:55:28 +0000 Subject: [PATCH 380/401] DEVX-10770: adding tests and implementation for typing indicators --- messages/src/vonage_messages/messages.py | 11 +++++-- .../src/vonage_messages/models/__init__.py | 2 ++ messages/src/vonage_messages/models/enums.py | 6 ++++ .../src/vonage_messages/models/whatsapp.py | 15 +++++++++- messages/tests/test_messages.py | 29 +++++++++++++++++++ messages/tests/test_whatsapp_models.py | 12 ++++++++ 6 files changed, 71 insertions(+), 4 deletions(-) diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 6ba05b98..93da6919 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -1,7 +1,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .models import BaseMessage +from .models import BaseMessage, ReplyingIndicatorText from .responses import SendMessageResponse @@ -66,7 +66,7 @@ def send( return SendMessageResponse(**response) @validate_call - def mark_whatsapp_message_read(self, message_uuid: str) -> None: + def mark_whatsapp_message_read(self, message_uuid: str, replying_indicator: ReplyingIndicatorText = None) -> None: """Mark a WhatsApp message as read. Note: to use this method, update the `api_host` attribute of the @@ -78,11 +78,16 @@ def mark_whatsapp_message_read(self, message_uuid: str) -> None: Args: message_uuid (str): The unique identifier of the WhatsApp message to mark as read. + replying_indicator (ReplyingIndicatorText, optional): An object indicating whether to show the replying indicator on the WhatsApp message. """ + body = {'status': 'read'} + if replying_indicator is not None: + body['replying_indicator'] = replying_indicator.model_dump(by_alias=True, exclude_none=True) + self._http_client.patch( self._http_client.api_host, f'/v1/messages/{message_uuid}', - {'status': 'read'}, + body, self._auth_type, ) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index ba920ac2..cf35548e 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -74,6 +74,7 @@ ViberVideoResource, ) from .whatsapp import ( + ReplyingIndicatorText, WhatsappAudio, WhatsappAudioResource, WhatsappContext, @@ -145,6 +146,7 @@ 'RcsImageAlignment', 'RcsCardWidth', 'RcsMediaHeight', + 'ReplyingIndicatorText', 'Sms', 'SmsOptions', 'ViberAction', diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 80fc9877..d6aeec16 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -109,3 +109,9 @@ class MmsContentItemType(str, Enum): VIDEO = 'video' FILE = 'file' VCARD = 'vcard' + + +class ReplyingIndicatorType(str, Enum): + """The type of a WhatsApp replying indicator.""" + + TEXT = 'text' diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index d4f4582e..a7595bd7 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -4,7 +4,7 @@ from vonage_utils.types import PhoneNumber from .base_message import BaseMessage -from .enums import ChannelType, MessageType +from .enums import ChannelType, MessageType, ReplyingIndicatorType class WhatsappContext(BaseModel): @@ -361,3 +361,16 @@ class WhatsappCustom(BaseWhatsapp): custom: Optional[dict] = None message_type: MessageType = MessageType.CUSTOM + + +class ReplyingIndicatorText(BaseModel): + """Model for the replying indicator of type `text` in a WhatsApp conversation. + + This is used to indicate activity within the WhatsApp UI that a message is being replied to. + + Args: + show (bool): Must be set to True to activate the replying indicator. If not included or set to False, the replying indicator will not be shown in the WhatsApp UI. + """ + + show: bool + type_: ReplyingIndicatorType = Field(ReplyingIndicatorType.TEXT, serialization_alias='type') diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index 5cde1bc0..6afae52e 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -2,6 +2,7 @@ from os.path import abspath import responses +import re from pytest import raises from vonage_http_client import Auth, HttpClient, HttpClientOptions, HttpRequestError from vonage_messages import ( @@ -9,6 +10,7 @@ MessengerImage, MessengerOptions, MessengerResource, + ReplyingIndicatorText, SendMessageResponse, Sms, ) @@ -191,6 +193,33 @@ def test_mark_whatsapp_message_read_not_found(): assert e.value.response.json()['title'] == 'Not Found' +@responses.activate +def test_mark_whatsapp_message_read_with_replying_indicator(): + responses.add( + responses.PATCH, + 'https://api-eu.vonage.com/v1/messages/asdf', + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + messages.http_client.http_client_options.api_host = 'api-eu.vonage.com' + messages.mark_whatsapp_message_read( + message_uuid='asdf', + replying_indicator=ReplyingIndicatorText( + show=True, + ), + ) + + request_body = loads(responses.calls[0].request.body) + assert request_body == { + "status": "read", + "replying_indicator": { + "show": True, + "type": "text" + } + } + + @responses.activate def test_revoke_rcs_message(): responses.add( diff --git a/messages/tests/test_whatsapp_models.py b/messages/tests/test_whatsapp_models.py index 6967d9dc..3d96beca 100644 --- a/messages/tests/test_whatsapp_models.py +++ b/messages/tests/test_whatsapp_models.py @@ -1,6 +1,7 @@ from copy import deepcopy from vonage_messages.models import ( + ReplyingIndicatorText, WhatsappAudio, WhatsappAudioResource, WhatsappContext, @@ -382,3 +383,14 @@ def test_whatsapp_custom_all_fields(): } assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_create_replying_indicator(): + whatsapp_model = ReplyingIndicatorText( + show=True, + ) + whatsapp_dict = { + 'show': True, + 'type': 'text', + } + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict From afc78970f11758ee928b446052e055492b7ce5c2 Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 27 Mar 2026 12:57:01 +0000 Subject: [PATCH 381/401] DEVX-10770: linting --- messages/src/vonage_messages/messages.py | 8 ++++++-- messages/src/vonage_messages/models/whatsapp.py | 4 +++- messages/tests/test_messages.py | 6 +----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 93da6919..e284fc00 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -66,7 +66,9 @@ def send( return SendMessageResponse(**response) @validate_call - def mark_whatsapp_message_read(self, message_uuid: str, replying_indicator: ReplyingIndicatorText = None) -> None: + def mark_whatsapp_message_read( + self, message_uuid: str, replying_indicator: ReplyingIndicatorText = None + ) -> None: """Mark a WhatsApp message as read. Note: to use this method, update the `api_host` attribute of the @@ -82,7 +84,9 @@ def mark_whatsapp_message_read(self, message_uuid: str, replying_indicator: Repl """ body = {'status': 'read'} if replying_indicator is not None: - body['replying_indicator'] = replying_indicator.model_dump(by_alias=True, exclude_none=True) + body['replying_indicator'] = replying_indicator.model_dump( + by_alias=True, exclude_none=True + ) self._http_client.patch( self._http_client.api_host, diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index a7595bd7..a6b44980 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -373,4 +373,6 @@ class ReplyingIndicatorText(BaseModel): """ show: bool - type_: ReplyingIndicatorType = Field(ReplyingIndicatorType.TEXT, serialization_alias='type') + type_: ReplyingIndicatorType = Field( + ReplyingIndicatorType.TEXT, serialization_alias='type' + ) diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index 6afae52e..b367b3e4 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -2,7 +2,6 @@ from os.path import abspath import responses -import re from pytest import raises from vonage_http_client import Auth, HttpClient, HttpClientOptions, HttpRequestError from vonage_messages import ( @@ -213,10 +212,7 @@ def test_mark_whatsapp_message_read_with_replying_indicator(): request_body = loads(responses.calls[0].request.body) assert request_body == { "status": "read", - "replying_indicator": { - "show": True, - "type": "text" - } + "replying_indicator": {"show": True, "type": "text"}, } From f86e7fe4112dfc8d5a7e21661e1ced9ff0009a4e Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 30 Mar 2026 15:15:01 +0100 Subject: [PATCH 382/401] DEVX-10006: fixing RCS card and carousel implementation --- .../src/vonage_messages/models/__init__.py | 8 +- messages/src/vonage_messages/models/rcs.py | 47 +- messages/tests/test_rcs_models.py | 1023 ++++++----------- 3 files changed, 371 insertions(+), 707 deletions(-) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index cf35548e..212d2113 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -36,10 +36,10 @@ MmsVideo, ) from .rcs import ( - RcsCardBase, - RcsCardItem, + RcsCard, RcsCardMessage, RcsCarousel, + RcsCarouselMessage, RcsCustom, RcsFile, RcsImage, @@ -120,10 +120,10 @@ 'MmsText', 'MmsVcard', 'MmsVideo', - 'RcsCardBase', - 'RcsCardItem', + 'RcsCard', 'RcsCardMessage', 'RcsCarousel', + 'RcsCarouselMessage', 'RcsCustom', 'RcsFile', 'RcsImage', diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 96aa2e92..0b906523 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -300,7 +300,7 @@ class RcsFile(BaseRcs): message_type: MessageType = MessageType.FILE -class RcsCardBase(BaseModel): +class RcsCard(BaseModel): """Base model for the content of an RCS card. Args: @@ -336,37 +336,13 @@ class RcsCardBase(BaseModel): ] = Field(None, min_length=1, max_length=4) -class RcsCardItem(RcsCardBase): - """Model for the content of an RCS card. - - Args: - title (str): The title of the card. - text (str): The text of the card. - media_url (str): The media URL for the card. Can be an image or a video. - media_height (str): The height of the media on the card (SHORT, MEDIUM, TALL). - media_description (str, Optional): A description of the media for accessibility purposes. - thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. - media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. - suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. - """ - - media_height: RcsMediaHeight - - -class RcsCardMessage(RcsCardBase, BaseRcs): +class RcsCardMessage(BaseRcs): """Model for an RCS card message. Args: to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. - title (str): The title of the card. - text (str): The text of the card. - media_url (str): The media URL for the card. Can be an image or a video. - media_height (str, Optional): The height of the media on the card (SHORT, MEDIUM, TALL). - media_description (str, Optional): A description of the media for accessibility purposes. - thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. - media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. - suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. + card (RcsCard): The content of the card. ttl (int, Optional): The duration in seconds for which the message is valid. trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. client_ref (str, Optional): An optional client reference. @@ -375,17 +351,28 @@ class RcsCardMessage(RcsCardBase, BaseRcs): rcs: (RcsOptionsCard, Optional): An optional RcsOptionsCard object to include in the message. """ + card: RcsCard rcs: Optional[RcsOptionsCard] = None message_type: MessageType = MessageType.CARD -class RcsCarousel(BaseRcs): +class RcsCarousel(BaseModel): + """Model for the content of an RCS carousel. + + Args: + cards (List[RcsCard]): A list of card items to include in the carousel. Can include up to 10 cards. + """ + + cards: List[RcsCard] = Field(..., min_length=2, max_length=10) + + +class RcsCarouselMessage(BaseRcs): """Model for an RCS carousel message. Args: to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. - cards (List[RcsCardItem]): A list of card items to include in the carousel. Can include up to 10 cards. + carousel (RcsCarousel): The content of the carousel. suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. ttl (int, Optional): The duration in seconds for which the message is valid. trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. @@ -395,7 +382,7 @@ class RcsCarousel(BaseRcs): rcs: (RcsOptionsCarousel): An RcsOptionsCarousel object to include in the message. """ - cards: List[RcsCardItem] = Field(..., min_length=2, max_length=10) + carousel: RcsCarousel suggestions: Optional[ List[ Union[ diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index f5cf1b0f..b999fad1 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1,10 +1,10 @@ import pytest from pydantic import ValidationError from vonage_messages.models import ( - RcsCardBase, - RcsCardItem, + RcsCard, RcsCardMessage, RcsCarousel, + RcsCarouselMessage, RcsCustom, RcsFile, RcsImage, @@ -357,22 +357,22 @@ def test_create_rcs_file(): assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict -def test_create_rcs_card_base(): - card_base = RcsCardBase( +def test_create_rcs_card(): + card = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', ) - card_base_dict = { + card_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', } - assert card_base.model_dump(by_alias=True, exclude_none=True) == card_base_dict + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_card_base_with_optional_params(): - card_base = RcsCardBase( +def test_create_rcs_card_with_optional_params(): + card = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -381,7 +381,7 @@ def test_create_rcs_card_base_with_optional_params(): thumbnail_url='https://example.com/thumbnail.jpg', media_force_refresh=True, ) - card_base_dict = { + card_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -390,11 +390,11 @@ def test_create_rcs_card_base_with_optional_params(): 'thumbnail_url': 'https://example.com/thumbnail.jpg', 'media_force_refresh': True, } - assert card_base.model_dump(by_alias=True, exclude_none=True) == card_base_dict + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_card_base_with_suggestions(): - card_base = RcsCardBase( +def test_create_rcs_card_with_suggestions(): + card = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -410,7 +410,7 @@ def test_create_rcs_card_base_with_suggestions(): ), ], ) - card_base_dict = { + card_dict = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -428,11 +428,11 @@ def test_create_rcs_card_base_with_suggestions(): }, ], } - assert card_base.model_dump(by_alias=True, exclude_none=True) == card_base_dict + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_card_base_with_all_suggestion_types(): - card_base_1 = RcsCardBase( +def test_create_rcs_card_with_all_suggestion_types(): + card_1 = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -460,7 +460,7 @@ def test_create_rcs_card_base_with_all_suggestion_types(): ), ], ) - card_base_2 = RcsCardBase( + card_2 = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -489,7 +489,7 @@ def test_create_rcs_card_base_with_all_suggestion_types(): ), ], ) - card_base_dict_1 = { + card_dict_1 = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -521,7 +521,7 @@ def test_create_rcs_card_base_with_all_suggestion_types(): }, ], } - card_base_dict_2 = { + card_dict_2 = { 'title': 'Card title', 'text': 'Card description', 'media_url': 'https://example.com/image.jpg', @@ -553,40 +553,40 @@ def test_create_rcs_card_base_with_all_suggestion_types(): }, ], } - assert card_base_1.model_dump(by_alias=True, exclude_none=True) == card_base_dict_1 - assert card_base_2.model_dump(by_alias=True, exclude_none=True) == card_base_dict_2 + assert card_1.model_dump(by_alias=True, exclude_none=True) == card_dict_1 + assert card_2.model_dump(by_alias=True, exclude_none=True) == card_dict_2 -def test_create_rcs_card_base_without_title(): +def test_create_rcs_card_without_title(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( text='Card description', media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) -def test_create_rcs_card_base_without_text(): +def test_create_rcs_card_without_text(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='Card title', media_url='https://example.com/image.jpg', ) assert "Field required" in str(err.value) -def test_create_rcs_card_base_without_media_url(): +def test_create_rcs_card_without_media_url(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='Card title', text='Card description', ) assert "Field required" in str(err.value) -def test_create_rcs_card_base_with_title_too_short(): +def test_create_rcs_card_with_title_too_short(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='', text='Card description', media_url='https://example.com/image.jpg', @@ -594,9 +594,9 @@ def test_create_rcs_card_base_with_title_too_short(): assert "String should have at least 1 character" in str(err.value) -def test_create_rcs_card_base_with_title_too_long(): +def test_create_rcs_card_with_title_too_long(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='A' * 200 + 'B', text='Card description', media_url='https://example.com/image.jpg', @@ -604,11 +604,9 @@ def test_create_rcs_card_base_with_title_too_long(): assert "String should have at most 200 characters" in str(err.value) -def test_create_rcs_card_base_with_text_too_short(): +def test_create_rcs_card_with_text_too_short(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( - to='1234567890', - from_='asdf1234', + card = RcsCard( title='Card title', text='', media_url='https://example.com/image.jpg', @@ -616,11 +614,9 @@ def test_create_rcs_card_base_with_text_too_short(): assert "String should have at least 1 character" in str(err.value) -def test_create_rcs_card_base_with_text_too_long(): +def test_create_rcs_card_with_text_too_long(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( - to='1234567890', - from_='asdf1234', + card = RcsCard( title='Card title', text='A' * 2000 + 'B', media_url='https://example.com/image.jpg', @@ -628,9 +624,9 @@ def test_create_rcs_card_base_with_text_too_long(): assert "String should have at most 2000 characters" in str(err.value) -def test_create_rcs_card_base_with_insuffient_suggestions(): +def test_create_rcs_card_with_insuffient_suggestions(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -639,9 +635,9 @@ def test_create_rcs_card_base_with_insuffient_suggestions(): assert "List should have at least 1 item" in str(err.value) -def test_create_rcs_card_base_with_too_many_suggestions(): +def test_create_rcs_card_with_too_many_suggestions(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -656,9 +652,9 @@ def test_create_rcs_card_base_with_too_many_suggestions(): assert "List should have at most 4 items" in str(err.value) -def test_create_rcs_card_base_with_inavalid_suggestion_types(): +def test_create_rcs_card_with_inavalid_suggestion_types(): with pytest.raises(ValidationError) as err: - card_base = RcsCardBase( + card = RcsCard( title='Card title', text='Card description', media_url='https://example.com/image.jpg', @@ -677,16 +673,20 @@ def test_create_rcs_card_message(): card = RcsCardMessage( to='1234567890', from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), ) card_dict = { 'to': '1234567890', 'from': 'asdf1234', - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', + 'card': { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + }, 'channel': 'rcs', 'message_type': 'card', } @@ -697,13 +697,11 @@ def test_create_rcs_card_message_with_optional_params(): card = RcsCardMessage( to='1234567890', from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_description='Image description', - media_height='MEDIUM', - thumbnail_url='https://example.com/thumbnail.jpg', - media_force_refresh=True, + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), rcs=RcsOptionsCard( card_orientation='VERTICAL', image_alignment='LEFT', @@ -712,13 +710,11 @@ def test_create_rcs_card_message_with_optional_params(): card_dict = { 'to': '1234567890', 'from': 'asdf1234', - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_description': 'Image description', - 'media_height': 'MEDIUM', - 'thumbnail_url': 'https://example.com/thumbnail.jpg', - 'media_force_refresh': True, + 'card': { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + }, 'rcs': { 'card_orientation': 'VERTICAL', 'image_alignment': 'LEFT', @@ -729,13 +725,200 @@ def test_create_rcs_card_message_with_optional_params(): assert card.model_dump(by_alias=True, exclude_none=True) == card_dict -def test_create_rcs_card_message_with_suggestions(): - card = RcsCardMessage( +def test_create_rcs_card_message_without_card(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel(): + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ) + carousel_dict = { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_with_insufficient_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ], + ) + assert "List should have at least 2 items" in str(err.value) + + +def test_create_rcs_carousel_with_too_many_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] * 11, + ) + assert "List should have at most 10 items" in str(err.value) + + +def test_create_rcs_carousel_with_invalid_card_type(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ), + RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + ), + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_carousel_message(): + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_optional_params(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + ] + * 2, + ), + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + ] + * 2, + }, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_suggestions(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), suggestions=[ RcsSuggestionReply( text='Reply', @@ -747,13 +930,24 @@ def test_create_rcs_card_message_with_suggestions(): phone_number='447900000000', ), ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), ) - card_dict = { + carousel_dict = { 'to': '1234567890', 'from': 'asdf1234', - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, 'suggestions': [ { 'type': 'reply', @@ -767,501 +961,30 @@ def test_create_rcs_card_message_with_suggestions(): 'phone_number': '447900000000', }, ], + 'rcs': { + 'card_width': 'MEDIUM', + }, 'channel': 'rcs', - 'message_type': 'card', + 'message_type': 'carousel', } - assert card.model_dump(by_alias=True, exclude_none=True) == card_dict - - -def test_create_rcs_card_message_without_title(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_message_without_text(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - media_url='https://example.com/image.jpg', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_message_without_media_url(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='Card description', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_message_with_title_too_short(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='', - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "String should have at least 1 character" in str(err.value) + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict -def test_create_rcs_card_message_with_title_too_long(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='A' * 200 + 'B', - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "String should have at most 200 characters" in str(err.value) - - -def test_create_rcs_card_message_with_text_too_short(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='', - media_url='https://example.com/image.jpg', - ) - assert "String should have at least 1 character" in str(err.value) - - -def test_create_rcs_card_message_with_text_too_long(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='A' * 2000 + 'B', - media_url='https://example.com/image.jpg', - ) - assert "String should have at most 2000 characters" in str(err.value) - - -def test_create_rcs_card_message_with_insuffient_suggestions(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[], - ) - assert "List should have at least 1 item" in str(err.value) - - -def test_create_rcs_card_message_with_too_many_suggestions(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[ - RcsSuggestionReply( - text='Reply', - postback_data='postback-data', - ), - ] - * 5, - ) - assert "List should have at most 4 items" in str(err.value) - - -def test_create_rcs_card_message_with_inavalid_suggestion_types(): - with pytest.raises(ValidationError) as err: - card = RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[ - RcsSuggestionReply( - text='Reply', - postback_data='postback-data', - ), - "Invalid suggestion type", - ], - ) - assert "Input should be a valid dictionary or instance" in str(err.value) - - -def test_create_rcs_card_item(): - card_content = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - card_item_dict = { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_height': 'MEDIUM', - } - assert card_content.model_dump(by_alias=True, exclude_none=True) == card_item_dict - - -def test_create_rcs_card_item_with_optional_params(): - card_content = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_description='Image description', - media_height='MEDIUM', - thumbnail_url='https://example.com/thumbnail.jpg', - media_force_refresh=True, - ) - card_item_dict = { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_description': 'Image description', - 'media_height': 'MEDIUM', - 'thumbnail_url': 'https://example.com/thumbnail.jpg', - 'media_force_refresh': True, - } - assert card_content.model_dump(by_alias=True, exclude_none=True) == card_item_dict - - -def test_create_rcs_card_item_with_suggestions(): - card_content = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - suggestions=[ - RcsSuggestionReply( - text='Reply', - postback_data='postback-data', - ), - RcsSuggestionActionDial( - text='Call us', - postback_data='postback-data', - phone_number='447900000000', - ), - ], - ) - card_item_dict = { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_height': 'MEDIUM', - 'suggestions': [ - { - 'type': 'reply', - 'text': 'Reply', - 'postback_data': 'postback-data', - }, - { - 'type': 'dial', - 'text': 'Call us', - 'postback_data': 'postback-data', - 'phone_number': '447900000000', - }, - ], - } - assert card_content.model_dump(by_alias=True, exclude_none=True) == card_item_dict - - -def test_create_rcs_card_item_without_title(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_item_without_text(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - media_url='https://example.com/image.jpg', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_item_without_media_url(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='Card description', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_item_without_media_height(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_card_item_with_title_too_short(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='', - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "String should have at least 1 character" in str(err.value) - - -def test_create_rcs_card_item_with_title_too_long(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='A' * 200 + 'B', - text='Card description', - media_url='https://example.com/image.jpg', - ) - assert "String should have at most 200 characters" in str(err.value) - - -def test_create_rcs_card_item_with_text_too_short(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='', - media_url='https://example.com/image.jpg', - ) - assert "String should have at least 1 character" in str(err.value) - - -def test_create_rcs_card_item_with_text_too_long(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='A' * 2000 + 'B', - media_url='https://example.com/image.jpg', - ) - assert "String should have at most 2000 characters" in str(err.value) - - -def test_create_rcs_card_item_with_insuffient_suggestions(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[], - ) - assert "List should have at least 1 item" in str(err.value) - - -def test_create_rcs_card_item_with_too_many_suggestions(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[ - RcsSuggestionReply( - text='Reply', - postback_data='postback-data', - ), - ] - * 5, - ) - assert "List should have at most 4 items" in str(err.value) - - -def test_create_rcs_card_item_with_inavalid_suggestion_types(): - with pytest.raises(ValidationError) as err: - card = RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - suggestions=[ - RcsSuggestionReply( - text='Reply', - postback_data='postback-data', - ), - "Invalid suggestion type", - ], - ) - assert "Input should be a valid dictionary or instance" in str(err.value) - - -def test_create_rcs_carousel(): - carousel = RcsCarousel( +def test_create_rcs_carousel_message_with_all_suggestion_types(): + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, - rcs=RcsOptionsCarousel( - card_width='MEDIUM', - ), - ) - carousel_dict = { - 'to': '1234567890', - 'from': 'asdf1234', - 'cards': [ - { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_height': 'MEDIUM', - } - ] - * 2, - 'rcs': { - 'card_width': 'MEDIUM', - }, - 'channel': 'rcs', - 'message_type': 'carousel', - } - assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict - - -def test_create_rcs_carousel_with_optional_params(): - carousel = RcsCarousel( - to='1234567890', - from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_description='Image description', - media_height='MEDIUM', - thumbnail_url='https://example.com/thumbnail.jpg', - media_force_refresh=True, - ) - ] - * 2, - rcs=RcsOptionsCarousel( - card_width='MEDIUM', - ), - ) - carousel_dict = { - 'to': '1234567890', - 'from': 'asdf1234', - 'cards': [ - { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_description': 'Image description', - 'media_height': 'MEDIUM', - 'thumbnail_url': 'https://example.com/thumbnail.jpg', - 'media_force_refresh': True, - } - ] - * 2, - 'rcs': { - 'card_width': 'MEDIUM', - }, - 'channel': 'rcs', - 'message_type': 'carousel', - } - assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict - - -def test_create_rcs_carousel_with_suggestions(): - carousel = RcsCarousel( - to='1234567890', - from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, - suggestions=[ - RcsSuggestionReply( - text='Reply', - postback_data='postback-data', - ), - RcsSuggestionActionDial( - text='Call us', - postback_data='postback-data', - phone_number='447900000000', - ), - ], - rcs=RcsOptionsCarousel( - card_width='MEDIUM', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, ), - ) - carousel_dict = { - 'to': '1234567890', - 'from': 'asdf1234', - 'cards': [ - { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_height': 'MEDIUM', - } - ] - * 2, - 'suggestions': [ - { - 'type': 'reply', - 'text': 'Reply', - 'postback_data': 'postback-data', - }, - { - 'type': 'dial', - 'text': 'Call us', - 'postback_data': 'postback-data', - 'phone_number': '447900000000', - }, - ], - 'rcs': { - 'card_width': 'MEDIUM', - }, - 'channel': 'rcs', - 'message_type': 'carousel', - } - assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict - - -def test_create_rcs_carousel_with_all_suggestion_types(): - carousel = RcsCarousel( - to='1234567890', - from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, suggestions=[ RcsSuggestionReply( text='Reply', @@ -1314,15 +1037,17 @@ def test_create_rcs_carousel_with_all_suggestion_types(): carousel_dict = { 'to': '1234567890', 'from': 'asdf1234', - 'cards': [ - { - 'title': 'Card title', - 'text': 'Card description', - 'media_url': 'https://example.com/image.jpg', - 'media_height': 'MEDIUM', - } - ] - * 2, + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, 'suggestions': [ { 'type': 'reply', @@ -1384,106 +1109,54 @@ def test_create_rcs_carousel_with_all_suggestion_types(): assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict -def test_create_rcs_carousel_without_rcs_options(): - with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( - to='1234567890', - from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, - ) - assert "Field required" in str(err.value) - - -def test_create_rcs_carousel_with_insufficient_cards(): +def test_create_rcs_carousel_message_without_carousel(): with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ], rcs=RcsOptionsCarousel( card_width='MEDIUM', ), ) - assert "List should have at least 2 items" in str(err.value) + assert "Field required" in str(err.value) -def test_create_rcs_carousel_with_too_many_cards(): +def test_create_rcs_carousel_message_without_rcs_options(): with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 11, - rcs=RcsOptionsCarousel( - card_width='MEDIUM', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, ), ) - assert "List should have at most 10 items" in str(err.value) + assert "Field required" in str(err.value) -def test_create_rcs_carousel_with_invalid_card_type(): +def test_create_rcs_carousel_message_with_insuffient_suggestions(): with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ), - RcsCardMessage( - to='1234567890', - from_='asdf1234', - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - ), - ], - rcs=RcsOptionsCarousel( - card_width='MEDIUM', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, ), - ) - assert "Input should be a valid dictionary or instance" in str(err.value) - - -def test_create_rcs_carousel_with_insuffient_suggestions(): - with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( - to='1234567890', - from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, suggestions=[], rcs=RcsOptionsCarousel( card_width='MEDIUM', @@ -1492,20 +1165,22 @@ def test_create_rcs_carousel_with_insuffient_suggestions(): assert "List should have at least 1 item" in str(err.value) -def test_create_rcs_carousel_with_too_many_suggestions(): +def test_create_rcs_carousel_message_with_too_many_suggestions(): with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), suggestions=[ RcsSuggestionReply( text='Reply', @@ -1520,20 +1195,22 @@ def test_create_rcs_carousel_with_too_many_suggestions(): assert "List should have at most 11 items" in str(err.value) -def test_create_rcs_carousel_with_inavalid_suggestion_types(): +def test_create_rcs_carousel_message_with_inavalid_suggestion_types(): with pytest.raises(ValidationError) as err: - carousel = RcsCarousel( + carousel = RcsCarouselMessage( to='1234567890', from_='asdf1234', - cards=[ - RcsCardItem( - title='Card title', - text='Card description', - media_url='https://example.com/image.jpg', - media_height='MEDIUM', - ) - ] - * 2, + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), suggestions=[ RcsSuggestionReply( text='Reply', From 294fe5aade602641ce718253808c1af46e492fd3 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 30 Mar 2026 15:21:14 +0100 Subject: [PATCH 383/401] DEVX-10006: linting --- messages/tests/test_rcs_models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index b999fad1..a97f0df8 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -785,7 +785,8 @@ def test_create_rcs_carousel_with_too_many_cards(): media_url='https://example.com/image.jpg', media_height='MEDIUM', ) - ] * 11, + ] + * 11, ) assert "List should have at most 10 items" in str(err.value) @@ -804,7 +805,7 @@ def test_create_rcs_carousel_with_invalid_card_type(): to='1234567890', from_='asdf1234', card=RcsCard( - title='Card title', + title='Card title', text='Card description', media_url='https://example.com/image.jpg', ), From 9c2d663911802089df3a88fccd613fdfe8ba1768 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 30 Mar 2026 17:51:12 +0100 Subject: [PATCH 384/401] DEVX-10537: Updating Verify v1 implementation and tests for Basic Auth support --- testutils/__init__.py | 4 +-- testutils/mock_auth.py | 12 ++++++- .../src/vonage_verify_legacy/verify_legacy.py | 2 +- verify_legacy/tests/test_verify_legacy.py | 32 ++++++++++++++++++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/testutils/__init__.py b/testutils/__init__.py index 4cfb4d9d..3a5bcc66 100644 --- a/testutils/__init__.py +++ b/testutils/__init__.py @@ -1,4 +1,4 @@ -from .mock_auth import get_mock_api_key_auth, get_mock_jwt_auth +from .mock_auth import get_mock_api_key_auth, get_mock_jwt_auth, get_base64_encoded_api_key_and_secret from .testutils import build_response -__all__ = ['build_response', 'get_mock_api_key_auth', 'get_mock_jwt_auth'] +__all__ = ['build_response', 'get_mock_api_key_auth', 'get_mock_jwt_auth', 'get_base64_encoded_api_key_and_secret'] diff --git a/testutils/mock_auth.py b/testutils/mock_auth.py index ac724e81..259ecc8a 100644 --- a/testutils/mock_auth.py +++ b/testutils/mock_auth.py @@ -1,7 +1,11 @@ from os.path import dirname, join +from base64 import b64encode from vonage_http_client.auth import Auth +test_api_key = 'test_api_key' +test_api_secret = 'test_api_secret' + def read_file(path): """Read a file from the testutils/data directory.""" @@ -10,10 +14,16 @@ def read_file(path): return input_file.read() +def get_base64_encoded_api_key_and_secret(): + """Return a base64 encoded string of the API key and secret.""" + + return b64encode(f'{test_api_key}:{test_api_secret}'.encode('utf-8')).decode('ascii') + + def get_mock_api_key_auth(): """Return an Auth object with an API key and secret.""" - return Auth(api_key='test_api_key', api_secret='test_api_secret') + return Auth(api_key=test_api_key, api_secret=test_api_secret) def get_mock_jwt_auth(): diff --git a/verify_legacy/src/vonage_verify_legacy/verify_legacy.py b/verify_legacy/src/vonage_verify_legacy/verify_legacy.py index 4ee46860..8c8f2583 100644 --- a/verify_legacy/src/vonage_verify_legacy/verify_legacy.py +++ b/verify_legacy/src/vonage_verify_legacy/verify_legacy.py @@ -31,7 +31,7 @@ class VerifyLegacy: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client self._sent_data_type = 'form' - self._auth_type = 'body' + self._auth_type = 'basic' @property def http_client(self) -> HttpClient: diff --git a/verify_legacy/tests/test_verify_legacy.py b/verify_legacy/tests/test_verify_legacy.py index 21482b2f..db9635d7 100644 --- a/verify_legacy/tests/test_verify_legacy.py +++ b/verify_legacy/tests/test_verify_legacy.py @@ -10,7 +10,7 @@ from vonage_verify_legacy.responses import NetworkUnblockStatus, VerifyControlStatus from vonage_verify_legacy.verify_legacy import VerifyLegacy -from testutils import build_response, get_mock_api_key_auth +from testutils import build_response, get_mock_api_key_auth, get_base64_encoded_api_key_and_secret path = abspath(__file__) @@ -32,6 +32,12 @@ def test_http_client_property(): assert isinstance(verify.http_client, HttpClient) +@responses.activate +def test_default_auth_type(): + verify = VerifyLegacy(HttpClient(get_mock_api_key_auth())) + assert verify._auth_type == 'basic' + + def test_create_verify_request_model(): params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', 'lg': LanguageCode.en_us, **data} request = VerifyRequest(**params) @@ -67,6 +73,9 @@ def test_make_verify_request(): assert response.request_id == 'abcdef0123456789abcdef0123456789' assert response.status == '0' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_make_psd2_request(): @@ -80,6 +89,9 @@ def test_make_psd2_request(): assert response.request_id == 'abcdef0123456789abcdef0123456789' assert response.status == '0' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_verify_request_error(): @@ -129,6 +141,9 @@ def test_check_code(): assert response.currency == 'EUR' assert response.estimated_price_messages_sent == '0.04675' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_check_code_error(): @@ -170,6 +185,9 @@ def test_search(): assert response.events[0].type == 'sms' assert response.events[0].id == '23f3a13d-6d03-4262-8f4d-67f12a56e1c8' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_search_list_of_ids(): @@ -187,6 +205,9 @@ def test_search_list_of_ids(): assert response1.status == 'SUCCESS' assert response1.checks[0].status == 'VALID' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_search_error(): @@ -217,6 +238,9 @@ def test_cancel_verification(): assert response.status == '0' assert response.command == 'cancel' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_cancel_verification_error(): @@ -249,6 +273,9 @@ def test_trigger_next_event(): assert response.status == '0' assert response.command == 'trigger_next_event' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_trigger_next_event_error(): @@ -283,6 +310,9 @@ def test_request_network_unblock(): assert response.network == '23410' assert response.unblocked_until == '2024-04-22T08:34:58Z' + request_headers = responses.calls[0].request.headers + assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + @responses.activate def test_request_network_unblock_error(): From 620421e8eb9c51d36e208ac9ce615a498394b96c Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 30 Mar 2026 17:52:21 +0100 Subject: [PATCH 385/401] DEVX-10537: linting --- testutils/__init__.py | 13 ++++++- testutils/mock_auth.py | 2 +- verify_legacy/tests/test_verify_legacy.py | 46 ++++++++++++++++++----- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/testutils/__init__.py b/testutils/__init__.py index 3a5bcc66..ad4d21dd 100644 --- a/testutils/__init__.py +++ b/testutils/__init__.py @@ -1,4 +1,13 @@ -from .mock_auth import get_mock_api_key_auth, get_mock_jwt_auth, get_base64_encoded_api_key_and_secret +from .mock_auth import ( + get_base64_encoded_api_key_and_secret, + get_mock_api_key_auth, + get_mock_jwt_auth, +) from .testutils import build_response -__all__ = ['build_response', 'get_mock_api_key_auth', 'get_mock_jwt_auth', 'get_base64_encoded_api_key_and_secret'] +__all__ = [ + 'build_response', + 'get_mock_api_key_auth', + 'get_mock_jwt_auth', + 'get_base64_encoded_api_key_and_secret', +] diff --git a/testutils/mock_auth.py b/testutils/mock_auth.py index 259ecc8a..4975ed4f 100644 --- a/testutils/mock_auth.py +++ b/testutils/mock_auth.py @@ -1,5 +1,5 @@ -from os.path import dirname, join from base64 import b64encode +from os.path import dirname, join from vonage_http_client.auth import Auth diff --git a/verify_legacy/tests/test_verify_legacy.py b/verify_legacy/tests/test_verify_legacy.py index db9635d7..297f0d65 100644 --- a/verify_legacy/tests/test_verify_legacy.py +++ b/verify_legacy/tests/test_verify_legacy.py @@ -10,7 +10,11 @@ from vonage_verify_legacy.responses import NetworkUnblockStatus, VerifyControlStatus from vonage_verify_legacy.verify_legacy import VerifyLegacy -from testutils import build_response, get_mock_api_key_auth, get_base64_encoded_api_key_and_secret +from testutils import ( + build_response, + get_base64_encoded_api_key_and_secret, + get_mock_api_key_auth, +) path = abspath(__file__) @@ -74,7 +78,10 @@ def test_make_verify_request(): assert response.status == '0' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -90,7 +97,10 @@ def test_make_psd2_request(): assert response.status == '0' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -142,7 +152,10 @@ def test_check_code(): assert response.estimated_price_messages_sent == '0.04675' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -186,7 +199,10 @@ def test_search(): assert response.events[0].id == '23f3a13d-6d03-4262-8f4d-67f12a56e1c8' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -206,7 +222,10 @@ def test_search_list_of_ids(): assert response1.checks[0].status == 'VALID' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -239,7 +258,10 @@ def test_cancel_verification(): assert response.command == 'cancel' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -274,7 +296,10 @@ def test_trigger_next_event(): assert response.command == 'trigger_next_event' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate @@ -311,7 +336,10 @@ def test_request_network_unblock(): assert response.unblocked_until == '2024-04-22T08:34:58Z' request_headers = responses.calls[0].request.headers - assert request_headers["Authorization"] == "Basic " + get_base64_encoded_api_key_and_secret() + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) @responses.activate From 0ba7029d925e2f6bea72939303b03e42150be13c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:31:31 +0000 Subject: [PATCH 386/401] feat: add missing validation tests for SMS, MMS, RCS and WhatsApp models Agent-Logs-Url: https://github.com/Vonage/vonage-python-sdk/sessions/50d9d0a2-7556-4233-8b65-abc9902d5b7f Co-authored-by: dragonmantank <108948+dragonmantank@users.noreply.github.com> --- messages/tests/test_mms_models.py | 22 +++++++++++ messages/tests/test_rcs_models.py | 52 +++++++++++++++++++++++++ messages/tests/test_sms_models.py | 23 +++++++++++ messages/tests/test_whatsapp_models.py | 54 ++++++++++++++++++++++++++ 4 files changed, 151 insertions(+) diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index 09b0b11e..72ca6059 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -520,3 +520,25 @@ def test_create_mms_content_with_invalid_content_item(): ], ) assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_mms_with_ttl_too_low(): + with pytest.raises(ValidationError) as err: + MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource(url='https://example.com/image.jpg'), + ttl=299, + ) + assert 'greater than or equal to 300' in str(err.value) + + +def test_create_mms_with_ttl_too_high(): + with pytest.raises(ValidationError) as err: + MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource(url='https://example.com/image.jpg'), + ttl=259201, + ) + assert 'less than or equal to 259200' in str(err.value) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index a97f0df8..30dc0a03 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -1804,3 +1804,55 @@ def test_create_rcs_options_carousel_card_width_with_invalid_option(): card_width='INVALID_WIDTH', ) assert "Input should be 'SMALL' or 'MEDIUM'" in str(err.value) + + +def test_create_rcs_text_too_short(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='', + ) + assert 'String should have at least 1 character' in str(err.value) + + +def test_create_rcs_text_too_long(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='a' * 3073, + ) + assert 'String should have at most 3072 characters' in str(err.value) + + +def test_create_rcs_with_ttl_too_low(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ttl=19, + ) + assert 'greater than or equal to 20' in str(err.value) + + +def test_create_rcs_with_ttl_too_high(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ttl=259201, + ) + assert 'less than or equal to 259200' in str(err.value) + + +def test_create_rcs_with_invalid_from_field(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='invalid from!', + text='Hello, World!', + ) + assert 'String should match pattern' in str(err.value) diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index 800bef52..4068f1ec 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -1,3 +1,5 @@ +import pytest +from pydantic import ValidationError from vonage_messages.models import Sms, SmsOptions from vonage_messages.models.enums import EncodingType, WebhookVersion @@ -56,3 +58,24 @@ def test_create_sms_all_fields(): } assert sms_model.model_dump(by_alias=True) == sms_dict + + +def test_create_sms_text_too_long(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='a' * 1001, + ) + assert 'String should have at most 1000 characters' in str(err.value) + + +def test_create_sms_with_invalid_encoding_type(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + sms=SmsOptions(encoding_type='invalid'), + ) + assert 'Input should be' in str(err.value) diff --git a/messages/tests/test_whatsapp_models.py b/messages/tests/test_whatsapp_models.py index 3d96beca..c0ae7e58 100644 --- a/messages/tests/test_whatsapp_models.py +++ b/messages/tests/test_whatsapp_models.py @@ -1,5 +1,7 @@ from copy import deepcopy +import pytest +from pydantic import ValidationError from vonage_messages.models import ( ReplyingIndicatorText, WhatsappAudio, @@ -394,3 +396,55 @@ def test_create_replying_indicator(): 'type': 'text', } assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_text_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappText( + to='1234567890', + from_='1234567890', + text='a' * 4097, + ) + assert 'String should have at most 4096 characters' in str(err.value) + + +def test_whatsapp_audio_url_too_short(): + with pytest.raises(ValidationError) as err: + WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='short'), + ) + assert 'String should have at least 10 characters' in str(err.value) + + +def test_whatsapp_audio_url_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='https://' + 'a' * 2000), + ) + assert 'String should have at most 2000 characters' in str(err.value) + + +def test_whatsapp_image_caption_too_short(): + with pytest.raises(ValidationError) as err: + WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource(url='https://example.com/image.jpg', caption=''), + ) + assert 'String should have at least 1 character' in str(err.value) + + +def test_whatsapp_image_caption_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource( + url='https://example.com/image.jpg', caption='a' * 3001 + ), + ) + assert 'String should have at most 3000 characters' in str(err.value) From e0c6bea969a45da87d0db72d7d0358f94968a0bb Mon Sep 17 00:00:00 2001 From: superchilled Date: Tue, 21 Apr 2026 17:05:13 +0100 Subject: [PATCH 387/401] build: Prepare release 4.8.0 --- .github/workflows/release.yml | 5 ++- .scripts/update_vonage_versions.py | 1 + account/CHANGES.md | 3 ++ account/src/vonage_account/_version.py | 2 +- application/CHANGES.md | 3 ++ .../src/vonage_application/_version.py | 2 +- http_client/CHANGES.md | 3 ++ .../src/vonage_http_client/_version.py | 2 +- messages/CHANGES.md | 7 ++++ messages/src/vonage_messages/_version.py | 2 +- sms/CHANGES.md | 3 ++ sms/src/vonage_sms/_version.py | 2 +- sms/src/vonage_sms/requests.py | 4 +- sms/tests/test_sms.py | 2 +- users/CHANGES.md | 3 ++ users/src/vonage_users/_version.py | 2 +- verify/CHANGES.md | 3 ++ verify/src/vonage_verify/_version.py | 2 +- verify_legacy/CHANGES.md | 3 ++ .../src/vonage_verify_legacy/_version.py | 2 +- voice/CHANGES.md | 8 ++++ voice/src/vonage_voice/_version.py | 2 +- vonage/CHANGES.md | 38 +++++++++++++++++++ vonage/pyproject.toml | 23 +++++------ vonage/src/vonage/_version.py | 2 +- 25 files changed, 104 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47b7e90c..e2e4407c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,11 +37,14 @@ jobs: previous_tag=$(git describe --tags --abbrev=0 HEAD^) echo "Comparing changes since $previous_tag" - changed_targets=$(pants --changed-since=$previous_tag list | awk -F/ '{print $1}' | sort -u) + changed_targets=$(pants --changed-since=$previous_tag list | awk -F/ '{print $1}' | awk -F: '{print $1}' | sort -u) if [ -n "$changed_targets" ]; then echo "Publishing changed targets: $changed_targets" for target in $changed_targets; do + if [ $target == testutils ] + then continue + fi pants publish $target:: done echo "published=true" >> $GITHUB_ENV diff --git a/.scripts/update_vonage_versions.py b/.scripts/update_vonage_versions.py index 992a808f..99db4267 100644 --- a/.scripts/update_vonage_versions.py +++ b/.scripts/update_vonage_versions.py @@ -8,6 +8,7 @@ "vonage-account", "vonage-application", "vonage-http-client", + "vonage-identity-insights", "vonage-messages", "vonage-network-auth", "vonage-network-sim-swap", diff --git a/account/CHANGES.md b/account/CHANGES.md index e63f3309..babd635a 100644 --- a/account/CHANGES.md +++ b/account/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Fixing imports for test file + # 1.1.1 - Update dependency versions diff --git a/account/src/vonage_account/_version.py b/account/src/vonage_account/_version.py index b3ddbc41..7b344eca 100644 --- a/account/src/vonage_account/_version.py +++ b/account/src/vonage_account/_version.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.1.2' diff --git a/application/CHANGES.md b/application/CHANGES.md index 528bd200..2ea9d5d1 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,3 +1,6 @@ +# 2.0.2 +- Fixing imports for test file + # 2.0.1 - Updated dependency versions diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py index 3f390799..668c3446 100644 --- a/application/src/vonage_application/_version.py +++ b/application/src/vonage_application/_version.py @@ -1 +1 @@ -__version__ = '2.0.1' +__version__ = '2.0.2' diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 4f995776..f87d1000 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.5.2 +- Fixing imports for test file + # 1.5.1 - Remove unnecessary `Content-Type` check on error diff --git a/http_client/src/vonage_http_client/_version.py b/http_client/src/vonage_http_client/_version.py index 51ed7c48..c3b38415 100644 --- a/http_client/src/vonage_http_client/_version.py +++ b/http_client/src/vonage_http_client/_version.py @@ -1 +1 @@ -__version__ = '1.5.1' +__version__ = '1.5.2' diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 45cf1396..3d4b7e58 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,10 @@ +# 1.7.0 +- Implement the `pool_id` parameter for SMS +- Implement the `trusted_recipient` parameter for SMS, MMS, and RCS +- Add new MMS types +- Add RCS native types +- Add Replying Indicator support to WhatsApp + # 1.5.0 - Add an optional "failover" property to `vonage_messages.Messages.send` diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 4a9b9788..0e1a38d3 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.6.2' +__version__ = '1.7.0' diff --git a/sms/CHANGES.md b/sms/CHANGES.md index 16cb22df..8d3c4e6f 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.0 +- Implement the `trusted_number` parameter + # 1.1.6 - Make returned response fields optional diff --git a/sms/src/vonage_sms/_version.py b/sms/src/vonage_sms/_version.py index 6ebd335c..58d478ab 100644 --- a/sms/src/vonage_sms/_version.py +++ b/sms/src/vonage_sms/_version.py @@ -1 +1 @@ -__version__ = '1.1.6' +__version__ = '1.2.0' diff --git a/sms/src/vonage_sms/requests.py b/sms/src/vonage_sms/requests.py index a1b95a57..1540fc7f 100644 --- a/sms/src/vonage_sms/requests.py +++ b/sms/src/vonage_sms/requests.py @@ -41,7 +41,7 @@ class SmsMessage(BaseModel): requirements when sending an SMS to specific countries. content_id (str, Optional): A string parameter that satisfies regulatory requirements when sending an SMS to specific countries. - trusted_sender (bool, Optional): overrides, on a per-message basis, any + trusted_number (bool, Optional): overrides, on a per-message basis, any protections set up via Fraud Defender """ @@ -69,7 +69,7 @@ class SmsMessage(BaseModel): 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') - trusted_sender: Optional[bool] = Field(None, serialization_alias="trusted_sender") + trusted_number: Optional[bool] = Field(None, serialization_alias="trusted-number") @field_validator('body', 'udh') @classmethod diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index 95b142c0..4ac3612e 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -38,7 +38,7 @@ def test_create_valid_SmsMessage(): 'client_ref': 'ref123', 'type': 'binary', 'ttl': 3000000, - 'trusted_sender': True, + 'trusted_number': True, 'status_report_req': True, 'callback': 'https://example.com/callback', 'message_class': 0, diff --git a/users/CHANGES.md b/users/CHANGES.md index e8c16f56..001620a7 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.2 +- Fix unit test + # 1.2.1 - Updated dependency versions diff --git a/users/src/vonage_users/_version.py b/users/src/vonage_users/_version.py index 3f262a63..923b9879 100644 --- a/users/src/vonage_users/_version.py +++ b/users/src/vonage_users/_version.py @@ -1 +1 @@ -__version__ = '1.2.1' +__version__ = '1.2.2' diff --git a/verify/CHANGES.md b/verify/CHANGES.md index b31dcc40..7a84470d 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,3 +1,6 @@ +# 2.2.0 +- Add support for WhatsApp mode + # 2.1.0 - Add support for API key/secret header authentication - Updated dependency versions diff --git a/verify/src/vonage_verify/_version.py b/verify/src/vonage_verify/_version.py index a33997dd..04188a16 100644 --- a/verify/src/vonage_verify/_version.py +++ b/verify/src/vonage_verify/_version.py @@ -1 +1 @@ -__version__ = '2.1.0' +__version__ = '2.2.0' diff --git a/verify_legacy/CHANGES.md b/verify_legacy/CHANGES.md index df46d595..3f8935ba 100644 --- a/verify_legacy/CHANGES.md +++ b/verify_legacy/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.0 +- Add support for Basic Auth + # 1.0.1 - Updated dependency versions diff --git a/verify_legacy/src/vonage_verify_legacy/_version.py b/verify_legacy/src/vonage_verify_legacy/_version.py index cd7ca498..1a72d32e 100644 --- a/verify_legacy/src/vonage_verify_legacy/_version.py +++ b/verify_legacy/src/vonage_verify_legacy/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.1.0' diff --git a/voice/CHANGES.md b/voice/CHANGES.md index 95b0a479..aae0ebd1 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,11 @@ +# 1.5.0 +- Add answer wehhook +- Implement `Wait` NCCO action +- Implement `Transfer` NCCO action +- Add support for 24k audio in Websocket +- Add `shaken` property to `Phone` endpoint +- Add `authorization` to `WebSocket` endpoints + # 1.4.0 - Increase maximum value of call `length_timer` to 86400s - Add additional fields `eventUrl` and `eventMethod` to NCCO model diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 96e3ce8d..77f1c8e6 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.4.0' +__version__ = '1.5.0' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index d8579d7a..dd3b4fd0 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,41 @@ +# 4.8.0 + +## Account +- Fixing imports for test file + +## Application +- Fixing imports for test file + +## HTTP Client +- Fixing imports for test file + +## Messages +- Implement the `pool_id` parameter for SMS +- Implement the `trusted_recipient` parameter for SMS, MMS, and RCS +- Add new MMS types +- Add RCS native types +- Add Replying Indicator support to WhatsApp + +## SMS +- Implement the `trusted_number` parameter + +## Users +- Fix unit test + +## Verify +- Add support for WhatsApp mode + +## Verify Legacy +- Add support for Basic Auth + +## Voice +- Add answer wehhook +- Implement `Wait` NCCO action +- Implement `Transfer` NCCO action +- Add support for 24k audio in Websocket +- Add `shaken` property to `Phone` endpoint +- Add `authorization` to `WebSocket` endpoints + # 4.7.2 - vonage-numbers: Added `by_alias=True` to the numbers update model to correct issue with incorrect body payload diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 3ac25bb2..78ea7737 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -5,23 +5,24 @@ description = "Python Server SDK for using Vonage APIs" readme = "README.md" requires-python = ">=3.9" dependencies = [ - "vonage-account>=1.1.1", - "vonage-application>=2.0.1", - "vonage-http-client>=1.5.1", - "vonage-messages>=1.5.0", + "vonage-account>=1.1.2", + "vonage-application>=2.0.2", + "vonage-http-client>=1.5.2", + "vonage-identity-insights>=1.0.0", + "vonage-messages>=1.7.0", "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", "vonage-number-insight>=1.0.7", - "vonage-numbers>=1.0.4", - "vonage-sms>=1.1.6", + "vonage-numbers>=1.0.5", + "vonage-sms>=1.2.0", "vonage-subaccounts>=1.0.4", - "vonage-users>=1.2.1", + "vonage-users>=1.2.2", "vonage-utils>=1.1.4", - "vonage-verify>=2.1.0", - "vonage-verify-legacy>=1.0.1", - "vonage-video>=1.2.0", - "vonage-voice>=1.4.0", + "vonage-verify>=2.2.0", + "vonage-verify-legacy>=1.1.0", + "vonage-video>=1.5.1", + "vonage-voice>=1.5.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 64f6e280..0d53216d 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.7.2' +__version__ = '4.8.0' From 17cc699309a1672734cf047cd79caffb21ae1777 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 18 May 2026 12:50:08 +0100 Subject: [PATCH 388/401] DEVX-11270: adding Messages API SMS ttl tests --- messages/tests/test_sms_models.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index 4068f1ec..c010aed3 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -79,3 +79,25 @@ def test_create_sms_with_invalid_encoding_type(): sms=SmsOptions(encoding_type='invalid'), ) assert 'Input should be' in str(err.value) + + +def test_create_sms_ttl_too_short(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ttl=19, + ) + assert 'Number should be greater than or equal to 20' in str(err.value) + + +def test_create_sms_ttl_too_long(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ttl=604801, + ) + assert 'Number should be less than or equal to 604800' in str(err.value) From 55d6bd5db2d42684020124903c2f4549cbda8b2a Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 18 May 2026 12:58:30 +0100 Subject: [PATCH 389/401] DEVX-11270: updating ttl defintion in Messages Sms model --- messages/src/vonage_messages/models/sms.py | 2 +- messages/tests/test_sms_models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index a37139db..de8af589 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -52,7 +52,7 @@ class Sms(BaseMessage): from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') text: str = Field(..., max_length=1000) - ttl: Optional[int] = None + ttl: Optional[int] = Field(None, ge=20, le=604800) trusted_recipient: Optional[bool] = None sms: Optional[SmsOptions] = None channel: ChannelType = ChannelType.SMS diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index c010aed3..f5fd62fc 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -89,7 +89,7 @@ def test_create_sms_ttl_too_short(): text='Hello, World!', ttl=19, ) - assert 'Number should be greater than or equal to 20' in str(err.value) + assert 'Input should be greater than or equal to 20' in str(err.value) def test_create_sms_ttl_too_long(): @@ -100,4 +100,4 @@ def test_create_sms_ttl_too_long(): text='Hello, World!', ttl=604801, ) - assert 'Number should be less than or equal to 604800' in str(err.value) + assert 'Input should be less than or equal to 604800' in str(err.value) From 8764c9f8a1223cf0f8b283b7c616ac9317dac747 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 18 May 2026 16:49:59 +0100 Subject: [PATCH 390/401] DEVX-11282: adding model validators for rcs card orientation --- messages/src/vonage_messages/models/rcs.py | 21 ++++++++++++++++- messages/tests/test_rcs_models.py | 27 ++++++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 0b906523..9b20db36 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -1,6 +1,6 @@ from typing import List, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from vonage_utils.types import PhoneNumber from .base_message import BaseMessage @@ -176,6 +176,15 @@ class RcsOptionsCard(RcsOptions): card_orientation: Optional[RcsCardOrientation] = None image_alignment: Optional[RcsImageAlignment] = None + @model_validator(mode='after') + def horizontal_orientation_requires_image_alignment(self): + """Validate that if the card orientation is horizontal, the image alignment is also specified.""" + if self.card_orientation == RcsCardOrientation.HORIZONTAL and not self.image_alignment: + raise ValueError( + 'image_alignment must be specified when card_orientation is HORIZONTAL' + ) + return self + class RcsOptionsCarousel(RcsOptions): """Model for an RCS carousel message options. @@ -355,6 +364,16 @@ class RcsCardMessage(BaseRcs): rcs: Optional[RcsOptionsCard] = None message_type: MessageType = MessageType.CARD + @model_validator(mode='after') + def vertical_orientation_requires_media_height(self): + """Validate that if the card orientation is vertical, the media height is also specified.""" + if self.rcs and self.rcs.card_orientation == RcsCardOrientation.VERTICAL: + if not self.card.media_height: + raise ValueError( + 'media_height must be specified when card_orientation is VERTICAL' + ) + return self + class RcsCarousel(BaseModel): """Model for the content of an RCS carousel. diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 30dc0a03..3222ea28 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -703,7 +703,7 @@ def test_create_rcs_card_message_with_optional_params(): media_url='https://example.com/image.jpg', ), rcs=RcsOptionsCard( - card_orientation='VERTICAL', + card_orientation='HORIZONTAL', image_alignment='LEFT', ), ) @@ -716,7 +716,7 @@ def test_create_rcs_card_message_with_optional_params(): 'media_url': 'https://example.com/image.jpg', }, 'rcs': { - 'card_orientation': 'VERTICAL', + 'card_orientation': 'HORIZONTAL', 'image_alignment': 'LEFT', }, 'channel': 'rcs', @@ -734,6 +734,23 @@ def test_create_rcs_card_message_without_card(): assert "Field required" in str(err.value) +def test_create_rcs_card_message_card_orientation_vertical_without_media_height(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + rcs=RcsOptionsCard( + card_orientation='VERTICAL', + ), + ) + assert "media_height must be specified when card_orientation is VERTICAL" in str(err.value) + + def test_create_rcs_carousel(): carousel = RcsCarousel( cards=[ @@ -1756,6 +1773,12 @@ def test_create_rcs_options_card_image_alignment_with_invalid_option(): assert "Input should be 'LEFT' or 'RIGHT'" in str(err.value) +def test_create_rcs_options_card_card_orientation_horizontal_without_image_alignment(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard(card_orientation='HORIZONTAL') + assert "image_alignment must be specified when card_orientation is HORIZONTAL" in str(err.value) + + def test_create_rcs_options_carousel(): options = RcsOptionsCarousel( card_width='MEDIUM', From 82af126738d3ec0bf972ec2f6d9b3cc211b8ee78 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 18 May 2026 16:52:14 +0100 Subject: [PATCH 391/401] Linting --- messages/src/vonage_messages/models/rcs.py | 11 ++++++++--- messages/tests/test_rcs_models.py | 8 ++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index 9b20db36..85f38276 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -178,8 +178,12 @@ class RcsOptionsCard(RcsOptions): @model_validator(mode='after') def horizontal_orientation_requires_image_alignment(self): - """Validate that if the card orientation is horizontal, the image alignment is also specified.""" - if self.card_orientation == RcsCardOrientation.HORIZONTAL and not self.image_alignment: + """Validate that if the card orientation is horizontal, the image alignment is + also specified.""" + if ( + self.card_orientation == RcsCardOrientation.HORIZONTAL + and not self.image_alignment + ): raise ValueError( 'image_alignment must be specified when card_orientation is HORIZONTAL' ) @@ -366,7 +370,8 @@ class RcsCardMessage(BaseRcs): @model_validator(mode='after') def vertical_orientation_requires_media_height(self): - """Validate that if the card orientation is vertical, the media height is also specified.""" + """Validate that if the card orientation is vertical, the media height is also + specified.""" if self.rcs and self.rcs.card_orientation == RcsCardOrientation.VERTICAL: if not self.card.media_height: raise ValueError( diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py index 3222ea28..c47c006e 100644 --- a/messages/tests/test_rcs_models.py +++ b/messages/tests/test_rcs_models.py @@ -748,7 +748,9 @@ def test_create_rcs_card_message_card_orientation_vertical_without_media_height( card_orientation='VERTICAL', ), ) - assert "media_height must be specified when card_orientation is VERTICAL" in str(err.value) + assert "media_height must be specified when card_orientation is VERTICAL" in str( + err.value + ) def test_create_rcs_carousel(): @@ -1776,7 +1778,9 @@ def test_create_rcs_options_card_image_alignment_with_invalid_option(): def test_create_rcs_options_card_card_orientation_horizontal_without_image_alignment(): with pytest.raises(ValidationError) as err: options = RcsOptionsCard(card_orientation='HORIZONTAL') - assert "image_alignment must be specified when card_orientation is HORIZONTAL" in str(err.value) + assert "image_alignment must be specified when card_orientation is HORIZONTAL" in str( + err.value + ) def test_create_rcs_options_carousel(): From 406fb8f0786094ccccb2cb00eb333acf877f806d Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 29 May 2026 10:41:40 +0100 Subject: [PATCH 392/401] Update Messages version and changelog --- messages/CHANGES.md | 4 ++++ messages/src/vonage_messages/_version.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 3d4b7e58..e0cc5a7a 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,7 @@ +# 1.7.1 +- Adds validations to the `ttl` param for `sms` +- Adds model validators for the conditional validations for `rcs` based on `card_orientation` + # 1.7.0 - Implement the `pool_id` parameter for SMS - Implement the `trusted_recipient` parameter for SMS, MMS, and RCS diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 0e1a38d3..48c2f6b0 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.7.0' +__version__ = '1.7.1' From 2782bccb9f9f70208bd49ce466ef86da69d16238 Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 29 May 2026 10:45:40 +0100 Subject: [PATCH 393/401] Updating main package dependencies --- vonage/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 78ea7737..c4097194 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "vonage-application>=2.0.2", "vonage-http-client>=1.5.2", "vonage-identity-insights>=1.0.0", - "vonage-messages>=1.7.0", + "vonage-messages>=1.7.1", "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", From 9a0851bebe8723c5c599bba95311badf9aeb5042 Mon Sep 17 00:00:00 2001 From: superchilled Date: Fri, 29 May 2026 10:54:51 +0100 Subject: [PATCH 394/401] Updating main package version and changelog --- vonage/CHANGES.md | 3 +++ vonage/src/vonage/_version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index dd3b4fd0..6c1b9c08 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 4.8.1 +- Fixes some Messages API validations + # 4.8.0 ## Account diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 0d53216d..ec5ed27c 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.8.0' +__version__ = '4.8.1' From 89695929c97bc4ed66f673da4659305e73cb14d1 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 20 Jul 2026 10:39:39 +0100 Subject: [PATCH 395/401] Updating the Voice API Phone model to be more permissive of phone param input --- voice/src/vonage_voice/models/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 99476fec..9fd880c4 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -9,10 +9,10 @@ class Phone(BaseModel): """Model for a phone number. Args: - number (PhoneNumber): The phone number. + number (str): The phone number. """ - number: PhoneNumber + number: str type: Channel = Channel.PHONE From 5ff0b864b628ea301b2dd03c830b94c15296c2a9 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 20 Jul 2026 10:41:47 +0100 Subject: [PATCH 396/401] Bumping patch version of Voice package --- voice/src/vonage_voice/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 77f1c8e6..51ed7c48 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.5.0' +__version__ = '1.5.1' From 34066ec4d37dc223349778a19cf41ee66bf2f589 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 20 Jul 2026 10:54:51 +0100 Subject: [PATCH 397/401] Updating changelog --- voice/CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/voice/CHANGES.md b/voice/CHANGES.md index aae0ebd1..9ebe2c4a 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.5.1 +- Updating the Voice API `Phone` model + # 1.5.0 - Add answer wehhook - Implement `Wait` NCCO action From c7abb61664d1d64e668fda6e89b384137c578017 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 20 Jul 2026 11:29:39 +0100 Subject: [PATCH 398/401] Updating pyproject.toml --- vonage/pyproject.toml | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index c4097194..d009f59b 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -1,45 +1,17 @@ [project] name = "vonage" -dynamic = ["version"] +dynamic = [ "version",] description = "Python Server SDK for using Vonage APIs" readme = "README.md" requires-python = ">=3.9" -dependencies = [ - "vonage-account>=1.1.2", - "vonage-application>=2.0.2", - "vonage-http-client>=1.5.2", - "vonage-identity-insights>=1.0.0", - "vonage-messages>=1.7.1", - "vonage-network-auth>=1.0.2", - "vonage-network-sim-swap>=1.1.2", - "vonage-network-number-verification>=1.0.2", - "vonage-number-insight>=1.0.7", - "vonage-numbers>=1.0.5", - "vonage-sms>=1.2.0", - "vonage-subaccounts>=1.0.4", - "vonage-users>=1.2.2", - "vonage-utils>=1.1.4", - "vonage-verify>=2.2.0", - "vonage-verify-legacy>=1.1.0", - "vonage-video>=1.5.1", - "vonage-voice>=1.5.0", -] -classifiers = [ - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "License :: OSI Approved :: Apache Software License", -] +dependencies = [ "vonage-account>=1.1.2", "vonage-application>=2.0.2", "vonage-http-client>=1.5.2", "vonage-identity-insights>=1.0.0", "vonage-messages>=1.7.1", "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", "vonage-number-insight>=1.0.7", "vonage-numbers>=1.0.5", "vonage-sms>=1.2.0", "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.2", "vonage-utils>=1.1.4", "vonage-verify>=2.2.0", "vonage-verify-legacy>=1.1.0", "vonage-video>=1.5.1", "vonage-voice>=1.5.1",] +classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License",] [[project.authors]] name = "Vonage" email = "devrel@vonage.com" [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = [ "setuptools>=61.0", "wheel",] build-backend = "setuptools.build_meta" [project.urls] From 795eca9991678c12bb7063afdf0ce6487aee0890 Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 20 Jul 2026 11:39:07 +0100 Subject: [PATCH 399/401] Bumping main package patch version and updating main changelog --- vonage/CHANGES.md | 4 ++++ vonage/src/vonage/_version.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 6c1b9c08..09796fb0 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,7 @@ +# 4.8.2 +- Updates the Voice API `Phone` model to fix a type constraint bug + + # 4.8.1 - Fixes some Messages API validations diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index ec5ed27c..29fd62a7 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.8.1' +__version__ = '4.8.2' From eac98a4e9f5db2edaa78aeb05917e3c963bbd32b Mon Sep 17 00:00:00 2001 From: superchilled Date: Mon, 20 Jul 2026 11:41:28 +0100 Subject: [PATCH 400/401] Linting --- voice/src/vonage_voice/models/common.py | 2 +- vonage/pyproject.toml | 36 ++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 9fd880c4..f68338c7 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -1,7 +1,7 @@ from typing import Literal, Optional from pydantic import BaseModel, Field -from vonage_utils.types import PhoneNumber, SipUri +from vonage_utils.types import SipUri from vonage_voice.models.enums import Channel diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index d009f59b..a98a6d82 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -1,17 +1,45 @@ [project] name = "vonage" -dynamic = [ "version",] +dynamic = ["version"] description = "Python Server SDK for using Vonage APIs" readme = "README.md" requires-python = ">=3.9" -dependencies = [ "vonage-account>=1.1.2", "vonage-application>=2.0.2", "vonage-http-client>=1.5.2", "vonage-identity-insights>=1.0.0", "vonage-messages>=1.7.1", "vonage-network-auth>=1.0.2", "vonage-network-sim-swap>=1.1.2", "vonage-network-number-verification>=1.0.2", "vonage-number-insight>=1.0.7", "vonage-numbers>=1.0.5", "vonage-sms>=1.2.0", "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.2", "vonage-utils>=1.1.4", "vonage-verify>=2.2.0", "vonage-verify-legacy>=1.1.0", "vonage-video>=1.5.1", "vonage-voice>=1.5.1",] -classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License",] +dependencies = [ + "vonage-account>=1.1.2", + "vonage-application>=2.0.2", + "vonage-http-client>=1.5.2", + "vonage-identity-insights>=1.0.0", + "vonage-messages>=1.7.1", + "vonage-network-auth>=1.0.2", + "vonage-network-sim-swap>=1.1.2", + "vonage-network-number-verification>=1.0.2", + "vonage-number-insight>=1.0.7", + "vonage-numbers>=1.0.5", + "vonage-sms>=1.2.0", + "vonage-subaccounts>=1.0.4", + "vonage-users>=1.2.2", + "vonage-utils>=1.1.4", + "vonage-verify>=2.2.0", + "vonage-verify-legacy>=1.1.0", + "vonage-video>=1.5.1", + "vonage-voice>=1.5.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] [[project.authors]] name = "Vonage" email = "devrel@vonage.com" [build-system] -requires = [ "setuptools>=61.0", "wheel",] +requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project.urls] From 44e2d4850cb22c6d2092b5d2b9130420a8a82fe7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:57:02 +0000 Subject: [PATCH 401/401] fix: pin pytest below 9.1 for Pants CI --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3cf935e8..cb722af2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pytest>=8.0.0 +pytest>=8.0.0,<9.1 requests>=2.31.0 responses>=0.24.1 pydantic>=2.9.2