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 36f9b729..00000000 Binary files a/tests/data/meetings/transparent_logo.png and /dev/null differ 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}