diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 0707c40f..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[bumpversion] -current_version = 2.4.0 -commit = True -tag = False - -[bumpversion:file:nexmo/__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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..0d1d3611 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + + + +## Expected Behavior + + + +## Current Behavior + + + +## Possible Solution + + + +## Steps to Reproduce (for bugs) + + +1. +2. +3. +4. + +## Context + + + +## Your Environment + +* Version used: +* Environment name and version (e.g. language and server version): +* Operating System and version: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/something-else.md b/.github/ISSUE_TEMPLATE/something-else.md new file mode 100644 index 00000000..fc32985f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/something-else.md @@ -0,0 +1,10 @@ +--- +name: Something else +about: Custom template +title: '' +labels: '' +assignees: '' + +--- + +Tell us what's up! diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..698497d5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Build +on: [push, pull_request] + +permissions: + actions: write + checks: write + contents: read + deployments: read + issues: write + discussions: write + packages: read + pages: write + pull-requests: write + security-events: write + statuses: write + +env: + PANTS_CONFIG_FILES: "pants.ci.toml" + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python: ["3.9", "3.10", "3.11", "3.12"] + os: ["ubuntu-latest"] + steps: + - name: Clone repo + uses: actions/checkout@v4 + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - 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: | + pants test --use-coverage :: diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml new file mode 100644 index 00000000..e02f4cfc --- /dev/null +++ b/.github/workflows/mutation-test.yml @@ -0,0 +1,48 @@ +name: Mutation Test +on: workflow_dispatch + +permissions: + actions: write + checks: write + contents: read + deployments: read + issues: write + discussions: write + packages: read + pages: write + pull-requests: write + security-events: write + statuses: write + +jobs: + mutation: + name: run mutation test + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.10"] + + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python -m pip install mutmut + - name: Run mutation test + run: | + mutmut run --no-progress --CI + - name: Save HTML output + run: | + mutmut html + - uses: actions/upload-artifact@v3 + with: + name: mutation-test-report + path: html/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e01b159d..e2e4407c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,94 @@ -name: Release published +name: Release Action + on: release: - types: [published] + types: + - published + workflow_dispatch: # for testing + jobs: - add-changelog: - name: Add Changelog + publish-package: runs-on: ubuntu-latest + name: Publish to PyPI + + outputs: + published: ${{ steps.set-published-state.outputs.published }} # Define an output for the job + steps: - - name: Add Changelog - uses: nexmo/github-actions/nexmo-changelog@master - env: - CHANGELOG_AUTH_TOKEN: ${{ secrets.CHANGELOG_AUTH_TOKEN }} - CHANGELOG_CATEGORY: Server SDK - CHANGELOG_RELEASE_TITLE: nexmo-python - CHANGELOG_SUBCATEGORY: python + - name: Checkout + id: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch history for all tags + + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + + - name: Initialize pants + uses: pantsbuild/actions/init-pants@main + with: + gha-cache-key: cache0-py3.13 + named-caches-hash: ${{ hashFiles('requirements.txt') }} + + - name: Publish to PyPI + run: | + previous_tag=$(git describe --tags --abbrev=0 HEAD^) + echo "Comparing changes since $previous_tag" + + changed_targets=$(pants --changed-since=$previous_tag list | awk -F/ '{print $1}' | awk -F: '{print $1}' | sort -u) + + if [ -n "$changed_targets" ]; then + echo "Publishing changed targets: $changed_targets" + for target in $changed_targets; do + if [ $target == testutils ] + then continue + fi + pants publish $target:: + done + echo "published=true" >> $GITHUB_ENV + else + echo "No changes detected, skipping publish" + echo "published=false" >> $GITHUB_ENV + fi + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + + - name: Set published state + id: set-published-state + run: echo "published=$published" >> $GITHUB_OUTPUT + + notify-release: + runs-on: ubuntu-latest + name: Notify Release + needs: publish-package + if: needs.publish-package.outputs.published == 'true' + strategy: + matrix: + url: [SLACK_WEBHOOK_ASK_DEVREL_URL, SLACK_WEBHOOK_DEVREL_TOOLING_URL, SLACK_WEBHOOK_DEVREL_PRIVATE_URL] + steps: + - name: Send to slack channels + uses: slackapi/slack-github-action@v2 + if: always() + continue-on-error: true + with: + webhook: ${{ secrets[matrix.url]}} + webhook-type: incoming-webhook + errors: true + payload: | + blocks: + - type: "header" + text: + type: "plain_text" + text: ":initial_external_notification_sent: :python: Version ${{ github.event.release.name }} of the Python SDK has been released" + - type: "section" + text: + type: "mrkdwn" + text: "${{ github.event.release.body }}" + - type: "divider" + - type: "section" + text: + type: "mrkdwn" + text: "You can view the full change log <${{github.event.release.html_url }}|here>" diff --git a/.gitignore b/.gitignore index 889abafc..1a72e6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ htmlcov/ .tox/ .coverage .coverage.* +.coveralls.* .cache nosetests.xml coverage.xml @@ -101,4 +102,19 @@ ENV* /site .requirements.txt -*_quickstart* \ No newline at end of file +*_quickstart* + +.DS_Store +.vscode +.idea +.pypirc +.pytest_cache +html/ +.mutmut-cache +_test_scripts/ +_dev_scripts/ + +# Pants workspace files +/.pants.* +/dist/ +/.pids diff --git a/.mend b/.mend new file mode 100644 index 00000000..24f3b3e5 --- /dev/null +++ b/.mend @@ -0,0 +1,6 @@ +{ + "settingsInheritedFrom": "Vonage/whitesource-config@main", + "scanSettings": { + "enableIaC": false + } +} \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd92b0ba..37623cbb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,6 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v1.4.0 - hooks: - - id: trailing-whitespace - language_version: python3.6 -- repo: https://github.com/ambv/black - rev: 18.6b4 - hooks: - - id: black - language_version: python3.6 \ No newline at end of file + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-yaml + - 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/.scripts/update_vonage_versions.py b/.scripts/update_vonage_versions.py new file mode 100644 index 00000000..99db4267 --- /dev/null +++ b/.scripts/update_vonage_versions.py @@ -0,0 +1,71 @@ +import os +import re + +import toml + +# Define the paths to the vonage packages +packages = [ + "vonage-account", + "vonage-application", + "vonage-http-client", + "vonage-identity-insights", + "vonage-messages", + "vonage-network-auth", + "vonage-network-sim-swap", + "vonage-network-number-verification", + "vonage-number-insight", + "vonage-numbers", + "vonage-sms", + "vonage-subaccounts", + "vonage-users", + "vonage-utils", + "vonage-verify", + "vonage-verify-legacy", + "vonage-video", + "vonage-voice", +] + + +# Function to read the version from _version.py +def get_version(folder_path: str, package_path: str) -> str: + content = None + try: + version_file = os.path.join( + folder_path.replace("-", "_"), + "src", + package_path.replace("-", "_"), + "_version.py", + ) + + with open(version_file, "r") as f: + content = f.read() + except FileNotFoundError: + if folder_path == "vonage-numbers": + return get_version("number-management", "vonage-numbers") + folder_path_fragments = package_path.split("-") + folder_path = "_".join(folder_path_fragments[1:]) + return get_version(folder_path, package_path) + + version_match = re.search(r'__version__\s*=\s*[\'"]([^\'"]+)[\'"]', content) + if version_match: + return version_match.group(1) + raise ValueError(f"Version not found in {version_file}") + + +# Read the existing pyproject.toml +with open("vonage/pyproject.toml", "r") as f: + pyproject = toml.load(f) + +# Update the dependencies with the versions from _version.py +dependencies = [] +for package in packages: + version = get_version(package, package) + dependencies.append(f"{package}>={version}") + +pyproject["project"]["dependencies"] = dependencies + +# Write the updated pyproject.toml +with open("vonage/pyproject.toml", "w") as f: + toml.dump(pyproject, f) + +print("pyproject.toml updated with local package versions.") diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b3179dee..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python - -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" -# Enable 3.7 without globally enabling sudo and dist: xenial for other build jobs -matrix: - include: - - python: 3.7 - dist: xenial - sudo: true - -install: - - make install - -script: - - make coverage - -after_success: - - coveralls 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/CHANGES.md b/CHANGES.md deleted file mode 100644 index 0a9e8d51..00000000 --- a/CHANGES.md +++ /dev/null @@ -1,92 +0,0 @@ -# 2.4.0 -* Application V2 API added under `Client.application_v2` -* Existing application methods under `Client` are now deprecated. - -# 2.3.0 -* Explicit parameter list for the `nexmo.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** -* Secret Management -* Support for Authorization header authentication. - -# 2.2.0 -* Add support for `redact_transaction`. - -# 2.1.0 -* Add support for `get_recording` -* Add support for SMS conversion -* Add debug logging for most calls, under the 'nexmo' logger. -* Internal refactoring (affects only private methods.) - -# 2.0.0 -* Drop support for Python 3.3 (in line with the cryptography library we depend upon) -* Ensure timestamp is added the params list if signing requests -* Avoid value injection in signature auth. -* Add support for different hashes for signature generation (thanks @trancee!) -* Tests ported to pytest - -# 1.5.0 - -* Add ability to provide a file path as private_key param no the nexmo.Client constructor - -* Add send/stop endpoints for audio/speech/dtmf - -* Add new number insight endpoints - -# 1.4.0 - -* Add new Voice API call methods - -* Add Application API methods - -* Add check_signature method for checking callback signatures - -* Deprecate old Verify API methods - -# 1.3.0 - -* Add get_sms_pricing method - -* Add get_voice_pricing method - -* Add get_event_alert_numbers method to get opt-in/opt-out numbers - -* Add resubscribe_event_alert_number method to opt-in a number - -* Add more clearly named methods for Verify API - -* Add app_name and app_version options - -# 1.2.0 - -* Add topup method - -* Add update_settings method - -* Add api_host attribute - -* Add ClientError and ServerError classes - -# 1.1.0 - -* Move repository to https://github.com/Nexmo/nexmo-python - -* Add get_basic_number_insight method for Number Insight Basic API - -* Add get_number_insight method for Number Insight Standard API - -* Add User-Agent header to requests - -# 1.0.3 - -* Change license from LGPL-3.0 to MIT - -# 1.0.2 - -* Remove merge helper function - -# 1.0.1 - -* Python 3.x fixes - -# 1.0.0 - -* First version! diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fbf13047..003f9af2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,21 +14,21 @@ appearance, race, religion, or sexual identity and orientation. Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or +- The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@nexmo.com. All +reported by contacting the project team at support@vonage.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3b44e590 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + + Copyright 2020 Vonage + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 5feca115..00000000 --- a/LICENSE.txt +++ /dev/null @@ -1,24 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2016 Nexmo Inc - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. 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 03df35cc..da833391 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,17 @@ -.PHONY: clean test dist coverage install requirements release release-test +.PHONY: format test coverage coverage-report install -clean: - rm -rf dist build - -coverage: - pytest -v --cov - coverage html +format: + pants lint :: + pants fix :: test: - pytest -v - -dist: - python setup.py sdist --formats zip,gztar bdist_wheel + pants test :: -release: - 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 - pip install --upgrade pip setuptools - pip install -r requirements.txt - pip freeze > .requirements.txt +install: + pip install -r requirements.txt \ No newline at end of file diff --git a/README.md b/README.md index 6d7f2178..075430ab 100644 --- a/README.md +++ b/README.md @@ -1,390 +1,1533 @@ -Nexmo Client Library for Python -=============================== +# Vonage Server SDK for Python -[![PyPI version](https://badge.fury.io/py/nexmo.svg)](https://badge.fury.io/py/nexmo) -[![Build Status](https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master)](https://travis-ci.org/Nexmo/nexmo-python) -[![Coverage Status](https://coveralls.io/repos/github/Nexmo/nexmo-python/badge.svg?branch=master)](https://coveralls.io/github/Nexmo/nexmo-python?branch=master) -[![Python versions supported](https://img.shields.io/pypi/pyversions/nexmo.svg)](https://pypi.python.org/pypi/nexmo) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +Vonage -This is the Python client library for Nexmo's API. To use it you'll -need a Nexmo account. Sign up [for free at nexmo.com][signup]. +[![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage) +[![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) +[![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 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) +- [Account API](#account-api) +- [Application API](#application-api) +- [HTTP Client](#http-client) +- [JWT Client](#jwt-client) +- [Identity Insights](#identity-insights) +- [Messages API](#messages-api) +- [Network Number Verification API](#network-number-verification-api) +- [Network Sim Swap API](#network-sim-swap-api) +- [Number Insight API](#number-insight-api) +- [Numbers API](#numbers-api) +- [SMS API](#sms-api) +- [Subaccounts API](#subaccounts-api) +- [Users API](#users-api) +- [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 + +It's recommended to create a new virtual environment to install the SDK. You can do this with -* [Installation](#installation) -* [Usage](#usage) -* [SMS API](#sms-api) -* [Voice API](#voice-api) -* [Verify API](#verify-api) -* [Number Insight API](#number-insight-api) -* [Managing Secrets](#managing-secrets) -* [Application API](#application-api) -* [License](#license) +```bash +# Create the virtual environment +python3 -m venv venv +# Activate the virtual environment in Mac/Linux +. ./venv/bin/activate -Installation ------------- +# Or on Windows Command Prompt +venv\Scripts\activate +``` -To install the Python client library using pip: +To install the Python SDK package using pip: - pip install nexmo +```bash +pip install vonage +``` To upgrade your installed client library using pip: - pip install nexmo --upgrade +```bash +pip install vonage --upgrade +``` + +Alternatively, you can clone the repository via the command line, or by opening it on GitHub desktop. + +## Migration Guides + +### V3 to V4 + +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. -Alternatively, you can clone the repository via the command line: +### OpenTok to Vonage Video API - git clone git@github.com:Nexmo/nexmo-python.git +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. -or by opening it on GitHub desktop. +## 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. -Usage ------ +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 `nexmo` 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 nexmo +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 = nexmo.Client(key=api_key, secret=api_secret) +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) ``` -For production, you can specify the `NEXMO_API_KEY` and `NEXMO_API_SECRET` -environment variables instead of specifying the key and secret explicitly. +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)) +``` -For newer endpoints that support JWT authentication such as the Voice API, -you can also specify the `application_id` and `private_key` arguments: +You can also access the underlying `HttpClient` instance through the `http_client` property: ```python -client = nexmo.Client(application_id=application_id, private_key=private_key) +user_agent = vonage.http_client.user_agent ``` -To check signatures for incoming webhook requests, you'll also need -to specify the `signature_secret` argument (or the `NEXMO_SIGNATURE_SECRET` -environment variable). +### Convert a Pydantic Model to Dict or Json +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` -## SMS API +```python +response = vonage.api_package.api_call(...) -### Send a text message +response_dict = response.model_dump() +response_json = response.model_dump_json() +``` + +## Account API + +### Get Account Balance ```python -response = client.send_message({'from': 'Python', 'to': 'YOUR-NUMBER', 'text': 'Hello world'}) +balance = vonage_client.account.get_balance() +print(balance) +``` -response = response['messages'][0] +### Top-Up Account + +```python +response = vonage_client.account.top_up(trx='1234567890') +print(response) +``` -if response['status'] == '0': - print('Sent message', response['message-id']) +### Update the Default SMS Webhook - print('Remaining balance is', response['remaining-balance']) -else: - print('Error:', response['error-text']) +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) ``` -Docs: [https://developer.nexmo.com/api/sms#send-an-sms](https://developer.nexmo.com/api/sms?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#send-an-sms) +### Get Service Pricing for a Specific Country + +```python +from vonage_account import GetCountryPricingRequest -### Tell Nexmo the SMS was received +response = vonage_client.account.get_country_pricing( + GetCountryPricingRequest(type='sms', country_code='US') +) +print(response) +``` -The following submits a successful conversion to Nexmo with the current timestamp. This feature must -be enabled on your account first. +### Get Service Pricing for All Countries ```python -response = client.submit_sms_conversion(message_id) +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) +``` + +### 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) +``` + +## Application API -## Voice API -### Make a call +### 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 -response = client.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) +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) ``` -Docs: [https://developer.nexmo.com/api/voice#createCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#createCall) +### 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) +``` -### Retrieve a list of calls +### Get an Application ```python -response = client.get_calls() +app_data = client.application.get_application('MY_APP_ID') +app_data_as_dict = app.model_dump(exclude_none=True) ``` -Docs: [https://developer.nexmo.com/api/voice#getCalls](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCalls) +### 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) +``` -### Retrieve a single call +### Delete an Application ```python -response = client.get_call(uuid) +vonage_client.applications.delete_application('MY_APP_ID') ``` -Docs: [https://developer.nexmo.com/api/voice#getCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getCall) +## HTTP Client -### Update a call ```python -response = client.update_call(uuid, action='hangup') +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'}) ``` -Docs: [https://developer.nexmo.com/api/voice#updateCall](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#updateCall) +### Get the Last Request and Last Response from the HTTP Client -### Stream audio to a call +The `HttpClient` class exposes two properties, `last_request` and `last_response` that cache the last sent request and response. ```python -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' +# Get last request, has type requests.PreparedRequest +request = client.last_request -response = client.send_audio(uuid, stream_url=[stream_url]) +# Get last response, has type requests.Response +response = client.last_response ``` -Docs: [https://developer.nexmo.com/api/voice#startStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startStream) +### Appending to the User-Agent Header -### Stop streaming audio to a call +The `HttpClient` class also supports appending additional information to the User-Agent header via the append_to_user_agent method: ```python -response = client.stop_audio(uuid) +client.append_to_user_agent('additional_info') ``` -Docs: [https://developer.nexmo.com/api/voice#stopStream](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopStream) +### Changing the Authentication Method Used -### Send a synthesized speech message to a call +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 -response = client.send_speech(uuid, text='Hello') +# Use basic authentication for this request +response = client.get(host='api.nexmo.com', request_path='/v1/messages', auth_type='basic') ``` -Docs: [https://developer.nexmo.com/api/voice#startTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startTalk) +### Catching errors -### Stop sending a synthesized speech message to a call +Error objects are exposed in the package scope, so you can catch errors like this: ```python -response = client.stop_speech(uuid) +from vonage_http_client import HttpRequestError + +try: + client.post(...) +except HttpRequestError: + ... ``` -Docs: [https://developer.nexmo.com/api/voice#stopTalk](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#stopTalk) +## 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. -### Send DTMF tones to a call +It can also be used as a standalone JWT generator for use with Vonage APIs, like so: + +### Import the `JwtClient` object ```python -response = client.send_dtmf(uuid, digits='1234') +from vonage_jwt import JwtClient ``` -Docs: [https://developer.nexmo.com/api/voice#startDTMF](https://developer.nexmo.com/api/voice?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#startDTMF) +### Create a `JwtClient` object + +```python +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) +```python +jwt_client.generate_application_jwt() ``` +Optional JWT claims can be provided in a python dictionary: -## Verify API +```python +claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100} +jwt_client.generate_application_jwt(claims) +``` -### Start a verification +## Verifying a JWT signature + +You can use the `verify_jwt.verify_signature` method to verify a JWT signature is valid. ```python -response = client.start_verification(number='441632960960', brand='MyApp') +from vonage_jwt import verify_signature -if response['status'] == '0': - print('Started verification request_id={request_id}'.format(request_id=response['request_id'])) -else: - print('Error:', response['error_text']) +verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean ``` -Docs: [https://developer.nexmo.com/api/verify#verify-request](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-request) +## Identity Insights + +### Get Insights + +```python +from vonage_identity_insights import ( + IdentityInsightsRequest, + InsightsRequest, + EmptyInsight, + SimSwapInsight, +) + +options = HttpClientOptions(api_host='api-eu.vonage.com', timeout=30) + +client = Vonage(auth=auth, http_client_options=options) + +request = IdentityInsightsRequest( + phone_number='1234567890', + purpose='FraudPreventionAndDetection', + insights=InsightsRequest( + format=EmptyInsight(), + sim_swap=SimSwapInsight(period=240) + ) +) + +response = client.identity_insights.get_insights(request) +``` -The response contains a verification request id which you will need to -store temporarily (in the session, database, url, etc). +## Messages API -### Check a verification +### How to Construct a Message + +In order to send a message, you must construct a message object of the correct type. + +```python +from vonage_messages 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 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 -response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') +from vonage import Auth, Vonage +from vonage_messages import Sms + +vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) -if response['status'] == '0': - print('Verification complete, event_id={event_id}'.format(event_id=response['event_id'])) -else: - print('Error:', response['error_text']) +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) + +vonage_client.messages.send(message) ``` -Docs: [https://developer.nexmo.com/api/verify#verify-check](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-check) +### Mark a WhatsApp Message as Read -The verification request id comes from the call to the start_verification method. -The PIN code is entered into your application by the user. +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. -### Cancel a verification +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. ```python -client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') +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') ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +### 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. -### Trigger next verification step +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. ```python -client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') +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 ``` -Docs: [https://developer.nexmo.com/api/verify#verify-control](https://developer.nexmo.com/api/verify?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#verify-control) +## Network Number Verification API + +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) +``` + +## Network Sim Swap API + +### 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) +``` ## Number Insight API -### Basic Number Insight +### Make a Basic Number Insight Request ```python -client.get_basic_number_insight(number='447700900000') +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) +) ``` -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) +### Make an Asynchronous Advanced Number Insight Request -### Standard Number Insight +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.get_standard_number_insight(number='447700900000') +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') +) ``` -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) +## Numbers API -### Advanced Number Insight +### List Numbers You Own ```python -client.get_advanced_number_insight(number='447700900000') +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) ``` -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 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) +``` -## Managing Secrets +### 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 - 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. +status = vonage_client.numbers.cancel_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) +``` -### List Secrets +### Update a Number - ```python -secrets = client.list_secrets(API_KEY) +```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) ``` -### Create A New Secret +## SMS API + +### 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) - Create a new secret (the created dates will help you know which is which): - ```python -client.create_secret(API_KEY, 'awes0meNewSekret!!;'); +print(response.model_dump(exclude_unset=True)) ``` +## Subaccounts API -### Delete A Secret +### List Subaccounts -Delete the old secret (any application still using these credentials will stop working): +```python +response = vonage_client.subaccounts.list_subaccounts() +print(response.model_dump) +``` + +### Create Subaccount ```python -client.delete_secret(API_KEY, 'my-secret-id') +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 -## Application API +```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) +``` + +## 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 +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) +``` + +## Verify API + +### 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') +``` + +## Verify API (Legacy) + +### 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') +``` + +## Video API + +### Generate a Client Token + +```python +from vonage_video import TokenOptions + +token_options = TokenOptions(session_id='your_session_id', role='publisher') +client_token = vonage_client.video.generate_client_token(token_options) +``` + +### Create a Session + +```python +from vonage_video 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 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) +``` -### Create an application +### Send a Signal ```python -response = client.application_v2.create_application({name='Example App', type='voice'}) +from vonage_video import SignalData + +signal_data = SignalData(type='chat', data='Hello, World!') +vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) ``` -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) +### Disconnect a Client -### Retrieve a list of applications +```python +vonage_client.video.disconnect_client(session_id='your_session_id', connection_id='your_connection_id') +``` + +### Mute a Stream ```python -response = client.application_v2.list_applications() +vonage_client.video.mute_stream(session_id='your_session_id', stream_id='your_stream_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) +### Mute All Streams + +```python +vonage_client.video.mute_all_streams(session_id='your_session_id', excluded_stream_ids=['stream_id_1', 'stream_id_2']) +``` -### Retrieve a single application +### Disable Mute All Streams ```python -response = client.application_v2.get_application(uuid) +vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ``` -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) +### Start Captions + +```python +from vonage_video import CaptionsOptions + +captions_options = CaptionsOptions(language='en-US') +captions_data = vonage_client.video.start_captions(captions_options) +``` -### Update an application +### Stop Captions ```python -response = client.application_v2.update_application(uuid, answer_method='POST') +from vonage_video import CaptionsData + +captions_data = CaptionsData(captions_id='your_captions_id') +vonage_client.video.stop_captions(captions_data) ``` -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) +### Start Audio Connector -### Delete an application +```python +from vonage_video import AudioConnectorOptions + +audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') +audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) +``` + +### Start Experience Composer ```python -response = client.application_v2.delete_application(uuid) +from vonage_video import ExperienceComposerOptions + +experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') +experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) ``` -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) +### List Experience Composers +```python +from vonage_video import ListExperienceComposersFilter + +filter = ListExperienceComposersFilter(page_size=10) +experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) +print(experience_composers) +``` -## Validate webhook signatures +### Get Experience Composer ```python -client = nexmo.Client(signature_secret='secret') +experience_composer = vonage_client.video.get_experience_composer(experience_composer_id='experience_composer_id') +``` -if client.check_signature(request.query): - # valid signature -else: - # invalid signature +### Stop Experience Composer + +```python +vonage_client.video.stop_experience_composer(experience_composer_id='experience_composer_id') ``` -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) +### List Archives -Note: you'll need to contact support@nexmo.com to enable message signing on -your account before you can validate webhook signatures. +```python +from vonage_video import ListArchivesFilter +filter = ListArchivesFilter(offset=2) +archives, count, next_page_offset = vonage_client.video.list_archives(filter) +print(archives) +``` -## JWT parameters +### Start Archive -By default, the library generates short-lived tokens for JWT authentication. +```python +from vonage_video import CreateArchiveRequest + +archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') +archive = vonage_client.video.start_archive(archive_options) +``` -Use the auth method to specify parameters for a longer life token or to -specify a different token identifier: +### Get Archive ```python -client.auth(nbf=nbf, exp=exp, jti=jti) +archive = vonage_client.video.get_archive(archive_id='your_archive_id') +print(archive) ``` -Contributing ------------- +### Delete Archive -We :heart: contributions! But if you plan to work on something big or controversial, please [contact us](mailto:devrel@nexmo.com) first! +```python +vonage_client.video.delete_archive(archive_id='your_archive_id') +``` + +### Add Stream to Archive + +```python +from vonage_video import AddStreamRequest + +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) +``` + +### 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) +``` -We recommend working on `nexmo-python` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: +### Change Archive Layout + +```python +from vonage_video 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 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 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 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 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 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) +``` + +## Voice API + +### 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 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 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 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 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*#') +``` + +## Vonage Utils Package + +```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) +``` + +## Frequently Asked Questions + +### Supported APIs + +The following is a list of Vonage APIs and whether the Python SDK provides support for them: + +| API | API Release Status | Supported? | +| --------------------- | :------------------: | :--------: | +| Account API | General Availability | ✅ | +| Application API | General Availability | ✅ | +| Audit API | Beta | ❌ | +| Conversation API | Beta | ❌ | +| Dispatch API | Beta | ❌ | +| External Accounts API | Beta | ❌ | +| Media API | Beta | ❌ | +| Messages API | General Availability | ✅ | +| Identity Insights API | General Availability | ✅ | +| Number Insight API | General Availability | ✅ | +| Number Management API | General Availability | ✅ | +| Pricing API | General Availability | ✅ | +| Redact API | Developer Preview | ❌ | +| Reports API | Beta | ❌ | +| SMS API | General Availability | ✅ | +| Subaccounts API | 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. + +## Contributing + +We :heart: contributions! But if you plan to work on something big or controversial, please contact us by raising an issue first! + +We recommend working on `vonage-python-sdk` within a virtual environment - below we're using the [venv](https://docs.python.org/3/library/venv.html)] module: ```bash -make install +# Create the virtual environment +python3 -m venv venv + +# Activate the virtual environment in Mac/Linux +. ./venv/bin/activate + +# Or on Windows Command Prompt +venv\Scripts\activate +``` + +The following command will install all the Python dependencies you need to run the tests: + +```bash +pip install -r requirements.txt ``` The tests are all written with pytest. You run them with: ```bash -make test +PYTHONPATH=. pytest -v ``` +We use [Black](https://black.readthedocs.io/en/stable/index.html) for code formatting, with our config in the `pyproject.toml` file. To ensure a PR follows the right format, you can set up and use our pre-commit settings with + +```bash +pre-commit install +``` + +Then when you commit code, if it's not in the right format, it will be automatically fixed for you. After that, just commit again and everything should work as expected! + +## License -License -------- +This library is released under the [Apache License](license). -This library is released under the [MIT License][license]. +## Additional Resources -[virtualenv]: https://virtualenv.pypa.io/en/stable/ -[report-a-bug]: https://github.com/Nexmo/nexmo-python/issues/new -[pull-request]: https://github.com/Nexmo/nexmo-python/pulls -[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..3ff48265 --- /dev/null +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -0,0 +1,257 @@ +# 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` + +Also, the `voice.get_recording` method from v3 has been replaced by `voice.download_recording` in v4. The behaviour also changes as now the method will download the recording for you by streaming the response into a file. + +See the [Voice API samples](voice/README.md) for more information. + +### Network Number Verification API + +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..babd635a --- /dev/null +++ b/account/CHANGES.md @@ -0,0 +1,18 @@ +# 1.1.2 +- Fixing imports for test file + +# 1.1.1 +- Update dependency versions + +# 1.1.0 +- Add support for the [Vonage Pricing API](https://developer.vonage.com/en/api/pricing) +- Update dependency versions + +# 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..96455ec6 --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..7b344eca --- /dev/null +++ b/account/src/vonage_account/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.2' 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..f3043d52 --- /dev/null +++ b/account/tests/test_account.py @@ -0,0 +1,239 @@ +from os.path import abspath + +import responses +from pytest import raises +from testutils import build_response, get_mock_api_key_auth +from vonage_account.account import Account +from vonage_account.errors import InvalidSecretError +from vonage_account.requests import ( + GetCountryPricingRequest, + GetPrefixPricingRequest, + ServiceType, +) +from vonage_http_client.errors import ForbiddenError +from vonage_http_client.http_client import HttpClient + +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..2ea9d5d1 --- /dev/null +++ b/application/CHANGES.md @@ -0,0 +1,21 @@ +# 2.0.2 +- Fixing imports for test file + +# 2.0.1 +- Updated dependency versions + +# 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..1293c7db --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..668c3446 --- /dev/null +++ b/application/src/vonage_application/_version.py @@ -0,0 +1 @@ +__version__ = '2.0.2' 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..675f61f8 --- /dev/null +++ b/application/tests/test_application.py @@ -0,0 +1,356 @@ +from os.path import abspath + +import responses +from pytest import raises +from testutils import build_response, get_mock_api_key_auth +from vonage_application.application import Application +from vonage_application.common import ( + ApplicationUrl, + 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 + +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/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index e35d8850..00000000 --- a/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_build diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 46afce51..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,228 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " epub3 to make an epub3" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - @echo " dummy to check syntax errors of document sources" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -quickstart.rst: ../README.md - pandoc -f markdown -t rst ../README.md -o quickstart.rst - -.PHONY: html -html: quickstart.rst - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Nexmo.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Nexmo.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Nexmo" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Nexmo" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: epub3 -epub3: - $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 - @echo - @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." - -.PHONY: dummy -dummy: - $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy - @echo - @echo "Build finished. Dummy builder generates no files." diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore deleted file mode 100644 index dfdb05af..00000000 --- a/docs/_static/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ - -# Created by https://www.gitignore.io/api/osx -# Edit at https://www.gitignore.io/?templates=osx - -### OSX ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# End of https://www.gitignore.io/api/osx diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 3555201f..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,347 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Nexmo documentation build configuration file, created by -# sphinx-quickstart on Sun Sep 18 14:36:55 2016. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import datetime -import os -import sys - -sys.path.insert(0, os.path.abspath("..")) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.doctest", - "sphinx.ext.todo", - "sphinx.ext.coverage", - "sphinx.ext.viewcode", - "sphinx.ext.githubpages", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The encoding of source files. -# -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = u"Nexmo" -copyright = u"{0}, Tim Craft".format(datetime.datetime.now().year) -author = u"Tim Craft" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = u"2.3.0" -# The full version, including alpha/beta/rc tags. -release = u"2.3.0" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# -# today = '' -# -# Else, today_fmt is used as the format for a strftime call. -# -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - -# -- Options for HTML output ---------------------------------------------- - - -import sphinx_rtd_theme - -html_theme = "sphinx_rtd_theme" - -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -# -# html_title = u'Nexmo v1.4.0' - -# A shorter title for the navigation bar. Default is the same as html_title. -# -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# -# html_logo = None - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# -# html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -# -# html_last_updated_fmt = None - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# -# html_additional_pages = {} - -# If false, no module index is generated. -# -# html_domain_indices = True - -# If false, no index is generated. -# -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -# -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -# -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = "Nexmodoc" - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, "Nexmo.tex", u"Nexmo Documentation", u"Tim Craft", "manual") -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "nexmo", u"Nexmo Documentation", [author], 1)] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "Nexmo", - u"Nexmo Documentation", - author, - "Nexmo", - "One line description of project.", - "Miscellaneous", - ) -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 109e4e3a..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,18 +0,0 @@ - -Welcome to Nexmo's documentation! -================================= - -.. toctree:: - :maxdepth: 2 - - quickstart - reference - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 8f65cffb..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,281 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Nexmo.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Nexmo.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end diff --git a/docs/quickstart.rst b/docs/quickstart.rst deleted file mode 100644 index ae7fa996..00000000 --- a/docs/quickstart.rst +++ /dev/null @@ -1,356 +0,0 @@ -Nexmo Client Library for Python -=============================== - -|PyPI version| |Build Status| - -This is the Python client library for Nexmo's API. To use it you'll need -a Nexmo account. Sign up `for free at -nexmo.com `__. - -- `Installation <#installation>`__ -- `Usage <#usage>`__ -- `SMS API <#sms-api>`__ -- `Voice API <#voice-api>`__ -- `Verify API <#verify-api>`__ -- `Application API <#application-api>`__ -- `Coverage <#api-coverage>`__ -- `License <#license>`__ - -Installation ------------- - -To install the Python client library using pip: - -:: - - pip install nexmo - -Alternatively, you can clone the repository: - -:: - - git clone git@github.com:Nexmo/nexmo-python.git - -Usage ------ - -Begin by importing the nexmo module: - -.. code:: python - - import nexmo - -Then construct a client object with your key and secret: - -.. code:: python - - client = nexmo.Client(key=api_key, secret=api_secret) - -For production, you can specify the ``NEXMO_API_KEY`` and -``NEXMO_API_SECRET`` environment variables instead of specifying the key -and secret explicitly. - -For newer endpoints that support JWT authentication such as the Voice -API, you can also specify the ``application_id`` and ``private_key`` -arguments: - -.. code:: python - - client = nexmo.Client(application_id=application_id, private_key=private_key) - -In order to check signatures for incoming webhook requests, you'll also -need to specify the ``signature_secret`` argument (or the -``NEXMO_SIGNATURE_SECRET`` environment variable). - -If the argument ``signature_method`` is omitted, it will default to the md5 hash -algorithm. Otherwise, it will use the selected method as in md5, sha1, sha256 or -sha512 with hmac. - -SMS API -------- - -Send a text message -~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.send_message({'from': 'Python', 'to': 'YOUR-NUMBER', 'text': 'Hello world'}) - - response = response['messages'][0] - - if response['status'] == '0': - print('Sent message', response['message-id']) - - print('Remaining balance is', response['remaining-balance']) - else: - print('Error:', response['error-text']) - -Docs: -`https://docs.nexmo.com/messaging/sms-api/api-reference#request `__ - -Voice API ---------- - -Make a call -~~~~~~~~~~~ - -.. code:: python - - response = client.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] - }) - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_create `__ - -Retrieve a list of calls -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_calls() - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve `__ - -Retrieve a single call -~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_call(uuid) - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_retrieve\_single `__ - -Update a call -~~~~~~~~~~~~~ - -.. code:: python - - response = client.update_call(uuid, action='hangup') - -Docs: -`https://docs.nexmo.com/voice/voice-api/api-reference#call\_modify\_single `__ - -Verify API ----------- - -Start a verification -~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.start_verification(number='441632960960', brand='MyApp') - - if response['status'] == '0': - print 'Started verification request_id={request_id}'.format(request_id=response['request_id']) - else: - print('Error:', response['error_text']) - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#vrequest `__ - -The response contains a verification request id which you will need to -store temporarily (in the session, database, url etc). - -Check a verification -~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.check_verification('00e6c3377e5348cdaf567e1417c707a5', code='1234') - - if response['status'] == '0': - print 'Verification complete, event_id={event_id}'.format(event_id=response['event_id']) - else: - print('Error:', response['error_text']) - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#check `__ - -The verification request id comes from the call to the -start\_verification method. The PIN code is entered into your -application by the user. - -Cancel a verification -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - client.cancel_verification('00e6c3377e5348cdaf567e1417c707a5') - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ - -Trigger next verification step -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - client.trigger_next_verification_event('00e6c3377e5348cdaf567e1417c707a5') - -Docs: -`https://docs.nexmo.com/verify/api-reference/api-reference#control `__ - -Application API ---------------- - -Create an application -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.create_application(name='Example App', type='voice', answer_url=answer_url) - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#create `__ - -Retrieve a list of applications -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_applications() - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#list `__ - -Retrieve a single application -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.get_application(uuid) - -Docs: -`https://developer.nexmo.com/api/application#retrieve-an-application `__ - -Update an application -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.update_application(uuid, answer_method='POST') - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#update `__ - -Delete an application -~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - response = client.delete_application(uuid) - -Docs: -`https://docs.nexmo.com/tools/application-api/api-reference#delete `__ - -Validate webhook signatures ---------------------------- - -.. code:: python - - client = nexmo.Client(signature_secret='secret') - - if client.check_signature(request.query): - # valid signature - else: - # invalid signature - - - or by using signature method via POST: - - client = nexmo.Client(signature_secret='secret', signature_method='sha256') - - if client.check_signature(request.body.decode()): - # valid signature - else: - # invalid signature - -Docs: -`https://docs.nexmo.com/messaging/signing-messages `__ - -Note: you'll need to contact support@nexmo.com to enable message signing -on your account before you can validate webhook signatures. - -JWT parameters --------------- - -By default, the library generates short-lived tokens for JWT -authentication. - -Use the auth method to specify parameters for a longer life token or to -specify a different token identifier: - -.. code:: python - - client.auth(nbf=nbf, exp=exp, jti=jti) - -API Coverage ------------- - -- Account - - - [X] Balance - - [X] Pricing - - [X] Settings - - [X] Top Up - - [X] Numbers - - - [X] Search - - [X] Buy - - [X] Cancel - - [X] Update - -- Number Insight - - - [X] Basic - - [X] Standard - - [X] Advanced - - [ ] Webhook Notification - -- Verify - - - [X] Verify - - [X] Check - - [X] Search - - [X] Control - -- Messaging - - - [X] Send - - [ ] Delivery Receipt - - [ ] Inbound Messages - - [X] Search - - - [X] Message - - [X] Messages - - [X] Rejections - - - [X] US Short Codes - - - [X] Two-Factor Authentication - - [X] Event Based Alerts - - - [X] Sending Alerts - - [X] Campaign Subscription Management - -- Voice - - - [X] Outbound Calls - - [ ] Inbound Call - - [X] Text-To-Speech Call - - [X] Text-To-Speech Prompt - -License -------- - -This library is released under the `MIT License `__ - -.. |PyPI version| image:: https://badge.fury.io/py/nexmo.svg - :target: https://badge.fury.io/py/nexmo -.. |Build Status| image:: https://api.travis-ci.org/Nexmo/nexmo-python.svg?branch=master - :target: https://travis-ci.org/Nexmo/nexmo-python diff --git a/docs/reference.rst b/docs/reference.rst deleted file mode 100644 index c2334d7d..00000000 --- a/docs/reference.rst +++ /dev/null @@ -1,14 +0,0 @@ -API Reference -============= - -.. autoclass:: nexmo.Client - :members: - :undoc-members: - - .. attribute:: application_v2 - - An instance of :class:`nexmo.ApplicationV2` for accessing the Application API. - -.. autoclass:: nexmo.ApplicationV2 - :members: - :undoc-members: \ No newline at end of file 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..f87d1000 --- /dev/null +++ b/http_client/CHANGES.md @@ -0,0 +1,46 @@ +# 1.5.2 +- Fixing imports for test file + +# 1.5.1 +- Remove unnecessary `Content-Type` check on error + +# 1.5.0 +- Add new `HttpClient.download_file_stream` method +- Add new `FileStreamingError` exception type +- Add backoff exponential timeout increase for HTTP request retries +- Add retries for `RemoteDisconnected` exceptions + +# 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..3db9c45d --- /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.5", + "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..14ed5207 --- /dev/null +++ b/http_client/src/vonage_http_client/__init__.py @@ -0,0 +1,30 @@ +from .auth import Auth +from .errors import ( + AuthenticationError, + FileStreamingError, + ForbiddenError, + HttpRequestError, + InvalidAuthError, + InvalidHttpClientOptionsError, + JWTGenerationError, + NotFoundError, + RateLimitedError, + ServerError, +) +from .http_client import HttpClient, HttpClientOptions + +__all__ = [ + 'Auth', + 'AuthenticationError', + 'FileStreamingError', + '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..c3b38415 --- /dev/null +++ b/http_client/src/vonage_http_client/_version.py @@ -0,0 +1 @@ +__version__ = '1.5.2' 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..b708853b --- /dev/null +++ b/http_client/src/vonage_http_client/errors.py @@ -0,0 +1,142 @@ +from json import JSONDecodeError, dumps +from typing import Optional + +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. + + Attributes: + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response): + self.response = response + self.message = self._format_error_message() + super().__init__(self.message) + + def _format_error_message(self) -> str: + body = self._get_response_body() + base_message = f'{self.response.status_code} response from {self.response.url}' + + if body: + return f'{base_message}. Error response body: \n{body}' + return base_message + + def _get_response_body(self) -> Optional[str]: + if not self.response.content: + return None + try: + return dumps(self.response.json(), indent=4) + except JSONDecodeError: + return self.response.text + + +class AuthenticationError(HttpRequestError): + """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. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response): + super().__init__(response) + + +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. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response): + super().__init__(response) + + +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. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response): + super().__init__(response) + + +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. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response): + super().__init__(response) + + +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. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response): + super().__init__(response) + + +class FileStreamingError(VonageError): + """Exception indicating an error occurred while streaming a file in a Vonage SDK + request.""" diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py new file mode 100644 index 00000000..97c3e8f2 --- /dev/null +++ b/http_client/src/vonage_http_client/http_client.py @@ -0,0 +1,359 @@ +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.exceptions import ConnectionError +from requests.sessions import Session +from urllib3 import Retry +from vonage_http_client.auth import Auth +from vonage_http_client.errors import ( + AuthenticationError, + FileStreamingError, + 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=Retry( + total=self._http_client_options.max_retries, backoff_factor=0.1 + ), + ) + 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, + ): + """Make an HTTP request to the specified host. This method will automatically + handle retries in the event of a connection error caused by a RemoteDisconnect + exception. + + It will retry the amount of times equal to the maximum number of connections + allowed in a connection pool. I.e., assuming if all connections in a given pool + are in use but the TCP connections to the Vonage host have failed, it will retry + the amount of times equal to the maximum number of connections in the pool. + + Args: + request_type (str): The type of request to make (GET, POST, PATCH, PUT, DELETE). + host (str): The host to make the request to. + request_path (str, optional): The path to make the request to. + params (dict, optional): The parameters to send with the request. + auth_type (str, optional): The type of authentication to use with the request. + sent_data_type (str, optional): The type of data being sent with the request. + token (str, optional): The token to use for OAuth2 authentication. + + Returns: + dict: The response data from the request. + + Raises: + ConnectionError: If the request fails after the maximum number of retries. + """ + url = f'https://{host}{request_path}' + logger.debug( + f'{request_type} request to {url}, with data: {params}; headers: {self._headers}' + ) + 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 + + max_retries = self._http_client_options.pool_maxsize or 10 + attempt = 0 + while attempt < max_retries: + try: + with self._session.request(**request_params) as response: + return self._parse_response(response) + except ConnectionError as e: + logger.debug(f'Connection Error: {e}') + if 'RemoteDisconnected' in str(e.args): + attempt += 1 + if attempt >= max_retries: + raise e + logger.debug( + f'ConnectionError caused by RemoteDisconnected exception. Retrying request, attempt {attempt + 1} of {max_retries}' + ) + else: + raise e + + def download_file_stream(self, url: str, file_path: str) -> bytes: + """Download a file from a URL and save it to a local file. This method streams the + file to disk. + + Args: + url (str): The URL of the file to download. + file_path (str): The local path to save the file to. + + Returns: + bytes: The content of the file. + """ + headers = { + 'User-Agent': self.user_agent, + 'Authorization': self.auth.create_jwt_auth_string(), + } + + logger.debug( + f'Downloading file by streaming from {url} to local location: {file_path}, with headers: {self._headers}' + ) + try: + with self._session.get(url, headers=headers, stream=True) as response: + if response.status_code >= 400: + self._parse_response(response) + with open(file_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=4096): + f.write(chunk) + except Exception as e: + logger.error(f'Error downloading file from {url}: {e}') + raise FileStreamingError(f'Error downloading file from {url}: {e}') from e + + def append_to_user_agent(self, string: str): + """Append a string to the User-Agent header. + + 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: + 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) + if response.status_code == 403: + raise ForbiddenError(response) + elif response.status_code == 404: + raise NotFoundError(response) + elif response.status_code == 429: + raise RateLimitedError(response) + elif response.status_code == 500: + raise ServerError(response) + raise HttpRequestError(response) 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/http_client/tests/data/429.json b/http_client/tests/data/429.json new file mode 100644 index 00000000..cc29b165 --- /dev/null +++ b/http_client/tests/data/429.json @@ -0,0 +1,6 @@ +{ + "title": "Rate Limit Hit", + "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/data/file_stream.mp3 b/http_client/tests/data/file_stream.mp3 new file mode 100644 index 00000000..f529d1c3 Binary files /dev/null and b/http_client/tests/data/file_stream.mp3 differ 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..687a9a1f --- /dev/null +++ b/http_client/tests/test_http_client.py @@ -0,0 +1,326 @@ +from http.client import RemoteDisconnected +from json import loads +from os.path import abspath, dirname, join +from unittest.mock import patch + +import responses +from pytest import raises +from requests import PreparedRequest, Response, Session +from requests.exceptions import ConnectionError +from responses import matchers +from testutils import build_response, get_mock_jwt_auth +from vonage_http_client.auth import Auth +from vonage_http_client.errors import ( + AuthenticationError, + FileStreamingError, + ForbiddenError, + HttpRequestError, + InvalidHttpClientOptionsError, + RateLimitedError, + ServerError, +) +from vonage_http_client.http_client import HttpClient, HttpClientOptions + +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 + + +@responses.activate +def test_download_file_stream(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'file_stream.mp3', + ) + + client = HttpClient(get_mock_jwt_auth()) + client.download_file_stream( + url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + file_path='http_client/tests/data/file_stream.mp3', + ) + + with open('http_client/tests/data/file_stream.mp3', 'rb') as file: + file_content = file.read() + assert file_content.startswith(b'ID3') + + +@responses.activate +def test_download_file_stream_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + status_code=400, + mock_path='400.json', + ) + + client = HttpClient(get_mock_jwt_auth()) + with raises(FileStreamingError) as e: + client.download_file_stream( + url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + file_path='file.mp3', + ) + assert '400 response from' in e.exconly() + + +@patch.object(Session, 'request') +def test_retry_on_remote_disconnected_connection_error(mock_request): + mock_request.side_effect = ConnectionError( + RemoteDisconnected('Remote end closed connection without response') + ) + client = HttpClient( + Auth(application_id=application_id, private_key=private_key), + http_client_options=HttpClientOptions(), + ) + params = { + 'test': 'post request', + 'testing': 'http client', + } + with raises(ConnectionError) as e: + client.post(host='example.com', request_path='/post_json', params=params) + assert mock_request.call_count == 10 + assert 'Remote end closed connection without response' in str(e.value) + + +@patch.object(Session, 'request') +def test_dont_retry_on_generic_connection_error(mock_request): + mock_request.side_effect = ConnectionError('Error in connection to remote server') + client = HttpClient( + Auth(application_id=application_id, private_key=private_key), + http_client_options=HttpClientOptions(), + ) + with raises(ConnectionError) as e: + client.get(host='example.com', request_path='/get_json') + assert mock_request.call_count == 1 + assert 'Error in connection to remote server' in str(e.value) diff --git a/identity_insights/BUILD b/identity_insights/BUILD new file mode 100644 index 00000000..2bd77f26 --- /dev/null +++ b/identity_insights/BUILD @@ -0,0 +1,16 @@ +resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + +files(sources=['tests/data/*']) + +python_distribution( + name='vonage-identity-insights', + dependencies=[ + ':pyproject', + ':readme', + 'identity_insights/src/vonage_identity_insights', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/identity_insights/CHANGES.md b/identity_insights/CHANGES.md new file mode 100644 index 00000000..be516a55 --- /dev/null +++ b/identity_insights/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/identity_insights/README.md b/identity_insights/README.md new file mode 100644 index 00000000..8cf71f47 --- /dev/null +++ b/identity_insights/README.md @@ -0,0 +1,35 @@ +# Vonage Identity Insights Package + +This package contains the code to use the [Vonage Identity Insights API](https://developer.vonage.com/en/identity-insights/overview) in Python. The API provides real-time access to a broad range of attributes related to the carrier, subscriber, or device associated with a phone number. To use it you will need a Vonage account. Sign up [for free at vonage.com][signup]. + +## Usage + +It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. + +### Make a Standard Identity Insights Request + +```python +from vonage import Vonage, Auth, HttpClientOptions +from vonage_identity_insights import ( + IdentityInsightsRequest, + InsightsRequest, + EmptyInsight, + SimSwapInsight, +) + +options = HttpClientOptions(api_host="api-eu.vonage.com", timeout=30) + +client = Vonage(auth=auth, http_client_options=options) + +request = IdentityInsightsRequest( + phone_number="1234567890", + purpose="FraudPreventionAndDetection", + insights=InsightsRequest( + format=EmptyInsight(), sim_swap=SimSwapInsight(period=240) + ), +) + +response = client.identity_insights.requests(request) + +``` + diff --git a/identity_insights/pyproject.toml b/identity_insights/pyproject.toml new file mode 100644 index 00000000..b4cd6a6b --- /dev/null +++ b/identity_insights/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = 'vonage-identity-insights' +dynamic = ["version"] +description = 'Vonage Identity Insights package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_identity_insights._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/identity_insights/src/vonage_identity_insights/BUILD b/identity_insights/src/vonage_identity_insights/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/identity_insights/src/vonage_identity_insights/__init__.py b/identity_insights/src/vonage_identity_insights/__init__.py new file mode 100644 index 00000000..18629588 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/__init__.py @@ -0,0 +1,20 @@ +from . import errors +from .identity_insights import IdentityInsights +from .requests import ( + EmptyInsight, + IdentityInsightsRequest, + InsightsRequest, + SimSwapInsight, +) +from .responses import IdentityInsightsResponse, InsightStatus + +__all__ = [ + "IdentityInsights", + "IdentityInsightsRequest", + "InsightsRequest", + "EmptyInsight", + "SimSwapInsight", + "IdentityInsightsResponse", + "InsightStatus", + "errors", +] diff --git a/identity_insights/src/vonage_identity_insights/_version.py b/identity_insights/src/vonage_identity_insights/_version.py new file mode 100644 index 00000000..5becc17c --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/_version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/identity_insights/src/vonage_identity_insights/errors.py b/identity_insights/src/vonage_identity_insights/errors.py new file mode 100644 index 00000000..3006b318 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/errors.py @@ -0,0 +1,9 @@ +from vonage_utils.errors import VonageError + + +class IdentityInsightsError(VonageError): + """Indicates an error when using the Vonage Identity Insights API.""" + + +class EmptyInsightsRequestException(VonageError): + """At least one insight must be provided.""" diff --git a/identity_insights/src/vonage_identity_insights/identity_insights.py b/identity_insights/src/vonage_identity_insights/identity_insights.py new file mode 100644 index 00000000..b7e12f4d --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/identity_insights.py @@ -0,0 +1,88 @@ +from logging import getLogger + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import EmptyInsightsRequestException, IdentityInsightsError +from .requests import IdentityInsightsRequest +from .responses import IdentityInsightsResponse + +logger = getLogger("vonage_identity_insights") + + +class IdentityInsights: + """Calls Vonage's Identity Insights API.""" + + def __init__(self, http_client: HttpClient) -> None: + """Initialize the IdentityInsights client. + + Args: + http_client (HttpClient): Configured HTTP client used to make + authenticated requests to the Vonage API. + """ + self._http_client = http_client + self._auth_type = "jwt" + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Vonage Indentity Insights API. + + Returns: + HttpClient: The HTTP client used to make requests to the Identity Insights API. + """ + return self._http_client + + @validate_call + def requests( + self, insights_request: IdentityInsightsRequest + ) -> IdentityInsightsResponse: + """Retrieve identity insights for a phone number. + + Sends an aggregated request to the Identity Insights API and returns + the results for each requested insight. + + Args: + insights_request (IdentityInsightsRequest): The request object + containing the phone number and the set of identity insights + to retrieve. + + Returns: + IdentityInsightsResponse: The response object containing the results + and status of each requested insight. + + Raises: + IdentityInsightsError: If the API returns an error response in + `application/problem+json` format. + """ + payload = insights_request.model_dump(exclude_none=True) + + insights = payload.get("insights") + if not insights or not isinstance(insights, dict): + raise EmptyInsightsRequestException() + + response = self._http_client.post( + self._http_client.api_host, + "/identity-insights/v1/requests", + payload, + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return IdentityInsightsResponse(**response) + + def _check_for_error(self, response: dict) -> None: + """Check whether the API response represents an error. + + The Identity Insights API returns errors using the + `application/problem+json` format. If such an error is detected, this + method raises an IdentityInsightsError. + + Args: + response (dict): Raw response returned by the HTTP client. + + Raises: + IdentityInsightsError: If the response contains an error payload. + """ + if "title" in response and "detail" in response: + error_message = f"Error with the following details: {response}" + raise IdentityInsightsError(error_message) diff --git a/identity_insights/src/vonage_identity_insights/requests.py b/identity_insights/src/vonage_identity_insights/requests.py new file mode 100644 index 00000000..b6996533 --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/requests.py @@ -0,0 +1,79 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + + +class EmptyInsight(BaseModel): + """Model for an insight request without parameters. + + This model represents insights that must be included as an empty JSON object (`{}`) to + indicate that the insight is requested. + """ + + +class SimSwapInsight(BaseModel): + """Model for a SIM swap insight request. + + This insight checks whether a SIM swap has occurred within a specified + period of time. + + Args: + period (int, Optional): Period in hours to be checked for SIM swap. + Must be between 1 and 2400. Defaults to 240. + """ + + period: Optional[int] = Field( + default=240, + ge=1, + le=2400, + description="Period in hours to be checked for SIM swap", + ) + + +class InsightsRequest(BaseModel): + """Model for a collection of identity insight requests. + + Each field represents an individual insight. Only the insights included + in this object will be processed and returned in the response. + + Args: + format (EmptyInsight, Optional): Request phone number format validation. + sim_swap (SimSwapInsight, Optional): Request SIM swap information. + original_carrier (EmptyInsight, Optional): Request original carrier + information. + current_carrier (EmptyInsight, Optional): Request current carrier + information. + """ + + format: Optional[EmptyInsight] = None + sim_swap: Optional[SimSwapInsight] = None + original_carrier: Optional[EmptyInsight] = None + current_carrier: Optional[EmptyInsight] = None + + def at_least_one_insight(cls, values): + """Validate that at least one insight is provided.""" + if not any(v is not None for v in values.values()): + raise ValueError("At least one insight must be provided") + return values + + +class IdentityInsightsRequest(BaseModel): + """Model for an Identity Insights API request. + + This model represents a single aggregated request for one or more identity + insights related to a phone number. + + Args: + phone_number (PhoneNumber): The phone number to retrieve identity + insights for. + purpose (str, Optional): Purpose of the request. Required for insights + that rely on the Network Registry. + insights (InsightsRequest): Collection of requested insights. + """ + + phone_number: PhoneNumber + purpose: Optional[str] = Field( + None, description="Purpose of the request (required for some insights)" + ) + insights: InsightsRequest diff --git a/identity_insights/src/vonage_identity_insights/responses.py b/identity_insights/src/vonage_identity_insights/responses.py new file mode 100644 index 00000000..6239ce5c --- /dev/null +++ b/identity_insights/src/vonage_identity_insights/responses.py @@ -0,0 +1,126 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel + + +class InsightStatus(BaseModel): + """Model for the status of an individual insight response. + + Args: + code (str): Status code of the insight processing. + message (str, Optional): Human-readable description of the status. + """ + + code: str + message: Optional[str] = None + + +class FormatInsightResponse(BaseModel): + """Model for the response of the `format` insight. + + This insight validates the phone number format and provides information + derived from its numbering plan. + + Args: + country_code (str, Optional): Country code in ISO 3166-1 alpha-2 format. + country_name (str, Optional): Full country name. + country_prefix (str, Optional): Numeric country calling code. + offline_location (str, Optional): Location derived from the number prefix. + time_zones (List[str], Optional): Time zones associated with the number. + number_international (str, Optional): Phone number in E.164 format. + number_national (str, Optional): Phone number in national format. + is_format_valid (bool, Optional): Indicates whether the number format is valid. + status (InsightStatus): Processing status of the insight. + """ + + country_code: Optional[str] = None + country_name: Optional[str] = None + country_prefix: Optional[str] = None + offline_location: Optional[str] = None + time_zones: Optional[List[str]] = None + number_international: Optional[str] = None + number_national: Optional[str] = None + is_format_valid: Optional[bool] = None + status: InsightStatus + + +class SimSwapInsightResponse(BaseModel): + """Model for the response of the `sim_swap` insight. + + This insight indicates whether a SIM swap has occurred recently. + + Args: + latest_sim_swap_at (datetime, Optional): Timestamp of the most recent + SIM swap, in UTC. + is_swapped (bool, Optional): Indicates whether a SIM swap occurred + within the requested period. + status (InsightStatus): Processing status of the insight. + """ + + latest_sim_swap_at: Optional[datetime] = None + is_swapped: Optional[bool] = None + status: InsightStatus + + +class CarrierInsightResponse(BaseModel): + """Model for the response of the `original_carrier` and `current_carrier` insights. + + Provides information about the network to which the phone number was + originally assigned. + + Args: + name (str, Optional): Full name of the original carrier. + network_type (str, Optional): Type of the network (e.g. MOBILE, LANDLINE). + country_code (str, Optional): Country code in ISO 3166-1 alpha-2 format. + network_code (str, Optional): MCC + MNC network identifier. + status (InsightStatus): Processing status of the insight. + """ + + name: Optional[str] = None + network_type: Optional[str] = None + country_code: Optional[str] = None + network_code: Optional[str] = None + status: InsightStatus + + +class InsightsResponse(BaseModel): + """Model for the collection of identity insight responses. + + Each field corresponds to an insight requested in the original request. + Only insights that were requested will be present in the response. + + Args: + format (FormatInsightResponse, Optional): Format validation response. + sim_swap (SimSwapInsightResponse, Optional): SIM swap response. + original_carrier (OriginalCarrierInsightResponse, Optional): Original + carrier response. + current_carrier (CurrentCarrierInsightResponse, Optional): Current + carrier response. + location_verification (LocationVerificationInsightResponse, Optional): + Location verification response. + subscriber_match (SubscriberMatchInsightResponse, Optional): Subscriber + match response. + roaming (RoamingInsightResponse, Optional): Roaming status response. + reachability (ReachabilityInsightResponse, Optional): Reachability response. + """ + + format: Optional[FormatInsightResponse] = None + sim_swap: Optional[SimSwapInsightResponse] = None + original_carrier: Optional[CarrierInsightResponse] = None + current_carrier: Optional[CarrierInsightResponse] = None + + +class IdentityInsightsResponse(BaseModel): + """Model for an Identity Insights API response. + + Represents the aggregated response containing the results of all requested + identity insights. + + Args: + request_id (str, Optional): Unique identifier for the request. + insights (InsightsResponse): Collection of insight responses. + """ + + request_id: Optional[str] = None + insights: InsightsResponse diff --git a/identity_insights/tests/BUILD b/identity_insights/tests/BUILD new file mode 100644 index 00000000..40c70fd4 --- /dev/null +++ b/identity_insights/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['identity_insights', 'testutils']) diff --git a/identity_insights/tests/data/format.json b/identity_insights/tests/data/format.json new file mode 100644 index 00000000..969a17e9 --- /dev/null +++ b/identity_insights/tests/data/format.json @@ -0,0 +1,29 @@ +{ + "request_id": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "insights": { + "format": { + "country_code": "US", + "country_name": "United States", + "country_prefix": "1", + "offline_location": "Georgia", + "time_zones": [ + "America/New_York" + ], + "number_international": "+14040000000", + "number_national": "(404) 000-0000", + "is_format_valid": true, + "status": { + "code": "OK", + "message": "Success" + } + }, + "sim_swap": { + "latest_sim_swap_at": "2024-07-08T09:30:27.504Z", + "is_swapped": true, + "status": { + "code": "OK", + "message": "Success" + } + } + } +} diff --git a/identity_insights/tests/data/insight_error.json b/identity_insights/tests/data/insight_error.json new file mode 100644 index 00000000..1993541c --- /dev/null +++ b/identity_insights/tests/data/insight_error.json @@ -0,0 +1,7 @@ +{ + "title": "Malformed JSON", + "type": "https://developer.vonage.com/api-errors#invalid-json", + "instance": "868cf2e9-bd6d-4bac-96ba-2b08120d8cf9", + "detail": "Malformed JSON payload" +} + diff --git a/identity_insights/tests/data/nullable_response.json b/identity_insights/tests/data/nullable_response.json new file mode 100644 index 00000000..eb6aa5b8 --- /dev/null +++ b/identity_insights/tests/data/nullable_response.json @@ -0,0 +1,16 @@ +{ + "request_id": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "insights": { + "format": { + "status": { + "code": "OK" + } + }, + "original_carrier": { + "status": { + "code": "OK", + "message": "Success" + } + } + } +} diff --git a/identity_insights/tests/test_identity_insights.py b/identity_insights/tests/test_identity_insights.py new file mode 100644 index 00000000..e7347b62 --- /dev/null +++ b/identity_insights/tests/test_identity_insights.py @@ -0,0 +1,108 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.http_client import HttpClient, HttpClientOptions +from vonage_identity_insights.errors import ( + EmptyInsightsRequestException, + IdentityInsightsError, +) +from vonage_identity_insights.identity_insights import IdentityInsights +from vonage_identity_insights.requests import ( + EmptyInsight, + IdentityInsightsRequest, + InsightsRequest, +) + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +options = HttpClientOptions(api_host="api-eu.vonage.com", timeout=30) +identity_insights = IdentityInsights(HttpClient(get_mock_jwt_auth(), options)) + + +def test_http_client_property(): + http_client = identity_insights.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_format_insight(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/identity-insights/v1/requests', + 'format.json', + ) + + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest(format=EmptyInsight()) + ) + + response = identity_insights.requests(options) + + assert response.insights.format.status.code == "OK" + assert response.insights.format.status.message == "Success" + + +@responses.activate +def test_basic_insight_error(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/identity-insights/v1/requests', + 'insight_error.json', + ) + + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest(format=EmptyInsight()) + ) + + with raises(IdentityInsightsError) as e: + identity_insights.requests(options) + + assert "Malformed JSON" in str(e.value) + + +@responses.activate +def test_empty_insights_request_raises_exception(): + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest() + ) + + with raises(EmptyInsightsRequestException): + identity_insights.requests(options) + + +@responses.activate +def test_nullable_response_fields(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/identity-insights/v1/requests', + 'nullable_response.json', + ) + + options = IdentityInsightsRequest( + phone_number="1234567890", insights=InsightsRequest(format=EmptyInsight()) + ) + + response = identity_insights.requests(options) + + # Verify that optional fields with missing values are None + assert response.insights.format.country_code is None + assert response.insights.format.country_name is None + assert response.insights.format.time_zones is None + assert response.insights.format.is_format_valid is None + assert response.insights.format.status.code == "OK" + assert response.insights.format.status.message is None + + # Verify original_carrier fields are None when not provided + assert response.insights.original_carrier.name is None + assert response.insights.original_carrier.network_type is None + assert response.insights.original_carrier.country_code is None + assert response.insights.original_carrier.network_code is None + assert response.insights.original_carrier.status.code == "OK" + assert response.insights.original_carrier.status.message == "Success" 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..e023f7ee --- /dev/null +++ b/jwt/CHANGES.md @@ -0,0 +1,21 @@ +# 1.1.5 +- Improve `verify_signature` docstring + +# 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..99d2a6fa --- /dev/null +++ b/jwt/src/vonage_jwt/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.5' 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..6b9ef216 --- /dev/null +++ b/jwt/src/vonage_jwt/verify_jwt.py @@ -0,0 +1,26 @@ +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. + + Args: + token (str): The token to verify. + signature_secret (str, optional): The signature to verify the token against. + + Returns: + bool: True if the token is verified, False otherwise. + + Raises: + VonageVerifyJwtError: The signature could not be verified. + """ + + try: + decode(token, signature_secret, algorithms='HS256') + 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..e0cc5a7a --- /dev/null +++ b/messages/CHANGES.md @@ -0,0 +1,42 @@ +# 1.7.1 +- Adds validations to the `ttl` param for `sms` +- Adds model validators for the conditional validations for `rcs` based on `card_orientation` + +# 1.7.0 +- Implement the `pool_id` parameter for SMS +- Implement the `trusted_recipient` parameter for SMS, MMS, and RCS +- Add new MMS types +- Add RCS native types +- Add Replying Indicator support to WhatsApp + +# 1.5.0 +- Add an optional "failover" property to `vonage_messages.Messages.send` + +# 1.4.0 +- Make all models originally accessed by `vonage_messages.models.***` available at the top level of the package, i.e. `vonage_messages.***` + +# 1.3.0 +- Add support for API key/secret header authentication +- Updated dependency versions + +# 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..206d174c --- /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. + +```python +from vonage_messages 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 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 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..c1b529af --- /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.5.0", + "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..b4ac8969 --- /dev/null +++ b/messages/src/vonage_messages/__init__.py @@ -0,0 +1,6 @@ +from . import models # Import models to access the module directly +from .messages import Messages +from .models import * # Need this to directly expose data models +from .responses import SendMessageResponse + +__all__ = ['models', 'Messages', 'SendMessageResponse'] diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py new file mode 100644 index 00000000..48c2f6b0 --- /dev/null +++ b/messages/src/vonage_messages/_version.py @@ -0,0 +1 @@ +__version__ = '1.7.1' diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py new file mode 100644 index 00000000..e284fc00 --- /dev/null +++ b/messages/src/vonage_messages/messages.py @@ -0,0 +1,117 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .models import BaseMessage, ReplyingIndicatorText +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 + self._auth_type = 'jwt' + + if self._http_client.auth.application_id is None: + self._auth_type = 'basic' + + @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, failover: list[BaseMessage] = None + ) -> SendMessageResponse: + """Send a message using Vonage's Messages API. + + Args: + message (BaseMessage): The message to be sent as a Pydantic model. + Use the provided models (in `vonage_messages.models`) to create messages and pass them in to this method. + failover (list[BaseMessage]): A list of failover messages to be attempted if the primary message fails. + + Returns: + SendMessageResponse: Response model containing the unique identifier of the sent message. + Access the identifier with the `message_uuid` attribute. + """ + body = message.model_dump(by_alias=True, exclude_none=True) or message + + if failover is not None: + failover_body = [ + m.model_dump(by_alias=True, exclude_none=True) or m for m in failover + ] + body = { + **body, + 'failover': failover_body, + } + + response = self._http_client.post( + self._http_client.api_host, + '/v1/messages', + body, + self._auth_type, + ) + + return SendMessageResponse(**response) + + @validate_call + def mark_whatsapp_message_read( + self, message_uuid: str, replying_indicator: ReplyingIndicatorText = None + ) -> None: + """Mark a WhatsApp message as read. + + Note: to use this method, update the `api_host` attribute of the + `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. + replying_indicator (ReplyingIndicatorText, optional): An object indicating whether to show the replying indicator on the WhatsApp message. + """ + body = {'status': 'read'} + if replying_indicator is not None: + body['replying_indicator'] = replying_indicator.model_dump( + by_alias=True, exclude_none=True + ) + + self._http_client.patch( + self._http_client.api_host, + f'/v1/messages/{message_uuid}', + body, + self._auth_type, + ) + + @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'}, + self._auth_type, + ) 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..212d2113 --- /dev/null +++ b/messages/src/vonage_messages/models/__init__.py @@ -0,0 +1,182 @@ +from .base_message import BaseMessage +from .enums import ( + ChannelType, + EncodingType, + MessageType, + MmsContentItemType, + RcsCardOrientation, + RcsCardWidth, + RcsCategory, + RcsImageAlignment, + RcsMediaHeight, + WebhookVersion, +) +from .messenger import ( + MessengerAudio, + MessengerFile, + MessengerImage, + MessengerOptions, + MessengerResource, + MessengerText, + MessengerVideo, +) +from .mms import ( + MmsAudio, + MmsContent, + MmsContentItemAudio, + MmsContentItemFile, + MmsContentItemImage, + MmsContentItemVcard, + MmsContentItemVideo, + MmsFile, + MmsImage, + MmsResource, + MmsText, + MmsVcard, + MmsVideo, +) +from .rcs import ( + RcsCard, + RcsCardMessage, + RcsCarousel, + RcsCarouselMessage, + RcsCustom, + RcsFile, + RcsImage, + RcsOptions, + RcsOptionsCard, + RcsOptionsCarousel, + RcsResource, + RcsSuggestionActionCreateCalendarEvent, + RcsSuggestionActionDial, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionShareLocation, + RcsSuggestionActionViewLocation, + RcsSuggestionBase, + RcsSuggestionReply, + 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 ( + ReplyingIndicatorText, + 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', + 'MmsContent', + 'MmsContentItemImage', + 'MmsContentItemAudio', + 'MmsContentItemVideo', + 'MmsContentItemFile', + 'MmsContentItemVcard', + 'MmsContentItemType', + 'MmsFile', + 'MmsImage', + 'MmsResource', + 'MmsText', + 'MmsVcard', + 'MmsVideo', + 'RcsCard', + 'RcsCardMessage', + 'RcsCarousel', + 'RcsCarouselMessage', + 'RcsCustom', + 'RcsFile', + 'RcsImage', + 'RcsOptions', + 'RcsOptionsCard', + 'RcsOptionsCarousel', + 'RcsResource', + 'RcsSuggestionActionCreateCalendarEvent', + 'RcsSuggestionActionDial', + 'RcsSuggestionActionOpenUrl', + 'RcsSuggestionActionOpenUrlWebview', + 'RcsSuggestionActionShareLocation', + 'RcsSuggestionActionViewLocation', + 'RcsSuggestionBase', + 'RcsSuggestionReply', + 'RcsText', + 'RcsVideo', + 'RcsCategory', + 'RcsCardOrientation', + 'RcsImageAlignment', + 'RcsCardWidth', + 'RcsMediaHeight', + 'ReplyingIndicatorText', + '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..d6aeec16 --- /dev/null +++ b/messages/src/vonage_messages/models/enums.py @@ -0,0 +1,117 @@ +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' + CARD = 'card' + CAROUSEL = 'carousel' + CONTENT = 'content' + + +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' + + +class SuggestionType(str, Enum): + """The type of RCS suggestion.""" + + REPLY = 'reply' + DIAL = 'dial' + VIEW_LOCATION = 'view_location' + SHARE_LOCATION = 'share_location' + OPEN_URL = 'open_url' + OPEN_URL_IN_WEBVIEW = 'open_url_in_webview' + CREATE_CALENDAR_EVENT = 'create_calendar_event' + + +class UrlWebviewViewMode(str, Enum): + """The view mode for an RCS suggestion that opens a URL in a webview.""" + + FULL = 'FULL' + TALL = 'TALL' + HALF = 'HALF' + + +class RcsCategory(str, Enum): + """The category of an RCS message.""" + + ACKNOWLEDGEMENT = 'acknowledgement' + AUTHENTICATION = 'authentication' + PROMOTION = 'promotion' + SERVICE_REQUEST = 'service-request' + TRANSACTION = 'transaction' + + +class RcsCardOrientation(str, Enum): + """The orientation of an RCS card.""" + + VERTICAL = 'VERTICAL' + HORIZONTAL = 'HORIZONTAL' + + +class RcsImageAlignment(str, Enum): + """The alignment of an image on an RCS card.""" + + LEFT = 'LEFT' + RIGHT = 'RIGHT' + + +class RcsCardWidth(str, Enum): + """The width of a card in an RCS carousel.""" + + SMALL = 'SMALL' + MEDIUM = 'MEDIUM' + + +class RcsMediaHeight(str, Enum): + """The height of media on an RCS card.""" + + SHORT = 'SHORT' + MEDIUM = 'MEDIUM' + TALL = 'TALL' + + +class MmsContentItemType(str, Enum): + """The type of a content item in an MMS Content message.""" + + IMAGE = 'image' + AUDIO = 'audio' + VIDEO = 'video' + FILE = 'file' + VCARD = 'vcard' + + +class ReplyingIndicatorType(str, Enum): + """The type of a WhatsApp replying indicator.""" + + TEXT = 'text' 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..bdc54be5 --- /dev/null +++ b/messages/src/vonage_messages/models/mms.py @@ -0,0 +1,236 @@ +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, MmsContentItemType + + +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, with a maximum length of 3000 characters. + """ + + url: str + caption: Optional[str] = Field(None, min_length=1, max_length=3000) + + +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. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + to: PhoneNumber + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + ttl: Optional[int] = Field(None, ge=300, le=259200) + trusted_recipient: Optional[bool] = None + channel: ChannelType = ChannelType.MMS + + +class MmsText(BaseMms): + """Model for an MMS text message. + + Args: + text (str): The text of the message. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + text: str + message_type: MessageType = MessageType.TEXT + + +class MmsImage(BaseMms): + """Model for an MMS image message. + + 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. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + 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. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + 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. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + 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. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + video: MmsResource + message_type: MessageType = MessageType.VIDEO + + +class MmsFile(BaseMms): + """Model for an MMS file message. + + Args: + file (MmsResource): The file resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + file: MmsResource + message_type: MessageType = MessageType.FILE + + +class MmsContentItemImage(MmsResource): + """Model for an image content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.IMAGE, serialization_alias='type' + ) + + +class MmsContentItemAudio(MmsResource): + """Model for an audio content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.AUDIO, serialization_alias='type' + ) + + +class MmsContentItemVideo(MmsResource): + """Model for a video content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.VIDEO, serialization_alias='type' + ) + + +class MmsContentItemFile(MmsResource): + """Model for a file content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field(MmsContentItemType.FILE, serialization_alias='type') + + +class MmsContentItemVcard(MmsResource): + """Model for a vCard content item in an MMS Content message. + + Args: + url (str): The URL of the content item. + caption (str, Optional): Additional text to accompany the content item, with a maximum length of 3000 characters. + """ + + type_: MmsContentItemType = Field( + MmsContentItemType.VCARD, serialization_alias='type' + ) + + +class MmsContent(BaseMms): + """Model for an MMS message with content that can be of various types. + + Args: + content (list[MmsContentItem]): A list of content items for the message (images, audio, video, files, or vCards). + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + + content: list[ + Union[ + MmsContentItemImage, + MmsContentItemAudio, + MmsContentItemVideo, + MmsContentItemFile, + MmsContentItemVcard, + ] + ] + message_type: MessageType = MessageType.CONTENT diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py new file mode 100644 index 00000000..85f38276 --- /dev/null +++ b/messages/src/vonage_messages/models/rcs.py @@ -0,0 +1,443 @@ +from typing import List, Optional, Union + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types import PhoneNumber + +from .base_message import BaseMessage +from .enums import ( + ChannelType, + MessageType, + RcsCardOrientation, + RcsCardWidth, + RcsCategory, + RcsImageAlignment, + RcsMediaHeight, + SuggestionType, + UrlWebviewViewMode, +) + + +class RcsResource(BaseModel): + """Model for a resource in an RCS message. + + Args: + url (str): The URL of the resource. + """ + + url: str + + +class RcsSuggestionBase(BaseModel): + """Base model for a suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + text: str = Field(..., min_length=1, max_length=25) + postback_data: str + + +class RcsSuggestionReply(RcsSuggestionBase): + """Model for a reply suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + type_: SuggestionType = Field(SuggestionType.REPLY, serialization_alias='type') + + +class RcsSuggestionActionDial(RcsSuggestionBase): + """Model for a dial action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + phone_number (str): The phone number to dial when the suggestion is selected. In E.164 format without the leading plus sign. + fallback_url (str, Optional): The URL to open if the device doesn't support the dial action. + """ + + type_: SuggestionType = Field(SuggestionType.DIAL, serialization_alias='type') + phone_number: PhoneNumber + fallback_url: Optional[str] = None + + +class RcsSuggestionActionViewLocation(RcsSuggestionBase): + """Model for a view location action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + latitude (float): The latitude of the location to view when the suggestion is selected. + longitude (float): The longitude of the location to view when the suggestion is selected. + pin_label (str): The label to display on the location pin. + fallback_url (str, Optional): The URL to open if the device doesn't support the view location action. + """ + + type_: SuggestionType = Field( + SuggestionType.VIEW_LOCATION, serialization_alias='type' + ) + latitude: str + longitude: str + pin_label: str + fallback_url: Optional[str] = None + + +class RcsSuggestionActionShareLocation(RcsSuggestionBase): + """Model for a share location action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + """ + + type_: SuggestionType = Field( + SuggestionType.SHARE_LOCATION, serialization_alias='type' + ) + + +class RcsSuggestionActionOpenUrl(RcsSuggestionBase): + """Model for an open URL action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + url (str): The URL to open when the suggestion is selected. + description (str): A short description of the URL for accessibility purposes. + """ + + type_: SuggestionType = Field(SuggestionType.OPEN_URL, serialization_alias='type') + url: str + description: str = Field(..., min_length=1, max_length=500) + + +class RcsSuggestionActionOpenUrlWebview(RcsSuggestionActionOpenUrl): + """Model for an open URL in webview action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + url (str): The URL to open in a webview when the suggestion is selected. + description (str): A short description of the URL for accessibility purposes. + view_mode (str, Optional): The view mode for the webview (FULL, TALL, HALF). If not specified, the default view mode for the device will be used. + """ + + type_: SuggestionType = Field( + SuggestionType.OPEN_URL_IN_WEBVIEW, serialization_alias='type' + ) + view_mode: Optional[UrlWebviewViewMode] = None + + +class RcsSuggestionActionCreateCalendarEvent(RcsSuggestionBase): + """Model for a create calendar event action suggestion in an RCS message. + + Args: + text (str): The text to display on the suggestion chip. + postback_data (str): The data that will be sent via the Inbound Message webhook when the suggestion is selected. + start_time (str): The start time of the calendar event in ISO 8601 format. + end_time (str): The end time of the calendar event in ISO 8601 format + title (str): The title of the calendar event. + description (str): The description of the calendar event. + fallback_url (str, Optional): The URL to open if the device doesn't support the create calendar event action. + """ + + type_: SuggestionType = Field( + SuggestionType.CREATE_CALENDAR_EVENT, serialization_alias='type' + ) + start_time: str + end_time: str + title: str = Field(..., min_length=1, max_length=100) + description: str = Field(..., min_length=1, max_length=500) + fallback_url: Optional[str] = None + + +class RcsOptions(BaseModel): + """Base model for RCS message options. + + Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). + """ + + category: Optional[RcsCategory] = None + + +class RcsOptionsCard(RcsOptions): + """Model for an RCS card message options. + + Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). + card_orientation (str): The orientation of the card (HORIZONTAL or VERTICAL). + image_alignment (str): The alignment of the image on the card (LEFT or RIGHT). + """ + + card_orientation: Optional[RcsCardOrientation] = None + image_alignment: Optional[RcsImageAlignment] = None + + @model_validator(mode='after') + def horizontal_orientation_requires_image_alignment(self): + """Validate that if the card orientation is horizontal, the image alignment is + also specified.""" + if ( + self.card_orientation == RcsCardOrientation.HORIZONTAL + and not self.image_alignment + ): + raise ValueError( + 'image_alignment must be specified when card_orientation is HORIZONTAL' + ) + return self + + +class RcsOptionsCarousel(RcsOptions): + """Model for an RCS carousel message options. + + Args: + category (str, Optional): The category of the RCS message (authentication, transaction, promotion, service, request, acknowledgement). + card_width (str): The width of each card in the carousel (SMALL or MEDIUM). + """ + + card_width: RcsCardWidth + + +class BaseRcs(BaseMessage): + """Base model for a base RCS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: RcsOptions, Optional: An optional RcsOptions object to include in the message. + """ + + to: PhoneNumber + from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9-_&]+$') + ttl: Optional[int] = Field(None, ge=20, le=259200) + trusted_recipient: Optional[bool] = None + rcs: Optional[RcsOptions] = None + channel: ChannelType = ChannelType.RCS + + +class RcsText(BaseRcs): + """Model for an RCS text message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + text (str): The text of the message. + suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. + """ + + text: str = Field(..., min_length=1, max_length=3072) + message_type: MessageType = MessageType.TEXT + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=11) + + +class RcsImage(BaseRcs): + """Model for an RCS image message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + image (RcsResource): The image resource. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. + """ + + image: RcsResource + message_type: MessageType = MessageType.IMAGE + + +class RcsVideo(BaseRcs): + """Model for an RCS video message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + video (RcsResource): The video resource. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. + """ + + video: RcsResource + message_type: MessageType = MessageType.VIDEO + + +class RcsFile(BaseRcs): + """Model for an RCS file message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The RCS Agent ID. + file (RcsResource): The file resource. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the message. + """ + + file: RcsResource + message_type: MessageType = MessageType.FILE + + +class RcsCard(BaseModel): + """Base model for the content of an RCS card. + + Args: + title (str): The title of the card. + text (str): The text of the card. + media_url (str): The media URL for the card. Can be an image or a video. + media_height (str, Optional): The height of the media on the card (SHORT, MEDIUM, TALL). + media_description (str, Optional): A description of the media for accessibility purposes. + thumbnail_url (str, Optional): The URL of the thumbnail image for the media. If not specified, the media URL will be used as the thumbnail. + media_force_refresh (bool, Optional): Whether to force refresh the media on the card. If true, the media will be refreshed on the device even if the media URL is the same as a previous message. Defaults to false. + suggestions (List, Optional): An optional list of suggestions to include in the message. A card can include up to 4 suggestions. + """ + + title: str = Field(..., min_length=1, max_length=200) + text: str = Field(..., min_length=1, max_length=2000) + media_url: str + media_description: Optional[str] = None + media_height: Optional[RcsMediaHeight] = None + thumbnail_url: Optional[str] = None + media_force_refresh: Optional[bool] = None + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=4) + + +class RcsCardMessage(BaseRcs): + """Model for an RCS card message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + card (RcsCard): The content of the card. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptionsCard, Optional): An optional RcsOptionsCard object to include in the message. + """ + + card: RcsCard + rcs: Optional[RcsOptionsCard] = None + message_type: MessageType = MessageType.CARD + + @model_validator(mode='after') + def vertical_orientation_requires_media_height(self): + """Validate that if the card orientation is vertical, the media height is also + specified.""" + if self.rcs and self.rcs.card_orientation == RcsCardOrientation.VERTICAL: + if not self.card.media_height: + raise ValueError( + 'media_height must be specified when card_orientation is VERTICAL' + ) + return self + + +class RcsCarousel(BaseModel): + """Model for the content of an RCS carousel. + + Args: + cards (List[RcsCard]): A list of card items to include in the carousel. Can include up to 10 cards. + """ + + cards: List[RcsCard] = Field(..., min_length=2, max_length=10) + + +class RcsCarouselMessage(BaseRcs): + """Model for an RCS carousel message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + carousel (RcsCarousel): The content of the carousel. + suggestions (List, Optional): An optional list of suggestions to include in the message. Can include up to 11 suggestions. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptionsCarousel): An RcsOptionsCarousel object to include in the message. + """ + + carousel: RcsCarousel + suggestions: Optional[ + List[ + Union[ + RcsSuggestionReply, + RcsSuggestionActionDial, + RcsSuggestionActionViewLocation, + RcsSuggestionActionShareLocation, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionCreateCalendarEvent, + ] + ] + ] = Field(None, min_length=1, max_length=11) + rcs: RcsOptionsCarousel + message_type: MessageType = MessageType.CAROUSEL + + +class RcsCustom(BaseRcs): + """Model for an RCS custom 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. + custom (dict): The custom message data. + ttl (int, Optional): The duration in seconds for which the message is valid. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + rcs: (RcsOptions, Optional): An optional RcsOptions object to include in the 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..de8af589 --- /dev/null +++ b/messages/src/vonage_messages/models/sms.py @@ -0,0 +1,59 @@ +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. + pool_id (str, Optional): The ID of the Number Pool to use as the sender of this message. + If specified, a number from the pool will be used as the from number. + The from number is still required even when specifying a pool_id and will be used as a fall-back if the number pool cannot be used. + See the Number Pools documentation for more information: https://developer.vonage.com/numbers/number-pools-api/overview. + """ + + encoding_type: Optional[EncodingType] = None + content_id: Optional[str] = None + entity_id: Optional[str] = None + pool_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. + trusted_recipient (bool, Optional): Whether the recipient is a trusted recipient. Setting this parameter to true overrides, on a per-message basis, any protections set up via Fraud Defender. Defaults to false. + sms (SmsOptions, Optional): SMS options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + 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] = Field(None, ge=20, le=604800) + trusted_recipient: Optional[bool] = 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..a6b44980 --- /dev/null +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -0,0 +1,378 @@ +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, ReplyingIndicatorType + + +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 + + +class ReplyingIndicatorText(BaseModel): + """Model for the replying indicator of type `text` in a WhatsApp conversation. + + This is used to indicate activity within the WhatsApp UI that a message is being replied to. + + Args: + show (bool): Must be set to True to activate the replying indicator. If not included or set to False, the replying indicator will not be shown in the WhatsApp UI. + """ + + show: bool + type_: ReplyingIndicatorType = Field( + ReplyingIndicatorType.TEXT, serialization_alias='type' + ) diff --git a/messages/src/vonage_messages/responses.py b/messages/src/vonage_messages/responses.py new file mode 100644 index 00000000..99e594ed --- /dev/null +++ b/messages/src/vonage_messages/responses.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel + + +class SendMessageResponse(BaseModel): + """Response from Vonage's Messages API. + + Attributes: + message_uuid (str): The UUID of the sent message. + workflow_id [str]: Workflow ID if the `failover` parameter was used in the request. + """ + + message_uuid: str + workflow_id: Optional[str] = None diff --git a/messages/tests/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/data/send_message_with_failover.json b/messages/tests/data/send_message_with_failover.json new file mode 100644 index 00000000..7a8a12be --- /dev/null +++ b/messages/tests/data/send_message_with_failover.json @@ -0,0 +1,4 @@ +{ + "message_uuid": "d8f86df1-dec6-442f-870a-2241be27d721", + "workflow_id": "3TcNjguHxr2vcCZ9Ddsnq6tw8yQUpZ9rMHv9QXSxLan5ibMxqSzLdx9" +} \ No newline at end of file diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py new file mode 100644 index 00000000..b367b3e4 --- /dev/null +++ b/messages/tests/test_messages.py @@ -0,0 +1,248 @@ +from json import loads +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client import Auth, HttpClient, HttpClientOptions, HttpRequestError +from vonage_messages import ( + Messages, + MessengerImage, + MessengerOptions, + MessengerResource, + ReplyingIndicatorText, + SendMessageResponse, + Sms, +) + +from testutils import build_response, get_mock_api_key_auth, get_mock_jwt_auth + +path = abspath(__file__) + + +messages = Messages(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_default_auth_type(): + messages = Messages( + HttpClient( + Auth( + api_key='asdf', + api_secret='asdf', + application_id='asdf', + private_key='-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZz9Zz\n-----END PRIVATE-KEY----', + ) + ) + ) + assert messages._auth_type == 'jwt' + + +@responses.activate +def test_send_message(): + build_response( + 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' + assert messages._auth_type == 'jwt' + + +@responses.activate +def test_send_message_with_failover(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v1/messages', + 'send_message_with_failover.json', + 202, + ) + sms = Sms( + from_='Vonage APIs', + to='1234567890', + text='Hello, World!', + ) + failover = [ + Sms(from_='Vonage APIs', to='1987654321', text='Failover message'), + ] + + response = messages.send(sms, failover=failover) + print(messages._http_client.last_request.body) + assert loads(messages._http_client.last_request.body) == { + "to": "1234567890", + "from": "Vonage APIs", + "text": "Hello, World!", + "channel": "sms", + "message_type": "text", + "failover": [ + { + "to": "1987654321", + "from": "Vonage APIs", + "text": "Failover message", + "channel": "sms", + "message_type": "text", + } + ], + } + assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + assert ( + response.workflow_id == '3TcNjguHxr2vcCZ9Ddsnq6tw8yQUpZ9rMHv9QXSxLan5ibMxqSzLdx9' + ) + assert messages._auth_type == 'jwt' + + +@responses.activate +def test_send_message_basic_auth(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/messages', 'send_message.json', 202 + ) + messages = Messages(HttpClient(get_mock_api_key_auth())) + sms = Sms( + from_='Vonage APIs', + to='1234567890', + text='Hello, World!', + ) + response = messages.send(sms) + assert type(response) == SendMessageResponse + assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + assert messages._auth_type == 'basic' + + +@responses.activate +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_mark_whatsapp_message_read_with_replying_indicator(): + responses.add( + responses.PATCH, + 'https://api-eu.vonage.com/v1/messages/asdf', + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + messages.http_client.http_client_options.api_host = 'api-eu.vonage.com' + messages.mark_whatsapp_message_read( + message_uuid='asdf', + replying_indicator=ReplyingIndicatorText( + show=True, + ), + ) + + request_body = loads(responses.calls[0].request.body) + assert request_body == { + "status": "read", + "replying_indicator": {"show": True, "type": "text"}, + } + + +@responses.activate +def test_revoke_rcs_message(): + responses.add( + 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..72ca6059 --- /dev/null +++ b/messages/tests/test_mms_models.py @@ -0,0 +1,544 @@ +import pytest +from pydantic import ValidationError +from vonage_messages.models import ( + MmsAudio, + MmsContent, + MmsContentItemAudio, + MmsContentItemFile, + MmsContentItemImage, + MmsContentItemVcard, + MmsContentItemVideo, + MmsFile, + MmsImage, + MmsResource, + MmsText, + MmsVcard, + MmsVideo, +) +from vonage_messages.models.enums import WebhookVersion + + +def test_create_mms_resource(): + mms_resource = MmsResource( + url='https://example.com/resource', + ) + mms_resource_dict = { + 'url': 'https://example.com/resource', + } + + assert mms_resource.model_dump(exclude_none=True) == mms_resource_dict + + +def test_create_mms_resource_with_caption(): + mms_resource = MmsResource( + url='https://example.com/resource', + caption='Resource caption', + ) + mms_resource_dict = { + 'url': 'https://example.com/resource', + 'caption': 'Resource caption', + } + + assert mms_resource.model_dump(exclude_none=True) == mms_resource_dict + + +def test_create_mms_resource_without_url(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + caption='Resource caption', + ) + assert "Field required" in str(err.value) + + +def test_create_mms_resource_with_caption_too_short(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + url='https://example.com/resource', + caption='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_mms_resource_with_caption_too_long(): + with pytest.raises(ValidationError) as err: + mms_resource = MmsResource( + url='https://example.com/resource', + caption='a' * 3001, + ) + assert "String should have at most 3000 characters" in str(err.value) + + +def test_create_mms_text(): + mms_model = MmsText( + to='1234567890', + from_='1234567890', + text='Hello, world!', + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, world!', + 'channel': 'mms', + 'message_type': 'text', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_text_all_fields(): + mms_model = MmsText( + to='1234567890', + from_='1234567890', + text='Hello, world!', + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, world!', + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'text', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_image(): + mms_model = MmsImage( + to='1234567890', + from_='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, + trusted_recipient=True, + ) + 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, + 'trusted_recipient': True, + '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, + trusted_recipient=True, + ) + 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, + 'trusted_recipient': True, + '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, + trusted_recipient=True, + ) + 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, + 'trusted_recipient': True, + '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, + trusted_recipient=True, + ) + 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, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'video', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_file(): + mms_model = MmsFile( + to='1234567890', + from_='1234567890', + file=MmsResource( + url='https://example.com/file.pdf', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + }, + 'channel': 'mms', + 'message_type': 'file', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_file_all_fields(): + mms_model = MmsFile( + to='1234567890', + from_='1234567890', + file=MmsResource( + url='https://example.com/file.pdf', + caption='File caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'file', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_content(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + ], + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + ], + 'channel': 'mms', + 'message_type': 'content', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_content_all_fields(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + ], + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + ], + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'mms', + 'message_type': 'content', + } + + +def test_create_mms_content_all_content_types(): + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsContentItemImage( + url='https://example.com/image.jpg', + caption='Image caption', + ), + MmsContentItemAudio( + url='https://example.com/audio.mp3', + caption='Audio caption', + ), + MmsContentItemVideo( + url='https://example.com/video.mp4', + caption='Video caption', + ), + MmsContentItemFile( + url='https://example.com/file.pdf', + caption='File caption', + ), + MmsContentItemVcard( + url='https://example.com/vcard.vcf', + caption='Vcard caption', + ), + ], + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'content': [ + { + 'type': 'image', + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + { + 'type': 'audio', + 'url': 'https://example.com/audio.mp3', + 'caption': 'Audio caption', + }, + { + 'type': 'video', + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + { + 'type': 'file', + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + }, + { + 'type': 'vcard', + 'url': 'https://example.com/vcard.vcf', + 'caption': 'Vcard caption', + }, + ], + 'channel': 'mms', + 'message_type': 'content', + } + + +def test_create_mms_content_with_invalid_content_item(): + with pytest.raises(ValidationError) as err: + mms_model = MmsContent( + to='1234567890', + from_='1234567890', + content=[ + MmsResource( + url='https://example.com/resource', + ), + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_mms_with_ttl_too_low(): + with pytest.raises(ValidationError) as err: + MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource(url='https://example.com/image.jpg'), + ttl=299, + ) + assert 'greater than or equal to 300' in str(err.value) + + +def test_create_mms_with_ttl_too_high(): + with pytest.raises(ValidationError) as err: + MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource(url='https://example.com/image.jpg'), + ttl=259201, + ) + assert 'less than or equal to 259200' in str(err.value) diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py new file mode 100644 index 00000000..c47c006e --- /dev/null +++ b/messages/tests/test_rcs_models.py @@ -0,0 +1,1885 @@ +import pytest +from pydantic import ValidationError +from vonage_messages.models import ( + RcsCard, + RcsCardMessage, + RcsCarousel, + RcsCarouselMessage, + RcsCustom, + RcsFile, + RcsImage, + RcsOptions, + RcsOptionsCard, + RcsOptionsCarousel, + RcsResource, + RcsSuggestionActionCreateCalendarEvent, + RcsSuggestionActionDial, + RcsSuggestionActionOpenUrl, + RcsSuggestionActionOpenUrlWebview, + RcsSuggestionActionShareLocation, + RcsSuggestionActionViewLocation, + RcsSuggestionBase, + RcsSuggestionReply, + 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_with_ampersand(): + """Tests that RCS from fields will allow an ampersand (&) character. + + See also: DEVX-10155 + """ + rcs_model = RcsText( + to='1234567890', + from_='Acme&SonsCo', + text='Hello, World!', + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'Acme&SonsCo', + 'text': 'Hello, World!', + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_all_fields(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + client_ref='client-ref', + webhook_url='https://example.com', + ttl=600, + trusted_recipient=True, + rcs=RcsOptions( + category='transaction', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'rcs', + 'message_type': 'text', + 'rcs': { + 'category': 'transaction', + }, + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_suggestions(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_all_suggestion_types(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + }, + ], + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_text_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 12, + ) + assert "List should have at most 11 items" in str(err.value) + + +def test_create_rcs_text_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_image(): + rcs_model = RcsImage( + to='1234567890', + 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_card(): + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ) + card_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_optional_params(): + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + card_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_suggestions(): + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + ) + card_dict = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_with_all_suggestion_types(): + card_1 = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + ], + ) + card_2 = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + ) + card_dict_1 = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + ], + } + card_dict_2 = { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'suggestions': [ + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + }, + ], + } + assert card_1.model_dump(by_alias=True, exclude_none=True) == card_dict_1 + assert card_2.model_dump(by_alias=True, exclude_none=True) == card_dict_2 + + +def test_create_rcs_card_without_title(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_without_text(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + media_url='https://example.com/image.jpg', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_without_media_url(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_with_title_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_with_title_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='A' * 200 + 'B', + text='Card description', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 200 characters" in str(err.value) + + +def test_create_rcs_card_with_text_too_short(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='', + media_url='https://example.com/image.jpg', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_create_rcs_card_with_text_too_long(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='A' * 2000 + 'B', + media_url='https://example.com/image.jpg', + ) + assert "String should have at most 2000 characters" in str(err.value) + + +def test_create_rcs_card_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[], + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_card_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 5, + ) + assert "List should have at most 4 items" in str(err.value) + + +def test_create_rcs_card_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + card = RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_card_message(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'card': { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + }, + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_message_with_optional_params(): + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + rcs=RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment='LEFT', + ), + ) + card_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'card': { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + }, + 'rcs': { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + }, + 'channel': 'rcs', + 'message_type': 'card', + } + assert card.model_dump(by_alias=True, exclude_none=True) == card_dict + + +def test_create_rcs_card_message_without_card(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_card_message_card_orientation_vertical_without_media_height(): + with pytest.raises(ValidationError) as err: + card = RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + rcs=RcsOptionsCard( + card_orientation='VERTICAL', + ), + ) + assert "media_height must be specified when card_orientation is VERTICAL" in str( + err.value + ) + + +def test_create_rcs_carousel(): + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ) + carousel_dict = { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_with_insufficient_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ], + ) + assert "List should have at least 2 items" in str(err.value) + + +def test_create_rcs_carousel_with_too_many_cards(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 11, + ) + assert "List should have at most 10 items" in str(err.value) + + +def test_create_rcs_carousel_with_invalid_card_type(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ), + RcsCardMessage( + to='1234567890', + from_='asdf1234', + card=RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + ), + ), + ], + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_carousel_message(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_optional_params(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_description='Image description', + media_height='MEDIUM', + thumbnail_url='https://example.com/thumbnail.jpg', + media_force_refresh=True, + ) + ] + * 2, + ), + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_description': 'Image description', + 'media_height': 'MEDIUM', + 'thumbnail_url': 'https://example.com/thumbnail.jpg', + 'media_force_refresh': True, + } + ] + * 2, + }, + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_suggestions(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + ], + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_with_all_suggestion_types(): + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + ), + RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ), + RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ), + RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ), + RcsSuggestionActionOpenUrlWebview( + text='Open URL in webview', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL in a webview', + view_mode='FULL', + ), + RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ), + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + carousel_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'carousel': { + 'cards': [ + { + 'title': 'Card title', + 'text': 'Card description', + 'media_url': 'https://example.com/image.jpg', + 'media_height': 'MEDIUM', + } + ] + * 2, + }, + 'suggestions': [ + { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + }, + { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + }, + { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + }, + { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + }, + { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + }, + { + 'type': 'open_url_in_webview', + 'text': 'Open URL in webview', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL in a webview', + 'view_mode': 'FULL', + }, + { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + }, + ], + 'rcs': { + 'card_width': 'MEDIUM', + }, + 'channel': 'rcs', + 'message_type': 'carousel', + } + assert carousel.model_dump(by_alias=True, exclude_none=True) == carousel_dict + + +def test_create_rcs_carousel_message_without_carousel(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel_message_without_rcs_options(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_carousel_message_with_insuffient_suggestions(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at least 1 item" in str(err.value) + + +def test_create_rcs_carousel_message_with_too_many_suggestions(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + ] + * 12, + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "List should have at most 11 items" in str(err.value) + + +def test_create_rcs_carousel_message_with_inavalid_suggestion_types(): + with pytest.raises(ValidationError) as err: + carousel = RcsCarouselMessage( + to='1234567890', + from_='asdf1234', + carousel=RcsCarousel( + cards=[ + RcsCard( + title='Card title', + text='Card description', + media_url='https://example.com/image.jpg', + media_height='MEDIUM', + ) + ] + * 2, + ), + suggestions=[ + RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ), + "Invalid suggestion type", + ], + rcs=RcsOptionsCarousel( + card_width='MEDIUM', + ), + ) + assert "Input should be a valid dictionary or instance" in str(err.value) + + +def test_create_rcs_custom(): + rcs_model = RcsCustom( + to='1234567890', + 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 + + +def test_rcs_suggestion_base(): + suggestion = RcsSuggestionBase( + text='Reply', + postback_data='postback-data', + ) + suggestion_dict = { + 'text': 'Reply', + 'postback_data': 'postback-data', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_base_without_text(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + postback_data='postback-data', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_base_without_postback_data(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='Reply', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_base_with_text_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='', + postback_data='postback-data', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_base_with_text_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionBase( + text='A' * 25 + 'B', + postback_data='postback-data', + ) + assert "String should have at most 25 characters" in str(err.value) + + +def test_rcs_suggestion_reply(): + suggestion = RcsSuggestionReply( + text='Reply', + postback_data='postback-data', + ) + suggestion_dict = { + 'type': 'reply', + 'text': 'Reply', + 'postback_data': 'postback-data', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_dial(): + suggestion = RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + phone_number='447900000000', + fallback_url='https://example.com/dial', + ) + suggestion_dict = { + 'type': 'dial', + 'text': 'Call us', + 'postback_data': 'postback-data', + 'phone_number': '447900000000', + 'fallback_url': 'https://example.com/dial', + } + + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_dial_without_phone_number(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionDial( + text='Call us', + postback_data='postback-data', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location(): + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ) + suggestion_dict = { + 'type': 'view_location', + 'text': 'View location', + 'postback_data': 'postback-data', + 'latitude': '51.5074', + 'longitude': '-0.1278', + 'pin_label': 'London', + 'fallback_url': 'https://example.com/location', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_view_location_without_latitude(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + longitude='-0.1278', + pin_label='London', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location_without_longitude(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + pin_label='London', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_view_location_without_pin_label(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionViewLocation( + text='View location', + postback_data='postback-data', + latitude='51.5074', + longitude='-0.1278', + fallback_url='https://example.com/location', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_share_location(): + suggestion = RcsSuggestionActionShareLocation( + text='Share location', + postback_data='postback-data', + ) + suggestion_dict = { + 'type': 'share_location', + 'text': 'Share location', + 'postback_data': 'postback-data', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url(): + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + ) + suggestion_dict = { + 'type': 'open_url', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url_without_url(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + description='Click to open the URL', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_open_url_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrl( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview(): + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + view_mode='FULL', + ) + suggestion_dict = { + 'type': 'open_url_in_webview', + 'text': 'Open URL', + 'postback_data': 'postback-data', + 'url': 'https://example.com', + 'description': 'Click to open the URL', + 'view_mode': 'FULL', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_open_url_in_webview_without_url(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + description='Click to open the URL', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_rcs_suggestion_action_open_url_in_webview_with_invalid_view_mode(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionOpenUrlWebview( + text='Open URL', + postback_data='postback-data', + url='https://example.com', + description='Click to open the URL', + view_mode='INVALID_VIEW_MODE', + ) + assert "Input should be 'FULL', 'TALL' or 'HALF'" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event(): + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + fallback_url='https://example.com/calendar-event', + ) + suggestion_dict = { + 'type': 'create_calendar_event', + 'text': 'Add to calendar', + 'postback_data': 'postback-data', + 'start_time': '2024-01-01T12:00:00Z', + 'end_time': '2024-01-01T13:00:00Z', + 'title': 'Meeting with Bob', + 'description': 'Discuss project updates', + 'fallback_url': 'https://example.com/calendar-event', + } + assert suggestion.model_dump(by_alias=True, exclude_none=True) == suggestion_dict + + +def test_rcs_suggestion_action_create_calendar_event_without_start_time(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_end_time(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + title='Meeting with Bob', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_title(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + description='Discuss project updates', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_without_description(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + ) + assert "Field required" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_title_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='', + description='Discuss project updates', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_title_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='A' * 100 + 'B', + description='Discuss project updates', + ) + assert "String should have at most 100 characters" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_description_too_short(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='', + ) + assert "String should have at least 1 character" in str(err.value) + + +def test_rcs_suggestion_action_create_calendar_event_with_description_too_long(): + with pytest.raises(ValidationError) as err: + suggestion = RcsSuggestionActionCreateCalendarEvent( + text='Add to calendar', + postback_data='postback-data', + start_time='2024-01-01T12:00:00Z', + end_time='2024-01-01T13:00:00Z', + title='Meeting with Bob', + description='A' * 500 + 'B', + ) + assert "String should have at most 500 characters" in str(err.value) + + +def test_create_rcs_options(): + options = RcsOptions( + category='transaction', + ) + options_dict = { + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_with_each_valid_category(): + valid_options = [ + 'acknowledgement', + 'authentication', + 'promotion', + 'service-request', + 'transaction', + ] + for option in valid_options: + options = RcsOptions( + category=option, + ) + options_dict = { + 'category': option, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_with_invalid_category(): + with pytest.raises(ValidationError) as err: + options = RcsOptions( + category='invalid-category', + ) + assert ( + "Input should be 'acknowledgement', 'authentication', 'promotion', 'service-request' or 'transaction'" + in str(err.value) + ) + + +def test_create_rcs_options_card(): + options = RcsOptionsCard(card_orientation='HORIZONTAL', image_alignment='LEFT') + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_with_all_options(): + options = RcsOptionsCard( + card_orientation='HORIZONTAL', + image_alignment='LEFT', + category='transaction', + ) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': 'LEFT', + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_card_orientation_with_each_valid_option(): + valid_orientations = ['VERTICAL', 'HORIZONTAL'] + for orientation in valid_orientations: + options = RcsOptionsCard(card_orientation=orientation, image_alignment='LEFT') + options_dict = { + 'card_orientation': orientation, + 'image_alignment': 'LEFT', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_image_alignment_with_each_valid_option(): + valid_alignments = ['LEFT', 'RIGHT'] + for alignment in valid_alignments: + options = RcsOptionsCard(card_orientation='HORIZONTAL', image_alignment=alignment) + options_dict = { + 'card_orientation': 'HORIZONTAL', + 'image_alignment': alignment, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_card_card_orientation_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard( + card_orientation='INVALID_ORIENTATION', image_alignment='LEFT' + ) + assert "Input should be 'VERTICAL' or 'HORIZONTAL'" in str(err.value) + + +def test_create_rcs_options_card_image_alignment_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard( + card_orientation='HORIZONTAL', image_alignment='INVALID_ALIGNMENT' + ) + assert "Input should be 'LEFT' or 'RIGHT'" in str(err.value) + + +def test_create_rcs_options_card_card_orientation_horizontal_without_image_alignment(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCard(card_orientation='HORIZONTAL') + assert "image_alignment must be specified when card_orientation is HORIZONTAL" in str( + err.value + ) + + +def test_create_rcs_options_carousel(): + options = RcsOptionsCarousel( + card_width='MEDIUM', + ) + options_dict = { + 'card_width': 'MEDIUM', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_with_all_options(): + options = RcsOptionsCarousel( + card_width='MEDIUM', + category='transaction', + ) + options_dict = { + 'card_width': 'MEDIUM', + 'category': 'transaction', + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_without_card_width(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCarousel( + category='transaction', + ) + assert "Field required" in str(err.value) + + +def test_create_rcs_options_carousel_card_width_with_each_valid_option(): + valid_widths = ['SMALL', 'MEDIUM'] + for width in valid_widths: + options = RcsOptionsCarousel( + card_width=width, + ) + options_dict = { + 'card_width': width, + } + assert options.model_dump(by_alias=True, exclude_none=True) == options_dict + + +def test_create_rcs_options_carousel_card_width_with_invalid_option(): + with pytest.raises(ValidationError) as err: + options = RcsOptionsCarousel( + card_width='INVALID_WIDTH', + ) + assert "Input should be 'SMALL' or 'MEDIUM'" in str(err.value) + + +def test_create_rcs_text_too_short(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='', + ) + assert 'String should have at least 1 character' in str(err.value) + + +def test_create_rcs_text_too_long(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='a' * 3073, + ) + assert 'String should have at most 3072 characters' in str(err.value) + + +def test_create_rcs_with_ttl_too_low(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ttl=19, + ) + assert 'greater than or equal to 20' in str(err.value) + + +def test_create_rcs_with_ttl_too_high(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ttl=259201, + ) + assert 'less than or equal to 259200' in str(err.value) + + +def test_create_rcs_with_invalid_from_field(): + with pytest.raises(ValidationError) as err: + RcsText( + to='1234567890', + from_='invalid from!', + text='Hello, World!', + ) + assert 'String should match pattern' in str(err.value) diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py new file mode 100644 index 00000000..f5fd62fc --- /dev/null +++ b/messages/tests/test_sms_models.py @@ -0,0 +1,103 @@ +import pytest +from pydantic import ValidationError +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', + pool_id='abc123', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + trusted_recipient=True, + ) + sms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'sms': { + 'encoding_type': 'text', + 'content_id': 'content-id', + 'entity_id': 'entity-id', + 'pool_id': 'abc123', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'trusted_recipient': True, + 'channel': 'sms', + 'message_type': 'text', + } + + assert sms_model.model_dump(by_alias=True) == sms_dict + + +def test_create_sms_text_too_long(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='a' * 1001, + ) + assert 'String should have at most 1000 characters' in str(err.value) + + +def test_create_sms_with_invalid_encoding_type(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + sms=SmsOptions(encoding_type='invalid'), + ) + assert 'Input should be' in str(err.value) + + +def test_create_sms_ttl_too_short(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ttl=19, + ) + assert 'Input should be greater than or equal to 20' in str(err.value) + + +def test_create_sms_ttl_too_long(): + with pytest.raises(ValidationError) as err: + Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ttl=604801, + ) + assert 'Input should be less than or equal to 604800' in str(err.value) 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..c0ae7e58 --- /dev/null +++ b/messages/tests/test_whatsapp_models.py @@ -0,0 +1,450 @@ +from copy import deepcopy + +import pytest +from pydantic import ValidationError +from vonage_messages.models import ( + ReplyingIndicatorText, + 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 + + +def test_create_replying_indicator(): + whatsapp_model = ReplyingIndicatorText( + show=True, + ) + whatsapp_dict = { + 'show': True, + 'type': 'text', + } + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_text_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappText( + to='1234567890', + from_='1234567890', + text='a' * 4097, + ) + assert 'String should have at most 4096 characters' in str(err.value) + + +def test_whatsapp_audio_url_too_short(): + with pytest.raises(ValidationError) as err: + WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='short'), + ) + assert 'String should have at least 10 characters' in str(err.value) + + +def test_whatsapp_audio_url_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='https://' + 'a' * 2000), + ) + assert 'String should have at most 2000 characters' in str(err.value) + + +def test_whatsapp_image_caption_too_short(): + with pytest.raises(ValidationError) as err: + WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource(url='https://example.com/image.jpg', caption=''), + ) + assert 'String should have at least 1 character' in str(err.value) + + +def test_whatsapp_image_caption_too_long(): + with pytest.raises(ValidationError) as err: + WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource( + url='https://example.com/image.jpg', caption='a' * 3001 + ), + ) + assert 'String should have at most 3000 characters' in str(err.value) 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..c4feb6f2 --- /dev/null +++ b/network_auth/CHANGES.md @@ -0,0 +1,15 @@ +# 1.0.2 +- Updated dependency versions + +# 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..dfb9ad37 --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..a6221b3d --- /dev/null +++ b/network_auth/src/vonage_network_auth/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.2' 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..b4e5f476 --- /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..a9a90dc1 --- /dev/null +++ b/network_number_verification/CHANGES.md @@ -0,0 +1,8 @@ +# 1.0.2 +- Updated dependency versions + +# 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..77d3fce7 --- /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.5.0", + "vonage-network-auth>=1.0.2", + "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..a6221b3d --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.2' 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..8f8fd0b8 --- /dev/null +++ b/network_sim_swap/CHANGES.md @@ -0,0 +1,17 @@ +# 1.1.2 +- Updated dependency versions + +# 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..e654b3bc --- /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.5.0", + "vonage-network-auth>=1.0.2", + "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..7b344eca --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.2' 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/nexmo/__init__.py b/nexmo/__init__.py deleted file mode 100644 index 3a841515..00000000 --- a/nexmo/__init__.py +++ /dev/null @@ -1,675 +0,0 @@ -from datetime import datetime -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import jwt -import os -import pytz -import requests -import sys -import time -from uuid import uuid4 -import warnings - -if sys.version_info[0] == 3: - string_types = (str, bytes) - from urllib.parse import urlparse - -else: - string_types = (unicode, str) - from urlparse import urlparse - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -from .errors import * -from ._internal import ApplicationV2, BasicAuthenticatedServer, _format_date_param - -__version__ = "2.4.0" - -logger = logging.getLogger("nexmo") - - -class Client: - """ - Create a Client object to start making calls to Nexmo APIs. - - Most methods corresponding to Nexmo API calls are on this class itself, - although newer APIs are under namespaces like :attr:`Client.application_v2`. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Nexmo API docs `_ for details of the - authentication used by the APIs you wish to use, and instantiate your - Client with the appropriate credentials. - - :param str key: Your Nexmo API key - :param str secret: Your Nexmo API secret. - :param str signature_secret: Your Nexmo API signature secret. - You may need to have this enabled by Nexmo support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Nexmo Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key if calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used by Nexmo to track your app statistics. - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - ): - self.api_key = key or os.environ.get("NEXMO_API_KEY", None) - - self.api_secret = secret or os.environ.get("NEXMO_API_SECRET", None) - - self.signature_secret = signature_secret or os.environ.get( - "NEXMO_SIGNATURE_SECRET", None - ) - self.signature_method = signature_method or os.environ.get( - "NEXMO_SIGNATURE_METHOD", None - ) - - if self.signature_method in {"md5", "sha1", "sha256", "sha512"}: - self.signature_method = getattr(hashlib, signature_method) - - self.application_id = application_id - - self.private_key = private_key - - if isinstance(self.private_key, string_types) and "\n" not in self.private_key: - with open(self.private_key, "rb") as key_file: - self.private_key = key_file.read() - - self.host = "rest.nexmo.com" - - self.api_host = "api.nexmo.com" - - user_agent = "nexmo-python/{version} python/{python_version}".format( - version=__version__, python_version=python_version() - ) - - if app_name and app_version: - user_agent += " {app_name}/{app_version}".format( - app_name=app_name, app_version=app_version - ) - - self.headers = {"User-Agent": user_agent} - - self.auth_params = {} - - api_server = BasicAuthenticatedServer( - "https://api.nexmo.com", - user_agent=user_agent, - api_key=self.api_key, - api_secret=self.api_secret, - ) - self.application_v2 = ApplicationV2(api_server) - - self.session = requests.Session() - - def auth(self, params=None, **kwargs): - self.auth_params = params or kwargs - - def send_message(self, params): - return self.post(self.host, "/sms/json", params) - - def get_balance(self): - return self.get(self.host, "/account/get-balance") - - def get_country_pricing(self, country_code): - return self.get( - self.host, "/account/get-pricing/outbound", {"country": country_code} - ) - - def get_prefix_pricing(self, prefix): - return self.get( - self.host, "/account/get-prefix-pricing/outbound", {"prefix": prefix} - ) - - def get_sms_pricing(self, number): - return self.get( - self.host, "/account/get-phone-pricing/outbound/sms", {"phone": number} - ) - - def get_voice_pricing(self, number): - return self.get( - self.host, "/account/get-phone-pricing/outbound/voice", {"phone": number} - ) - - def update_settings(self, params=None, **kwargs): - return self.post(self.host, "/account/settings", params or kwargs) - - def topup(self, params=None, **kwargs): - return self.post(self.host, "/account/top-up", params or kwargs) - - def get_account_numbers(self, params=None, **kwargs): - return self.get(self.host, "/account/numbers", params or kwargs) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self.get( - self.host, "/number/search", dict(params or kwargs, country=country_code) - ) - - def buy_number(self, params=None, **kwargs): - return self.post(self.host, "/number/buy", params or kwargs) - - def cancel_number(self, params=None, **kwargs): - return self.post(self.host, "/number/cancel", params or kwargs) - - def update_number(self, params=None, **kwargs): - return self.post(self.host, "/number/update", params or kwargs) - - def get_message(self, message_id): - return self.get(self.host, "/search/message", {"id": message_id}) - - def get_message_rejections(self, params=None, **kwargs): - return self.get(self.host, "/search/rejections", params or kwargs) - - def search_messages(self, params=None, **kwargs): - return self.get(self.host, "/search/messages", params or kwargs) - - def send_ussd_push_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd/json", params or kwargs) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self.post(self.host, "/ussd-prompt/json", params or kwargs) - - def send_2fa_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/2fa/json", params or kwargs) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Nexmo that an SMS was successfully received. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self.post(self.api_host, "/conversions/sms", params) - - def send_event_alert_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/json", params or kwargs) - - def send_marketing_message(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/marketing/json", params or kwargs) - - def get_event_alert_numbers(self): - return self.get(self.host, "/sc/us/alert/opt-in/query/json") - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self.post(self.host, "/sc/us/alert/opt-in/manage/json", params or kwargs) - - def initiate_call(self, params=None, **kwargs): - return self.post(self.host, "/call/json", params or kwargs) - - def initiate_tts_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts/json", params or kwargs) - - def initiate_tts_prompt_call(self, params=None, **kwargs): - return self.post(self.api_host, "/tts-prompt/json", params or kwargs) - - def start_verification(self, params=None, **kwargs): - return self.post(self.api_host, "/verify/json", params or kwargs) - - def send_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#send_verification_request is deprecated (use #start_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host, "/verify/json", params or kwargs) - - def check_verification(self, request_id, params=None, **kwargs): - return self.post( - self.api_host, - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - ) - - def check_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#check_verification_request is deprecated (use #check_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host, "/verify/check/json", params or kwargs) - - def get_verification(self, request_id): - return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} - ) - - def get_verification_request(self, request_id): - warnings.warn( - "nexmo.Client#get_verification_request is deprecated (use #get_verification instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get( - self.api_host, "/verify/search/json", {"request_id": request_id} - ) - - def cancel_verification(self, request_id): - return self.post( - self.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - ) - - def trigger_next_verification_event(self, request_id): - return self.post( - self.api_host, - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - ) - - def control_verification_request(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#control_verification_request is deprecated", - DeprecationWarning, - stacklevel=2, - ) - - return self.post(self.api_host, "/verify/control/json", params or kwargs) - - def get_basic_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/basic/json", params or kwargs) - - def get_standard_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/standard/json", params or kwargs) - - def get_number_insight(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_number_insight is deprecated (use #get_standard_number_insight instead)", - DeprecationWarning, - stacklevel=2, - ) - - return self.get(self.api_host, "/number/lookup/json", params or kwargs) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self.get(self.api_host, "/ni/advanced/json", params or kwargs) - - def request_number_insight(self, params=None, **kwargs): - return self.post(self.host, "/ni/json", params or kwargs) - - def get_applications(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#get_applications is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get(self.api_host, "/v1/applications", params or kwargs) - - def get_application(self, application_id): - warnings.warn( - "nexmo.Client#get_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.get( - self.api_host, - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_application(self, params=None, **kwargs): - warnings.warn( - "nexmo.Client#create_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.post(self.api_host, "/v1/applications", params or kwargs) - - def update_application(self, application_id, params=None, **kwargs): - warnings.warn( - "nexmo.Client#update_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.put( - self.api_host, - "/v1/applications/{application_id}".format(application_id=application_id), - params or kwargs, - ) - - def delete_application(self, application_id): - warnings.warn( - "nexmo.Client#delete_application is deprecated (use methods from #application_v2 instead)", - DeprecationWarning, - stacklevel=2, - ) - return self.delete( - self.api_host, - "/v1/applications/{application_id}".format(application_id=application_id), - ) - - def create_call(self, params=None, **kwargs): - return self._jwt_signed_post("/v1/calls", params or kwargs) - - def get_calls(self, params=None, **kwargs): - return self._jwt_signed_get("/v1/calls", params or kwargs) - - def get_call(self, uuid): - return self._jwt_signed_get("/v1/calls/{uuid}".format(uuid=uuid)) - - def update_call(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}".format(uuid=uuid), params or kwargs - ) - - def send_audio(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/stream".format(uuid=uuid), params or kwargs - ) - - def stop_audio(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/stream".format(uuid=uuid)) - - def send_speech(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/talk".format(uuid=uuid), params or kwargs - ) - - def stop_speech(self, uuid): - return self._jwt_signed_delete("/v1/calls/{uuid}/talk".format(uuid=uuid)) - - def send_dtmf(self, uuid, params=None, **kwargs): - return self._jwt_signed_put( - "/v1/calls/{uuid}/dtmf".format(uuid=uuid), params or kwargs - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - return self.parse(hostname, self.session.get(url, headers=self._headers())) - - def redact_transaction(self, id, product, type=None): - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._post_json(self.api_host, "/v1/redact/transaction", params) - - def list_secrets(self, api_key): - return self.get( - self.api_host, - "/accounts/{api_key}/secrets".format(api_key=api_key), - header_auth=True, - ) - - def get_secret(self, api_key, secret_id): - return self.get( - self.api_host, - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._post_json( - self.api_host, "/accounts/{api_key}/secrets".format(api_key=api_key), body - ) - - def delete_secret(self, api_key, secret_id): - return self.delete( - self.api_host, - "/accounts/{api_key}/secrets/{secret_id}".format( - api_key=api_key, secret_id=secret_id - ), - header_auth=True, - ) - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), digestmod=self.signature_method - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update("&{key}={value}".format(key=key, value=value).encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict( - params or {}, api_key=self.api_key, api_secret=self.api_secret - ) - logger.debug("GET to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.get(uri, params=params, headers=headers)) - - def post(self, host, request_uri, params, header_auth=False): - """ - Post form-encoded data to `request_uri`. - - Auth is either key/secret added to the post data, or basic auth, - if `header_auth` is True. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("POST to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.post(uri, data=params, headers=headers)) - - def _post_json(self, host, request_uri, json): - """ - Post json to `request_uri`, using basic auth. - """ - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - auth = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - headers = dict( - self.headers or {}, Authorization="Basic {hash}".format(hash=auth) - ) - logger.debug( - "POST to %r with body: %r, headers: %r", request_uri, json, headers - ) - return self.parse(host, self.session.post(uri, headers=headers, json=json)) - - def put(self, host, request_uri, params, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = dict(params, api_key=self.api_key, api_secret=self.api_secret) - logger.debug("PUT to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.put(uri, json=params, headers=headers)) - - def delete(self, host, request_uri, header_auth=False): - uri = "https://{host}{request_uri}".format(host=host, request_uri=request_uri) - - params = None - headers = self.headers - if header_auth: - h = base64.b64encode( - ( - "{api_key}:{api_secret}".format( - api_key=self.api_key, api_secret=self.api_secret - ).encode("utf-8") - ) - ).decode("ascii") - # Must create a new headers dict here, otherwise we'd be mutating `self.headers`: - headers = dict(headers or {}, Authorization="Basic {hash}".format(hash=h)) - else: - params = {"api_key": self.api_key, "api_secret": self.api_secret} - logger.debug("DELETE to %r with params %r, headers %r", uri, params, headers) - return self.parse(host, self.session.delete(uri, params=params, headers=headers)) - - def parse(self, host, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - # Strip off any encoding from the content-type header: - content_mime = response.headers.get("content-type").split(";", 1)[0] - if content_mime == "application/json": - return response.json() - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response from {host}".format( - code=response.status_code, host=host - ) - raise ServerError(message) - - def _jwt_signed_get(self, request_uri, params=None): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, - self.session.get(uri, params=params or {}, headers=self._headers()), - ) - - def _jwt_signed_post(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.post(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_put(self, request_uri, params): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse( - self.api_host, self.session.put(uri, json=params, headers=self._headers()) - ) - - def _jwt_signed_delete(self, request_uri): - uri = "https://{api_host}{request_uri}".format( - api_host=self.api_host, request_uri=request_uri - ) - - return self.parse(self.api_host, self.session.delete(uri, headers=self._headers())) - - def _headers(self): - token = self.generate_application_jwt() - return dict(self.headers, Authorization=b"Bearer " + token) - - def generate_application_jwt(self, when=None): - iat = int(when if when is not None else time.time()) - - payload = dict(self.auth_params) - payload.setdefault("application_id", self.application_id) - payload.setdefault("iat", iat) - payload.setdefault("exp", iat + 60) - payload.setdefault("jti", str(uuid4())) - - return jwt.encode(payload, self.private_key, algorithm="RS256") diff --git a/nexmo/_internal.py b/nexmo/_internal.py deleted file mode 100644 index a32921f0..00000000 --- a/nexmo/_internal.py +++ /dev/null @@ -1,175 +0,0 @@ -import logging - -from requests.sessions import Session - -from .errors import AuthenticationError, ClientError, ServerError - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("nexmo") - - -class BasicAuthenticatedServer(object): - def __init__(self, host, user_agent, api_key, api_secret): - self._host = host - self._session = session = Session() - session.auth = (api_key, api_secret) # Basic authentication. - session.headers.update({"User-Agent": user_agent}) - - def _uri(self, path): - return "{host}{path}".format(host=self._host, path=path) - - def get(self, path, params=None, headers=None): - return self._parse( - self._session.get(self._uri(path), params=params, headers=headers) - ) - - def post(self, path, body=None, headers=None): - return self._parse( - self._session.post(self._uri(path), json=body, headers=headers) - ) - - def put(self, path, body=None, headers=None): - return self._parse( - self._session.put(self._uri(path), json=body, headers=headers) - ) - - def delete(self, path, body=None, headers=None): - return self._parse( - self._session.delete(self._uri(path), json=body, headers=headers) - ) - - def _parse(self, response): - logger.debug("Response headers %r", response.headers) - if response.status_code == 401: - raise AuthenticationError() - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - return response.json() - elif 400 <= response.status_code < 500: - logger.warning( - "Client error: %s %r", response.status_code, response.content - ) - message = "{code} response".format(code=response.status_code) - # Test for standard error format: - try: - error_data = response.json() - if ( - "type" in error_data - and "title" in error_data - and "detail" in error_data - ): - message = "{title}: {detail} ({type})".format( - title=error_data["title"], - detail=error_data["detail"], - type=error_data["type"], - ) - except JSONDecodeError: - pass - raise ClientError(message) - elif 500 <= response.status_code < 600: - logger.warning( - "Server error: %s %r", response.status_code, response.content - ) - message = "{code} response".format(code=response.status_code) - raise ServerError(message) - - -class ApplicationV2(object): - """ - Provides Application API v2 functionality. - - Don't instantiate this class yourself, access it via :py:attr:`nexmo.Client.application_v2` - """ - - def __init__(self, api_server): - self._api_server = api_server - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application_v2.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.nexmo.com/api/application.v2#createApplication - """ - return self._api_server.post("/v2/applications", application_data) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.nexmo.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._api_server.get( - "/v2/applications/{application_id}".format(application_id=application_id), - headers={"content-type": "application/json"}, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._api_server.put( - "/v2/applications/{application_id}".format(application_id=application_id), - params, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._api_server.delete( - "/v2/applications/{application_id}".format(application_id=application_id), - headers={"content-type": "application/json"}, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._api_server.get( - "/v2/applications", - params=params, - headers={"content-type": "application/json"}, - ) - - -def _filter_none_values(d): - return {k: v for k, v in d.items() if v is not None} - - -def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): - """ - Utility function to convert datetime values to strings. - - If the value is already a str, or is not in the dict, no change is made. - - :param params: A `dict` of params that may contain a `datetime` value. - :param key: The datetime value to be converted to a `str` - :param format: The `strftime` format to be used to format the date. The default value is '%Y-%m-%d %H:%M:%S' - """ - if key in params: - param = params[key] - if hasattr(param, "strftime"): - params[key] = param.strftime(format) diff --git a/nexmo/errors.py b/nexmo/errors.py deleted file mode 100644 index 88995700..00000000 --- a/nexmo/errors.py +++ /dev/null @@ -1,14 +0,0 @@ -class Error(Exception): - pass - - -class ClientError(Error): - pass - - -class ServerError(Error): - pass - - -class AuthenticationError(ClientError): - pass diff --git a/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..a33b08fd --- /dev/null +++ b/number_insight/CHANGES.md @@ -0,0 +1,24 @@ +# 1.0.7 +- Use basic header auth instead of request body auth + +# 1.0.6 +- Updated dependency versions + +# 1.0.5 +- Fix missed method renaming +- Docstring update + +# 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..d98d5557 --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..887a3427 --- /dev/null +++ b/number_insight/src/vonage_number_insight/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.7' 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..cebc1227 --- /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 = 'basic' + + @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 get_standard_info( + 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..217e65e5 --- /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. + callback (str): The URL to send the asynchronous response to. + country (str, Optional): The country code for the phone number. + cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. + """ + + callback: str + + +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..90536e0f --- /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.get_standard_info(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_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..813f7da0 --- /dev/null +++ b/number_management/CHANGES.md @@ -0,0 +1,17 @@ +# 1.0.5 +- Added `by_alias=True` to the numbers update model + +# 1.0.4 +- Updated dependency versions + +# 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..575123a8 --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..858de170 --- /dev/null +++ b/number_management/src/vonage_numbers/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.5' 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..2bf05175 --- /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(by_alias=True, 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/number_management/tests/data/nothing.json b/number_management/tests/data/nothing.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/number_management/tests/data/nothing.json @@ -0,0 +1 @@ +{} \ No newline at end of file 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..3ad6fb2f --- /dev/null +++ b/number_management/tests/test_numbers.py @@ -0,0 +1,332 @@ +from os.path import abspath +from urllib.parse import parse_qs + +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' + + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447000000000', + } + assert parsed_data == expected_data + + +@responses.activate +def test_cancel_number(): + 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' + + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447000000000', + } + assert parsed_data == expected_data + + +@responses.activate +def test_cancel_number_error_no_number(): + 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') + + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447000000000', + } + assert parsed_data == expected_data + + +@responses.activate +def test_update_number(): + 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', + ) + ) + + # Verify the response + assert response.error_code == '200' + assert response.error_code_label == 'success' + + # The data is sent as form-encoded, so we need to parse it accordingly + request_body = responses.calls[0].request.body + form_data = parse_qs(request_body) + + # parse_qs returns values as lists, so we need to extract the first item + parsed_data = {key: value[0] for key, value in form_data.items()} + + expected_data = { + 'country': 'GB', + 'msisdn': '447009000000', + 'app_id': '29f769u7-7ce1-46c9-ade3-f2dedee4fr4t', + 'moHttpUrl': 'https://example.com', + 'moSmppSysType': 'inbound', + 'voiceCallbackType': 'tel', + 'voiceCallbackValue': '447009000000', + 'voiceStatusCallback': 'https://example.com', + } + assert parsed_data == expected_data + + +def test_update_number_options_error(): + with raises(NumbersError) as e: + 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..299dd51f --- /dev/null +++ b/pants.ci.toml @@ -0,0 +1,5 @@ +[GLOBAL] +colors = true + +[python] +interpreter_constraints = ['>=3.9'] diff --git a/pants.toml b/pants.toml new file mode 100644 index 00000000..730bfed9 --- /dev/null +++ b/pants.toml @@ -0,0 +1,46 @@ +[GLOBAL] +pants_version = '2.26.0' + +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.13.*'] + +[pytest] +args = ['-vv', '--no-header'] + +[coverage-py] +interpreter_constraints = ['>=3.9'] +report = ['html', 'console'] + +[black] +args = ['--line-length=90', '--skip-string-normalization'] +interpreter_constraints = ['>=3.9'] + +[isort] +args = ['--profile=black', '--line-length=90'] +interpreter_constraints = ['>=3.9'] + +[docformatter] +args = ['--wrap-summaries=90', '--wrap-descriptions=90'] +interpreter_constraints = ['>=3.9'] + +[autoflake] +interpreter_constraints = ['>=3.9'] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..867680d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.coverage.run] +omit = ['**/tests/*', '**/src/**/_version.py'] diff --git a/requirements.txt b/requirements.txt index 91203619..cb722af2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,29 @@ --e . -pytest==3.1.2 -pytest-cov==2.5.1 -responses==0.5.1 -coveralls -glom==18.3.1 +pytest>=8.0.0,<9.1 +requests>=2.31.0 +responses>=0.24.1 +pydantic>=2.9.2 +typing-extensions>=4.9.0 +pyjwt[crypto]>=1.6.4 +toml>=0.10.2 +urllib3 + +-e jwt +-e http_client +-e account +-e application +-e identity_insights +-e messages +-e network_auth +-e network_number_verification +-e network_sim_swap +-e number_insight +-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/requirements/docs.txt b/requirements/docs.txt deleted file mode 100644 index 4994a700..00000000 --- a/requirements/docs.txt +++ /dev/null @@ -1,2 +0,0 @@ -sphinx==1.4.6 -sphinx_rtd_theme==0.1.9 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 42eb6747..00000000 --- a/setup.cfg +++ /dev/null @@ -1,13 +0,0 @@ -[tool:pytest] -testpaths=tests -addopts=--tb=short -p no:doctest -norecursedirs = bin dist docs htmlcov .* {args} - -[pycodestyle] -max-line-length=120 - -[coverage:run] -source= nexmo - -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index d078053e..00000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -import io -import os - -from setuptools import setup - - -with io.open(os.path.join(os.path.dirname(__file__), "README.md"), - encoding='utf-8') as f: - long_description = f.read() - -setup( - name="nexmo", - version="2.4.0", - description="Nexmo Client Library for Python", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Nexmo/nexmo-python", - author="Nexmo", - author_email="devrel@nexmo.com", - license="MIT", - packages=["nexmo"], - platforms=["any"], - install_requires=[ - "requests>=2.4.2", - "PyJWT[crypto]>=1.6.4", - "pytz>=2018.5" - ], - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", - tests_require=[ - "cryptography>=2.3.1", - ], - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - ], -) 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..8d3c4e6f --- /dev/null +++ b/sms/CHANGES.md @@ -0,0 +1,32 @@ +# 1.2.0 +- Implement the `trusted_number` parameter + +# 1.1.6 +- Make returned response fields optional + +# 1.1.5 +- Updated dependency versions + +# 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..8d88647d --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..58d478ab --- /dev/null +++ b/sms/src/vonage_sms/_version.py @@ -0,0 +1 @@ +__version__ = '1.2.0' 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..1540fc7f --- /dev/null +++ b/sms/src/vonage_sms/requests.py @@ -0,0 +1,88 @@ +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. + trusted_number (bool, Optional): overrides, on a per-message basis, any + protections set up via Fraud Defender + """ + + 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') + trusted_number: Optional[bool] = Field(None, serialization_alias="trusted-number") + + @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..f9bc0053 --- /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: Optional[str] = None + message_id: Optional[str] = Field(None, validation_alias='message-id') + status: Optional[str] = None + remaining_balance: Optional[str] = Field(None, validation_alias='remaining-balance') + message_price: Optional[str] = Field(None, validation_alias='message-price') + network: Optional[str] = None + client_ref: Optional[str] = Field(None, validation_alias='client-ref') + account_ref: Optional[str] = Field(None, validation_alias='account-ref') + + +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..4ac3612e --- /dev/null +++ b/sms/tests/test_sms.py @@ -0,0 +1,179 @@ +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, + 'trusted_number': True, + '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 '402 response from https://api.nexmo.com/conversions/sms.' in err.message + + +def test_http_client_property(): + sms = Sms(HttpClient(Auth(api_key='qwerasdf', api_secret='1234qwerasdfzxcv'))) + assert isinstance(sms.http_client, HttpClient) 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..bcf4107a --- /dev/null +++ b/subaccounts/CHANGES.md @@ -0,0 +1,17 @@ +# 1.0.4 +- Updated dependency versions + +# 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..bed193bc --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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 8bbf454f..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,46 +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 nexmo - - self.api_key = "nexmo-api-key" - self.api_secret = "nexmo-api-secret" - self.signature_secret = "secret" - self.application_id = "nexmo-application-id" - self.private_key = read_file("data/private_key.txt") - self.public_key = read_file("data/public_key.txt") - self.user_agent = "nexmo-python/{} python/{}".format( - nexmo.__version__, platform.python_version() - ) - - -@pytest.fixture(scope="session") -def dummy_data(): - return DummyData() - - -@pytest.fixture -def client(dummy_data): - import nexmo - - return nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=dummy_data.private_key, - ) 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_v2/create_application.json b/tests/data/applications_v2/create_application.json deleted file mode 100644 index 18ab0453..00000000 --- a/tests/data/applications_v2/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_v2/get_application.json b/tests/data/applications_v2/get_application.json deleted file mode 100644 index 8f18afcd..00000000 --- a/tests/data/applications_v2/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_v2/list_applications.json b/tests/data/applications_v2/list_applications.json deleted file mode 100644 index c2f67537..00000000 --- a/tests/data/applications_v2/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_v2/update_application.json b/tests/data/applications_v2/update_application.json deleted file mode 100644 index 8a268167..00000000 --- a/tests/data/applications_v2/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/test_account.py b/tests/test_account.py deleted file mode 100644 index 07e94303..00000000 --- a/tests/test_account.py +++ /dev/null @@ -1,230 +0,0 @@ -import platform - -from glom import glom - -from util import * - -import nexmo - - -@responses.activate -def test_get_balance(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_application_info_options(dummy_data): - app_name, app_version = "ExampleApp", "X.Y.Z" - - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - app_name=app_name, - app_version=app_version, - ) - user_agent = "nexmo-python/{} python/{} {}/{}".format( - nexmo.__version__, - platform.python_version(), - app_name, - app_version, - ) - - assert isinstance(client.get_balance(), dict) - assert request_user_agent() == user_agent - - -@responses.activate -def test_get_country_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound") - - assert isinstance(client.get_country_pricing("GB"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=GB" in request_query() - - -@responses.activate -def test_get_prefix_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound") - - assert isinstance(client.get_prefix_pricing(44), dict) - assert request_user_agent() == dummy_data.user_agent - assert "prefix=44" in request_query() - - -@responses.activate -def test_get_sms_pricing(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") - - assert isinstance(client.get_sms_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_get_voice_pricing(client, dummy_data): - stub( - responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice" - ) - - assert isinstance(client.get_voice_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_update_settings(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/settings") - - params = {"moCallBackUrl": "http://example.com/callback"} - - assert isinstance(client.update_settings(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() - - -@responses.activate -def test_topup(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/top-up") - - params = {"trx": "00X123456Y7890123Z"} - - assert isinstance(client.topup(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "trx=00X123456Y7890123Z" in request_body() - - -@responses.activate -def test_get_account_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") - - assert isinstance(client.get_account_numbers(size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] - - -@responses.activate -def test_list_secrets(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/list.json", - ) - - secrets = client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - glom(secrets, "_embedded.secrets.0.id") - == "ad6dc56f-07b5-46e1-a527-85530e625800" - ) - - -@responses.activate -def test_list_secrets_missing(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=404, - fixture_path="account/secret_management/missing.json", - ) - - with pytest.raises(nexmo.ClientError) as ce: - client.list_secrets("meaccountid") - assert_basic_auth() - assert ( - """ClientError: Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - in str(ce) - ) - - -@responses.activate -def test_get_secret(client): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - fixture_path="account/secret_management/get.json", - ) - - secret = client.get_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_delete_secret(client): - stub( - responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret" - ) - - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - - -@responses.activate -def test_delete_secret_last_secret(client): - stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - status_code=403, - fixture_path="account/secret_management/last-secret.json", - ) - with pytest.raises(nexmo.ClientError) as ce: - client.delete_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - in str(ce) - ) - - -@responses.activate -def test_create_secret(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/create.json", - ) - - secret = client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret_max_secrets(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=403, - fixture_path="account/secret_management/max-secrets.json", - ) - - with pytest.raises(nexmo.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - in str(ce) - ) - - -@responses.activate -def test_create_secret_validation(client): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=400, - fixture_path="account/secret_management/create-validation.json", - ) - - with pytest.raises(nexmo.ClientError) as ce: - client.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - """ClientError: Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - in str(ce) - ) diff --git a/tests/test_applications.py b/tests/test_applications.py deleted file mode 100644 index 9e760b3a..00000000 --- a/tests/test_applications.py +++ /dev/null @@ -1,58 +0,0 @@ -from util import * - - -@responses.activate -def test_get_applications(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications") - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.get_applications(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_get_application(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications/xx-xx-xx-xx") - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.get_application("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_create_application(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - - params = {"name": "Example App", "type": "voice"} - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.create_application(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "name=Example+App" in request_body() - assert "type=voice" in request_body() - - -@responses.activate -def test_update_application(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications/xx-xx-xx-xx") - - params = {"answer_url": "https://example.com/ncco"} - - with pytest.warns(DeprecationWarning) as warning_info: - assert isinstance(client.update_application("xx-xx-xx-xx", params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b'"answer_url": "https://example.com/ncco"' in request_body() - - -@responses.activate -def test_delete_application(client, dummy_data): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v1/applications/xx-xx-xx-xx", - status=204, - ) - - with pytest.warns(DeprecationWarning) as warning_info: - assert client.delete_application("xx-xx-xx-xx") is None - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_applications_v2.py b/tests/test_applications_v2.py deleted file mode 100644 index 7dcc6883..00000000 --- a/tests/test_applications_v2.py +++ /dev/null @@ -1,153 +0,0 @@ -import json -from util import * - -import nexmo - - -@responses.activate -def test_list_applications(client, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications", - fixture_path="applications_v2/list_applications.json", - ) - - apps = client.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_get_application(client, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications_v2/get_application.json", - ) - - app = client.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_create_application(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/v2/applications", - fixture_path="applications_v2/create_application.json", - ) - - params = {"name": "Example App", "type": "voice"} - - app = client.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_update_application(client, dummy_data): - stub( - responses.PUT, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications_v2/update_application.json", - ) - - params = {"answer_url": "https://example.com/ncco"} - - app = client.application_v2.update_application("xx-xx-xx-xx", params) - assert_basic_auth() - assert isinstance(app, dict) - assert request_user_agent() == dummy_data.user_agent - 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_v2.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(nexmo.AuthenticationError): - client.application_v2.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(nexmo.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") - assert ( - str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - ) - - -@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(nexmo.ClientError) as exc_info: - client.application_v2.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "430 response" - - -@responses.activate -def test_server_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(nexmo.ServerError): - client.application_v2.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_server_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(nexmo.ServerError): - client.application_v2.delete_application("xx-xx-xx-xx") diff --git a/tests/test_insight.py b/tests/test_insight.py deleted file mode 100644 index d4089a10..00000000 --- a/tests/test_insight.py +++ /dev/null @@ -1,49 +0,0 @@ -from util import * - - -@responses.activate -def test_get_basic_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/basic/json") - - assert isinstance(client.get_basic_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_standard_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/standard/json") - - assert isinstance(client.get_standard_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/number/lookup/json") - - assert isinstance(client.get_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_advanced_number_insight(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") - - assert isinstance(client.get_advanced_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_request_number_insight(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ni/json") - - params = {"number": "447525856424", "callback": "https://example.com"} - - assert isinstance(client.request_number_insight(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "callback=https%3A%2F%2Fexample.com" in request_body() diff --git a/tests/test_misc.py b/tests/test_misc.py deleted file mode 100644 index 414b0a3e..00000000 --- a/tests/test_misc.py +++ /dev/null @@ -1,107 +0,0 @@ -from util import * - - -@responses.activate -def test_get(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) - 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_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, header_auth=True) - 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(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) - 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_auth(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params, header_auth=True) - 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(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.put(host, request_uri, params=params) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() - - -@responses.activate -def test_put_with_auth(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.put(host, request_uri, params=params, header_auth=True) - 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(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - response = client.delete(host, request_uri) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_delete_with_auth(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - response = client.delete(host, request_uri, header_auth=True) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert_basic_auth() diff --git a/tests/test_nexmo.py b/tests/test_nexmo.py deleted file mode 100644 index f0774799..00000000 --- a/tests/test_nexmo.py +++ /dev/null @@ -1,213 +0,0 @@ -import nexmo -from util import * - -import sys - -if sys.version_info[0] == 3: - bytes_type = bytes -else: - bytes_type = str - - -@responses.activate -def test_send_ussd_push_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd/json") - - params = {"from": "MyCompany20", "to": "447525856424", "text": "Hello"} - - assert isinstance(client.send_ussd_push_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=MyCompany20" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_send_ussd_prompt_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") - - params = {"from": "long-virtual-number", "to": "447525856424", "text": "Hello"} - - assert isinstance(client.send_ussd_prompt_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=long-virtual-number" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_send_2fa_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") - - params = {"to": "16365553226", "pin": "1234"} - - assert isinstance(client.send_2fa_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "pin=1234" in request_body() - - -@responses.activate -def test_send_event_alert_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/json") - - params = {"to": "16365553226", "server": "host", "link": "http://example.com/"} - - assert isinstance(client.send_event_alert_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "server=host" in request_body() - assert "link=http%3A%2F%2Fexample.com%2F" in request_body() - - -@responses.activate -def test_send_marketing_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/marketing/json") - - params = { - "from": "short-code", - "to": "16365553226", - "keyword": "NEXMO", - "text": "Hello", - } - - assert isinstance(client.send_marketing_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=short-code" in request_body() - assert "to=16365553226" in request_body() - assert "keyword=NEXMO" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_get_event_alert_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/sc/us/alert/opt-in/query/json") - - assert isinstance(client.get_event_alert_numbers(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_resubscribe_event_alert_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/opt-in/manage/json") - - params = {"msisdn": "441632960960"} - - assert isinstance(client.resubscribe_event_alert_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "msisdn=441632960960" in request_body() - - -def test_check_signature(dummy_data): - params = { - "a": "1", - "b": "2", - "timestamp": "1461605396", - "sig": "6af838ef94998832dbfc29020b564830", - } - - client = nexmo.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 = nexmo.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 = nexmo.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 = nexmo.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 = nexmo.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 = nexmo.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 = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha512", - ) - assert ( - client.signature(params) - == "812a18f76680fa0fe1b8bd9ee1625466ceb1bd96242e4d050d2cfd9a7b40166c63ed26ec9702168781b6edcf1633db8ff95af9341701004eec3fcf9550572ee8" - ) - - -def test_client_doesnt_require_api_key(): - client = nexmo.Client(application_id="myid", private_key="abc\nde") - assert client is not None - assert client.api_key is None - assert client.api_secret is None - - -@responses.activate -def test_client_can_make_application_requests_without_api_key(dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - client = nexmo.Client(application_id="myid", private_key=dummy_data.private_key) - client.create_call("123455") - - -@responses.activate -def test_get_recording(client, dummy_data): - stub_bytes( - responses.GET, - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", - ) - - assert isinstance( - client.get_recording( - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957" - ), - bytes_type, - ) - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_numbers.py b/tests/test_numbers.py deleted file mode 100644 index 7f4a4c99..00000000 --- a/tests/test_numbers.py +++ /dev/null @@ -1,48 +0,0 @@ -from util import * - - -@responses.activate -def test_get_available_numbers(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/number/search") - - assert isinstance(client.get_available_numbers("CA", size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=CA" in request_query() - assert "size=25" in request_query() - - -@responses.activate -def test_buy_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/buy") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(client.buy_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - - -@responses.activate -def test_cancel_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/cancel") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(client.cancel_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - - -@responses.activate -def test_update_number(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/update") - - params = {"country": "US", "msisdn": "number", "moHttpUrl": "callback"} - - assert isinstance(client.update_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - assert "moHttpUrl=callback" in request_body() diff --git a/tests/test_redact.py b/tests/test_redact.py deleted file mode 100644 index 06e6c5ca..00000000 --- a/tests/test_redact.py +++ /dev/null @@ -1,30 +0,0 @@ -from util import * - - -@responses.activate -def test_redact_transaction(client, dummy_data): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/redact/transaction", - body=None, - status=204, - ) - - assert client.redact_transaction(id="not-a-real-id", product="sms") is None - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_redact_transaction_with_type(client, dummy_data): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/redact/transaction", - body=None, - status=204, - ) - - assert client.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_search.py b/tests/test_search.py deleted file mode 100644 index e36afaa8..00000000 --- a/tests/test_search.py +++ /dev/null @@ -1,42 +0,0 @@ -from util import * - - -@responses.activate -def test_get_message(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/message") - - assert isinstance(client.get_message("00A0B0C0"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "id=00A0B0C0" in request_query() - - -@responses.activate -def test_get_message_rejections(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/rejections") - - assert isinstance(client.get_message_rejections(date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - - -@responses.activate -def test_search_messages(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance(client.search_messages(to="1234567890", date="YYYY-MM-DD"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "date=YYYY-MM-DD" in request_query() - assert "to=1234567890" in request_query() - - -@responses.activate -def test_search_messages_by_ids(client, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/search/messages") - - assert isinstance( - client.search_messages(ids=["00A0B0C0", "00A0B0C1", "00A0B0C2"]), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "ids=00A0B0C0" in request_query() - assert "ids=00A0B0C1" in request_query() - assert "ids=00A0B0C2" in request_query() diff --git a/tests/test_sms.py b/tests/test_sms.py deleted file mode 100644 index 8d3b18ad..00000000 --- a/tests/test_sms.py +++ /dev/null @@ -1,52 +0,0 @@ -import nexmo -from util import * - - -@responses.activate -def test_send_message(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(client.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(nexmo.AuthenticationError): - client.send_message({}) - - -@responses.activate -def test_client_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(nexmo.ClientError) as excinfo: - client.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(client): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(nexmo.ServerError) as excinfo: - client.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(client): - responses.add( - responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK" - ) - - client.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py deleted file mode 100644 index 8e0bb184..00000000 --- a/tests/test_verify.py +++ /dev/null @@ -1,101 +0,0 @@ -from util import * - - -@responses.activate -def test_start_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_send_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(client.send_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance( - client.check_verification("8g88g88eg8g8gg9g90", code="123445"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_check_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - params = {"code": "123445", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.check_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_get_verification_request(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(client.get_verification_request("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(client.cancel_verification("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance( - client.trigger_next_verification_event("8g88g88eg8g8gg9g90"), dict - ) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_control_verification_request(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - params = {"cmd": "cancel", "request_id": "8g88g88eg8g8gg9g90"} - - assert isinstance(client.control_verification_request(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() diff --git a/tests/test_voice.py b/tests/test_voice.py deleted file mode 100644 index 9b7e4f68..00000000 --- a/tests/test_voice.py +++ /dev/null @@ -1,151 +0,0 @@ -import os.path -import time - -import jwt - -import nexmo -from util import * - - -@responses.activate -def test_create_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(client.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(client.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(client.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - client.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(client.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(client.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(client.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-nexmo-application-id" - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(application_id=application_id, nbf=nbf, exp=exp) - client.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithm="RS256") - - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = nexmo.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - client.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithm="RS256" - ) - assert token["application_id"] == dummy_data.application_id diff --git a/tests/test_voice_deprecated.py b/tests/test_voice_deprecated.py deleted file mode 100644 index daa172a9..00000000 --- a/tests/test_voice_deprecated.py +++ /dev/null @@ -1,44 +0,0 @@ -from util import * - - -@responses.activate -def test_initiate_call(client, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/call/json") - - params = {"to": "16365553226", "answer_url": "http://example.com/answer"} - - assert isinstance(client.initiate_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "answer_url=http%3A%2F%2Fexample.com%2Fanswer" in request_body() - - -@responses.activate -def test_initiate_tts_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/tts/json") - - params = {"to": "16365553226", "text": "Hello"} - - assert isinstance(client.initiate_tts_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_initiate_tts_prompt_call(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/tts-prompt/json") - - params = { - "to": "16365553226", - "text": "Hello", - "max_digits": 4, - "bye_text": "Goodbye", - } - - assert isinstance(client.initiate_tts_prompt_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "text=Hello" in request_body() - assert "max_digits=4" in request_body() - assert "bye_text=Goodbye" in request_body() diff --git a/tests/util.py b/tests/util.py deleted file mode 100644 index aa8511fb..00000000 --- a/tests/util.py +++ /dev/null @@ -1,72 +0,0 @@ -import os.path -import re - -import pytest - - -try: - from urllib.parse import urlparse, parse_qs -except ImportError: - from urlparse 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): - responses.add(method, url, body=b"THISISANMP3", status=200) - - -def assert_re(pattern, string): - __tracebackhide__ = True - if not re.search(pattern, string): - pytest.fail("Cannot find pattern {!r} in {!r}".format(pattern, 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..ad4d21dd --- /dev/null +++ b/testutils/__init__.py @@ -0,0 +1,13 @@ +from .mock_auth import ( + get_base64_encoded_api_key_and_secret, + get_mock_api_key_auth, + get_mock_jwt_auth, +) +from .testutils import build_response + +__all__ = [ + 'build_response', + 'get_mock_api_key_auth', + 'get_mock_jwt_auth', + 'get_base64_encoded_api_key_and_secret', +] 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..4975ed4f --- /dev/null +++ b/testutils/mock_auth.py @@ -0,0 +1,35 @@ +from base64 import b64encode +from os.path import dirname, join + +from vonage_http_client.auth import Auth + +test_api_key = 'test_api_key' +test_api_secret = 'test_api_secret' + + +def read_file(path): + """Read a file from the testutils/data directory.""" + + with open(join(dirname(__file__), path)) as input_file: + return input_file.read() + + +def get_base64_encoded_api_key_and_secret(): + """Return a base64 encoded string of the API key and secret.""" + + return b64encode(f'{test_api_key}:{test_api_secret}'.encode('utf-8')).decode('ascii') + + +def get_mock_api_key_auth(): + """Return an Auth object with an API key and secret.""" + + return Auth(api_key=test_api_key, api_secret=test_api_secret) + + +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..34c04b94 --- /dev/null +++ b/testutils/testutils.py @@ -0,0 +1,59 @@ +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.""" + + try: + with open(join(dirname(caller_file_path), 'data', mock_path)) as file: + return file.read() + except UnicodeDecodeError: + with open(join(dirname(caller_file_path), 'data', mock_path), 'rb') as file: + return file.read() + + +def _filter_none_values(data: dict) -> dict: + """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/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..001620a7 --- /dev/null +++ b/users/CHANGES.md @@ -0,0 +1,32 @@ +# 1.2.2 +- Fix unit test + +# 1.2.1 +- Updated dependency versions + +# 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..79280fdc --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..923b9879 --- /dev/null +++ b/users/src/vonage_users/_version.py @@ -0,0 +1 @@ +__version__ = '1.2.2' diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py new file mode 100644 index 00000000..b7af86c7 --- /dev/null +++ b/users/src/vonage_users/common.py @@ -0,0 +1,174 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, 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. + """ + + model_config = ConfigDict(populate_by_name=True) + + uri: str = Field(pattern=r'^(ws|wss):\/\/[a-zA-Z0-9~#%@&-_?\/.,:;)(\]\[]*$') + content_type: Optional[str] = Field( + None, alias='content-type', pattern='^audio/l16;rate=(8000|16000|24000)$' + ) + 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/users/tests/test_websocket_channel_24k.py b/users/tests/test_websocket_channel_24k.py new file mode 100644 index 00000000..a869a86c --- /dev/null +++ b/users/tests/test_websocket_channel_24k.py @@ -0,0 +1,8 @@ +from vonage_users.common import WebsocketChannel + + +def test_websocket_channel_24k_audio(): + channel = WebsocketChannel( + uri="wss://example.com/socket", content_type="audio/l16;rate=24000" + ) + assert channel.model_dump(by_alias=True)["content-type"] == "audio/l16;rate=24000" diff --git a/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..7a84470d --- /dev/null +++ b/verify/CHANGES.md @@ -0,0 +1,28 @@ +# 2.2.0 +- Add support for WhatsApp mode + +# 2.1.0 +- Add support for API key/secret header authentication +- Updated dependency versions + +# 2.0.0 +- Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. This package now contains code for the Verify v2 API +- 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..8d84878d --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..34b87445 --- /dev/null +++ b/verify/src/vonage_verify/__init__.py @@ -0,0 +1,28 @@ +from .enums import ChannelType, Locale, WhatsappMode +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', + 'WhatsappMode', + '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..04188a16 --- /dev/null +++ b/verify/src/vonage_verify/_version.py @@ -0,0 +1 @@ +__version__ = '2.2.0' diff --git a/verify/src/vonage_verify/enums.py b/verify/src/vonage_verify/enums.py new file mode 100644 index 00000000..987f0e32 --- /dev/null +++ b/verify/src/vonage_verify/enums.py @@ -0,0 +1,30 @@ +from enum import Enum + + +class ChannelType(str, Enum): + SILENT_AUTH = 'silent_auth' + SMS = 'sms' + WHATSAPP = 'whatsapp' + VOICE = 'voice' + EMAIL = 'email' + + +class WhatsappMode(str, Enum): + ZERO_TAP = 'zero_tap' + OTP_CODE = 'otp_code' + + +class Locale(str, Enum): + EN_US = 'en-us' + EN_GB = 'en-gb' + 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..478b1c59 --- /dev/null +++ b/verify/src/vonage_verify/requests.py @@ -0,0 +1,188 @@ +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, WhatsappMode +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): [Deprecated] 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. + mode (WhatsappMode, Optional): Defines the WhatsApp verification experience. Use + `WhatsappMode.ZERO_TAP` for automatic verification on Android apps. Defaults + to `WhatsappMode.OTP_CODE`. + + Raises: + VerifyError: If the `from_` field is not a valid phone number or string of 3-11 + alphanumeric characters. + """ + + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + mode: Optional[WhatsappMode] = None + 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..d87b4d38 --- /dev/null +++ b/verify/src/vonage_verify/verify.py @@ -0,0 +1,93 @@ +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 + self._auth_type = 'jwt' + + if self._http_client.auth.application_id is None: + self._auth_type = 'basic' + + @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), + self._auth_type, + ) + + 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}, + self._auth_type, + ) + 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}', + auth_type=self._auth_type, + ) + + @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', + auth_type=self._auth_type, + ) diff --git a/verify/tests/BUILD b/verify/tests/BUILD new file mode 100644 index 00000000..4b3ba9ae --- /dev/null +++ b/verify/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['verify', 'testutils']) diff --git a/verify/tests/data/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/verify/tests/data/check_code_400.json b/verify/tests/data/check_code_400.json new file mode 100644 index 00000000..23690a83 --- /dev/null +++ b/verify/tests/data/check_code_400.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#bad-request", + "title": "Invalid Code", + "detail": "The code you provided does not match the expected value.", + "instance": "475343c0-9239-4715-aed1-72b4a18379d1" +} \ No newline at end of file diff --git a/verify/tests/data/check_code_410.json b/verify/tests/data/check_code_410.json new file mode 100644 index 00000000..9d2534c7 --- /dev/null +++ b/verify/tests/data/check_code_410.json @@ -0,0 +1,6 @@ +{ + "title": "Invalid Code", + "detail": "An incorrect code has been provided too many times. Workflow terminated.", + "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..d34358c3 --- /dev/null +++ b/verify/tests/test_models.py @@ -0,0 +1,155 @@ +from pytest import raises +from vonage_verify.enums import ChannelType, Locale, WhatsappMode +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, 'mode': None} + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + WhatsappChannel(**params) + + +def test_create_whatsapp_channel_with_mode(): + params = { + 'channel': ChannelType.WHATSAPP, + 'to': '1234567890', + 'from_': 'Vonage', + 'mode': WhatsappMode.ZERO_TAP, + } + channel = WhatsappChannel(**params) + + assert channel.mode == WhatsappMode.ZERO_TAP + assert channel.model_dump()['mode'] == WhatsappMode.ZERO_TAP + + params['mode'] = WhatsappMode.OTP_CODE + channel = WhatsappChannel(**params) + assert channel.mode == WhatsappMode.OTP_CODE + + +def test_create_voice_channel(): + params = { + 'channel': ChannelType.VOICE, + '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..b5926137 --- /dev/null +++ b/verify/tests/test_verify.py @@ -0,0 +1,271 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.auth import Auth +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_verify.requests import * +from vonage_verify.verify import Verify + +from testutils import build_response, get_mock_api_key_auth, get_mock_jwt_auth + +path = abspath(__file__) + + +verify = Verify(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_default_auth_type(): + verify = Verify( + HttpClient( + Auth( + api_key='asdf', + api_secret='asdf', + application_id='asdf', + private_key='-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZz9Zz\n-----END PRIVATE-KEY----', + ) + ) + ) + assert verify._auth_type == 'jwt' + + +@responses.activate +def test_make_verify_request(): + build_response( + 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 + assert verify._auth_type == 'jwt' + + +@responses.activate +def test_make_verify_request_basic_auth(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + request = VerifyRequest(**params) + + verify = Verify(HttpClient(get_mock_api_key_auth())) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert verify._http_client.last_response.status_code == 202 + assert verify._auth_type == 'basic' + + +@responses.activate +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_basic_auth(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', + ) + verify = Verify(HttpClient(get_mock_api_key_auth())) + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' + ) + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' + assert verify._auth_type == 'basic' + + +@responses.activate +def test_check_code_invalid_code_error(): + build_response( + 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_cancel_verification_basic_auth(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, + ) + + verify = Verify(HttpClient(get_mock_api_key_auth())) + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 + assert verify._auth_type == 'basic' + + +@responses.activate +def test_trigger_next_workflow(): + responses.add( + 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_workflow_basic_auth(): + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, + ) + + verify = Verify(HttpClient(get_mock_api_key_auth())) + assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 200 + assert verify._auth_type == 'basic' + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + 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..3f8935ba --- /dev/null +++ b/verify_legacy/CHANGES.md @@ -0,0 +1,8 @@ +# 1.1.0 +- Add support for Basic Auth + +# 1.0.1 +- Updated dependency versions + +# 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..e3f105b2 --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..1a72d32e --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.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..8c8f2583 --- /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 = 'basic' + + @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..297f0d65 --- /dev/null +++ b/verify_legacy/tests/test_verify_legacy.py @@ -0,0 +1,362 @@ +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_base64_encoded_api_key_and_secret, + 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) + + +@responses.activate +def test_default_auth_type(): + verify = VerifyLegacy(HttpClient(get_mock_api_key_auth())) + assert verify._auth_type == 'basic' + + +def test_create_verify_request_model(): + params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', 'lg': LanguageCode.en_us, **data} + request = VerifyRequest(**params) + + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_make_psd2_request(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_verify_request_error(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_check_code_error(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_search_list_of_ids(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_search_error(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_cancel_verification_error(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_trigger_next_event_error(): + 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' + + request_headers = responses.calls[0].request.headers + assert ( + request_headers["Authorization"] + == "Basic " + get_base64_encoded_api_key_and_secret() + ) + + +@responses.activate +def test_request_network_unblock_error(): + 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..bfad17f4 --- /dev/null +++ b/video/CHANGES.md @@ -0,0 +1,20 @@ +# 1.2.0 +- Make all models originally accessed by `vonage_video.models.***` available at the top level of the package, i.e. `vonage_video.***` + +# 1.1.0 +- Add new `max_bitrate` field for archives + +# 1.0.4 +- Updated dependency versions + +# 1.0.3 +- Make the filter optional in `Video.list_archives` and `Video.list_broadcasts` + +# 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..f7a2b2d9 --- /dev/null +++ b/video/OPENTOK_TO_VONAGE_MIGRATION.md @@ -0,0 +1,111 @@ +# 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` package, e.g. + +```python +from vonage_video 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. +- 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`| + +## 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..3820f99a --- /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. + +### Generate a Client Token + +```python +from vonage_video import TokenOptions + +token_options = TokenOptions(session_id='your_session_id', role='publisher') +client_token = vonage_client.video.generate_client_token(token_options) +``` + +### Create a Session + +```python +from vonage_video 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 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 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 import CaptionsOptions + +captions_options = CaptionsOptions(language='en-US') +captions_data = vonage_client.video.start_captions(captions_options) +``` + +### Stop Captions + +```python +from vonage_video import CaptionsData + +captions_data = CaptionsData(captions_id='your_captions_id') +vonage_client.video.stop_captions(captions_data) +``` + +### Start Audio Connector + +```python +from vonage_video import AudioConnectorOptions + +audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') +audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) +``` + +### Start Experience Composer + +```python +from vonage_video import ExperienceComposerOptions + +experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') +experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) +``` + +### List Experience Composers + +```python +from vonage_video 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 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 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 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 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 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 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 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 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 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='your_username', password='your_password'), + 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..baed076a --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..a91ef7a5 --- /dev/null +++ b/video/src/vonage_video/__init__.py @@ -0,0 +1,5 @@ +from . import errors, models # Import models to access the module directly +from .models import * # Need this to directly expose data models +from .video import Video + +__all__ = ['Video', 'errors', 'models'] diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py new file mode 100644 index 00000000..51ed7c48 --- /dev/null +++ b/video/src/vonage_video/_version.py @@ -0,0 +1 @@ +__version__ = '1.5.1' 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..800c7e61 --- /dev/null +++ b/video/src/vonage_video/models/archive.py @@ -0,0 +1,195 @@ +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. + url (str, Optional): The URL of the transcription file. + primaryLanguageCode (str, Optional): The primary language code for transcription. + hasSummary (bool, Optional): Whether the transcription includes a summary. + """ + + status: Optional[str] = None + reason: Optional[str] = None + url: Optional[str] = None + primaryLanguageCode: Optional[str] = None + hasSummary: Optional[bool] = None + + +class Archive(BaseModel): + """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. + max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per + second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. + """ + + id: Optional[str] = None + 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 + max_bitrate: Optional[int] = Field(None, validation_alias='maxBitrate') + quantization_parameter: Optional[int] = Field( + None, validation_alias='quantizationParameter' + ) + + +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"). + max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per + second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. + Raises: + NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. + IndividualArchivePropertyError: If `resolution`, `layout`, or `quantization_parameter` 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') + max_bitrate: Optional[int] = Field( + None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' + ) + quantization_parameter: Optional[int] = Field( + None, ge=15, le=40, serialization_alias='quantizationParameter' + ) + + @model_validator(mode='after') + def validate_audio_or_video(self): + 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\'`.' + ) + if ( + self.output_mode == OutputMode.INDIVIDUAL + and self.quantization_parameter is not None + ): + raise IndividualArchivePropertyError( + 'The `quantization_parameter` property cannot be set for `archive_mode: \'individual\'`.' + ) + return self + + @model_validator(mode='after') + 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..4c8fc060 --- /dev/null +++ b/video/src/vonage_video/models/audio_connector.py @@ -0,0 +1,65 @@ +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. + bidirectional (bool): Whether the websocket is bidirectional. + """ + + uri: str + streams: Optional[list[str]] = None + headers: Optional[dict] = None + audio_rate: Optional[AudioSampleRate] = Field(None, serialization_alias='audioRate') + bidirectional: Optional[bool] = Field( + None, description="Whether the websocket is bidirectional." + ) + + def model_dump(self, *args, **kwargs): + data = super().model_dump(*args, **kwargs) + if self.bidirectional is not True and 'bidirectional' in data: + del data['bidirectional'] + + if 'audioRate' in data and isinstance(data['audioRate'], AudioSampleRate): + data['audioRate'] = data['audioRate'].value + return data + + +class AudioConnectorOptions(BaseModel): + """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 + + def model_dump(self, *args, **kwargs): + data = super().model_dump(*args, **kwargs) + if isinstance(self.websocket, AudioConnectorWebSocket): + data['websocket'] = self.websocket.model_dump(*args, **kwargs) + return data + + +class AudioConnectorData(BaseModel): + """Class containing Audio Connector WebSocket ID and connection ID. + + 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..01413c0f --- /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, Optional): 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 = ListArchivesFilter() + ) -> tuple[list[Archive], int, Optional[int]]: + """Lists archives associated with a Vonage Application. + + Args: + filter (ListArchivesFilter, Optional): 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 = ListBroadcastsFilter() + ) -> tuple[list[Broadcast], int, Optional[int]]: + """Lists broadcasts associated with a Vonage Application. + + Args: + filter (ListBroadcastsFilter, Optional): 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 = 1 if request_filter is not None else request_filter.offset + 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..7871653a --- /dev/null +++ b/video/tests/data/archive.json @@ -0,0 +1,25 @@ +{ + "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, + "maxBitrate": 2000000, + "quantizationParameter": 25 +} \ No newline at end of file diff --git a/video/tests/data/archive_with_transcription.json b/video/tests/data/archive_with_transcription.json new file mode 100644 index 00000000..1e207bb7 --- /dev/null +++ b/video/tests/data/archive_with_transcription.json @@ -0,0 +1,30 @@ +{ + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "available", + "name": "archive with transcription", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 1048576, + "duration": 180, + "outputMode": "individual", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": true, + "sha256sum": "abc123def456", + "password": "", + "updatedAt": 1727870634977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4", + "transcription": { + "status": "completed", + "reason": "transcription completed successfully", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "en-US", + "hasSummary": true + } +} \ No newline at end of file diff --git a/video/tests/data/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/video/tests/data/create_session.json b/video/tests/data/create_session.json new file mode 100644 index 00000000..514ba8ec --- /dev/null +++ b/video/tests/data/create_session.json @@ -0,0 +1,19 @@ +[ + { + "session_id": "1_MX4yOWY3NjBmOC03Y2UxLTQ2YzktYWRlMy1mMmRlZGVlNGVkNWZ-fjE3MjY0NjI1ODg2NDd-MTF4TGExYmJoelBlR1FHbVhzbWd4STBrfn5-", + "project_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "partner_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", + "create_dt": "Sun Sep 15 21:56:28 PDT 2024", + "session_status": null, + "status_invalid": null, + "media_server_hostname": null, + "messaging_server_url": null, + "messaging_url": null, + "symphony_address": null, + "properties": null, + "ice_server": null, + "session_segment_id": "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..b839ef2f --- /dev/null +++ b/video/tests/data/list_archives.json @@ -0,0 +1,52 @@ +{ + "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", + "maxBitrate": 2000000 + } + ] +} \ No newline at end of file diff --git a/video/tests/data/list_archives_with_transcription.json b/video/tests/data/list_archives_with_transcription.json new file mode 100644 index 00000000..48eb3a07 --- /dev/null +++ b/video/tests/data/list_archives_with_transcription.json @@ -0,0 +1,82 @@ +{ + "count": 3, + "items": [ + { + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "paused", + "name": "archive without transcription", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727870434977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": null + }, + { + "id": "a9cdeb69-f6cf-408b-9197-6f99e6eac5aa", + "status": "available", + "name": "completed archive", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 1024000, + "duration": 134, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "test_sha256_sum", + "password": "", + "updatedAt": 1727870634977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4", + "maxBitrate": 2000000 + }, + { + "id": "c1d2e3f4-g5h6-i7j8-k9l0-m1n2o3p4q5r6", + "status": "available", + "name": "transcribed archive", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 2048000, + "duration": 240, + "outputMode": "individual", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": true, + "sha256sum": "test_transcription_sha256", + "password": "", + "updatedAt": 1727870734977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/transcribed_archive.mp4", + "transcription": { + "status": "completed", + "reason": "transcription completed successfully", + "url": "https://example.com/transcriptions/c1d2e3f4.json", + "primaryLanguageCode": "es-ES", + "hasSummary": true + } + } + ] +} \ No newline at end of file diff --git a/video/tests/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..5d9f0089 --- /dev/null +++ b/video/tests/test_archive.py @@ -0,0 +1,637 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client import HttpClient +from vonage_video import ( + AddStreamRequest, + ComposedLayout, + CreateArchiveRequest, + LayoutType, + ListArchivesFilter, + OutputMode, + StreamMode, + Video, + VideoResolution, +) +from vonage_video.errors import ( + IndividualArchivePropertyError, + InvalidArchiveStateError, + LayoutScreenshareTypeError, + LayoutStylesheetError, + NoAudioOrVideoError, +) +from vonage_video.models.archive import Transcription + +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_create_archive_request_valid_quantization_parameter(): + """Test that quantization_parameter is accepted for composed archives with valid + values.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=25, + ) + assert request.quantization_parameter == 25 + + +def test_create_archive_request_quantization_parameter_boundary_values(): + """Test that quantization_parameter accepts boundary values (15 and 40).""" + # Test minimum value + request_min = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=15, + ) + assert request_min.quantization_parameter == 15 + + # Test maximum value + request_max = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=40, + ) + assert request_max.quantization_parameter == 40 + + +def test_create_archive_request_quantization_parameter_invalid_low(): + """Test that quantization_parameter rejects values below 15.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=14, + ) + + +def test_create_archive_request_quantization_parameter_invalid_high(): + """Test that quantization_parameter rejects values above 40.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=41, + ) + + +def test_create_archive_request_individual_output_mode_with_quantization_parameter(): + """Test that quantization_parameter is rejected for individual archives.""" + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + output_mode=OutputMode.INDIVIDUAL, + quantization_parameter=25, + ) + + +def test_create_archive_request_serialization_with_quantization_parameter(): + """Test that quantization_parameter is properly serialized with the correct alias.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=30, + ) + + serialized = request.model_dump(by_alias=True, exclude_unset=True) + assert 'quantizationParameter' in serialized + assert serialized['quantizationParameter'] == 30 + assert ( + 'quantization_parameter' not in serialized + ) # Ensure Python field name is not used + + +def test_layout_custom_without_stylesheet(): + with raises(LayoutStylesheetError): + ComposedLayout(type=LayoutType.CUSTOM) + + +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' + assert archives[1].max_bitrate == 2_000_000 + + @responses.activate + def test_list_archives_no_parameters(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives.json', + ) + + filter = ListArchivesFilter(session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + assert count == 2 + assert next_page is None + assert archives[0].id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archives[0].status == 'paused' + assert archives[0].resolution == '1280x720' + assert archives[0].session_id == 'test_session_id' + assert archives[1].id == 'a9cdeb69-f6cf-408b-9197-6f99e6eac5aa' + assert archives[1].status == 'available' + assert archives[1].reason == 'session ended' + assert archives[1].duration == 134 + assert archives[1].sha256_sum == 'test_sha256_sum' + assert archives[1].url == 'https://example.com/archive.mp4' + assert archives[1].max_bitrate == 2_000_000 + + +@responses.activate +def test_start_archive(): + 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, + max_bitrate=2_000_000, + ) + + 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' + assert archive.max_bitrate == 2_000_000 + assert archive.quantization_parameter == 25 + + +@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' + assert archive.quantization_parameter == 25 + + +@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 + + +# Tests for new Transcription options +def test_transcription_model_with_all_options(): + """Test that the Transcription model can be created with all new options.""" + transcription = Transcription( + status="completed", + reason="transcription completed successfully", + url="https://example.com/transcription.json", + primaryLanguageCode="en-US", + hasSummary=True, + ) + + assert transcription.status == "completed" + assert transcription.reason == "transcription completed successfully" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode == "en-US" + assert transcription.hasSummary is True + + +def test_transcription_model_with_partial_options(): + """Test that the Transcription model can be created with only some new options.""" + transcription = Transcription( + status="processing", url="https://example.com/transcription.json" + ) + + assert transcription.status == "processing" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + assert transcription.reason is None + + +def test_transcription_model_with_url_only(): + """Test that the Transcription model can be created with just the url option.""" + transcription = Transcription(url="https://example.com/transcription.json") + + assert transcription.url == "https://example.com/transcription.json" + assert transcription.status is None + assert transcription.reason is None + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + + +def test_transcription_model_with_primary_language_code_only(): + """Test that the Transcription model can be created with just the primaryLanguageCode + option.""" + transcription = Transcription(primaryLanguageCode="es-ES") + + assert transcription.primaryLanguageCode == "es-ES" + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.hasSummary is None + + +def test_transcription_model_with_has_summary_only(): + """Test that the Transcription model can be created with just the hasSummary + option.""" + transcription = Transcription(hasSummary=False) + + assert transcription.hasSummary is False + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.primaryLanguageCode is None + + +def test_transcription_model_empty(): + """Test that the Transcription model can be created with no options set.""" + transcription = Transcription() + + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + + +def test_transcription_model_serialization(): + """Test that the Transcription model serializes correctly.""" + transcription = Transcription( + status="completed", + reason="success", + url="https://example.com/transcription.json", + primaryLanguageCode="en-US", + hasSummary=True, + ) + + serialized = transcription.model_dump() + expected = { + "status": "completed", + "reason": "success", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "en-US", + "hasSummary": True, + } + + assert serialized == expected + + +def test_transcription_model_serialization_exclude_unset(): + """Test that the Transcription model serializes correctly excluding unset values.""" + transcription = Transcription( + url="https://example.com/transcription.json", hasSummary=True + ) + + serialized = transcription.model_dump(exclude_unset=True) + expected = {"url": "https://example.com/transcription.json", "hasSummary": True} + + assert serialized == expected + assert "status" not in serialized + assert "reason" not in serialized + assert "primaryLanguageCode" not in serialized + + +def test_transcription_model_deserialization(): + """Test that the Transcription model can be created from dictionary data.""" + data = { + "status": "completed", + "reason": "transcription finished", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "fr-FR", + "hasSummary": True, + } + + transcription = Transcription(**data) + + assert transcription.status == "completed" + assert transcription.reason == "transcription finished" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode == "fr-FR" + assert transcription.hasSummary is True + + +def test_transcription_model_with_various_language_codes(): + """Test that the Transcription model accepts various language codes.""" + test_cases = ["en-US", "es-ES", "fr-FR", "de-DE", "ja-JP", "zh-CN", "pt-BR"] + + for lang_code in test_cases: + transcription = Transcription(primaryLanguageCode=lang_code) + assert transcription.primaryLanguageCode == lang_code + + +def test_transcription_model_with_various_urls(): + """Test that the Transcription model accepts various URL formats.""" + test_urls = [ + "https://example.com/transcription.json", + "https://storage.googleapis.com/bucket/file.json", + "https://s3.amazonaws.com/bucket/transcription.txt", + "http://example.org/path/to/transcription", + "https://vonage.example.com/transcriptions/12345", + ] + + for url in test_urls: + transcription = Transcription(url=url) + assert transcription.url == url + + +def test_transcription_model_boolean_has_summary(): + """Test that hasSummary properly handles boolean values.""" + # Test True + transcription_true = Transcription(hasSummary=True) + assert transcription_true.hasSummary is True + + # Test False + transcription_false = Transcription(hasSummary=False) + assert transcription_false.hasSummary is False + + # Test None (default) + transcription_none = Transcription() + assert transcription_none.hasSummary is None + + +@responses.activate +def test_archive_with_transcription_options(): + """Test that Archive model properly deserializes with transcription containing new + options.""" + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'archive_with_transcription.json', + ) + + archive = video.get_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.has_transcription is True + + # Test transcription object and its new properties + assert archive.transcription is not None + assert archive.transcription.status == "completed" + assert archive.transcription.reason == "transcription completed successfully" + assert archive.transcription.url == "https://example.com/transcription.json" + assert archive.transcription.primaryLanguageCode == "en-US" + assert archive.transcription.hasSummary is True + + +@responses.activate +def test_list_archives_with_transcription_options(): + """Test that listing archives properly handles transcription with new options.""" + # Create a modified list response that includes transcription data + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives_with_transcription.json', + ) + + filter = ListArchivesFilter(session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + # Find the archive with transcription + transcribed_archive = None + for archive in archives: + if archive.has_transcription: + transcribed_archive = archive + break + + assert transcribed_archive is not None + assert transcribed_archive.transcription is not None + assert transcribed_archive.transcription.url is not None + assert transcribed_archive.transcription.primaryLanguageCode is not None + assert transcribed_archive.transcription.hasSummary is not None diff --git a/video/tests/test_audio_connector.py b/video/tests/test_audio_connector.py new file mode 100644 index 00000000..32ee4ad0 --- /dev/null +++ b/video/tests/test_audio_connector.py @@ -0,0 +1,87 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video import ( + AudioConnectorOptions, + AudioConnectorWebSocket, + AudioSampleRate, + TokenOptions, + TokenRole, + 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_no_flag = AudioConnectorOptions( + session_id='test_session_id', + token='test_token', + websocket=AudioConnectorWebSocket( + uri='test_uri', + streams=['test_stream_id'], + headers={'test_header': 'test_value'}, + audio_rate=AudioSampleRate.KHZ_16, + ), + ) + websocket_dict = options_no_flag.model_dump(by_alias=True)["websocket"] + assert "bidirectional" not in websocket_dict + options = AudioConnectorOptions( + session_id='test_session_id', + token='test_token', + websocket=AudioConnectorWebSocket( + uri='test_uri', + streams=['test_stream_id'], + headers={'test_header': 'test_value'}, + audio_rate=AudioSampleRate.KHZ_16, + bidirectional=True, + ), + ) + + actual = options.model_dump(by_alias=True) + expected = { + 'sessionId': 'test_session_id', + 'token': 'test_token', + 'websocket': { + 'uri': 'test_uri', + 'streams': ['test_stream_id'], + 'headers': {'test_header': 'test_value'}, + 'audioRate': 16000, + 'bidirectional': True, + }, + } + assert actual == expected + + +@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/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..9ebe2c4a --- /dev/null +++ b/voice/CHANGES.md @@ -0,0 +1,55 @@ +# 1.5.1 +- Updating the Voice API `Phone` model + +# 1.5.0 +- Add answer wehhook +- Implement `Wait` NCCO action +- Implement `Transfer` NCCO action +- Add support for 24k audio in Websocket +- Add `shaken` property to `Phone` endpoint +- Add `authorization` to `WebSocket` endpoints + +# 1.4.0 +- Increase maximum value of call `length_timer` to 86400s +- Add additional fields `eventUrl` and `eventMethod` to NCCO model + +# 1.3.0 +- Add new `headers` and `standard_headers` options to the `Sip` data model +- Add new `standardHeaders` option to the `SipEndpoint` NCCO model +- Add check for invalid hostnames when downloading a recording with `Voice.download_recording` +- Allow the `CreateCallRequest` model to accept a SIP URI as well as a phone number in the `from_` field + +# 1.2.0 +- Make all models originally accessed by `vonage_voice.models.***` available at the top level of the package, i.e. `vonage_voice.***` + +# 1.1.2 +- Update incorrect return type annotation for `Voice.download_recording` + +# 1.1.1 +- Remove maximum webhook uri length constraint + +# 1.1.0 +- Add `Voice.get_recording` method to get call recordings +- Add `Voice.verify_signature` method to expose the verification functionality from `vonage-jwt` +- Updated dependency versions + +# 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..1c8a3338 --- /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. 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 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 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 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 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..257748eb --- /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.5.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[tool.setuptools.dynamic] +version = { attr = "vonage_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..5f500f22 --- /dev/null +++ b/voice/src/vonage_voice/__init__.py @@ -0,0 +1,5 @@ +from . import errors, models # Import models to access the module directly +from .models import * # Need this to directly expose data models +from .voice import Voice + +__all__ = ['Voice', 'errors', 'models'] diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py new file mode 100644 index 00000000..51ed7c48 --- /dev/null +++ b/voice/src/vonage_voice/_version.py @@ -0,0 +1 @@ +__version__ = '1.5.1' 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..a3661c98 --- /dev/null +++ b/voice/src/vonage_voice/models/__init__.py @@ -0,0 +1,88 @@ +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, + Transfer, + Wait, +) +from .requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + ToPhone, + TtsStreamOptions, +) +from .responses import ( + CallInfo, + CallList, + CallMessage, + CreateCallResponse, + Embedded, + HalLinks, +) +from .webhooks import AnswerWebhook + +__all__ = [ + "AdvancedMachineDetection", + "AppEndpoint", + "AudioStreamOptions", + "AnswerWebhook", + "CallInfo", + "CallList", + "CallMessage", + "CallState", + "Channel", + "Connect", + "ConnectEndpointType", + "Conversation", + "CreateCallRequest", + "CreateCallResponse", + "Dtmf", + "Embedded", + "Input", + "ListCallsFilter", + "HalLinks", + "NccoAction", + "NccoActionType", + "Notify", + "OnAnswer", + "Phone", + "PhoneEndpoint", + "Record", + "Sip", + "SipEndpoint", + "Speech", + "Stream", + "Talk", + "Transfer", + "Wait", + "ToPhone", + "TtsLanguageCode", + "TtsStreamOptions", + "Vbc", + "VbcEndpoint", + "Websocket", + "WebsocketEndpoint", +] diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py new file mode 100644 index 00000000..f68338c7 --- /dev/null +++ b/voice/src/vonage_voice/models/common.py @@ -0,0 +1,97 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import SipUri +from vonage_voice.models.enums import Channel + + +class Phone(BaseModel): + """Model for a phone number. + + Args: + number (str): The phone number. + """ + + number: str + type: Channel = Channel.PHONE + + +class Sip(BaseModel): + """Model for a SIP URI. + + Args: + uri (SipUri): The SIP URI. + headers (Optional[dict]): Metadata to include in the request. The headers are transmitted as part of the SIP INVITE as `X-key: value` headers. + standard_headers (Optional[dict]): Standard SIP headers to include in the request. Unlike `headers`, these are not prepended with `X-`. + This should be of the form `{'User-to-User': '342342ef34;encoding=hex'} + """ + + uri: SipUri + headers: Optional[dict] = None + standard_headers: Optional[dict] = None + type: Channel = Channel.SIP + + +class WebsocketAuthorization(BaseModel): + """Authorization settings for a WebSocket endpoint. + + Args: + type (Literal['vonage', 'custom']): The authorization mode. Use `vonage` to have + Vonage generate and send a JWT for you, or `custom` to provide your own + Authorization header value. + value (str, Optional): Authorization header value to send when `type` is + `custom`. Ignored for `vonage`. + """ + + type: Literal['vonage', 'custom'] + value: Optional[str] = None + + +class Websocket(BaseModel): + """Model for a WebSocket connection. + + Args: + uri (str): The URI of the WebSocket connection. + content_type (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000', 'audio/l16;rate=24000']): + The content type of the audio stream. + headers (Optional[dict]): The headers to include with the WebSocket connection. + authorization (WebsocketAuthorization, Optional): Authorization configuration for + the WebSocket handshake. + """ + + uri: str = Field(..., min_length=1) + content_type: Literal[ + "audio/l16;rate=8000", "audio/l16;rate=16000", "audio/l16;rate=24000" + ] = Field("audio/l16;rate=16000", serialization_alias="content-type") + headers: Optional[dict] = None + authorization: Optional[WebsocketAuthorization] = 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..16fb5aa6 --- /dev/null +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -0,0 +1,101 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import Dtmf, PhoneNumber, SipUri + +from .common import WebsocketAuthorization +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. + shaken (Optional[str]): STIR/SHAKEN Identity header content to use for this call. + """ + + number: PhoneNumber + dtmfAnswer: Optional[Dtmf] = None + onAnswer: Optional[OnAnswer] = None + shaken: Optional[str] = None + type: ConnectEndpointType = ConnectEndpointType.PHONE + + +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', 'audio/l16;rate=24000']): + The internet media type for the audio you are streaming. + headers (Optional[dict]): The headers to include with the WebSocket connection. + authorization (WebsocketAuthorization, Optional): Authorization configuration for + the WebSocket handshake. + """ + + uri: str + contentType: Literal[ + 'audio/l16;rate=8000', 'audio/l16;rate=16000', 'audio/l16;rate=24000' + ] = Field(None, serialization_alias='content-type') + headers: Optional[dict] = None + authorization: Optional[WebsocketAuthorization] = 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. + standardHeaders (Optional[dict]): Standard SIP headers to include in the request. Unlike + `headers`, these are not prepended with `X-`. This should be of the form + `{'User-to-User': '342342ef34;encoding=hex'}`. + """ + + uri: SipUri + headers: Optional[dict] = None + standardHeaders: Optional[dict] = None + type: ConnectEndpointType = ConnectEndpointType.SIP + + +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..868abce4 --- /dev/null +++ b/voice/src/vonage_voice/models/enums.py @@ -0,0 +1,91 @@ +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' + WAIT = 'wait' + TRANSFER = 'transfer' + + +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..6309dc4c --- /dev/null +++ b/voice/src/vonage_voice/models/ncco.py @@ -0,0 +1,332 @@ +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. + eventUrl (Optional[list[str]]): The URL to send asynchronous events to. + eventMethod (Optional[str]): The HTTP method used to send the event to `eventUrl`. + + Raises: + NccoActionError: If the `mute` option is used with the `canSpeak` option. + """ + + 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 + eventUrl: Optional[list] = None + eventMethod: Optional[str] = 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 + + +class Wait(NccoAction): + """Use the Wait action to add a pause to an NCCO. + + The wait period starts when the action is executed and ends after the provided + or default timeout value. Execution of the NCCO then resumes with the next action. + + Args: + timeout (Optional[float]): Duration of the wait period in seconds. Valid values + are from 0.1 to 7200. Values below 0.1 are treated as 0.1; values above + 7200 are treated as 7200. If not specified, defaults to 10 seconds. + """ + + timeout: Optional[float] = 10.0 + action: NccoActionType = NccoActionType.WAIT + + @model_validator(mode='after') + def clamp_timeout(self): + if self.timeout is None: + self.timeout = 10.0 + elif self.timeout < 0.1: + self.timeout = 0.1 + elif self.timeout > 7200: + self.timeout = 7200.0 + return self + + +class Transfer(NccoAction): + """Use the Transfer action to move all legs from the current conversation into another + existing conversation. + + The transfer action is synchronous and terminal for the current conversation. + The target conversation's NCCO continues to control its behaviour. + + Args: + conversationId (str): The target conversation ID. + canHear (Optional[list[str]]): Leg UUIDs this participant can hear. If not + provided, the participant can hear everyone. If an empty list is provided, + the participant will not hear any other participants. + canSpeak (Optional[list[str]]): Leg UUIDs this participant can be heard by. If + not provided, the participant can be heard by everyone. If an empty list is + provided, the participant will not be heard by anyone. + mute (Optional[bool]): Set to `True` to mute the participant. When using + `canSpeak`, the `mute` parameter is not supported. + + Raises: + NccoActionError: If the `mute` option is used with the `canSpeak` option. + """ + + conversationId: str + canHear: Optional[list[str]] = None + canSpeak: Optional[list[str]] = None + mute: Optional[bool] = None + action: NccoActionType = NccoActionType.TRANSFER + + @model_validator(mode='after') + def validate_mute_and_can_speak(self): + if self.canSpeak and self.mute: + raise NccoActionError( + 'Cannot use mute option if canSpeak option is specified.' + ) + return self diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py new file mode 100644 index 00000000..d738710f --- /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. + shaken (Optional[str]): STIR/SHAKEN Identity header content to use for this call. + """ + + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + shaken: Optional[str] = None + + +class CreateCallRequest(BaseModel): + """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[Union[Phone, str]]): The phone number or SIP URI to use when calling. Mutually exclusive with the `random_from_number` property. + random_from_number (Optional[bool]): Whether to use a random number as the caller's + phone number. The number will be selected from the list of the numbers assigned + to the current application. Mutually exclusive with the `from_` property. + 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[Union[Phone, str]] = Field(None, serialization_alias='from') + random_from_number: Optional[bool] = None + event_url: Optional[list[str]] = None + event_method: Optional[Literal['POST', 'GET']] = None + machine_detection: Optional[Literal['continue', 'hangup']] = None + advanced_machine_detection: Optional[AdvancedMachineDetection] = None + length_timer: Optional[int] = Field(None, ge=1, le=86400) + 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/models/webhooks.py b/voice/src/vonage_voice/models/webhooks.py new file mode 100644 index 00000000..6fb84a1b --- /dev/null +++ b/voice/src/vonage_voice/models/webhooks.py @@ -0,0 +1,36 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class AnswerWebhook(BaseModel): + """Model for the Voice API Answer webhook payload. + + Args: + to (str, Optional): The number or endpoint that answered the call. + from_ (str, Optional): The number or endpoint that initiated the call. + from_user (str, Optional): The Client SDK user that initiated the call. + endpoint_type (str, Optional): The type of endpoint that answered the call. + uuid (str, Optional): The unique identifier for this call. + conversation_uuid (str, Optional): The unique identifier for this conversation. + region_url (str, Optional): Regional API endpoint to control the call. + custom_data (dict, Optional): Custom data object passed from the Client SDK. + sipheader_user_to_user (str, Optional): Content of the SIP User-to-User header, + received as the `SipHeader_User-to-User` parameter on the webhook. + """ + + model_config = ConfigDict(populate_by_name=True) + + to: Optional[str] = None + from_: Optional[str] = Field(None, alias='from') + from_user: Optional[str] = None + endpoint_type: Optional[str] = None + uuid: Optional[str] = None + conversation_uuid: Optional[str] = None + region_url: Optional[str] = None + custom_data: Optional[dict] = None + sipheader_user_to_user: Optional[str] = Field( + None, + validation_alias='SipHeader_User-to-User', + serialization_alias='SipHeader_User-to-User', + ) diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py new file mode 100644 index 00000000..6ebd38e0 --- /dev/null +++ b/voice/src/vonage_voice/voice.py @@ -0,0 +1,296 @@ +from typing import Optional + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_jwt.verify_jwt import verify_signature +from vonage_utils.types import Dtmf +from vonage_voice.errors import VoiceError +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, as a string of digits. It can include + the characters from 0-9, #, *, and p. + + 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) + + @validate_call + def download_recording(self, url: str, file_path: str) -> None: + """Downloads a call recording from the specified URL and saves it to a local file. + + Args: + url (str): The URL of the recording to get. + file_path (str): The path to save the recording to. + """ + if not 'vonage.com' in url and not 'nexmo.com' in url: + raise VoiceError('The recording URL must be from a Vonage or Nexmo hostname.') + + self._http_client.download_file_stream(url=url, file_path=file_path) + + @validate_call + def verify_signature(self, token: str, signature: str) -> bool: + """Verifies that a token has been signed with the provided signature. Used to + verify that a webhook was sent by Vonage. + + Args: + token (str): The token to verify. + signature (str): The signature to verify the token against. + + Returns: + bool: True if the token was signed with the provided signature, False otherwise. + + Raises: + VonageVerifyJwtError: The signature could not be verified. + """ + return verify_signature(token, signature) diff --git a/voice/tests/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/file_stream.mp3 b/voice/tests/data/file_stream.mp3 new file mode 100644 index 00000000..f529d1c3 Binary files /dev/null and b/voice/tests/data/file_stream.mp3 differ 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_answer_webhook.py b/voice/tests/test_answer_webhook.py new file mode 100644 index 00000000..ab56057a --- /dev/null +++ b/voice/tests/test_answer_webhook.py @@ -0,0 +1,24 @@ +from vonage_voice.models import AnswerWebhook + + +def test_answer_webhook_sipheader_user_to_user_alias(): + payload = { + 'to': '442079460000', + 'from': '447700900000', + 'uuid': 'aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'conversation_uuid': 'CON-aaaaaaaa-bbbb-cccc-dddd-0123456789ab', + 'SipHeader_User-to-User': '1234567890abcdef;encoding=hex', + } + + hook = AnswerWebhook(**payload) + + assert hook.to == '442079460000' + assert hook.from_ == '447700900000' + assert ( + hook.sipheader_user_to_user == '1234567890abcdef;encoding=hex' + ), 'Field should be populated from SipHeader_User-to-User' + + dumped = hook.model_dump(by_alias=True, exclude_none=True) + assert ( + dumped['SipHeader_User-to-User'] == '1234567890abcdef;encoding=hex' + ), 'Field should serialize back with the SipHeader_User-to-User key' diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py new file mode 100644 index 00000000..93bc2e84 --- /dev/null +++ b/voice/tests/test_ncco_actions.py @@ -0,0 +1,403 @@ +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'}, + shaken='shaken-token', + ).model_dump() == { + 'number': '447000000000', + 'dtmfAnswer': '1234', + 'onAnswer': {'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + 'shaken': 'shaken-token', + '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'}, + authorization={ + 'type': 'custom', + 'value': 'Bearer eyJhbGciOi...', + }, + ).model_dump(by_alias=True) == { + 'uri': 'wss://example.com', + 'content-type': 'audio/l16;rate=8000', + 'headers': {'asdf': 'qwer'}, + 'authorization': { + 'type': 'custom', + 'value': 'Bearer eyJhbGciOi...', + }, + 'type': 'websocket', + } + + ws_24k = connect_endpoints.WebsocketEndpoint( + uri='wss://example.com', + contentType='audio/l16;rate=24000', + headers={'asdf': 'qwer'}, + ) + assert ws_24k.model_dump(by_alias=True)['content-type'] == 'audio/l16;rate=24000' + + assert connect_endpoints.SipEndpoint( + uri='sip:example@sip.example.com', + headers={'qwer': 'asdf'}, + standardHeaders={'User-to-User': '342342ef34;encoding=hex'}, + ).model_dump() == { + 'uri': 'sip:example@sip.example.com', + 'headers': {'qwer': 'asdf'}, + 'standardHeaders': {'User-to-User': '342342ef34;encoding=hex'}, + 'type': 'sip', + } + + 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', + } + + +def test_wait_default_timeout(): + wait = ncco.Wait() + assert wait.model_dump(by_alias=True, exclude_none=True) == { + 'timeout': 10.0, + 'action': 'wait', + } + + +def test_wait_custom_timeout(): + wait = ncco.Wait(timeout=0.5) + assert wait.model_dump(by_alias=True, exclude_none=True) == { + 'timeout': 0.5, + 'action': 'wait', + } + + +def test_wait_timeout_clamped_min_max(): + wait_min = ncco.Wait(timeout=0.01) + assert wait_min.timeout == 0.1 + + wait_max = ncco.Wait(timeout=10000) + assert wait_max.timeout == 7200.0 + + +def test_transfer_basic(): + transfer = ncco.Transfer(conversationId='CON-1234567890') + assert transfer.model_dump(by_alias=True, exclude_none=True) == { + 'conversationId': 'CON-1234567890', + 'action': 'transfer', + } + + +def test_transfer_options(): + transfer = ncco.Transfer( + conversationId='CON-1234567890', + canHear=['leg-a'], + canSpeak=['leg-b', 'leg-c'], + mute=False, + ) + assert transfer.model_dump(by_alias=True, exclude_none=True) == { + 'conversationId': 'CON-1234567890', + 'canHear': ['leg-a'], + 'canSpeak': ['leg-b', 'leg-c'], + 'mute': False, + 'action': 'transfer', + } + + +def test_transfer_mute_with_canspeak_error(): + with raises(NccoActionError) as e: + ncco.Transfer( + conversationId='CON-1234567890', + canSpeak=['leg-a'], + mute=True, + ) + + assert e.match('Cannot use mute option if canSpeak option is specified.') diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py new file mode 100644 index 00000000..e58b252e --- /dev/null +++ b/voice/tests/test_voice.py @@ -0,0 +1,527 @@ +import json +from os.path import abspath + +import responses +from pytest import raises +from responses.matchers import json_params_matcher +from testutils import build_response, get_mock_jwt_auth +from vonage_http_client.http_client import HttpClient +from vonage_voice import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + Sip, + ToPhone, + TtsStreamOptions, + Websocket, +) +from vonage_voice.errors import VoiceError +from vonage_voice.models.ncco import Talk +from vonage_voice.models.responses import CreateCallResponse +from vonage_voice.voice import Voice + +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=[ + Sip( + uri="sip:test@example.com", + headers={"location": "New York City"}, + standard_headers={"User-to-User": "342342ef34;encoding=hex"}, + ) + ], + random_from_number=True, + ) + + response = voice.create_call(call) + + body = json.loads(voice.http_client.last_request.body) + assert body["to"][0]["headers"] == {"location": "New York City"} + assert body["to"][0]["standard_headers"] == { + "User-to-User": "342342ef34;encoding=hex" + } + assert type(response) == CreateCallResponse + assert response.uuid == "106a581a-34d0-432a-a625-220221fd434f" + assert response.status == "started" + assert response.direction == "outbound" + assert response.conversation_uuid == "CON-2be039b2-d0a4-4274-afc8-d7b241c7c044" + + +@responses.activate +def test_create_call_basic_ncco_from_sip(): + build_response( + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 + ) + ncco = [Talk(text="Hello world")] + call = CreateCallRequest( + ncco=ncco, + to=[Sip(uri="sip:test@example.com")], + from_="sip:from_sip_uri@example.com", + ) + + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + 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_phone_shaken(): + build_response( + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 + ) + ncco = [Talk(text="Hello world")] + to_phone = ToPhone(number="1234567890", dtmf_answer="1234", shaken="shaken-token") + call = CreateCallRequest( + ncco=ncco, + to=[to_phone], + from_={"number": "1234567890", "type": "phone"}, + ) + response = voice.create_call(call) + + body = json.loads(voice.http_client.last_request.body) + assert body["to"][0]["number"] == "1234567890" + assert body["to"][0]["dtmfAnswer"] == "1234" + assert body["to"][0]["shaken"] == "shaken-token" + assert type(response) == CreateCallResponse + + +@responses.activate +def test_create_call_basic_answer_url(): + build_response( + path, "POST", "https://api.nexmo.com/v1/calls", "create_call.json", 201 + ) + ws = Websocket( + uri='wss://example.com/websocket', + content_type='audio/l16;rate=8000', + headers={'key': 'value'}, + ) + call = CreateCallRequest( + to=[ws], + 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_websocket_authorization_custom(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + ws = Websocket( + uri='wss://example.com/websocket', + content_type='audio/l16;rate=16000', + headers={'key': 'value'}, + authorization={'type': 'custom', 'value': 'Bearer eyJhbGciOi...'}, + ) + call = CreateCallRequest( + ncco=ncco, + to=[ws], + random_from_number=True, + ) + + response = voice.create_call(call) + body = json.loads(voice.http_client.last_request.body) + assert body['to'][0]['authorization'] == { + 'type': 'custom', + 'value': 'Bearer eyJhbGciOi...', + } + assert type(response) == CreateCallResponse + + +@responses.activate +def test_create_call_answer_url_options(): + build_response( + 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 + + +@responses.activate +def test_download_recording(): + build_response( + path, + "GET", + "https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "file_stream.mp3", + ) + + voice.download_recording( + url="https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + file_path="voice/tests/data/file_stream.mp3", + ) + + with open("voice/tests/data/file_stream.mp3", "rb") as file: + file_content = file.read() + assert file_content.startswith(b"ID3") + + +def test_download_recording_invalid_url(): + with raises(VoiceError) as e: + voice.download_recording( + url="https://invalid.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + file_path="voice/tests/data/file_stream.mp3", + ) + assert e.match("The recording URL must be from a Vonage or Nexmo hostname.") + + +def test_verify_signature(): + token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM" + valid_signature = "qwertyuiopasdfghjklzxcvbnm123456" + + assert voice.verify_signature(token, valid_signature) is True 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/vonage/CHANGES.md b/vonage/CHANGES.md new file mode 100644 index 00000000..09796fb0 --- /dev/null +++ b/vonage/CHANGES.md @@ -0,0 +1,385 @@ +# 4.8.2 +- Updates the Voice API `Phone` model to fix a type constraint bug + + +# 4.8.1 +- Fixes some Messages API validations + +# 4.8.0 + +## Account +- Fixing imports for test file + +## Application +- Fixing imports for test file + +## HTTP Client +- Fixing imports for test file + +## Messages +- Implement the `pool_id` parameter for SMS +- Implement the `trusted_recipient` parameter for SMS, MMS, and RCS +- Add new MMS types +- Add RCS native types +- Add Replying Indicator support to WhatsApp + +## SMS +- Implement the `trusted_number` parameter + +## Users +- Fix unit test + +## Verify +- Add support for WhatsApp mode + +## Verify Legacy +- Add support for Basic Auth + +## Voice +- Add answer wehhook +- Implement `Wait` NCCO action +- Implement `Transfer` NCCO action +- Add support for 24k audio in Websocket +- Add `shaken` property to `Phone` endpoint +- Add `authorization` to `WebSocket` endpoints + +# 4.7.2 +- vonage-numbers: Added `by_alias=True` to the numbers update model to correct issue with incorrect body payload + +# 4.7.1 +- vonage-video: Added missing transcription values from archive responses + +# 4.7.0 +- vonage-video: Added quantization parameter for video archives + +# 4.6.0 +- vonage-video: Added bidirectional websocket flag + +# 4.5.0 +- vonage-messages: add an optional "failover" property to `vonage_messages.Messages.send` + +# 4.4.3 +- vonage-number-insight: use basic header auth instead of request body auth + +# 4.4.2 +- vonage-sms: make returned response fields optional + +# 4.4.1 +- Update some Voice API parameters + +# 4.4.0 +Vonage Voice Package: +- Add new `headers` and `standard_headers` options to the `Sip` data model +- Add new `standardHeaders` option to the `SipEndpoint` NCCO model +- Add check for invalid hostnames when downloading a recording with `Voice.download_recording` +- Allow the `CreateCallRequest` model to accept a SIP URI as well as a phone number in the `from_` field + +# 4.3.0 +- Make all models originally accessed by `vonage_voice.models.***` available at the top level of the package, i.e. `vonage_voice.***` +- Make all models originally accessed by `vonage_video.models.***` available at the top level of the package, i.e. `vonage_video.***` +- Make all models originally accessed by `vonage_messages.models.***` available at the top level of the package, i.e. `vonage_messages.***` + +# 4.2.0 +- Add new `max_bitrate` field for Video API archives +- Fix a bug with error types +- Update an outdated Voice class type hint + +# 4.1.2 +- Remove max length constraint in Voice API for webhook URIs + +# 4.1.1 +- Include new package versions + +# 4.1.0 +- Add support for API key/secret header authentication for the Messages and Verify APIs (JWT is the default and recommended method) +- Add `Voice.get_recording` method to get call recordings +- Add `Voice.verify_signature` method to expose the verification functionality from `vonage-jwt` +- Add backoff exponential timeout increase for HTTP request retries +- Add automatic retries for `RemoteDisconnected` exceptions +- Add new `http_client.FileStreamingError` exception type + +# 4.0.0 +A complete, ground-up rewrite of the SDK. +Key changes: +- Monorepo structure, with each API under separate packages +- Support for Python 3.9+ +- Feature parity with v3 +- Add support for the new network APIs - the [Vonage Sim Swap Network API](https://developer.vonage.com/en/sim-swap/overview) and the [Vonage Number Verification Network API](https://developer.vonage.com/en/number-verification/overview) +- Usage of data models throughout +- 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` + +# 3.17.4 +- Drop support for Python 3.8, add support for 3.13 + +# 3.17.3 +- Fix bug in JWT generator + +# 3.17.2 +- Update `vonage-jwt` dependency version to fix JWT timeout issue + +# 3.17.1 +- Add "mark WhatsApp message as read" option for Messages API + +# 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 + +# 3.12.0 +- Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) + +# 3.11.1 +- Add checks for silent auth workflow optional parameters `redirect_url` and `sandbox` + +# 3.11.0 +- Add method to check JWT signatures of Voice API webhooks: `vonage.Voice.verify_signature` + +# 3.10.0 +- Indicating support for Python 3.12 + +# 3.9.1 +- Updating Meetings API url to a `/v1` endpoint + +# 3.9.0 +- Dropped support for Python 3.7 as it's end-of-life and no longer receiving security updates + +# 3.8.0 +- Adding support for the [Users component of the Vonage Application API](https://developer.vonage.com/en/api/application.v2#User) + +# 3.7.1 +- Fixing dependency version to a specific major version + +# 3.7.0 +- Adding support for the [Vonage Meetings API](https://developer.vonage.com/en/meetings/overview) +- Adding partial support for the [Vonage Proactive Connect API](https://developer.vonage.com/en/proactive-connect/overview) - supporting API methods relating to `lists`, `items` and `events` +- Returning a more descriptive (non-internal) error message if invalid values are provided for `application_id` and/or `private_key` when instantiating a Vonage client object + +# 3.6.0 +- Adding support for the [Vonage Subaccounts API](https://developer.vonage.com/en/account/subaccounts/overview) + +# 3.5.2 +- Using the [Vonage JWT Generator](https://github.com/Vonage/vonage-python-jwt) instead of `PyJWT` for generating JWTs. +- Other internal refactoring and enhancements + +# 3.5.1 +- Updating the internal use of the `fraud_check` parameter in the Vonage Verify V2 API + +# 3.5.0 +- Adding support for V2 of the Vonage Verify API + - Multiple authentication channels are supported (sms, voice, email, whatsapp, whatsapp interactive messages and silent authentication) + - Using fallback channels is now possible in case verification methods fail + - You can now customise the verification code that is sent, or even specify your own custom code +- Adding `advancedMachineDetection` functionality to the NCCO builder for the Vonage Voice API + +# 3.4.0 +- Internal refactoring changes +- Using header authentication for the Numbers API + +# 3.3.0 +- Updated Messages API: + - Added new messaging channels for Viber Service Messages (`video`, `file`) + - Added new WhatsApp `sticker` message channel + - Increased `client_ref` max value to 100 characters +- Deprecated `pay` action in the NCCO builder as it is being removed by Vonage + +# 3.2.2 +- Fixing a bug on Windows + +# 3.2.1 +- Fixing an import bug + +# 3.2.0 +- Adding an NCCO Builder to make it easier to work with NCCOs when using the Voice API +- Individual NCCO Actions can be created as Pydantic models, which can be built into an NCCO via the `Ncco.build_ncco` method + +# 3.1.0 +- Supporting Python 3.11 +- Upgrading some old dependencies + +# 3.0.2 +- Bugfix in `messages.py` where authentication method was not being checked for correctly, throwing an error when using header auth. + +# 3.0.1 +- Fixed bug where a JWT was created globally and could expire. Now a new JWT is generated when a request is made. +- Fixed bug where timeout was not passed to session object. + +# 3.0.0 +Breaking changes: +- Removed deprecated methods from `client.py` that are now available in specific modules related to each of the available Vonage APIs. E.g. to call the number insight API, the methods are now called in this way: `client.number_insight.get_basic_number_insight(...)`, or by instantiating the `NumberInsight` class directly: `ni = vonage.NumberInsight(client)`, `ni.get_basic_number_insight(...)` etc. +- Removed automatic client creation when instantiating an `sms`, `voice` or `verify` object. You can now use these APIs from a client instance you create (e.g. `client.sms.send_message()`) or pass in a client to the API class to create it (e.g. `sms = vonage.Sms(client)`), as has been the case since v2.7.0 of the SDK. +- Removed methods to call the Message Search API, which has been retired by Vonage. +- Removed deprecated voice and number insight methods from `voice.py` (`initiate_call, initiate_tts_call and initiate_tts_prompt_call`) and `number_insight.py` (`request_number_insight`). +- Renamed the `Account.delete_secret()` method to `revoke_secret()` to bring it in line with what is described in our documentation. + +Deprecations: +- Deprecated the ApplicationV2 class and created an Application class with the same methods to bring the naming in line with other classes. This can be called from the client object with `client.application.create_application(...)` etc. or directly with `application = vonage.Application(client)`, `application.create_application(...)` etc. +- Deprecated old Pricing API methods `get_sms_pricing` and `get_voice_pricing`. +- Deprecated Redact class as it's a dev preview product that's unsupported in the SDK and will be removed in a later release. + +Enhancements: +- Added `get_all_countries_pricing` method to `Account` object. +- Added a `type` parameter for pricing calls, so `sms` or `voice` pricing can now be chosen. +- Added `max_retries`, `timeout`, `pool_connections` and `pool_maxsize` optional keyword arguments to the `Client` class, which can now be specified on instantiation and used in the API calls made with the client. + +# 2.8.0 +- Added Messages API v1.0 support. Messages API can now be used by calling the `client.messages.send_message()` method. + +# 2.7.0 +- Moved some client methods into their own classes: `account.py, application.py, +message_search.py, number_insight.py, numbers.py, short_codes.py, ussd.py` +- Deprecated the corresponding client methods. These will be removed in a major release that's coming soon. +- Client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` +instantiates instances of `Account`, `Sms`, `NumberInsight` etc. These instances can now be called directly from `Client`, e.g. +``` +client = vonage.Client(key="mykey", secret="mysecret") + +print(f"Account balance is: {client.account.get_balance()}") + +print("Sending an SMS") +client.sms.send_message( + "from": "Vonage", + "to": "SOME_PHONE_NUMBER", + "text": "Hello from Vonage's SMS API" +) + +``` + +# 2.6.x + +- Dropped support for Python 3.6 and below +- Now supporting currently supported stable versions of Python, i.e. Python 3.7-3.10 +- Internal refactoring and enhancements +- Adding default `max_retries` option to the `BasicAuthenticationServer` constructor, specifying optional parameters + +# 2.5.5 + +- Support for Independent SMS, Voice and Verify APIs with tests as well as current client methods +- Getters/Setters to extract/rewrite custom attributes +- PSD2 Verification support +- Dropping support for Python 2.7 +- Roadmap to better error handling +- Supporting Python 3.8 + +# 2.4.0 + +- Application V2 API added under `Client.application_v2` +- Existing application methods under `Client` are now deprecated. + +# 2.3.0 + +- Explicit parameter list for the `nexmo.Client` constructor. **This may cause errors in code passing incorrect or spurious arguments to the Client constructor.** +- Secret Management +- Support for Authorization header authentication. + +# 2.2.0 + +- Add support for `redact_transaction`. + +# 2.1.0 + +- Add support for `get_recording` +- Add support for SMS conversion +- Add debug logging for most calls, under the 'nexmo' logger. +- Internal refactoring (affects only private methods.) + +# 2.0.0 + +- Drop support for Python 3.3 (in line with the cryptography library we depend upon) +- Ensure timestamp is added the params list if signing requests +- Avoid value injection in signature auth. +- Add support for different hashes for signature generation (thanks @trancee!) +- Tests ported to pytest + +# 1.5.0 + +- Add ability to provide a file path as private_key param no the nexmo.Client constructor + +- Add send/stop endpoints for audio/speech/dtmf + +- Add new number insight endpoints + +# 1.4.0 + +- Add new Voice API call methods + +- Add Application API methods + +- Add check_signature method for checking callback signatures + +- Deprecate old Verify API methods + +# 1.3.0 + +- Add get_sms_pricing method + +- Add get_voice_pricing method + +- Add get_event_alert_numbers method to get opt-in/opt-out numbers + +- Add resubscribe_event_alert_number method to opt-in a number + +- Add more clearly named methods for Verify API + +- Add app_name and app_version options + +# 1.2.0 + +- Add topup method + +- Add update_settings method + +- Add api_host attribute + +- Add ClientError and ServerError classes + +# 1.1.0 + +- Move repository to https://github.com/nexmo/nexmo-python + +- Add get_basic_number_insight method for Number Insight Basic API + +- Add get_number_insight method for Number Insight Standard API + +- Add User-Agent header to requests + +# 1.0.3 + +- Change license from LGPL-3.0 to MIT + +# 1.0.2 + +- Remove merge helper function + +# 1.0.1 + +- Python 3.x fixes + +# 1.0.0 + +- First version! 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..a98a6d82 --- /dev/null +++ b/vonage/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "vonage" +dynamic = ["version"] +description = "Python Server SDK for using Vonage APIs" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "vonage-account>=1.1.2", + "vonage-application>=2.0.2", + "vonage-http-client>=1.5.2", + "vonage-identity-insights>=1.0.0", + "vonage-messages>=1.7.1", + "vonage-network-auth>=1.0.2", + "vonage-network-sim-swap>=1.1.2", + "vonage-network-number-verification>=1.0.2", + "vonage-number-insight>=1.0.7", + "vonage-numbers>=1.0.5", + "vonage-sms>=1.2.0", + "vonage-subaccounts>=1.0.4", + "vonage-users>=1.2.2", + "vonage-utils>=1.1.4", + "vonage-verify>=2.2.0", + "vonage-verify-legacy>=1.1.0", + "vonage-video>=1.5.1", + "vonage-voice>=1.5.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", +] +[[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..29fd62a7 --- /dev/null +++ b/vonage/src/vonage/_version.py @@ -0,0 +1 @@ +__version__ = '4.8.2' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py new file mode 100644 index 00000000..cfd2b567 --- /dev/null +++ b/vonage/src/vonage/vonage.py @@ -0,0 +1,59 @@ +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_identity_insights import IdentityInsights +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) + self.identity_insights = IdentityInsights(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}