From ef219d25f347391d7e5f6bcc8abb7839ef0e9647 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 7 Feb 2024 02:58:22 +0000 Subject: [PATCH 01/98] squash merge --- .bumpversion.cfg | 8 - .editorconfig | 24 - .github/workflows/build.yml | 32 +- .gitignore | 5 + .pre-commit-config.yaml | 7 +- .pyup.yml | 4 - BUILD | 3 + CONTRIBUTING.md | 19 - MANIFEST.in | 16 - Makefile | 27 +- http_client/BUILD | 11 + http_client/CHANGES.md | 2 + http_client/README.md | 17 + http_client/pyproject.toml | 9 + http_client/src/http_client/BUILD | 1 + .../src/http_client/__init__.py | 0 http_client/src/http_client/auth.py | 77 ++ http_client/src/http_client/errors.py | 104 +++ http_client/src/http_client/http_client.py | 138 +++ http_client/tests/BUILD | 1 + http_client/tests/data/400.json | 1 + http_client/tests/data/400.txt | 1 + http_client/tests/data/401.json | 1 + http_client/tests/data/429.json | 1 + http_client/tests/data/500.json | 1 + .../tests/data/dummy_private_key.txt | 0 .../tests/data/dummy_public_key.txt | 0 http_client/tests/data/example_get.json | 3 + http_client/tests/data/example_post.json | 3 + .../tests}/data/video/broadcast.json | 0 .../tests}/data/video/create_archive.json | 0 .../tests}/data/video/create_session.json | 0 .../tests}/data/video/create_sip_call.json | 0 .../video/disable_mute_multiple_streams.json | 0 .../tests}/data/video/get_archive.json | 0 .../tests}/data/video/get_stream.json | 0 .../tests}/data/video/list_archives.json | 0 .../tests}/data/video/list_broadcasts.json | 0 .../tests}/data/video/list_streams.json | 0 .../data/video/mute_multiple_streams.json | 0 .../data/video/mute_specific_stream.json | 0 .../tests}/data/video/null.json | 0 .../data/video/play_dtmf_invalid_error.json | 0 .../tests}/data/video/stop_archive.json | 0 http_client/tests/test_auth.py | 104 +++ http_client/tests/test_http_client.py | 154 ++++ {tests => http_client/tests}/test_video.py | 0 libs/BUILD | 1 + libs/errors.py | 13 + libs/format_phone_number.py | 36 + libs/pyproject.toml | 8 + libs/testing_utils.py | 23 + libs/tests/BUILD | 1 + libs/tests/test_format_phone_number.py | 30 + number_insight_v2/BUILD | 9 + number_insight_v2/README.md | 3 + number_insight_v2/pyproject.toml | 9 + number_insight_v2/src/number_insight_v2/BUILD | 1 + .../src/number_insight_v2/__init__.py | 0 .../src/number_insight_v2/errors.py | 5 + .../number_insight_v2/number_insight_v2.py | 43 + number_insight_v2/tests/BUILD | 1 + number_insight_v2/tests/test_ni2.py | 7 + pants.ci.toml | 5 + pants.toml | 39 + pyproject.toml | 5 - requirements.txt | 16 +- setup.cfg | 19 - setup.py | 41 - src/vonage/__init__.py | 4 - src/vonage/_internal.py | 31 - src/vonage/account.py | 116 --- src/vonage/application.py | 174 ---- src/vonage/client.py | 463 ----------- src/vonage/errors.py | 72 -- src/vonage/meetings.py | 173 ---- src/vonage/messages.py | 113 --- src/vonage/ncco_builder/__init__.py | 1 - src/vonage/ncco_builder/connect_endpoints.py | 63 -- src/vonage/ncco_builder/input_types.py | 26 - src/vonage/ncco_builder/ncco.py | 259 ------ src/vonage/ncco_builder/pay_prompts.py | 54 -- src/vonage/number_insight.py | 48 -- src/vonage/number_management.py | 34 - src/vonage/proactive_connect.py | 187 ----- src/vonage/redact.py | 31 - src/vonage/short_codes.py | 34 - src/vonage/sms.py | 47 -- src/vonage/subaccounts.py | 163 ---- src/vonage/users.py | 72 -- src/vonage/ussd.py | 15 - src/vonage/verify.py | 54 -- src/vonage/verify2.py | 137 --- src/vonage/video.py | 353 -------- src/vonage/voice.py | 100 --- tests/conftest.py | 141 ---- .../secret_management/create-validation.json | 12 - .../account/secret_management/create.json | 9 - tests/data/account/secret_management/get.json | 9 - .../secret_management/last-secret.json | 6 - .../data/account/secret_management/list.json | 20 - .../secret_management/max-secrets.json | 6 - .../account/secret_management/missing.json | 6 - .../secret_management/unauthorized.json | 6 - .../data/applications/create_application.json | 14 - tests/data/applications/get_application.json | 13 - .../data/applications/list_applications.json | 45 - .../data/applications/update_application.json | 13 - .../meetings/delete_recording_not_found.json | 5 - tests/data/meetings/delete_theme_in_use.json | 8 - tests/data/meetings/empty_themes.json | 1 - tests/data/meetings/get_recording.json | 12 - .../meetings/get_recording_not_found.json | 5 - .../data/meetings/get_session_recordings.json | 18 - .../get_session_recordings_not_found.json | 5 - tests/data/meetings/list_dial_in_numbers.json | 12 - .../data/meetings/list_logo_upload_urls.json | 47 -- .../list_rooms_theme_id_not_found.json | 5 - .../meetings/list_rooms_with_theme_id.json | 57 -- tests/data/meetings/list_themes.json | 34 - tests/data/meetings/logo_key_error.json | 11 - tests/data/meetings/long_term_room.json | 37 - .../meetings/long_term_room_with_theme.json | 37 - tests/data/meetings/meeting_room.json | 38 - tests/data/meetings/multiple_fewer_rooms.json | 94 --- tests/data/meetings/multiple_rooms.json | 205 ----- tests/data/meetings/theme.json | 16 - tests/data/meetings/theme_name_in_use.json | 5 - tests/data/meetings/theme_not_found.json | 5 - tests/data/meetings/transparent_logo.png | Bin 8843 -> 0 bytes tests/data/meetings/unauthorized.json | 4 - .../meetings/update_application_theme.json | 5 - ...update_application_theme_id_not_found.json | 5 - tests/data/meetings/update_no_keys.json | 5 - tests/data/meetings/update_room.json | 37 - .../data/meetings/update_room_type_error.json | 5 - .../meetings/update_theme_already_exists.json | 5 - tests/data/meetings/updated_theme.json | 16 - tests/data/meetings/upload_to_aws_error.xml | 1 - .../proactive_connect/create_list_400.json | 10 - .../proactive_connect/create_list_basic.json | 16 - .../proactive_connect/create_list_manual.json | 28 - .../create_list_salesforce.json | 30 - .../data/proactive_connect/csv_to_upload.csv | 4 - .../proactive_connect/fetch_list_400.json | 6 - tests/data/proactive_connect/get_list.json | 28 - tests/data/proactive_connect/item.json | 11 - tests/data/proactive_connect/item_400.json | 9 - tests/data/proactive_connect/list_404.json | 6 - .../proactive_connect/list_all_items.json | 40 - tests/data/proactive_connect/list_events.json | 70 -- tests/data/proactive_connect/list_items.csv | 4 - tests/data/proactive_connect/list_lists.json | 84 -- tests/data/proactive_connect/not_found.json | 6 - tests/data/proactive_connect/update_item.json | 11 - tests/data/proactive_connect/update_list.json | 29 - .../update_list_salesforce.json | 28 - .../proactive_connect/upload_from_csv.json | 3 - tests/data/subaccounts/balance_transfer.json | 14 - tests/data/subaccounts/credit_transfer.json | 14 - tests/data/subaccounts/forbidden.json | 6 - .../data/subaccounts/insufficient_credit.json | 6 - .../data/subaccounts/invalid_credentials.json | 6 - .../subaccounts/invalid_number_transfer.json | 6 - tests/data/subaccounts/invalid_transfer.json | 6 - .../subaccounts/list_balance_transfers.json | 27 - .../subaccounts/list_credit_transfers.json | 27 - tests/data/subaccounts/list_subaccounts.json | 31 - .../data/subaccounts/modified_subaccount.json | 10 - tests/data/subaccounts/must_be_number.json | 12 - tests/data/subaccounts/not_found.json | 6 - tests/data/subaccounts/number_not_found.json | 6 - .../same_from_and_to_accounts.json | 12 - tests/data/subaccounts/subaccount.json | 11 - tests/data/subaccounts/transfer_number.json | 7 - .../transfer_validation_error.json | 12 - tests/data/subaccounts/validation_error.json | 12 - tests/data/users/invalid_content_type.json | 13 - tests/data/users/list_users_400.json | 13 - tests/data/users/list_users_404.json | 7 - tests/data/users/list_users_500.json | 7 - tests/data/users/list_users_basic.json | 43 - tests/data/users/list_users_options.json | 37 - tests/data/users/rate_limit.json | 7 - tests/data/users/user_400.json | 13 - tests/data/users/user_404.json | 7 - tests/data/users/user_basic.json | 13 - tests/data/users/user_options.json | 69 -- tests/data/users/user_updated.json | 19 - tests/data/verify/blocked_with_network.json | 1 - .../blocked_with_network_and_request_id.json | 1 - .../data/verify/blocked_with_request_id.json | 1 - tests/data/verify2/already_verified.json | 6 - tests/data/verify2/check_code.json | 4 - tests/data/verify2/code_not_supported.json | 6 - tests/data/verify2/create_request.json | 3 - .../verify2/create_request_silent_auth.json | 4 - tests/data/verify2/error_conflict.json | 7 - .../verify2/fraud_check_invalid_account.json | 6 - tests/data/verify2/invalid_code.json | 6 - tests/data/verify2/invalid_email.json | 12 - tests/data/verify2/invalid_sender.json | 6 - tests/data/verify2/rate_limit.json | 6 - tests/data/verify2/request_not_found.json | 6 - .../data/verify2/too_many_code_attempts.json | 6 - tests/test_account.py | 222 ----- tests/test_application.py | 276 ------ tests/test_client.py | 36 - tests/test_getters_setters.py | 13 - tests/test_jwt.py | 54 -- tests/test_meetings.py | 783 ------------------ tests/test_messages_send_message.py | 42 - tests/test_messages_validate_input.py | 315 ------- .../ncco_samples/ncco_action_samples.py | 55 -- .../ncco_samples/ncco_builder_samples.py | 183 ---- .../test_connect_endpoints.py | 64 -- tests/test_ncco_builder/test_input_types.py | 47 -- tests/test_ncco_builder/test_ncco_actions.py | 332 -------- tests/test_ncco_builder/test_ncco_builder.py | 40 - tests/test_ncco_builder/test_pay_prompts.py | 71 -- tests/test_number_insight.py | 50 -- tests/test_number_management.py | 57 -- tests/test_packages.py | 14 - tests/test_proactive_connect.py | 649 --------------- tests/test_redact.py | 36 - tests/test_rest_calls.py | 139 ---- tests/test_short_codes.py | 64 -- tests/test_signature.py | 86 -- tests/test_sms.py | 50 -- tests/test_subaccounts.py | 730 ---------------- tests/test_users.py | 369 --------- tests/test_ussd.py | 27 - tests/test_verify.py | 204 ----- tests/test_verify2.py | 647 --------------- tests/test_voice.py | 224 ----- tests/util.py | 63 -- tox.ini | 13 - vonage/BUILD | 9 + CHANGES.md => vonage/CHANGES.md | 10 +- vonage/README.md | 15 + vonage/pyproject.toml | 9 + vonage/src/vonage/BUILD | 1 + vonage/src/vonage/__init__.py | 0 vonage/src/vonage/vonage.py | 26 + vonage/tests/BUILD | 1 + vonage/tests/test_vonage.py | 13 + 246 files changed, 980 insertions(+), 10995 deletions(-) delete mode 100644 .bumpversion.cfg delete mode 100644 .editorconfig delete mode 100644 .pyup.yml create mode 100644 BUILD delete mode 100644 CONTRIBUTING.md delete mode 100644 MANIFEST.in create mode 100644 http_client/BUILD create mode 100644 http_client/CHANGES.md create mode 100644 http_client/README.md create mode 100644 http_client/pyproject.toml create mode 100644 http_client/src/http_client/BUILD rename tests/data/account/secret_management/delete.json => http_client/src/http_client/__init__.py (100%) create mode 100644 http_client/src/http_client/auth.py create mode 100644 http_client/src/http_client/errors.py create mode 100644 http_client/src/http_client/http_client.py create mode 100644 http_client/tests/BUILD create mode 100644 http_client/tests/data/400.json create mode 100644 http_client/tests/data/400.txt create mode 100644 http_client/tests/data/401.json create mode 100644 http_client/tests/data/429.json create mode 100644 http_client/tests/data/500.json rename tests/data/private_key.txt => http_client/tests/data/dummy_private_key.txt (100%) rename tests/data/public_key.txt => http_client/tests/data/dummy_public_key.txt (100%) create mode 100644 http_client/tests/data/example_get.json create mode 100644 http_client/tests/data/example_post.json rename {tests => http_client/tests}/data/video/broadcast.json (100%) rename {tests => http_client/tests}/data/video/create_archive.json (100%) rename {tests => http_client/tests}/data/video/create_session.json (100%) rename {tests => http_client/tests}/data/video/create_sip_call.json (100%) rename {tests => http_client/tests}/data/video/disable_mute_multiple_streams.json (100%) rename {tests => http_client/tests}/data/video/get_archive.json (100%) rename {tests => http_client/tests}/data/video/get_stream.json (100%) rename {tests => http_client/tests}/data/video/list_archives.json (100%) rename {tests => http_client/tests}/data/video/list_broadcasts.json (100%) rename {tests => http_client/tests}/data/video/list_streams.json (100%) rename {tests => http_client/tests}/data/video/mute_multiple_streams.json (100%) rename {tests => http_client/tests}/data/video/mute_specific_stream.json (100%) rename {tests => http_client/tests}/data/video/null.json (100%) rename {tests => http_client/tests}/data/video/play_dtmf_invalid_error.json (100%) rename {tests => http_client/tests}/data/video/stop_archive.json (100%) create mode 100644 http_client/tests/test_auth.py create mode 100644 http_client/tests/test_http_client.py rename {tests => http_client/tests}/test_video.py (100%) create mode 100644 libs/BUILD create mode 100644 libs/errors.py create mode 100644 libs/format_phone_number.py create mode 100644 libs/pyproject.toml create mode 100644 libs/testing_utils.py create mode 100644 libs/tests/BUILD create mode 100644 libs/tests/test_format_phone_number.py create mode 100644 number_insight_v2/BUILD create mode 100644 number_insight_v2/README.md create mode 100644 number_insight_v2/pyproject.toml create mode 100644 number_insight_v2/src/number_insight_v2/BUILD rename tests/data/no_content.json => number_insight_v2/src/number_insight_v2/__init__.py (100%) create mode 100644 number_insight_v2/src/number_insight_v2/errors.py create mode 100644 number_insight_v2/src/number_insight_v2/number_insight_v2.py create mode 100644 number_insight_v2/tests/BUILD create mode 100644 number_insight_v2/tests/test_ni2.py create mode 100644 pants.ci.toml create mode 100644 pants.toml delete mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 src/vonage/__init__.py delete mode 100644 src/vonage/_internal.py delete mode 100644 src/vonage/account.py delete mode 100644 src/vonage/application.py delete mode 100644 src/vonage/client.py delete mode 100644 src/vonage/errors.py delete mode 100644 src/vonage/meetings.py delete mode 100644 src/vonage/messages.py delete mode 100644 src/vonage/ncco_builder/__init__.py delete mode 100644 src/vonage/ncco_builder/connect_endpoints.py delete mode 100644 src/vonage/ncco_builder/input_types.py delete mode 100644 src/vonage/ncco_builder/ncco.py delete mode 100644 src/vonage/ncco_builder/pay_prompts.py delete mode 100644 src/vonage/number_insight.py delete mode 100644 src/vonage/number_management.py delete mode 100644 src/vonage/proactive_connect.py delete mode 100644 src/vonage/redact.py delete mode 100644 src/vonage/short_codes.py delete mode 100644 src/vonage/sms.py delete mode 100644 src/vonage/subaccounts.py delete mode 100644 src/vonage/users.py delete mode 100644 src/vonage/ussd.py delete mode 100644 src/vonage/verify.py delete mode 100644 src/vonage/verify2.py delete mode 100644 src/vonage/video.py delete mode 100644 src/vonage/voice.py delete mode 100644 tests/conftest.py delete mode 100644 tests/data/account/secret_management/create-validation.json delete mode 100644 tests/data/account/secret_management/create.json delete mode 100644 tests/data/account/secret_management/get.json delete mode 100644 tests/data/account/secret_management/last-secret.json delete mode 100644 tests/data/account/secret_management/list.json delete mode 100644 tests/data/account/secret_management/max-secrets.json delete mode 100644 tests/data/account/secret_management/missing.json delete mode 100644 tests/data/account/secret_management/unauthorized.json delete mode 100644 tests/data/applications/create_application.json delete mode 100644 tests/data/applications/get_application.json delete mode 100644 tests/data/applications/list_applications.json delete mode 100644 tests/data/applications/update_application.json delete mode 100644 tests/data/meetings/delete_recording_not_found.json delete mode 100644 tests/data/meetings/delete_theme_in_use.json delete mode 100644 tests/data/meetings/empty_themes.json delete mode 100644 tests/data/meetings/get_recording.json delete mode 100644 tests/data/meetings/get_recording_not_found.json delete mode 100644 tests/data/meetings/get_session_recordings.json delete mode 100644 tests/data/meetings/get_session_recordings_not_found.json delete mode 100644 tests/data/meetings/list_dial_in_numbers.json delete mode 100644 tests/data/meetings/list_logo_upload_urls.json delete mode 100644 tests/data/meetings/list_rooms_theme_id_not_found.json delete mode 100644 tests/data/meetings/list_rooms_with_theme_id.json delete mode 100644 tests/data/meetings/list_themes.json delete mode 100644 tests/data/meetings/logo_key_error.json delete mode 100644 tests/data/meetings/long_term_room.json delete mode 100644 tests/data/meetings/long_term_room_with_theme.json delete mode 100644 tests/data/meetings/meeting_room.json delete mode 100644 tests/data/meetings/multiple_fewer_rooms.json delete mode 100644 tests/data/meetings/multiple_rooms.json delete mode 100644 tests/data/meetings/theme.json delete mode 100644 tests/data/meetings/theme_name_in_use.json delete mode 100644 tests/data/meetings/theme_not_found.json delete mode 100644 tests/data/meetings/transparent_logo.png delete mode 100644 tests/data/meetings/unauthorized.json delete mode 100644 tests/data/meetings/update_application_theme.json delete mode 100644 tests/data/meetings/update_application_theme_id_not_found.json delete mode 100644 tests/data/meetings/update_no_keys.json delete mode 100644 tests/data/meetings/update_room.json delete mode 100644 tests/data/meetings/update_room_type_error.json delete mode 100644 tests/data/meetings/update_theme_already_exists.json delete mode 100644 tests/data/meetings/updated_theme.json delete mode 100644 tests/data/meetings/upload_to_aws_error.xml delete mode 100644 tests/data/proactive_connect/create_list_400.json delete mode 100644 tests/data/proactive_connect/create_list_basic.json delete mode 100644 tests/data/proactive_connect/create_list_manual.json delete mode 100644 tests/data/proactive_connect/create_list_salesforce.json delete mode 100644 tests/data/proactive_connect/csv_to_upload.csv delete mode 100644 tests/data/proactive_connect/fetch_list_400.json delete mode 100644 tests/data/proactive_connect/get_list.json delete mode 100644 tests/data/proactive_connect/item.json delete mode 100644 tests/data/proactive_connect/item_400.json delete mode 100644 tests/data/proactive_connect/list_404.json delete mode 100644 tests/data/proactive_connect/list_all_items.json delete mode 100644 tests/data/proactive_connect/list_events.json delete mode 100644 tests/data/proactive_connect/list_items.csv delete mode 100644 tests/data/proactive_connect/list_lists.json delete mode 100644 tests/data/proactive_connect/not_found.json delete mode 100644 tests/data/proactive_connect/update_item.json delete mode 100644 tests/data/proactive_connect/update_list.json delete mode 100644 tests/data/proactive_connect/update_list_salesforce.json delete mode 100644 tests/data/proactive_connect/upload_from_csv.json delete mode 100644 tests/data/subaccounts/balance_transfer.json delete mode 100644 tests/data/subaccounts/credit_transfer.json delete mode 100644 tests/data/subaccounts/forbidden.json delete mode 100644 tests/data/subaccounts/insufficient_credit.json delete mode 100644 tests/data/subaccounts/invalid_credentials.json delete mode 100644 tests/data/subaccounts/invalid_number_transfer.json delete mode 100644 tests/data/subaccounts/invalid_transfer.json delete mode 100644 tests/data/subaccounts/list_balance_transfers.json delete mode 100644 tests/data/subaccounts/list_credit_transfers.json delete mode 100644 tests/data/subaccounts/list_subaccounts.json delete mode 100644 tests/data/subaccounts/modified_subaccount.json delete mode 100644 tests/data/subaccounts/must_be_number.json delete mode 100644 tests/data/subaccounts/not_found.json delete mode 100644 tests/data/subaccounts/number_not_found.json delete mode 100644 tests/data/subaccounts/same_from_and_to_accounts.json delete mode 100644 tests/data/subaccounts/subaccount.json delete mode 100644 tests/data/subaccounts/transfer_number.json delete mode 100644 tests/data/subaccounts/transfer_validation_error.json delete mode 100644 tests/data/subaccounts/validation_error.json delete mode 100644 tests/data/users/invalid_content_type.json delete mode 100644 tests/data/users/list_users_400.json delete mode 100644 tests/data/users/list_users_404.json delete mode 100644 tests/data/users/list_users_500.json delete mode 100644 tests/data/users/list_users_basic.json delete mode 100644 tests/data/users/list_users_options.json delete mode 100644 tests/data/users/rate_limit.json delete mode 100644 tests/data/users/user_400.json delete mode 100644 tests/data/users/user_404.json delete mode 100644 tests/data/users/user_basic.json delete mode 100644 tests/data/users/user_options.json delete mode 100644 tests/data/users/user_updated.json delete mode 100644 tests/data/verify/blocked_with_network.json delete mode 100644 tests/data/verify/blocked_with_network_and_request_id.json delete mode 100644 tests/data/verify/blocked_with_request_id.json delete mode 100644 tests/data/verify2/already_verified.json delete mode 100644 tests/data/verify2/check_code.json delete mode 100644 tests/data/verify2/code_not_supported.json delete mode 100644 tests/data/verify2/create_request.json delete mode 100644 tests/data/verify2/create_request_silent_auth.json delete mode 100644 tests/data/verify2/error_conflict.json delete mode 100644 tests/data/verify2/fraud_check_invalid_account.json delete mode 100644 tests/data/verify2/invalid_code.json delete mode 100644 tests/data/verify2/invalid_email.json delete mode 100644 tests/data/verify2/invalid_sender.json delete mode 100644 tests/data/verify2/rate_limit.json delete mode 100644 tests/data/verify2/request_not_found.json delete mode 100644 tests/data/verify2/too_many_code_attempts.json delete mode 100644 tests/test_account.py delete mode 100644 tests/test_application.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_getters_setters.py delete mode 100644 tests/test_jwt.py delete mode 100644 tests/test_meetings.py delete mode 100644 tests/test_messages_send_message.py delete mode 100644 tests/test_messages_validate_input.py delete mode 100644 tests/test_ncco_builder/ncco_samples/ncco_action_samples.py delete mode 100644 tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py delete mode 100644 tests/test_ncco_builder/test_connect_endpoints.py delete mode 100644 tests/test_ncco_builder/test_input_types.py delete mode 100644 tests/test_ncco_builder/test_ncco_actions.py delete mode 100644 tests/test_ncco_builder/test_ncco_builder.py delete mode 100644 tests/test_ncco_builder/test_pay_prompts.py delete mode 100644 tests/test_number_insight.py delete mode 100644 tests/test_number_management.py delete mode 100644 tests/test_packages.py delete mode 100644 tests/test_proactive_connect.py delete mode 100644 tests/test_redact.py delete mode 100644 tests/test_rest_calls.py delete mode 100644 tests/test_short_codes.py delete mode 100644 tests/test_signature.py delete mode 100644 tests/test_sms.py delete mode 100644 tests/test_subaccounts.py delete mode 100644 tests/test_users.py delete mode 100644 tests/test_ussd.py delete mode 100644 tests/test_verify.py delete mode 100644 tests/test_verify2.py delete mode 100644 tests/test_voice.py delete mode 100644 tests/util.py delete mode 100644 tox.ini create mode 100644 vonage/BUILD rename CHANGES.md => vonage/CHANGES.md (97%) create mode 100644 vonage/README.md create mode 100644 vonage/pyproject.toml create mode 100644 vonage/src/vonage/BUILD create mode 100644 vonage/src/vonage/__init__.py create mode 100644 vonage/src/vonage/vonage.py create mode 100644 vonage/tests/BUILD create mode 100644 vonage/tests/test_vonage.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 13da7fa5..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[bumpversion] -current_version = 3.13.0 -commit = True -tag = False - -[bumpversion:file:src/vonage/__init__.py] - -[bumpversion:file:setup.py] diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index b8950ff3..00000000 --- a/.editorconfig +++ /dev/null @@ -1,24 +0,0 @@ -# EditorConfig is awesome: https://EditorConfig.org - -# top-most EditorConfig file -root = true - -# Unix-style newlines with a newline ending every file -[*] -end_of_line = lf -insert_final_newline = true - -# Python files 4 space indentation -[*.py] -charset = utf-8 -indent_style = space -indent_size = 4 - -# Makefiles tab indentation -[Makefile] -indent_style = tab - -# Yaml files 2-space indentation -[*.yml] -indent_style = space -indent_size = 2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 715918af..83e09410 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ name: Build -on: push +on: [push, pull_request] permissions: actions: write @@ -14,6 +14,9 @@ permissions: security-events: write statuses: write +env: + PANTS_CONFIG_FILES: "pants.ci.toml" + jobs: test: name: Test @@ -22,16 +25,25 @@ jobs: fail-fast: false matrix: python: ["3.8", "3.9", "3.10", "3.11", "3.12"] - os: ["ubuntu-latest", "macos-latest"] + os: ["ubuntu-latest"] steps: - - uses: actions/setup-python@v4 + - name: Clone repo + uses: actions/checkout@v4 + - name: Setup python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - - name: Clone repo - uses: actions/checkout@v3 - - name: Install dependencies - run: make install + - name: Initialize pants + uses: pantsbuild/actions/init-pants@main + with: + gha-cache-key: cache0-py${{ matrix.python }} + named-caches-hash: ${{ hashFiles('requirements.txt') }} + - name: Check BUILD files + run: | + pants tailor --check update-build-files --check :: + - name: Lint + run: | + pants lint :: - name: Run tests - run: make coverage - - name: Run codecov - uses: codecov/codecov-action@v3 + run: | + pants test --use-coverage :: diff --git a/.gitignore b/.gitignore index dd549bbf..e7513b24 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,8 @@ ENV* .pytest_cache html/ .mutmut-cache + +# Pants workspace files +/.pants.* +/dist/ +/.pids diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d58af5cd..37623cbb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,4 @@ repos: rev: v4.4.0 hooks: - id: check-yaml - - id: trailing-whitespace - - repo: https://github.com/ambv/black - rev: 23.7.0 - hooks: - - id: black - language_version: python3.11 + - id: trailing-whitespace \ No newline at end of file diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index 6143d800..00000000 --- a/.pyup.yml +++ /dev/null @@ -1,4 +0,0 @@ -# autogenerated pyup.io config file -# see https://pyup.io/docs/configuration/ for all available options - -update: insecure diff --git a/BUILD b/BUILD new file mode 100644 index 00000000..b8519ce5 --- /dev/null +++ b/BUILD @@ -0,0 +1,3 @@ +python_requirements( + name="reqs", +) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5b653254..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,19 +0,0 @@ -# Getting Involved - -Thanks for your interest in the project, we'd love to have you involved! Check out the sections below to find out more about what to do next... - -## Documentation -Check out our [Documentaion](https://developer.nexmo.com/documentation) - -## Opening an Issue - -We always welcome issues, if you've seen something that isn't quite right or you have a suggestion for a new feature, please go ahead and open an issue in this project. Include as much information as you have, it really helps. - -## Making a Code Change - -We're always open to pull requests, but these should be small and clearly described so that we can understand what you're trying to do. Feel free to open an issue first and get some discussion going. - -When you're ready to start coding, fork this repository to your own GitHub account and make your changes in a new branch. Once you're happy, open a pull request and explain what the change is and why you think we should include it in our project. - - - diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 9f16f1e1..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,16 +0,0 @@ -include .editorconfig -include CHANGES.md -include LICENSE.txt -include README.md -include requirements.txt -include .pyup.yml -include .bumpversion.cfg -include Makefile -recursive-include docs *.bat -recursive-include docs *.py -recursive-include docs *.rst -recursive-include docs Makefile -recursive-include requirements *.txt -recursive-include tests *.py -recursive-include tests *.txt -recursive-include tests *.json \ No newline at end of file diff --git a/Makefile b/Makefile index 863d937e..a0d095d7 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,7 @@ -.PHONY: clean test build coverage install requirements release - -coverage: - coverage run -m pytest -v - coverage html +.PHONY: test coverage test: - pytest -vv --disable-warnings - -clean: - rm -rf dist build - -build: - python -m build + pants test :: -release: - python -m twine upload dist/* - -install: requirements - -requirements: .requirements.txt - -.requirements.txt: requirements.txt - python -m pip install --upgrade pip setuptools - python -m pip install -r requirements.txt - python -m pip freeze > .requirements.txt +coverage: + pants test --use-coverage --open-coverage :: \ No newline at end of file diff --git a/http_client/BUILD b/http_client/BUILD new file mode 100644 index 00000000..d9eea45b --- /dev/null +++ b/http_client/BUILD @@ -0,0 +1,11 @@ +resource(name='pyproject', source='pyproject.toml') + +files(sources=['tests/data/*']) + +python_distribution( + name='vonage-http-client', + dependencies=[':pyproject', 'http_client/src/http_client'], + provides=python_artifact(), + generate_setup=False, + repositories=['https://test.pypi.org/legacy/'], +) diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md new file mode 100644 index 00000000..17cd0da7 --- /dev/null +++ b/http_client/CHANGES.md @@ -0,0 +1,2 @@ +# 0.1.0 +- Initial upload \ No newline at end of file diff --git a/http_client/README.md b/http_client/README.md new file mode 100644 index 00000000..6671dd88 --- /dev/null +++ b/http_client/README.md @@ -0,0 +1,17 @@ +# Need to redo this to be about the client! + +Vonage Authentication Package + +`vonage-auth` provides a convenient way to handle authentication related to Vonage APIs in your Python projects. This package includes an `Auth` class that allows you to manage API key- and secret-based authentication as well as JSON Web Token (JWT) authentication. + +This package (`vonage-auth`) is used by the `vonage` Python package so doesn't require manual installation unless you're not using that. + +For full API documentation refer to the [Vonage Developer documentation](https://developer.vonage.com). + +## Installation + +You can install the package using pip: + +```bash +pip install vonage-auth +``` diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml new file mode 100644 index 00000000..be9f8a88 --- /dev/null +++ b/http_client/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = 'vonage-http-client' +description = 'An asynchronous HTTP client for making requests to Vonage APIs.' +version = '0.1.0' +dependencies = ['vonage-jwt', 'requests'] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/http_client/src/http_client/BUILD b/http_client/src/http_client/BUILD new file mode 100644 index 00000000..7b9f5b6c --- /dev/null +++ b/http_client/src/http_client/BUILD @@ -0,0 +1 @@ +python_sources(name='http_client') diff --git a/tests/data/account/secret_management/delete.json b/http_client/src/http_client/__init__.py similarity index 100% rename from tests/data/account/secret_management/delete.json rename to http_client/src/http_client/__init__.py diff --git a/http_client/src/http_client/auth.py b/http_client/src/http_client/auth.py new file mode 100644 index 00000000..99ac7cc5 --- /dev/null +++ b/http_client/src/http_client/auth.py @@ -0,0 +1,77 @@ +from base64 import b64encode +from typing import Optional + +from pydantic import validate_call +from vonage_jwt.jwt import JwtClient + +from .errors import InvalidAuthError, JWTGenerationError + + +class Auth: + """Deals with Vonage API authentication. + + 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. + + 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, + ) -> None: + self._validate_input_combinations( + api_key, api_secret, application_id, private_key + ) + + self._api_key = api_key + self._api_secret = api_secret + + if application_id is not None and private_key is not None: + self._jwt_client = JwtClient(application_id, private_key) + + @property + def api_key(self): + return self._api_key + + @property + def api_secret(self): + return self._api_secret + + def create_jwt_auth_string(self): + return b'Bearer ' + self.generate_application_jwt() + + def generate_application_jwt(self): + try: + return self._jwt_client.generate_application_jwt() + 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): + hash = b64encode(f'{self.api_key}:{self.api_secret}'.encode('utf-8')).decode( + 'ascii' + ) + return f'Basic {hash}' + + def _validate_input_combinations( + self, api_key, api_secret, application_id, private_key + ): + if (api_key and not api_secret) or (not api_key and api_secret): + raise InvalidAuthError( + 'Both api_key and api_secret must be set or both must be None.' + ) + + 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/http_client/errors.py b/http_client/src/http_client/errors.py new file mode 100644 index 00000000..77921c09 --- /dev/null +++ b/http_client/src/http_client/errors.py @@ -0,0 +1,104 @@ +from json import JSONDecodeError + +from errors import VonageError +from requests import Response + + +class JWTGenerationError(VonageError): + """Indicates an error with generating a JWT.""" + + +class InvalidAuthError(VonageError): + """Indicates an error with the authentication credentials provided.""" + + +class InvalidHttpClientOptionsError(VonageError): + """The options passed to the HTTP Client were invalid.""" + + +class HttpRequestError(VonageError): + """Exception indicating an error in the response received from a Vonage SDK request. + + Args: + response (requests.Response): The HTTP response object. + content_type (str): The response content type. + + Attributes: + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response, content_type: str): + self.response = response + self.set_error_message(self.response, content_type) + super().__init__(self.message) + + def set_error_message(self, response: Response, content_type: str): + body = None + if content_type == 'application/json': + try: + body = response.json() + except JSONDecodeError: + pass + else: + body = response.text + + if body: + self.message = f'{response.status_code} response from {response.url}. Error response body: {body}' + else: + self.message = f'{response.status_code} response from {response.url}.' + + +class AuthenticationError(HttpRequestError): + """Exception indicating authentication failure in a Vonage SDK request. + + This error is raised when the HTTP response status code is 401 (Unauthorized). + + Args: + response (requests.Response): The HTTP response object. + content_type (str): The response content type. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response, content_type: str): + super().__init__(response, content_type) + + +class RateLimitedError(HttpRequestError): + """Exception indicating a rate limit was hit when making too many requests to a Vonage endpoint. + + This error is raised when the HTTP response status code is 429 (Too Many Requests). + + Args: + response (requests.Response): The HTTP response object. + content_type (str): The response content type. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response, content_type: str): + super().__init__(response, content_type) + + +class ServerError(HttpRequestError): + """Exception indicating an error was returned by a Vonage server in response to a Vonage SDK + request. + + This error is raised when the HTTP response status code is 500 (Internal Server Error). + + Args: + response (requests.Response): The HTTP response object. + content_type (str): The response content type. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response, content_type: str): + super().__init__(response, content_type) diff --git a/http_client/src/http_client/http_client.py b/http_client/src/http_client/http_client.py new file mode 100644 index 00000000..3fe68cfd --- /dev/null +++ b/http_client/src/http_client/http_client.py @@ -0,0 +1,138 @@ +from logging import getLogger +from platform import python_version +from typing import Literal, Optional + +from http_client.auth import Auth +from http_client.errors import ( + AuthenticationError, + HttpRequestError, + InvalidHttpClientOptionsError, + RateLimitedError, + ServerError, +) +from pydantic import BaseModel, Field, ValidationError, validate_call +from requests import Response +from requests.adapters import HTTPAdapter +from requests.sessions import Session +from typing_extensions import Annotated + +logger = getLogger('vonage-http-client-v2') + + +class HttpClientOptions(BaseModel): + api_host: str = 'api.nexmo.com' + rest_host: Optional[str] = 'rest.nexmo.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 (:class: 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. + + 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'. + 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): + 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._timeout = self._http_client_options.timeout + self._session = Session() + self._adapter = HTTPAdapter( + pool_connections=self._http_client_options.pool_connections, + pool_maxsize=self._http_client_options.pool_maxsize, + max_retries=self._http_client_options.max_retries, + ) + self._session.mount('https://', self._adapter) + + self._user_agent = f'vonage-python-sdk python/{python_version()}' + self._headers = {'User-Agent': self._user_agent, 'Accept': 'application/json'} + + @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 + + def post(self, host: str, request_path: str = '', params: dict = None): + return self.make_request('POST', host, request_path, params) + + def get(self, host: str, request_path: str = '', params: dict = None): + return self.make_request('GET', host, request_path, params) + + @validate_call + def make_request( + self, + request_type: Literal['GET', 'POST'], + host: str, + request_path: str = '', + params: Optional[dict] = None, + ): + url = f'https://{host}{request_path}' + logger.debug( + f'{request_type} request to {url}, with data: {params}; headers: {self._headers}' + ) + with self._session.request( + request_type, + url, + json=params, + headers=self._headers, + timeout=self._timeout, + ) as response: + return self._parse_response(response) + + def _parse_response(self, response: Response): + logger.debug( + f'Response received from {response.url} with status code: {response.status_code}; headers: {response.headers}' + ) + content_type = response.headers['Content-Type'].split(';', 1)[0] + if 200 <= response.status_code < 300: + if response.status_code == 204: + return None + return response.json() + 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 or response.status_code == 403: + raise AuthenticationError(response, content_type) + elif response.status_code == 429: + raise RateLimitedError(response, content_type) + elif response.status_code == 500: + raise ServerError(response, content_type) + raise HttpRequestError(response, content_type) diff --git a/http_client/tests/BUILD b/http_client/tests/BUILD new file mode 100644 index 00000000..c9a9fd7b --- /dev/null +++ b/http_client/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['http_client', 'libs']) 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..a98d881f --- /dev/null +++ b/http_client/tests/data/401.json @@ -0,0 +1 @@ +{"Error": "Authentication Failed"} \ 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..d6a0546b --- /dev/null +++ b/http_client/tests/data/429.json @@ -0,0 +1 @@ +{"Error": "Too Many Requests"} \ 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..35039b4c --- /dev/null +++ b/http_client/tests/data/500.json @@ -0,0 +1 @@ +{"Error": "Internal Server Error"} \ 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/tests/data/video/broadcast.json b/http_client/tests/data/video/broadcast.json similarity index 100% rename from tests/data/video/broadcast.json rename to http_client/tests/data/video/broadcast.json diff --git a/tests/data/video/create_archive.json b/http_client/tests/data/video/create_archive.json similarity index 100% rename from tests/data/video/create_archive.json rename to http_client/tests/data/video/create_archive.json diff --git a/tests/data/video/create_session.json b/http_client/tests/data/video/create_session.json similarity index 100% rename from tests/data/video/create_session.json rename to http_client/tests/data/video/create_session.json diff --git a/tests/data/video/create_sip_call.json b/http_client/tests/data/video/create_sip_call.json similarity index 100% rename from tests/data/video/create_sip_call.json rename to http_client/tests/data/video/create_sip_call.json diff --git a/tests/data/video/disable_mute_multiple_streams.json b/http_client/tests/data/video/disable_mute_multiple_streams.json similarity index 100% rename from tests/data/video/disable_mute_multiple_streams.json rename to http_client/tests/data/video/disable_mute_multiple_streams.json diff --git a/tests/data/video/get_archive.json b/http_client/tests/data/video/get_archive.json similarity index 100% rename from tests/data/video/get_archive.json rename to http_client/tests/data/video/get_archive.json diff --git a/tests/data/video/get_stream.json b/http_client/tests/data/video/get_stream.json similarity index 100% rename from tests/data/video/get_stream.json rename to http_client/tests/data/video/get_stream.json diff --git a/tests/data/video/list_archives.json b/http_client/tests/data/video/list_archives.json similarity index 100% rename from tests/data/video/list_archives.json rename to http_client/tests/data/video/list_archives.json diff --git a/tests/data/video/list_broadcasts.json b/http_client/tests/data/video/list_broadcasts.json similarity index 100% rename from tests/data/video/list_broadcasts.json rename to http_client/tests/data/video/list_broadcasts.json diff --git a/tests/data/video/list_streams.json b/http_client/tests/data/video/list_streams.json similarity index 100% rename from tests/data/video/list_streams.json rename to http_client/tests/data/video/list_streams.json diff --git a/tests/data/video/mute_multiple_streams.json b/http_client/tests/data/video/mute_multiple_streams.json similarity index 100% rename from tests/data/video/mute_multiple_streams.json rename to http_client/tests/data/video/mute_multiple_streams.json diff --git a/tests/data/video/mute_specific_stream.json b/http_client/tests/data/video/mute_specific_stream.json similarity index 100% rename from tests/data/video/mute_specific_stream.json rename to http_client/tests/data/video/mute_specific_stream.json diff --git a/tests/data/video/null.json b/http_client/tests/data/video/null.json similarity index 100% rename from tests/data/video/null.json rename to http_client/tests/data/video/null.json diff --git a/tests/data/video/play_dtmf_invalid_error.json b/http_client/tests/data/video/play_dtmf_invalid_error.json similarity index 100% rename from tests/data/video/play_dtmf_invalid_error.json rename to http_client/tests/data/video/play_dtmf_invalid_error.json diff --git a/tests/data/video/stop_archive.json b/http_client/tests/data/video/stop_archive.json similarity index 100% rename from tests/data/video/stop_archive.json rename to http_client/tests/data/video/stop_archive.json diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py new file mode 100644 index 00000000..24d4a26f --- /dev/null +++ b/http_client/tests/test_auth.py @@ -0,0 +1,104 @@ +from os.path import dirname, join +from unittest.mock import patch + +from http_client.auth import Auth +from http_client.errors import InvalidAuthError, JWTGenerationError +from pydantic import ValidationError +from pytest import raises +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') + + +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, + ) + + assert auth.api_key == api_key + assert auth.api_secret == api_secret + assert type(auth._jwt_client) == JwtClient + + +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) + + +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('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('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==' diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py new file mode 100644 index 00000000..1e2b4eb4 --- /dev/null +++ b/http_client/tests/test_http_client.py @@ -0,0 +1,154 @@ +from json import loads +from os.path import abspath + +import responses +from http_client.auth import Auth +from http_client.errors import ( + AuthenticationError, + HttpRequestError, + InvalidHttpClientOptionsError, + RateLimitedError, + ServerError, +) +from pytest import raises +from requests import Response +from testing_utils import build_response + +from http_client.http_client import HttpClient + +path = abspath(__file__) + + +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', + '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(): + build_response(path, 'GET', 'https://example.com/get_json', 'example_get.json') + client = HttpClient( + Auth('asdfqwer', 'asdfqwer1234'), + http_client_options={'api_host': 'example.com'}, + ) + res = client.get(host='example.com', request_path='/get_json') + assert res['hello'] == 'world' + + assert responses.calls[0].request.headers['User-Agent'] == client._user_agent + + +@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') + 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('asdfqwer', 'asdfqwer1234'), + 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_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') + 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') + 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()) + try: + client.get(host='example.com', request_path='/get_json') + except AuthenticationError as err: + assert err.response.json()['Error'] == 'Authentication Failed' + + +@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') + except AuthenticationError as err: + assert type(err.response) == Response + + +@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') + except RateLimitedError as err: + assert err.response.json()['Error'] == 'Too Many Requests' + + +@responses.activate +def test_server_error(): + build_response(path, 'GET', 'https://example.com/get_json', '500.json', 500) + + client = HttpClient(Auth()) + try: + client.get(host='example.com', request_path='/get_json') + except ServerError as err: + assert err.response.json()['Error'] == 'Internal Server Error' diff --git a/tests/test_video.py b/http_client/tests/test_video.py similarity index 100% rename from tests/test_video.py rename to http_client/tests/test_video.py diff --git a/libs/BUILD b/libs/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/libs/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/libs/errors.py b/libs/errors.py new file mode 100644 index 00000000..51203de3 --- /dev/null +++ b/libs/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/libs/format_phone_number.py b/libs/format_phone_number.py new file mode 100644 index 00000000..d4fb7f93 --- /dev/null +++ b/libs/format_phone_number.py @@ -0,0 +1,36 @@ +from re import search +from typing import Union + +from 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".' + ) diff --git a/libs/pyproject.toml b/libs/pyproject.toml new file mode 100644 index 00000000..4faf4d18 --- /dev/null +++ b/libs/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = 'vonage-lib' +description = 'lib functions for use with Vonage APIs' +version = '0.1.0' + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/libs/testing_utils.py b/libs/testing_utils.py new file mode 100644 index 00000000..d463e7a8 --- /dev/null +++ b/libs/testing_utils.py @@ -0,0 +1,23 @@ +import os +from typing import Literal + +import responses +from pydantic import validate_call + + +def _load_mock_data(caller_file_path: str, mock_path: str): + with open(os.path.join(os.path.dirname(caller_file_path), 'data', mock_path)) as file: + return file.read() + + +@validate_call +def build_response( + file_path: str, + method: Literal['GET', 'POST'], + url: str, + mock_path: str = None, + status_code: int = 200, + content_type: str = 'application/json', +): + body = _load_mock_data(file_path, mock_path) if mock_path else None + responses.add(method, url, body=body, status=status_code, content_type=content_type) diff --git a/libs/tests/BUILD b/libs/tests/BUILD new file mode 100644 index 00000000..acaef3ba --- /dev/null +++ b/libs/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['libs']) diff --git a/libs/tests/test_format_phone_number.py b/libs/tests/test_format_phone_number.py new file mode 100644 index 00000000..be51f527 --- /dev/null +++ b/libs/tests/test_format_phone_number.py @@ -0,0 +1,30 @@ +from errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError +from format_phone_number import format_phone_number +from pytest import raises + + +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 '""' in str(e.value) + + +def test_format_phone_number_invalid_format(): + number = 'not a phone number' + with raises(InvalidPhoneNumberError) as e: + format_phone_number(number) + assert '"not a phone number"' in str(e.value) diff --git a/number_insight_v2/BUILD b/number_insight_v2/BUILD new file mode 100644 index 00000000..7c7325b9 --- /dev/null +++ b/number_insight_v2/BUILD @@ -0,0 +1,9 @@ +resource(name='pyproject', source='pyproject.toml') + +python_distribution( + name='vonage-number-insight', + dependencies=[':pyproject', 'ni2/src/ni2'], + provides=python_artifact(), + generate_setup=False, + repositories=['https://test.pypi.org/legacy/'], +) diff --git a/number_insight_v2/README.md b/number_insight_v2/README.md new file mode 100644 index 00000000..fb308f6d --- /dev/null +++ b/number_insight_v2/README.md @@ -0,0 +1,3 @@ +# Vonage Number Insight Python SDK package + +This package contains the code to use Vonage's Number Insight API in Python. \ No newline at end of file diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml new file mode 100644 index 00000000..a41cd0ee --- /dev/null +++ b/number_insight_v2/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = 'vonage-number-insight-v2' +description = 'Vonage Number Insight v2 package' +version = '0.1.0' +dependencies = ['vonage-http-client'] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/number_insight_v2/src/number_insight_v2/BUILD b/number_insight_v2/src/number_insight_v2/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/number_insight_v2/src/number_insight_v2/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/tests/data/no_content.json b/number_insight_v2/src/number_insight_v2/__init__.py similarity index 100% rename from tests/data/no_content.json rename to number_insight_v2/src/number_insight_v2/__init__.py diff --git a/number_insight_v2/src/number_insight_v2/errors.py b/number_insight_v2/src/number_insight_v2/errors.py new file mode 100644 index 00000000..ca8a4d7f --- /dev/null +++ b/number_insight_v2/src/number_insight_v2/errors.py @@ -0,0 +1,5 @@ +from errors import VonageError + + +class NumberInsightV2Error(VonageError): + """Indicates an error with the Number Insight v2 Package.""" diff --git a/number_insight_v2/src/number_insight_v2/number_insight_v2.py b/number_insight_v2/src/number_insight_v2/number_insight_v2.py new file mode 100644 index 00000000..d784cce3 --- /dev/null +++ b/number_insight_v2/src/number_insight_v2/number_insight_v2.py @@ -0,0 +1,43 @@ +from copy import deepcopy +from dataclasses import dataclass +from typing import List, Union + +from pydantic import BaseModel + +from http_client.http_client import HttpClient + + +class FraudCheckRequest(BaseModel): + """""" + + number: str + insights: Union[str, List[str]] + + +@dataclass +class FraudCheckResponse: + ... + + +# phone: Phone +# sim: SimSwap + + +class NumberInsightv2: + """Number Insight API V2.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = deepcopy(http_client) + self._http_client._parse_response = self.response_parser + self._auth_type = 'header' + + def fraud_check(self, number: str, insights: Union[str, List[str]]): + """""" + + def fraud_check(self, request: FraudCheckRequest) -> FraudCheckResponse: + """""" + response = self._http_client.post('/ni/fraud', request.model_dump()) + return FraudCheckResponse(response) + + def response_parser(self, response): + """""" diff --git a/number_insight_v2/tests/BUILD b/number_insight_v2/tests/BUILD new file mode 100644 index 00000000..dabf212d --- /dev/null +++ b/number_insight_v2/tests/BUILD @@ -0,0 +1 @@ +python_tests() diff --git a/number_insight_v2/tests/test_ni2.py b/number_insight_v2/tests/test_ni2.py new file mode 100644 index 00000000..8bb43f93 --- /dev/null +++ b/number_insight_v2/tests/test_ni2.py @@ -0,0 +1,7 @@ +import pytest + + +def test_raise_exception(): + with pytest.raises(Exception): + raise Exception + assert True diff --git a/pants.ci.toml b/pants.ci.toml new file mode 100644 index 00000000..a0749d00 --- /dev/null +++ b/pants.ci.toml @@ -0,0 +1,5 @@ +[GLOBAL] +colors = true + +[python] +interpreter_constraints = ['>=3.8'] diff --git a/pants.toml b/pants.toml new file mode 100644 index 00000000..0acc97b1 --- /dev/null +++ b/pants.toml @@ -0,0 +1,39 @@ +[GLOBAL] +pants_version = '2.19.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", +] + +[anonymous-telemetry] +enabled = false + +[source] +root_patterns = ['/', 'src/', 'tests/', 'libs/'] + +[python] +interpreter_constraints = ['==3.11.*'] + +[pytest] +args = ['-vv', '--no-header'] + +[coverage-py] +interpreter_constraints = ['>=3.8'] +report = ['html', 'console'] +filter = ['vonage/src', 'http_client/src', 'ni2/src', 'libs'] + +[black] +args = ['--line-length=90', '--skip-string-normalization'] + +[isort] +args = ['--profile=black', '--line-length=90'] + +[docformatter] +args = ['--wrap-summaries=100', '--wrap-descriptions=100'] diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index e0286062..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[tool.black] -color = true -line-length = 100 -target-version = ['py311'] -skip-string-normalization = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6b622a6e..cc0e7360 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,6 @@ --e . -pytest==7.4.2 -responses==0.22.0 -coverage -pydantic==2.5.2 - -bump2version -build -twine -pre-commit +pytest>=8.0.0 +requests>=2.31.0 +responses>=0.24.1 +pydantic>=2.5.2 +typing_extensions>=4.8.0 +vonage-jwt>=1.1.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a8c354a5..00000000 --- a/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[tool:pytest] -testpaths=tests -addopts=--tb=short -p no:doctest -norecursedirs = bin dist docs htmlcov .* {args} - -[pycodestyle] -max-line-length=100 - -[coverage:run] -# TODO: Change this to True: -branch=False -source=src - -[coverage:paths] -source = - .tox/*/site-packages - -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 5b75759a..00000000 --- a/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -import io -import os - -from setuptools import setup, find_packages - - -with io.open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="vonage", - version="3.13.0", - description="Vonage Server SDK for Python", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Vonage/vonage-python-sdk", - author="Vonage", - author_email="devrel@vonage.com", - license="Apache", - packages=find_packages(where="src"), - package_dir={"": "src"}, - platforms=["any"], - install_requires=[ - "vonage-jwt>=1.1.0", - "requests>=2.4.2", - "pytz>=2018.5", - "Deprecated", - "pydantic>=2.5.2", - ], - python_requires=">=3.8", - tests_require=["cryptography>=2.3.1"], - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - ], -) diff --git a/src/vonage/__init__.py b/src/vonage/__init__.py deleted file mode 100644 index 50e2d8dd..00000000 --- a/src/vonage/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .client import * -from .ncco_builder.ncco import * - -__version__ = "3.13.0" diff --git a/src/vonage/_internal.py b/src/vonage/_internal.py deleted file mode 100644 index 5d6d2d74..00000000 --- a/src/vonage/_internal.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from vonage import Client - - -def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"): - """ - Utility function to convert datetime values to strings. - - If the value is already a str, or is not in the dict, no change is made. - - :param params: A `dict` of params that may contain a `datetime` value. - :param key: The datetime value to be converted to a `str` - :param format: The `strftime` format to be used to format the date. The default value is '%Y-%m-%d %H:%M:%S' - """ - if key in params: - param = params[key] - if hasattr(param, "strftime"): - params[key] = param.strftime(format) - - -def set_auth_type(client: Client) -> str: - """Sets the authentication type used. If a JWT Client has been created, - it will create a JWT and use JWT authentication.""" - - if hasattr(client, '_jwt_client'): - return 'jwt' - else: - return 'header' diff --git a/src/vonage/account.py b/src/vonage/account.py deleted file mode 100644 index 32968dcc..00000000 --- a/src/vonage/account.py +++ /dev/null @@ -1,116 +0,0 @@ -from .errors import PricingTypeError - -from deprecated import deprecated - - -class Account: - account_auth_type = 'params' - pricing_auth_type = 'params' - secrets_auth_type = 'header' - - allowed_pricing_types = {'sms', 'sms-transit', 'voice'} - - def __init__(self, client): - self._client = client - - def get_balance(self): - return self._client.get( - self._client.host(), "/account/get-balance", auth_type=Account.account_auth_type - ) - - def topup(self, params=None, **kwargs): - return self._client.post( - self._client.host(), - "/account/top-up", - params or kwargs, - auth_type=Account.account_auth_type, - body_is_json=False, - ) - - def get_country_pricing(self, country_code: str, type: str = 'sms'): - self._check_allowed_pricing_type(type) - return self._client.get( - self._client.host(), - f"/account/get-pricing/outbound/{type}", - {"country": country_code}, - auth_type=Account.pricing_auth_type, - ) - - def get_all_countries_pricing(self, type: str = 'sms'): - self._check_allowed_pricing_type(type) - return self._client.get( - self._client.host(), - f"/account/get-full-pricing/outbound/{type}", - auth_type=Account.pricing_auth_type, - ) - - def get_prefix_pricing(self, prefix: str, type: str = 'sms'): - self._check_allowed_pricing_type(type) - return self._client.get( - self._client.host(), - f"/account/get-prefix-pricing/outbound/{type}", - {"prefix": prefix}, - auth_type=Account.pricing_auth_type, - ) - - @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') - def get_sms_pricing(self, number: str): - return self._client.get( - self._client.host(), - "/account/get-phone-pricing/outbound/sms", - {"phone": number}, - auth_type=Account.pricing_auth_type, - ) - - @deprecated(version='3.0.0', reason='The "account/get-phone-pricing" endpoint is deprecated.') - def get_voice_pricing(self, number: str): - return self._client.get( - self._client.host(), - "/account/get-phone-pricing/outbound/voice", - {"phone": number}, - auth_type=Account.pricing_auth_type, - ) - - def update_default_sms_webhook(self, params=None, **kwargs): - return self._client.post( - self._client.host(), - "/account/settings", - params or kwargs, - auth_type=Account.account_auth_type, - body_is_json=False, - ) - - def list_secrets(self, api_key): - return self._client.get( - self._client.api_host(), - f"/accounts/{api_key}/secrets", - auth_type=Account.secrets_auth_type, - ) - - def get_secret(self, api_key, secret_id): - return self._client.get( - self._client.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - auth_type=Account.secrets_auth_type, - ) - - def create_secret(self, api_key, secret): - body = {"secret": secret} - return self._client.post( - self._client.api_host(), - f"/accounts/{api_key}/secrets", - body, - auth_type=Account.secrets_auth_type, - body_is_json=False, - ) - - def revoke_secret(self, api_key, secret_id): - return self._client.delete( - self._client.api_host(), - f"/accounts/{api_key}/secrets/{secret_id}", - auth_type=Account.secrets_auth_type, - ) - - def _check_allowed_pricing_type(self, type): - if type not in Account.allowed_pricing_types: - raise PricingTypeError('Invalid pricing type specified.') diff --git a/src/vonage/application.py b/src/vonage/application.py deleted file mode 100644 index c5398a76..00000000 --- a/src/vonage/application.py +++ /dev/null @@ -1,174 +0,0 @@ -from deprecated import deprecated - - -@deprecated( - version='3.0.0', - reason='Renamed to Application as V1 is out of support and this new \ - naming is in line with other APIs. Please use Application instead.', -) -class ApplicationV2: - auth_type = 'header' - - def __init__(self, client): - self._client = client - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication - """ - return self._client.post( - self._client.api_host(), - "/v2/applications", - application_data, - auth_type=ApplicationV2.auth_type, - ) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._client.get( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=ApplicationV2.auth_type, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._client.put( - self._client.api_host(), - f"/v2/applications/{application_id}", - params, - auth_type=ApplicationV2.auth_type, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._client.delete( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=ApplicationV2.auth_type, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._client.get( - self._client.api_host(), - "/v2/applications", - params=params, - auth_type=ApplicationV2.auth_type, - ) - - -class Application: - auth_type = 'header' - - def __init__(self, client): - self._client = client - - def create_application(self, application_data): - """ - Create an application using the provided `application_data`. - - :param dict application_data: A JSON-style dict describing the application to be created. - - >>> client.application.create_application({ 'name': 'My Cool App!' }) - - Details of the `application_data` dict are described at https://developer.vonage.com/api/application.v2#createApplication - """ - return self._client.post( - self._client.api_host(), - "/v2/applications", - application_data, - auth_type=Application.auth_type, - ) - - def get_application(self, application_id): - """ - Get application details for the application with `application_id`. - - The format of the returned dict is described at https://developer.vonage.com/api/application.v2#getApplication - - :param str application_id: The application ID. - :rtype: dict - """ - - return self._client.get( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=Application.auth_type, - ) - - def update_application(self, application_id, params): - """ - Update the application with `application_id` using the values provided in `params`. - - - """ - return self._client.put( - self._client.api_host(), - f"/v2/applications/{application_id}", - params, - auth_type=Application.auth_type, - ) - - def delete_application(self, application_id): - """ - Delete the application with `application_id`. - """ - - self._client.delete( - self._client.api_host(), - f"/v2/applications/{application_id}", - auth_type=Application.auth_type, - ) - - def list_applications(self, page_size=None, page=None): - """ - List all applications for your account. - - Results are paged, so each page will need to be requested to see all applications. - - :param int page_size: The number of items in the page to be returned - :param int page: The page number of the page to be returned. - """ - params = _filter_none_values({"page_size": page_size, "page": page}) - - return self._client.get( - self._client.api_host(), - "/v2/applications", - params=params, - auth_type=Application.auth_type, - ) - - -def _filter_none_values(d): - return {k: v for k, v in d.items() if v is not None} diff --git a/src/vonage/client.py b/src/vonage/client.py deleted file mode 100644 index af97036d..00000000 --- a/src/vonage/client.py +++ /dev/null @@ -1,463 +0,0 @@ -import vonage -from vonage_jwt.jwt import JwtClient - -from .account import Account -from .application import ApplicationV2, Application -from .errors import * -from .meetings import Meetings -from .messages import Messages -from .number_insight import NumberInsight -from .number_management import Numbers -from .proactive_connect import ProactiveConnect -from .redact import Redact -from .short_codes import ShortCodes -from .sms import Sms -from .subaccounts import Subaccounts -from .users import Users -from .ussd import Ussd -from .video import Video -from .voice import Voice -from .verify import Verify -from .verify2 import Verify2 - -import logging -from platform import python_version - -import base64 -import hashlib -import hmac -import os -import time - -from requests import Response -from requests.adapters import HTTPAdapter -from requests.sessions import Session - -string_types = (str, bytes) - -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -logger = logging.getLogger("vonage") - - -class Client: - """ - Create a Client object to start making calls to Vonage/Nexmo APIs. - - The credentials you provide when instantiating a Client determine which - methods can be called. Consult the `Vonage API docs ` - for details of the authentication used by the APIs you wish to use, and instantiate your - client with the appropriate credentials. - - :param str key: Your Vonage API key - :param str secret: Your Vonage API secret. - :param str signature_secret: Your Vonage API signature secret. - You may need to have this enabled by Vonage support. It is only used for SMS authentication. - :param str signature_method: - The encryption method used for signature encryption. This must match the method - configured in the Vonage Dashboard. We recommend `sha256` or `sha512`. - This should be one of `md5`, `sha1`, `sha256`, or `sha512` if using HMAC digests. - If you want to use a simple MD5 hash, leave this as `None`. - :param str application_id: Your application ID if calling methods which use JWT authentication. - :param str private_key: Your private key, for calling methods which use JWT authentication. - This should either be a str containing the key in its PEM form, or a path to a private key file. - :param str app_name: This optional value is added to the user-agent header - provided by this library and can be used to track your app statistics. - :param str app_version: This optional value is added to the user-agent header - provided by this library and can be used to track your app statistics. - :param timeout: (optional) How many seconds to wait for the server to send data - before giving up, as a float, or a (connect timeout, read - timeout) tuple. If set this timeout is used for every call to the Vonage enpoints - :type timeout: float or tuple - """ - - def __init__( - self, - key=None, - secret=None, - signature_secret=None, - signature_method=None, - application_id=None, - private_key=None, - app_name=None, - app_version=None, - timeout=None, - pool_connections=10, - pool_maxsize=10, - max_retries=3, - ): - self.api_key = key or os.environ.get("VONAGE_API_KEY", None) - self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None) - - self.application_id = application_id - - self.signature_secret = signature_secret or os.environ.get("VONAGE_SIGNATURE_SECRET", None) - self.signature_method = signature_method or os.environ.get("VONAGE_SIGNATURE_METHOD", None) - - if self.signature_method in { - "md5", - "sha1", - "sha256", - "sha512", - }: - self.signature_method = getattr(hashlib, signature_method) - - if private_key is not None and application_id is not None: - self._jwt_client = JwtClient(application_id, private_key) - - self._jwt_claims = {} - self._host = "rest.nexmo.com" - self._api_host = "api.nexmo.com" - self._video_host = "video.api.vonage.com" - self._meetings_api_host = "api-eu.vonage.com/v1/meetings" - self._proactive_connect_host = "api-eu.vonage.com" - - user_agent = f"vonage-python/{vonage.__version__} python/{python_version()}" - - if app_name and app_version: - user_agent += f" {app_name}/{app_version}" - - self.headers = { - "User-Agent": user_agent, - "Accept": "application/json", - } - - self.account = Account(self) - self.application = Application(self) - self.meetings = Meetings(self) - self.messages = Messages(self) - self.number_insight = NumberInsight(self) - self.numbers = Numbers(self) - self.proactive_connect = ProactiveConnect(self) - self.short_codes = ShortCodes(self) - self.sms = Sms(self) - self.subaccounts = Subaccounts(self) - self.users = Users(self) - self.ussd = Ussd(self) - self.verify = Verify(self) - self.verify2 = Verify2(self) - self.video = Video(self) - self.voice = Voice(self) - - self.timeout = timeout - self.session = Session() - self.adapter = HTTPAdapter( - pool_connections=pool_connections, - pool_maxsize=pool_maxsize, - max_retries=max_retries, - ) - self.session.mount("https://", self.adapter) - - # Gets and sets _host attribute - def host(self, value=None): - if value is None: - return self._host - else: - self._host = value - - # Gets and sets _api_host attribute - def api_host(self, value=None): - if value is None: - return self._api_host - else: - self._api_host = value - - def video_host(self, value=None): - if value is None: - return self._video_host - else: - self._video_host = value - - # Gets and sets _meetings_api_host attribute - def meetings_api_host(self, value=None): - if value is None: - return self._meetings_api_host - else: - self._meetings_api_host = value - - def proactive_connect_host(self, value=None): - if value is None: - return self._proactive_connect_host - else: - self._proactive_connect_host = value - - def auth(self, params=None, **kwargs): - self._jwt_claims = params or kwargs - - def check_signature(self, params): - params = dict(params) - signature = params.pop("sig", "").lower() - return hmac.compare_digest(signature, self.signature(params)) - - def signature(self, params): - if self.signature_method: - hasher = hmac.new( - self.signature_secret.encode(), - digestmod=self.signature_method, - ) - else: - hasher = hashlib.md5() - - # Add timestamp if not already present - if not params.get("timestamp"): - params["timestamp"] = int(time.time()) - - for key in sorted(params): - value = params[key] - - if isinstance(value, str): - value = value.replace("&", "_").replace("=", "_") - - hasher.update(f"&{key}={value}".encode("utf-8")) - - if self.signature_method is None: - hasher.update(self.signature_secret.encode()) - - return hasher.hexdigest() - - def get(self, host, request_uri, params=None, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'params': - params = dict( - params or {}, - api_key=self.api_key, - api_secret=self.api_secret, - ) - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug( - f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - return self.parse( - host, - self.session.get( - uri, - params=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - - def post( - self, - host, - request_uri, - params, - auth_type=None, - body_is_json=True, - supports_signature_auth=False, - ): - """ - Low-level method to make a post request to an API server. - This method automatically adds authentication, picking the first applicable authentication method from the following: - - If the supports_signature_auth param is True, and the client was instantiated with a signature_secret, - then signature authentication will be used. - :param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided - when initializing this client. - """ - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if supports_signature_auth and self.signature_secret: - params["api_key"] = self.api_key - params["sig"] = self.signature(params) - elif auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'params': - params = dict( - params, - api_key=self.api_key, - api_secret=self.api_secret, - ) - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug( - f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - if body_is_json: - return self.parse( - host, - self.session.post( - uri, - json=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - else: - return self.parse( - host, - self.session.post( - uri, - data=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - - def put(self, host, request_uri, params, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug( - f"PUT to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - # All APIs that currently use put methods require a json-formatted body so don't need to check this - return self.parse( - host, - self.session.put( - uri, - json=params, - headers=self._request_headers, - timeout=self.timeout, - ), - ) - - def patch(self, host, request_uri, params, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError(f"""Invalid authentication type.""") - - logger.debug( - f"PATCH to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}" - ) - # Only newer APIs (that expect json-bodies) currently use this method, so we will always send a json-formatted body - return self.parse( - host, - self.session.patch( - uri, - json=params, - headers=self._request_headers, - ), - ) - - def delete(self, host, request_uri, params=None, auth_type=None): - uri = f"https://{host}{request_uri}" - self._request_headers = self.headers - - if auth_type == 'jwt': - self._request_headers['Authorization'] = self._create_jwt_auth_string() - elif auth_type == 'header': - self._request_headers['Authorization'] = self._create_header_auth_string() - else: - raise InvalidAuthenticationTypeError( - f'Invalid authentication type. Must be one of "jwt", "header" or "params".' - ) - - logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}") - if params is not None: - logger.debug(f"DELETE call has params {repr(params)}") - return self.parse( - host, - self.session.delete( - uri, - headers=self._request_headers, - timeout=self.timeout, - params=params, - ), - ) - - def parse(self, host, response: Response): - logger.debug(f"Response headers {repr(response.headers)}") - if response.status_code == 401: - raise AuthenticationError("Authentication failed.") - elif response.status_code == 204: - return None - elif 200 <= response.status_code < 300: - # Strip off any encoding from the content-type header: - try: - content_mime = response.headers.get("content-type").split(";", 1)[0] - except AttributeError: - if response.json() is None: - return None - if content_mime == "application/json": - try: - return response.json() - except JSONDecodeError: - pass - else: - return response.content - elif 400 <= response.status_code < 500: - logger.warning(f"Client error: {response.status_code} {repr(response.content)}") - message = f"{response.status_code} response from {host}" - - # Test for standard error format: - try: - error_data = response.json() - if "type" in error_data and "title" in error_data and "detail" in error_data: - title = error_data["title"] - detail = error_data["detail"] - type = error_data["type"] - message = f"{title}: {detail} ({type}){self._add_individual_errors(error_data)}" - elif 'status' in error_data and 'message' in error_data and 'name' in error_data: - message = ( - f'Status Code {error_data["status"]}: {error_data["name"]}: {error_data["message"]}' - f'{self._add_individual_errors(error_data)}' - ) - else: - message = error_data - except JSONDecodeError: - pass - raise ClientError(message) - - elif 500 <= response.status_code < 600: - logger.warning(f"Server error: {response.status_code} {repr(response.content)}") - message = f"{response.status_code} response from {host}" - raise ServerError(message) - - def _add_individual_errors(self, error_data): - message = '' - if 'errors' in error_data: - for error in error_data["errors"]: - message += f"\nError: {error}" - return message - - def _create_jwt_auth_string(self): - return b"Bearer " + self._generate_application_jwt() - - def _generate_application_jwt(self): - try: - return self._jwt_client.generate_application_jwt(self._jwt_claims) - except AttributeError as err: - if '_jwt_client' in str(err): - raise ClientError( - 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' - ) - else: - raise err - - def _create_header_auth_string(self): - hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii") - return f"Basic {hash}" diff --git a/src/vonage/errors.py b/src/vonage/errors.py deleted file mode 100644 index 27e4f8dd..00000000 --- a/src/vonage/errors.py +++ /dev/null @@ -1,72 +0,0 @@ -class Error(Exception): - pass - - -class ClientError(Error): - pass - - -class ServerError(Error): - pass - - -class AuthenticationError(ClientError): - pass - - -class CallbackRequiredError(Error): - """Indicates a callback is required but was not present.""" - - -class MessagesError(Error): - """ - Indicates an error related to the Messages class which calls the Vonage Messages API. - """ - - -class PricingTypeError(Error): - """A pricing type was specified that is not allowed.""" - - -class RedactError(Error): - """Error related to the Redact class or Redact API.""" - - -class InvalidAuthenticationTypeError(Error): - """An authentication method was specified that is not allowed""" - - -class MeetingsError(ClientError): - """An error related to the Meetings class which calls the Vonage Meetings API.""" - - -class Verify2Error(ClientError): - """An error relating to the Verify (V2) API.""" - - -class SubaccountsError(ClientError): - """An error relating to the Subaccounts API.""" - - -class ProactiveConnectError(ClientError): - """An error relating to the Proactive Connect API.""" - - -class VideoError(ClientError): - """An error relating to the Video API.""" - - -class UsersError(ClientError): - """An error relating to the Users API.""" - - -class InvalidRoleError(ClientError): - """The specified role was invalid.""" - - -class TokenExpiryError(ClientError): - """The specified token expiry time was invalid.""" - - -class SipError(ClientError): - """Error related to usage of SIP calls.""" diff --git a/src/vonage/meetings.py b/src/vonage/meetings.py deleted file mode 100644 index a506bddc..00000000 --- a/src/vonage/meetings.py +++ /dev/null @@ -1,173 +0,0 @@ -from .errors import MeetingsError - -from typing_extensions import Literal -import logging -import requests - - -logger = logging.getLogger("vonage") - - -class Meetings: - """Class containing methods used to create and manage meetings using the Meetings API.""" - - _auth_type = 'jwt' - - def __init__(self, client): - self._client = client - self._meetings_api_host = client.meetings_api_host() - - def list_rooms(self, page_size: str = 20, start_id: str = None, end_id: str = None): - params = Meetings.set_start_and_end_params(start_id, end_id) - params['page_size'] = page_size - return self._client.get( - self._meetings_api_host, '/rooms', params, auth_type=Meetings._auth_type - ) - - def create_room(self, params: dict = {}): - if 'display_name' not in params: - raise MeetingsError( - 'You must include a value for display_name as a field in the params dict when creating a meeting room.' - ) - if 'type' not in params or 'type' in params and params['type'] != 'long_term': - if 'expires_at' in params: - raise MeetingsError('Cannot set "expires_at" for an instant room.') - elif params['type'] == 'long_term' and 'expires_at' not in params: - raise MeetingsError('You must set a value for "expires_at" for a long-term room.') - - return self._client.post( - self._meetings_api_host, '/rooms', params, auth_type=Meetings._auth_type - ) - - def get_room(self, room_id: str): - return self._client.get( - self._meetings_api_host, f'/rooms/{room_id}', auth_type=Meetings._auth_type - ) - - def update_room(self, room_id: str, params: dict): - return self._client.patch( - self._meetings_api_host, f'/rooms/{room_id}', params, auth_type=Meetings._auth_type - ) - - def add_theme_to_room(self, room_id: str, theme_id: str): - params = {'update_details': {'theme_id': theme_id}} - return self._client.patch( - self._meetings_api_host, f'/rooms/{room_id}', params, auth_type=Meetings._auth_type - ) - - def get_recording(self, recording_id: str): - return self._client.get( - self._meetings_api_host, f'/recordings/{recording_id}', auth_type=Meetings._auth_type - ) - - def delete_recording(self, recording_id: str): - return self._client.delete( - self._meetings_api_host, f'/recordings/{recording_id}', auth_type=Meetings._auth_type - ) - - def get_session_recordings(self, session_id: str): - return self._client.get( - self._meetings_api_host, - f'/sessions/{session_id}/recordings', - auth_type=Meetings._auth_type, - ) - - def list_dial_in_numbers(self): - return self._client.get( - self._meetings_api_host, '/dial-in-numbers', auth_type=Meetings._auth_type - ) - - def list_themes(self): - return self._client.get(self._meetings_api_host, '/themes', auth_type=Meetings._auth_type) - - def create_theme(self, params: dict): - if 'main_color' not in params or 'brand_text' not in params: - raise MeetingsError('Values for "main_color" and "brand_text" must be specified') - - return self._client.post( - self._meetings_api_host, '/themes', params, auth_type=Meetings._auth_type - ) - - def get_theme(self, theme_id: str): - return self._client.get( - self._meetings_api_host, f'/themes/{theme_id}', auth_type=Meetings._auth_type - ) - - def delete_theme(self, theme_id: str, force: bool = False): - params = {'force': force} - return self._client.delete( - self._meetings_api_host, - f'/themes/{theme_id}', - params=params, - auth_type=Meetings._auth_type, - ) - - def update_theme(self, theme_id: str, params: dict): - return self._client.patch( - self._meetings_api_host, f'/themes/{theme_id}', params, auth_type=Meetings._auth_type - ) - - def list_rooms_with_theme_id( - self, theme_id: str, page_size: int = 20, start_id: str = None, end_id: str = None - ): - params = Meetings.set_start_and_end_params(start_id, end_id) - params['page_size'] = page_size - - return self._client.get( - self._meetings_api_host, - f'/themes/{theme_id}/rooms', - params, - auth_type=Meetings._auth_type, - ) - - def update_application_theme(self, theme_id: str): - params = {'update_details': {'default_theme_id': theme_id}} - return self._client.patch( - self._meetings_api_host, '/applications', params, auth_type=Meetings._auth_type - ) - - def upload_logo_to_theme( - self, theme_id: str, path_to_image: str, logo_type: Literal['white', 'colored', 'favicon'] - ): - params = self._get_logo_upload_url(logo_type) - self._upload_to_aws(params, path_to_image) - self._add_logo_to_theme(theme_id, params['fields']['key']) - return f'Logo upload to theme: {theme_id} was successful.' - - def _get_logo_upload_url(self, logo_type): - upload_urls = self._client.get( - self._meetings_api_host, '/themes/logos-upload-urls', auth_type=Meetings._auth_type - ) - for url_object in upload_urls: - if url_object['fields']['logoType'] == logo_type: - return url_object - raise MeetingsError('Cannot find the upload URL for the specified logo type.') - - def _upload_to_aws(self, params, path_to_image): - form = {**params['fields'], 'file': open(path_to_image, 'rb')} - - logger.debug(f"POST to {params['url']} to upload file {path_to_image}") - logo_upload = requests.post( - url=params['url'], - files=form, - ) - if logo_upload.status_code != 204: - raise MeetingsError(f'Logo upload process failed. {logo_upload.content}') - - def _add_logo_to_theme(self, theme_id: str, key: str): - params = {'keys': [key]} - return self._client.put( - self._meetings_api_host, - f'/themes/{theme_id}/finalizeLogos', - params, - auth_type=Meetings._auth_type, - ) - - @staticmethod - def set_start_and_end_params(start_id, end_id): - params = {} - if start_id is not None: - params['start_id'] = start_id - if end_id is not None: - params['end_id'] = end_id - return params diff --git a/src/vonage/messages.py b/src/vonage/messages.py deleted file mode 100644 index 23e7df20..00000000 --- a/src/vonage/messages.py +++ /dev/null @@ -1,113 +0,0 @@ -from ._internal import set_auth_type -from .errors import MessagesError - -import re - - -class Messages: - valid_message_channels = {'sms', 'mms', 'whatsapp', 'messenger', 'viber_service'} - valid_message_types = { - 'sms': {'text'}, - 'mms': {'image', 'vcard', 'audio', 'video'}, - 'whatsapp': {'text', 'image', 'audio', 'video', 'file', 'template', 'sticker', 'custom'}, - 'messenger': {'text', 'image', 'audio', 'video', 'file'}, - 'viber_service': {'text', 'image', 'video', 'file'}, - } - - def __init__(self, client): - self._client = client - self._auth_type = set_auth_type(self._client) - - def send_message(self, params: dict): - self.validate_send_message_input(params) - - return self._client.post( - self._client.api_host(), - "/v1/messages", - params, - auth_type=self._auth_type, - ) - - def validate_send_message_input(self, params): - self._check_input_is_dict(params) - self._check_valid_message_channel(params) - self._check_valid_message_type(params) - self._check_valid_recipient(params) - self._check_valid_sender(params) - self._channel_specific_checks(params) - self._check_valid_client_ref(params) - - def _check_input_is_dict(self, params): - if type(params) is not dict: - raise MessagesError( - 'Parameters to the send_message method must be specified as a dictionary.' - ) - - def _check_valid_message_channel(self, params): - if params['channel'] not in Messages.valid_message_channels: - raise MessagesError( - f""" - "{params['channel']}" is an invalid message channel. - Must be one of the following types: {self.valid_message_channels}' - """ - ) - - def _check_valid_message_type(self, params): - if params['message_type'] not in self.valid_message_types[params['channel']]: - raise MessagesError( - f""" - "{params['message_type']}" is not a valid message type for channel "{params["channel"]}". - Must be one of the following types: {self.valid_message_types[params["channel"]]} - """ - ) - - def _check_valid_recipient(self, params): - if not isinstance(params['to'], str): - raise MessagesError(f'Message recipient ("to={params["to"]}") not in a valid format.') - elif params['channel'] != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', params['to']): - raise MessagesError( - f'Message recipient number ("to={params["to"]}") not in a valid format.' - ) - elif params['channel'] == 'messenger' and not 0 < len(params['to']) < 50: - raise MessagesError( - f'Message recipient ID ("to={params["to"]}") not in a valid format.' - ) - - def _check_valid_sender(self, params): - if not isinstance(params['from'], str) or params['from'] == "": - raise MessagesError( - f'Message sender ("frm={params["from"]}") set incorrectly. Set a valid name or number for the sender.' - ) - - def _channel_specific_checks(self, params): - if ( - ( - params['channel'] == 'whatsapp' - and params['message_type'] == 'template' - and 'whatsapp' not in params - ) - or ( - params['channel'] == 'whatsapp' - and params['message_type'] == 'sticker' - and 'sticker' not in params - ) - or (params['channel'] == 'viber_service' and 'viber_service' not in params) - ): - raise MessagesError( - f'''You must specify all required properties for message channel "{params["channel"]}".''' - ) - elif params['channel'] == 'whatsapp' and params['message_type'] == 'sticker': - self._check_valid_whatsapp_sticker(params['sticker']) - - def _check_valid_client_ref(self, params): - if 'client_ref' in params: - if len(params['client_ref']) <= 100: - self._client_ref = params['client_ref'] - else: - raise MessagesError('client_ref can be a maximum of 100 characters.') - - def _check_valid_whatsapp_sticker(self, sticker): - if ('id' not in sticker and 'url' not in sticker) or ('id' in sticker and 'url' in sticker): - raise MessagesError( - 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' - ) diff --git a/src/vonage/ncco_builder/__init__.py b/src/vonage/ncco_builder/__init__.py deleted file mode 100644 index f1908afe..00000000 --- a/src/vonage/ncco_builder/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .ncco import * diff --git a/src/vonage/ncco_builder/connect_endpoints.py b/src/vonage/ncco_builder/connect_endpoints.py deleted file mode 100644 index d77a0b8c..00000000 --- a/src/vonage/ncco_builder/connect_endpoints.py +++ /dev/null @@ -1,63 +0,0 @@ -from pydantic import BaseModel, HttpUrl, AnyUrl, constr, field_serializer -from typing import Dict -from typing_extensions import Literal - - -class ConnectEndpoints: - class Endpoint(BaseModel): - type: Literal['phone', 'app', 'websocket', 'sip', 'vbc'] = None - - class PhoneEndpoint(Endpoint): - type: Literal['phone'] = 'phone' - - number: constr(pattern=r'^[1-9]\d{6,14}$') - dtmfAnswer: constr(pattern='^[0-9*#p]+$') = None - onAnswer: Dict[str, HttpUrl] = None - - @field_serializer('onAnswer') - def serialize_dt(self, oa: Dict[str, HttpUrl], _info): - if oa is None: - return oa - - return {k: str(v) for k, v in oa.items()} - - class AppEndpoint(Endpoint): - type: Literal['app'] = 'app' - user: str - - class WebsocketEndpoint(Endpoint): - type: Literal['websocket'] = 'websocket' - - uri: AnyUrl - contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] - headers: dict = None - - @field_serializer('uri') - def serialize_uri(self, uri: AnyUrl, _info): - return str(uri) - - class SipEndpoint(Endpoint): - type: Literal['sip'] = 'sip' - uri: str - headers: dict = None - - class VbcEndpoint(Endpoint): - type: Literal['vbc'] = 'vbc' - extension: str - - @classmethod - def create_endpoint_model_from_dict(cls, d) -> Endpoint: - if d['type'] == 'phone': - return cls.PhoneEndpoint.model_validate(d) - elif d['type'] == 'app': - return cls.AppEndpoint.model_validate(d) - elif d['type'] == 'websocket': - return cls.WebsocketEndpoint.model_validate(d) - elif d['type'] == 'sip': - return cls.WebsocketEndpoint.model_validate(d) - elif d['type'] == 'vbc': - return cls.WebsocketEndpoint.model_validate(d) - else: - raise ValueError( - 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' - ) diff --git a/src/vonage/ncco_builder/input_types.py b/src/vonage/ncco_builder/input_types.py deleted file mode 100644 index 56737f24..00000000 --- a/src/vonage/ncco_builder/input_types.py +++ /dev/null @@ -1,26 +0,0 @@ -from pydantic import BaseModel, confloat, conint -from typing import List - - -class InputTypes: - class Dtmf(BaseModel): - timeOut: conint(ge=0, le=10) = None - maxDigits: conint(ge=1, le=20) = None - submitOnHash: bool = None - - class Speech(BaseModel): - uuid: str = None - endOnSilence: confloat(ge=0.4, le=10.0) = None - language: str = None - context: List[str] = None - startTimeout: conint(ge=1, le=60) = None - maxDuration: conint(ge=1, le=60) = None - saveAudio: bool = None - - @classmethod - def create_dtmf_model(cls, dict) -> Dtmf: - return cls.Dtmf.model_validate(dict) - - @classmethod - def create_speech_model(cls, dict) -> Speech: - return cls.Speech.model_validate(dict) diff --git a/src/vonage/ncco_builder/ncco.py b/src/vonage/ncco_builder/ncco.py deleted file mode 100644 index ed1c96d1..00000000 --- a/src/vonage/ncco_builder/ncco.py +++ /dev/null @@ -1,259 +0,0 @@ -from pydantic import BaseModel, Field, ValidationInfo, field_validator, constr, confloat, conint -from typing import Any, Dict, Union, List -from typing_extensions import Annotated, Literal - -from .connect_endpoints import ConnectEndpoints -from .input_types import InputTypes -from .pay_prompts import PayPrompts - -from deprecated import deprecated - - -class Ncco: - class Action(BaseModel): - action: Literal['record', 'conversation', 'connect', - 'talk', 'stream', 'input', 'notify', 'pay'] = None - - class Record(Action): - """Use the record action to record a call or part of a call.""" - - action: Literal['record'] = 'record' - format: Literal['mp3', 'wav', 'ogg'] = None - split: Literal['conversation'] = None - channels: conint(ge=1, le=32) = None - endOnSilence: conint(ge=3, le=10) = None - endOnKey: constr(pattern='^[0-9*#]$') = None - timeOut: conint(ge=3, le=7200) = None - beepStart: bool = None - eventUrl: Union[List[str], str] = None - eventMethod: constr(to_upper=True) = None - - @field_validator('channels') - @classmethod - def enable_split(cls, v, info: ValidationInfo): - values = info.data - if values['split'] is None: - values['split'] = 'conversation' - return v - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - class Conversation(Action): - """You can use the conversation action to create standard or moderated conferences, - while preserving the communication context. - Using conversation with the same name reuses the same persisted conversation.""" - - action: Literal['conversation'] = 'conversation' - name: str - musicOnHoldUrl: Union[List[str], str] = None - startOnEnter: bool = None - endOnExit: bool = None - record: bool = None - canSpeak: List[str] = None - canHear: List[str] = None - mute: bool = None - - @field_validator('musicOnHoldUrl') - @classmethod - def ensure_url_in_list(cls, v: Any): - return Ncco._ensure_object_in_list(v) - - @field_validator('mute') - @classmethod - def can_mute(cls, v, info: ValidationInfo): - values = info.data - if 'canSpeak' in values and values['canSpeak'] is not None: - raise ValueError('Cannot use mute option if canSpeak option is specified.') - return v - - class Connect(Action): - """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" - - action: Literal['connect'] = 'connect' - endpoint: Union[dict, ConnectEndpoints.Endpoint, List] - from_: Annotated[str, Field(alias='from_', serialization_alias='from', - pattern=r'^[1-9]\d{6,14}$')] = None - - randomFromNumber: bool = None - eventType: Literal['synchronous'] = None - timeout: int = None - limit: conint(le=7200) = None - machineDetection: Literal['continue', 'hangup'] = None - advancedMachineDetection: dict = None - eventUrl: Union[List[str], str] = None - eventMethod: constr(to_upper=True) = None - ringbackTone: str = None - - @field_validator('endpoint') - @classmethod - def validate_endpoint(cls, v: Any): - - if type(v) is dict: - return [ConnectEndpoints.create_endpoint_model_from_dict(v)] - elif type(v) is list: - return [ConnectEndpoints.create_endpoint_model_from_dict(v[0])] - else: - return [v] - - @field_validator('randomFromNumber') - @classmethod - def check_from_not_set(cls, v, info: ValidationInfo): - values = info.data - if v is True and 'from_' in values: - if values['from_'] is not None: - raise ValueError( - 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' - ) - return v - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @field_validator('advancedMachineDetection') - @classmethod - def validate_advancedMachineDetection(cls, v): - if 'behavior' in v and v['behavior'] not in ('continue', 'hangup'): - raise ValueError( - 'advancedMachineDetection["behavior"] must be one of: "continue", "hangup".' - ) - if 'mode' in v and v['mode'] not in ('detect, detect_beep'): - raise ValueError( - 'advancedMachineDetection["mode"] must be one of: "detect", "detect_beep".' - ) - return v - - class Talk(Action): - """The talk action sends synthesized speech to a Conversation.""" - - action: Literal['talk'] = 'talk' - text: constr(max_length=1500) - bargeIn: bool = None - loop: conint(ge=0) = None - level: confloat(ge=-1, le=1) = None - language: str = None - style: int = None - premium: bool = None - - class Stream(Action): - """The stream action allows you to send an audio stream to a Conversation.""" - - action: Literal['stream'] = 'stream' - streamUrl: Union[List[str], str] - level: confloat(ge=-1, le=1) = None - bargeIn: bool = None - loop: conint(ge=0) = None - - @field_validator('streamUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - class Input(Action): - """Collect digits or speech input by the person you are are calling.""" - - action: Literal['input'] = 'input' - - type: Union[ - Literal['dtmf', 'speech'], - List[Literal['dtmf']], - List[Literal['speech']], - List[Literal['dtmf', 'speech']], - ] - dtmf: Union[InputTypes.Dtmf, dict] = None - speech: Union[InputTypes.Speech, dict] = None - eventUrl: Union[List[str], str] = None - eventMethod: constr(to_upper=True) = None - - @field_validator('type', 'eventUrl') - @classmethod - def ensure_value_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @field_validator('dtmf') - @classmethod - def ensure_input_object_is_dtmf_model(cls, v): - if type(v) is dict: - return InputTypes.create_dtmf_model(v) - else: - return v - - @field_validator('speech') - @classmethod - def ensure_input_object_is_speech_model(cls, v): - if type(v) is dict: - return InputTypes.create_speech_model(v) - else: - return v - - class Notify(Action): - """Use the notify action to send a custom payload to your event URL.""" - - action: Literal['notify'] = 'notify' - - payload: dict - eventUrl: Union[List[str], str] - eventMethod: constr(to_upper=True) = None - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @deprecated(version='3.2.3', reason='The Pay NCCO action has been deprecated.') - class Pay(Action): - """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" - - action: Literal['pay'] = 'pay' - amount: confloat(ge=0) - currency: constr(to_lower=True) = None - eventUrl: Union[List[str], str] = None - prompts: Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict] = None - voice: Union[PayPrompts.VoicePrompt, dict] = None - - @field_validator('amount') - @classmethod - def round_amount(cls, v): - return round(v, 2) - - @field_validator('eventUrl') - @classmethod - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @field_validator('prompts') - @classmethod - def ensure_text_model(cls, v): - if type(v) is dict: - return PayPrompts.create_text_model(v) - else: - return v - - @field_validator('voice') - @classmethod - def ensure_voice_model(cls, v): - if type(v) is dict: - return PayPrompts.create_voice_model(v) - else: - return v - - @staticmethod - def build_ncco(*args: Action, actions: List[Action] = None) -> str: - ncco = [] - if actions is not None: - for action in actions: - ncco.append(action.model_dump(exclude_none=True, by_alias=True)) - for action in args: - ncco.append(action.model_dump(exclude_none=True, by_alias=True)) - return ncco - - @staticmethod - def _ensure_object_in_list(obj): - if type(obj) != list: - return [obj] - else: - return obj diff --git a/src/vonage/ncco_builder/pay_prompts.py b/src/vonage/ncco_builder/pay_prompts.py deleted file mode 100644 index 116acd66..00000000 --- a/src/vonage/ncco_builder/pay_prompts.py +++ /dev/null @@ -1,54 +0,0 @@ -from pydantic import BaseModel, ValidationInfo, field_validator, validator -from typing import Dict -from typing_extensions import Literal - - -class PayPrompts: - class VoicePrompt(BaseModel): - language: str = None - style: int = None - - class TextPrompt(BaseModel): - type: Literal['CardNumber', 'ExpirationDate', 'SecurityCode'] - text: str - errors: Dict[ - Literal[ - 'InvalidCardType', - 'InvalidCardNumber', - 'InvalidExpirationDate', - 'InvalidSecurityCode', - 'Timeout', - ], - Dict[Literal['text'], str], - ] - - @field_validator('errors') - @classmethod - def check_valid_error_format(cls, v, info: ValidationInfo): - values = info.data - - if values['type'] == 'CardNumber': - allowed_values = {'InvalidCardType', 'InvalidCardNumber', 'Timeout'} - cls.check_allowed_values(v, allowed_values, values['type']) - elif values['type'] == 'ExpirationDate': - allowed_values = {'InvalidExpirationDate', 'Timeout'} - cls.check_allowed_values(v, allowed_values, values['type']) - elif values['type'] == 'SecurityCode': - allowed_values = {'InvalidSecurityCode', 'Timeout'} - cls.check_allowed_values(v, allowed_values, values['type']) - return v - - def check_allowed_values(errors, allowed_values, prompt_type): - for key in errors: - if key not in allowed_values: - raise ValueError( - f'Value "{key}" is not a valid error for the "{prompt_type}" prompt type.' - ) - - @classmethod - def create_voice_model(cls, dict) -> VoicePrompt: - return cls.VoicePrompt.model_validate(dict) - - @classmethod - def create_text_model(cls, dict) -> TextPrompt: - return cls.TextPrompt.model_validate(dict) diff --git a/src/vonage/number_insight.py b/src/vonage/number_insight.py deleted file mode 100644 index 2b57dfb3..00000000 --- a/src/vonage/number_insight.py +++ /dev/null @@ -1,48 +0,0 @@ -from .errors import CallbackRequiredError - - -class NumberInsight: - auth_type = 'params' - - def __init__(self, client): - self._client = client - - def get_basic_number_insight(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), - "/ni/basic/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - - def get_standard_number_insight(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), - "/ni/standard/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - - def get_advanced_number_insight(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), - "/ni/advanced/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - - def get_async_advanced_number_insight(self, params=None, **kwargs): - argoparams = params or kwargs - if ( - "callback" in argoparams - and type(argoparams["callback"]) == str - and argoparams["callback"] != "" - ): - return self._client.get( - self._client.api_host(), - "/ni/advanced/async/json", - params or kwargs, - auth_type=NumberInsight.auth_type, - ) - else: - raise CallbackRequiredError("A callback is needed for async advanced number insight") diff --git a/src/vonage/number_management.py b/src/vonage/number_management.py deleted file mode 100644 index 41645372..00000000 --- a/src/vonage/number_management.py +++ /dev/null @@ -1,34 +0,0 @@ -class Numbers: - auth_type = 'header' - defaults = {'auth_type': auth_type, 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def get_account_numbers(self, params=None, **kwargs): - return self._client.get( - self._client.host(), "/account/numbers", params or kwargs, auth_type=Numbers.auth_type - ) - - def get_available_numbers(self, country_code, params=None, **kwargs): - return self._client.get( - self._client.host(), - "/number/search", - dict(params or kwargs, country=country_code), - auth_type=Numbers.auth_type, - ) - - def buy_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/buy", params or kwargs, **Numbers.defaults - ) - - def cancel_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/cancel", params or kwargs, **Numbers.defaults - ) - - def update_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/number/update", params or kwargs, **Numbers.defaults - ) diff --git a/src/vonage/proactive_connect.py b/src/vonage/proactive_connect.py deleted file mode 100644 index a9197a17..00000000 --- a/src/vonage/proactive_connect.py +++ /dev/null @@ -1,187 +0,0 @@ -from .errors import ProactiveConnectError - -import requests -import logging -from typing import List - -logger = logging.getLogger("vonage") - - -class ProactiveConnect: - def __init__(self, client): - self._client = client - self._auth_type = 'jwt' - - def list_all_lists(self, page: int = None, page_size: int = None): - params = self._check_pagination_params(page, page_size) - return self._client.get( - self._client.proactive_connect_host(), - '/v0.1/bulk/lists', - params, - auth_type=self._auth_type, - ) - - def create_list(self, params: dict): - self._validate_list_params(params) - return self._client.post( - self._client.proactive_connect_host(), - '/v0.1/bulk/lists', - params, - auth_type=self._auth_type, - ) - - def get_list(self, list_id: str): - return self._client.get( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}', - auth_type=self._auth_type, - ) - - def update_list(self, list_id: str, params: dict): - self._validate_list_params(params) - return self._client.put( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}', - params, - auth_type=self._auth_type, - ) - - def delete_list(self, list_id: str): - return self._client.delete( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}', - auth_type=self._auth_type, - ) - - def clear_list(self, list_id: str): - return self._client.post( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/clear', - params=None, - auth_type=self._auth_type, - ) - - def sync_list_from_datasource(self, list_id: str): - return self._client.post( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/fetch', - params=None, - auth_type=self._auth_type, - ) - - def list_all_items(self, list_id: str, page: int = None, page_size: int = None): - params = self._check_pagination_params(page, page_size) - return self._client.get( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items', - params, - auth_type=self._auth_type, - ) - - def create_item(self, list_id: str, data: dict): - params = {'data': data} - return self._client.post( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items', - params, - auth_type=self._auth_type, - ) - - def get_item(self, list_id: str, item_id: str): - return self._client.get( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items/{item_id}', - auth_type=self._auth_type, - ) - - def update_item(self, list_id: str, item_id: str, data: dict): - params = {'data': data} - return self._client.put( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items/{item_id}', - params, - auth_type=self._auth_type, - ) - - def delete_item(self, list_id: str, item_id: str): - return self._client.delete( - self._client.proactive_connect_host(), - f'/v0.1/bulk/lists/{list_id}/items/{item_id}', - auth_type=self._auth_type, - ) - - def download_list_items(self, list_id: str, file_path: str) -> List[dict]: - uri = f'https://{self._client.proactive_connect_host()}/v0.1/bulk/lists/{list_id}/items/download' - logger.debug( - f'GET request with Proactive Connect to {repr(uri)}, downloading items from list {list_id} to file {file_path}' - ) - headers = {**self._client.headers, 'Authorization': self._client._create_jwt_auth_string()} - response = requests.get( - uri, - headers=headers, - ) - if 200 <= response.status_code < 300: - with open(file_path, 'wb') as file: - file.write(response.content) - else: - return self._client.parse(self._client.proactive_connect_host(), response) - - def upload_list_items(self, list_id: str, file_path: str): - uri = f'https://{self._client.proactive_connect_host()}/v0.1/bulk/lists/{list_id}/items/import' - with open(file_path, 'rb') as csv_file: - logger.debug( - f'POST request with Proactive Connect uploading {file_path} to {repr(uri)}' - ) - headers = { - **self._client.headers, - 'Authorization': self._client._create_jwt_auth_string(), - } - response = requests.post( - uri, - headers=headers, - files={'file': ('list_items.csv', csv_file, 'text/csv')}, - ) - return self._client.parse(self._client.proactive_connect_host(), response) - - def list_events(self, page: int = None, page_size: int = None): - params = self._check_pagination_params(page, page_size) - return self._client.get( - self._client.proactive_connect_host(), - '/v0.1/bulk/events', - params, - auth_type=self._auth_type, - ) - - def _check_pagination_params(self, page: int = None, page_size: int = None) -> dict: - params = {} - if page is not None: - if type(page) == int and page > 0: - params['page'] = page - elif page <= 0: - raise ProactiveConnectError('"page" must be an int > 0.') - if page_size is not None: - if type(page_size) == int and page_size > 0: - params['page_size'] = page_size - elif page_size and page_size <= 0: - raise ProactiveConnectError('"page_size" must be an int > 0.') - return params - - def _validate_list_params(self, params: dict): - if 'name' not in params: - raise ProactiveConnectError('You must supply a name for the new list.') - if ( - 'datasource' in params - and 'type' in params['datasource'] - and params['datasource']['type'] == 'salesforce' - ): - self._check_salesforce_params_correct(params['datasource']) - - def _check_salesforce_params_correct(self, datasource): - if 'integration_id' not in datasource or 'soql' not in datasource: - raise ProactiveConnectError( - 'You must supply a value for "integration_id" and "soql" when creating a list with Salesforce.' - ) - if type(datasource['integration_id']) is not str or type(datasource['soql']) is not str: - raise ProactiveConnectError( - 'You must supply values for "integration_id" and "soql" as strings.' - ) diff --git a/src/vonage/redact.py b/src/vonage/redact.py deleted file mode 100644 index c1ec18f5..00000000 --- a/src/vonage/redact.py +++ /dev/null @@ -1,31 +0,0 @@ -from .errors import RedactError - -from deprecated import deprecated - - -@deprecated( - version='3.0.0', - reason='This is a dev preview product and as such is not supported in this SDK.', -) -class Redact: - auth_type = 'header' - - allowed_product_names = {'sms', 'voice', 'number-insight', 'verify', 'verify-sdk', 'messages'} - - def __init__(self, client): - self._client = client - - def redact_transaction(self, id: str, product: str, type=None): - self._check_allowed_product_name(product) - params = {"id": id, "product": product} - if type is not None: - params["type"] = type - return self._client.post( - self._client.api_host(), "/v1/redact/transaction", params, auth_type=Redact.auth_type - ) - - def _check_allowed_product_name(self, product): - if product not in self.allowed_product_names: - raise RedactError( - f'Invalid product name in redact request. Must be one of {self.allowed_product_names}.' - ) diff --git a/src/vonage/short_codes.py b/src/vonage/short_codes.py deleted file mode 100644 index 03150804..00000000 --- a/src/vonage/short_codes.py +++ /dev/null @@ -1,34 +0,0 @@ -class ShortCodes: - auth_type = 'params' - defaults = {'auth_type': auth_type, 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def send_2fa_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/sc/us/2fa/json", params or kwargs, **ShortCodes.defaults - ) - - def send_event_alert_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/sc/us/alert/json", params or kwargs, **ShortCodes.defaults - ) - - def send_marketing_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/sc/us/marketing/json", params or kwargs, **ShortCodes.defaults - ) - - def get_event_alert_numbers(self): - return self._client.get( - self._client.host(), "/sc/us/alert/opt-in/query/json", auth_type=ShortCodes.auth_type - ) - - def resubscribe_event_alert_number(self, params=None, **kwargs): - return self._client.post( - self._client.host(), - "/sc/us/alert/opt-in/manage/json", - params or kwargs, - **ShortCodes.defaults, - ) diff --git a/src/vonage/sms.py b/src/vonage/sms.py deleted file mode 100644 index 1eee72ac..00000000 --- a/src/vonage/sms.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytz -from datetime import datetime -from ._internal import _format_date_param - - -class Sms: - defaults = {'auth_type': 'params', 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def send_message(self, params): - """ - Send an SMS message. - Requires a client initialized with `key` and either `secret` or `signature_secret`. - :param dict params: A dict of values described at `Send an SMS `_ - """ - return self._client.post( - self._client.host(), - "/sms/json", - params, - supports_signature_auth=True, - **Sms.defaults, - ) - - def submit_sms_conversion(self, message_id, delivered=True, timestamp=None): - """ - Notify Vonage that an SMS was successfully received. - - If you are using the Verify API for 2FA, this information is sent to Vonage automatically - so you do not need to use this method to submit conversion data about 2FA messages. - - :param message_id: The `message-id` str returned by the send_message call. - :param delivered: A `bool` indicating that the message was or was not successfully delivered. - :param timestamp: A `datetime` object containing the time the SMS arrived. - :return: The parsed response from the server. On success, the bytestring b'OK' - """ - params = { - "message-id": message_id, - "delivered": delivered, - "timestamp": timestamp or datetime.now(pytz.utc), - } - # Ensure timestamp is a string: - _format_date_param(params, "timestamp") - return self._client.post( - self._client.api_host(), "/conversions/sms", params, **Sms.defaults - ) diff --git a/src/vonage/subaccounts.py b/src/vonage/subaccounts.py deleted file mode 100644 index c731f100..00000000 --- a/src/vonage/subaccounts.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING, Union - -from .errors import SubaccountsError - -if TYPE_CHECKING: - from vonage import Client - - -class Subaccounts: - """Class containing methods for working with the Vonage Subaccounts API.""" - - default_start_date = '1970-01-01T00:00:00Z' - - def __init__(self, client: Client): - self._client = client - self._api_key = self._client.api_key - self._api_host = self._client.api_host() - self._auth_type = 'header' - - def list_subaccounts(self): - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/subaccounts', - auth_type=self._auth_type, - ) - - def create_subaccount( - self, - name: str, - secret: str = None, - use_primary_account_balance: bool = None, - ): - params = {'name': name, 'secret': secret} - if self._is_boolean(use_primary_account_balance): - params['use_primary_account_balance'] = use_primary_account_balance - - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/subaccounts', - params=params, - auth_type=self._auth_type, - ) - - def get_subaccount(self, subaccount_key: str): - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/subaccounts/{subaccount_key}', - auth_type=self._auth_type, - ) - - def modify_subaccount( - self, - subaccount_key: str, - suspended: bool = None, - use_primary_account_balance: bool = None, - name: str = None, - ): - params = {'name': name} - if self._is_boolean(suspended): - params['suspended'] = suspended - if self._is_boolean(use_primary_account_balance): - params['use_primary_account_balance'] = use_primary_account_balance - - return self._client.patch( - self._api_host, - f'/accounts/{self._api_key}/subaccounts/{subaccount_key}', - params=params, - auth_type=self._auth_type, - ) - - def list_credit_transfers( - self, - start_date: str = default_start_date, - end_date: str = None, - subaccount: str = None, - ): - params = { - 'start_date': start_date, - 'end_date': end_date, - 'subaccount': subaccount, - } - - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/credit-transfers', - params=params, - auth_type=self._auth_type, - ) - - def transfer_credit( - self, - from_: str, - to: str, - amount: Union[float, int], - reference: str = None, - ): - params = { - 'from': from_, - 'to': to, - 'amount': amount, - 'reference': reference, - } - - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/credit-transfers', - params=params, - auth_type=self._auth_type, - ) - - def list_balance_transfers( - self, - start_date: str = default_start_date, - end_date: str = None, - subaccount: str = None, - ): - params = { - 'start_date': start_date, - 'end_date': end_date, - 'subaccount': subaccount, - } - - return self._client.get( - self._api_host, - f'/accounts/{self._api_key}/balance-transfers', - params=params, - auth_type=self._auth_type, - ) - - def transfer_balance( - self, - from_: str, - to: str, - amount: Union[float, int], - reference: str = None, - ): - params = {'from': from_, 'to': to, 'amount': amount, 'reference': reference} - - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/balance-transfers', - params=params, - auth_type=self._auth_type, - ) - - def transfer_number(self, from_: str, to: str, number: int, country: str): - params = {'from': from_, 'to': to, 'number': number, 'country': country} - return self._client.post( - self._api_host, - f'/accounts/{self._api_key}/transfer-number', - params=params, - auth_type=self._auth_type, - ) - - def _is_boolean(self, var): - if var is not None: - if type(var) == bool: - return True - else: - raise SubaccountsError( - f'If providing a value, it needs to be a boolean. You provided: "{var}"' - ) diff --git a/src/vonage/users.py b/src/vonage/users.py deleted file mode 100644 index 444e03d5..00000000 --- a/src/vonage/users.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from vonage import Client - -from .errors import UsersError -from ._internal import set_auth_type - - -class Users: - """Class containing methods for user management as part of the Application API.""" - - def __init__(self, client: Client): - self._client = client - self._auth_type = set_auth_type(self._client) - - def list_users( - self, - page_size: int = None, - order: str = 'asc', - cursor: str = None, - name: str = None, - ): - """ - Lists the name and user id of all users associated with the account. - For complete information on a user, call Users.get_user, passing in the user id. - """ - - if order.lower() not in ('asc', 'desc'): - raise UsersError( - 'Invalid order parameter. Must be one of: "asc", "desc", "ASC", "DESC".' - ) - - params = {'page_size': page_size, 'order': order.lower(), 'cursor': cursor, 'name': name} - return self._client.get( - self._client.api_host(), - '/v1/users', - params, - auth_type=self._auth_type, - ) - - def create_user(self, params: dict = None): - self._client.headers['Content-Type'] = 'application/json' - return self._client.post( - self._client.api_host(), - '/v1/users', - params, - auth_type=self._auth_type, - ) - - def get_user(self, user_id: str): - return self._client.get( - self._client.api_host(), - f'/v1/users/{user_id}', - auth_type=self._auth_type, - ) - - def update_user(self, user_id: str, params: dict): - return self._client.patch( - self._client.api_host(), - f'/v1/users/{user_id}', - params, - auth_type=self._auth_type, - ) - - def delete_user(self, user_id: str): - return self._client.delete( - self._client.api_host(), - f'/v1/users/{user_id}', - auth_type=self._auth_type, - ) diff --git a/src/vonage/ussd.py b/src/vonage/ussd.py deleted file mode 100644 index 98ade213..00000000 --- a/src/vonage/ussd.py +++ /dev/null @@ -1,15 +0,0 @@ -class Ussd: - defaults = {'auth_type': 'params', 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def send_ussd_push_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/ussd/json", params or kwargs, **Ussd.defaults - ) - - def send_ussd_prompt_message(self, params=None, **kwargs): - return self._client.post( - self._client.host(), "/ussd-prompt/json", params or kwargs, **Ussd.defaults - ) diff --git a/src/vonage/verify.py b/src/vonage/verify.py deleted file mode 100644 index 1c7168b7..00000000 --- a/src/vonage/verify.py +++ /dev/null @@ -1,54 +0,0 @@ -class Verify: - auth_type = 'params' - defaults = {'auth_type': auth_type, 'body_is_json': False} - - def __init__(self, client): - self._client = client - - def start_verification(self, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/json", - params or kwargs, - **Verify.defaults, - ) - - def check(self, request_id, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/check/json", - dict(params or kwargs, request_id=request_id), - **Verify.defaults, - ) - - def search(self, request_id): - return self._client.get( - self._client.api_host(), - "/verify/search/json", - {"request_id": request_id}, - auth_type=Verify.auth_type, - ) - - def cancel(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "cancel"}, - **Verify.defaults, - ) - - def trigger_next_event(self, request_id): - return self._client.post( - self._client.api_host(), - "/verify/control/json", - {"request_id": request_id, "cmd": "trigger_next_event"}, - **Verify.defaults, - ) - - def psd2(self, params=None, **kwargs): - return self._client.post( - self._client.api_host(), - "/verify/psd2/json", - params or kwargs, - **Verify.defaults, - ) diff --git a/src/vonage/verify2.py b/src/vonage/verify2.py deleted file mode 100644 index cb13a353..00000000 --- a/src/vonage/verify2.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING -from typing_extensions import Annotated - -if TYPE_CHECKING: - from vonage import Client - -from pydantic import BaseModel, StringConstraints, ValidationError, field_validator, conint -from typing import List - -import copy -import re - -from ._internal import set_auth_type -from .errors import Verify2Error - - -class Verify2: - valid_channels = [ - 'sms', - 'whatsapp', - 'whatsapp_interactive', - 'voice', - 'email', - 'silent_auth', - ] - - def __init__(self, client: Client): - self._client = client - self._auth_type = set_auth_type(self._client) - - def new_request(self, params: dict): - self._remove_unnecessary_fraud_check(params) - try: - params_to_verify = copy.deepcopy(params) - Verify2.VerifyRequest.model_validate(params_to_verify) - except (ValidationError, Verify2Error) as err: - raise err - - return self._client.post( - self._client.api_host(), - '/v2/verify', - params, - auth_type=self._auth_type, - ) - - def check_code(self, request_id: str, code: str): - params = {'code': str(code)} - - return self._client.post( - self._client.api_host(), - f'/v2/verify/{request_id}', - params, - auth_type=self._auth_type, - ) - - def cancel_verification(self, request_id: str): - return self._client.delete( - self._client.api_host(), - f'/v2/verify/{request_id}', - auth_type=self._auth_type, - ) - - def _remove_unnecessary_fraud_check(self, params): - if 'fraud_check' in params and params['fraud_check'] != False: - del params['fraud_check'] - - class VerifyRequest(BaseModel): - brand: str - workflow: List[dict] - locale: str = None - channel_timeout: conint(ge=60, le=900) = None - client_ref: str = None - code_length: conint(ge=4, le=10) = None - fraud_check: bool = None - code: Annotated[str, StringConstraints( - min_length=4, max_length=10 - )] = None - - @field_validator('code') - @classmethod - def regex_check(cls, c: str): - re_for_code: re.Pattern[str] = re.compile('^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$') - - if not re_for_code.match(c): - raise ValueError("string does not match regex") - return c - - @field_validator('workflow') - @classmethod - def check_valid_workflow(cls, v): - for workflow in v: - Verify2._check_valid_channel(workflow) - Verify2._check_valid_recipient(workflow) - Verify2._check_app_hash(workflow) - if workflow['channel'] == 'whatsapp' and 'from' in workflow: - Verify2._check_whatsapp_sender(workflow) - if workflow['channel'] == 'silent_auth': - Verify2._check_silent_auth_workflow(workflow) - - def _check_valid_channel(workflow): - if 'channel' not in workflow or workflow['channel'] not in Verify2.valid_channels: - raise Verify2Error( - f'You must specify a valid verify channel inside the "workflow" object, one of: "{Verify2.valid_channels}"' - ) - - def _check_valid_recipient(workflow): - if 'to' not in workflow or ( - workflow['channel'] != 'email' and not re.search(r'^[1-9]\d{6,14}$', workflow['to']) - ): - raise Verify2Error( - f'You must specify a valid "to" value for channel "{workflow["channel"]}"' - ) - - def _check_app_hash(workflow): - if workflow['channel'] == 'sms' and 'app_hash' in workflow: - if type(workflow['app_hash']) != str or len(workflow['app_hash']) != 11: - raise Verify2Error( - 'Invalid "app_hash" specified. If specifying app_hash, \ - it must be passed as a string and contain exactly 11 characters.' - ) - elif workflow['channel'] != 'sms' and 'app_hash' in workflow: - raise Verify2Error( - 'Cannot specify a value for "app_hash" unless using SMS for authentication.' - ) - - def _check_whatsapp_sender(workflow): - if not re.search(r'^[1-9]\d{6,14}$', workflow['from']): - raise Verify2Error('You must specify a valid "from" value if included.') - - def _check_silent_auth_workflow(workflow): - if 'redirect_url' in workflow: - if type(workflow['redirect_url']) != str: - raise Verify2Error('"redirect_url" must be a string if specified.') - if 'sandbox' in workflow: - if type(workflow['sandbox']) != bool: - raise Verify2Error('"sandbox" must be a boolean if specified.') diff --git a/src/vonage/video.py b/src/vonage/video.py deleted file mode 100644 index d1f84419..00000000 --- a/src/vonage/video.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from vonage import Client - -from .errors import ( - InvalidRoleError, - TokenExpiryError, - SipError, - VideoError, -) - -import re -from time import time -from uuid import uuid4 - - -class Video: - auth_type = 'jwt' - archive_mode_values = {'manual', 'always'} - media_mode_values = {'routed', 'relayed'} - token_roles = {'subscriber', 'publisher', 'moderator'} - - def __init__(self, client: Client): - self._client = client - - def create_session(self, session_options: dict = None): - if session_options is None: - session_options = {} - - params = {'archiveMode': 'manual', 'p2p.preference': 'disabled', 'location': None} - if ( - 'archive_mode' in session_options - and session_options['archive_mode'] not in Video.archive_mode_values - ): - raise VideoError( - f'Invalid archive_mode value. Must be one of {Video.archive_mode_values}.' - ) - elif 'archive_mode' in session_options: - params['archiveMode'] = session_options['archive_mode'] - if ( - 'media_mode' in session_options - and session_options['media_mode'] not in Video.media_mode_values - ): - raise VideoError(f'Invalid media_mode value. Must be one of {Video.media_mode_values}.') - elif 'media_mode' in session_options: - if session_options['media_mode'] == 'routed': - params['p2p.preference'] = 'disabled' - elif session_options['media_mode'] == 'relayed': - if params['archiveMode'] == 'always': - raise VideoError( - 'Invalid combination: cannot specify "archive_mode": "always" and "media_mode": "relayed".' - ) - else: - params['p2p.preference'] = 'enabled' - if 'location' in session_options: - params['location'] = session_options['location'] - - session = self._client.post( - self._client.video_host(), - '/session/create', - params, - auth_type=Video.auth_type, - body_is_json=False, - )[0] - - media_mode = self.get_media_mode(params['p2p.preference']) - session_info = { - 'session_id': session['session_id'], - 'archive_mode': params['archiveMode'], - 'media_mode': media_mode, - 'location': params['location'], - } - - return session_info - - def get_media_mode(self, p2p_preference): - if p2p_preference == 'disabled': - return 'routed' - elif p2p_preference == 'enabled': - return 'relayed' - - def get_stream(self, session_id, stream_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream/{stream_id}', - auth_type=Video.auth_type, - ) - - def list_streams(self, session_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream', - auth_type=Video.auth_type, - ) - - def set_stream_layout(self, session_id, items): - return self._client.put( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream', - items, - auth_type=Video.auth_type, - ) - - def send_signal(self, session_id, type, data, connection_id=None): - if connection_id: - request_uri = f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}/signal' - else: - request_uri = f'/v2/project/{self._client.application_id}/session/{session_id}/signal' - - params = {'type': type, 'data': data} - - return self._client.post( - self._client.video_host(), request_uri, params, auth_type=Video.auth_type - ) - - def disconnect_client(self, session_id, connection_id): - return self._client.delete( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}', - auth_type=Video.auth_type, - ) - - def mute_stream(self, session_id, stream_id): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/stream/{stream_id}/mute', - params=None, - auth_type=Video.auth_type, - ) - - def mute_all_streams(self, session_id, active=True, excluded_stream_ids: list = []): - params = {'active': active, 'excludedStreamIds': excluded_stream_ids} - - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/mute', - params, - auth_type=Video.auth_type, - ) - - def disable_mute_all_streams(self, session_id, excluded_stream_ids: list = []): - return self.mute_all_streams( - session_id, active=False, excluded_stream_ids=excluded_stream_ids - ) - - def list_archives(self, filter_params=None, **filter_kwargs): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive', - filter_params or filter_kwargs, - auth_type=Video.auth_type, - ) - - def create_archive(self, params=None, **kwargs): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive', - params or kwargs, - auth_type=Video.auth_type, - ) - - def get_archive(self, archive_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}', - auth_type=Video.auth_type, - ) - - def delete_archive(self, archive_id): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}', - auth_type=Video.auth_type, - ) - - def add_stream_to_archive(self, archive_id, stream_id, has_audio=True, has_video=True): - params = {'addStream': stream_id, 'hasAudio': has_audio, 'hasvideo': has_video} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def remove_stream_from_archive(self, archive_id, stream_id): - params = {'removeStream': stream_id} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def stop_archive(self, archive_id): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/stop', - params=None, - auth_type=Video.auth_type, - ) - - def change_archive_layout(self, archive_id, params=None, **kwargs): - return self._client.put( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/archive/{archive_id}/layout', - params or kwargs, - auth_type=Video.auth_type, - ) - - def create_sip_call(self, session_id: str, token: str, sip: dict): - if 'uri' not in sip: - raise SipError('You must specify a uri when creating a SIP call.') - - params = {'sessionId': session_id, 'token': token, 'sip': sip} - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/dial', - params, - auth_type=Video.auth_type, - ) - - def play_dtmf(self, session_id: str, digits: str, connection_id: str = None): - if not re.search('^[0-9*#p]+$', digits): - raise VideoError('Only digits 0-9, *, #, and "p" are allowed.') - - params = {'digits': digits} - - if connection_id is not None: - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/connection/{connection_id}/play-dtmf', - params, - auth_type=Video.auth_type, - ) - - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/session/{session_id}/play-dtmf', - params, - auth_type=Video.auth_type, - ) - - def list_broadcasts(self, offset: int = None, count: int = None, session_id: str = None): - if offset is not None and (type(offset) != int or offset < 0): - raise VideoError('Offset must be an int >= 0.') - if count is not None and (type(count) != int or count < 0 or count > 1000): - raise VideoError('Count must be an int between 0 and 1000.') - - params = {'offset': str(offset), 'count': str(count), 'sessionId': session_id} - - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast', - params, - auth_type=Video.auth_type, - ) - - def start_broadcast(self, params: dict): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast', - params, - auth_type=Video.auth_type, - ) - - def get_broadcast(self, broadcast_id: str): - return self._client.get( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}', - auth_type=Video.auth_type, - ) - - def stop_broadcast(self, broadcast_id: str): - return self._client.post( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}', - params={}, - auth_type=Video.auth_type, - ) - - def change_broadcast_layout(self, broadcast_id: str, params: dict): - return self._client.put( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/layout', - params=params, - auth_type=Video.auth_type, - ) - - def add_stream_to_broadcast( - self, broadcast_id: str, stream_id: str, has_audio=True, has_video=True - ): - params = {'addStream': stream_id, 'hasAudio': has_audio, 'hasvideo': has_video} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def remove_stream_from_broadcast(self, broadcast_id: str, stream_id: str): - params = {'removeStream': stream_id} - - return self._client.patch( - self._client.video_host(), - f'/v2/project/{self._client.application_id}/broadcast/{broadcast_id}/streams', - params, - auth_type=Video.auth_type, - ) - - def generate_client_token(self, session_id, token_options={}): - now = int(time()) - claims = { - 'scope': 'session.connect', - 'session_id': session_id, - 'role': 'publisher', - 'initial_layout_class_list': '', - 'jti': str(uuid4()), - 'iat': now, - } - if 'role' in token_options: - claims['role'] = token_options['role'] - if 'data' in token_options: - claims['data'] = token_options['data'] - if 'initialLayoutClassList' in token_options: - claims['initial_layout_class_list'] = token_options['initialLayoutClassList'] - if 'expireTime' in token_options and token_options['expireTime'] > now: - claims['exp'] = token_options['expireTime'] - if 'jti' in token_options: - claims['jti'] = token_options['jti'] - if 'iat' in token_options: - claims['iat'] = token_options['iat'] - if 'subject' in token_options: - claims['subject'] = token_options['subject'] - if 'acl' in token_options: - claims['acl'] = token_options['acl'] - - self.validate_client_token_options(claims) - self._client.auth(claims) - return self._client._generate_application_jwt() - - def validate_client_token_options(self, claims): - now = int(time()) - if claims['role'] not in Video.token_roles: - raise InvalidRoleError( - f'Invalid role specified for the client token. Valid values are: {Video.token_roles}' - ) - if 'exp' in claims and claims['exp'] > now + 3600 * 24 * 30: - raise TokenExpiryError('Token expiry date must be less than 30 days from now.') diff --git a/src/vonage/voice.py b/src/vonage/voice.py deleted file mode 100644 index 08e2e116..00000000 --- a/src/vonage/voice.py +++ /dev/null @@ -1,100 +0,0 @@ -from urllib.parse import urlparse -from vonage_jwt.verify_jwt import verify_signature - - -class Voice: - auth_type = 'jwt' - - def __init__(self, client): - self._client = client - - # Creates a new call session - def create_call(self, params, **kwargs): - """ - Adding Random From Number Feature for the Voice API, - if set to `True`, the from number will be randomly selected - from the pool of numbers available to the application making - the call. - - :param params is a dictionary that holds the 'from' and 'random_from_number' - - """ - if not params: - params = kwargs - - key = 'from' - if key not in params: - params['random_from_number'] = True - - return self._client.post( - self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type - ) - - # Get call history paginated. Pass start and end dates to filter the retrieved information - def get_calls(self, params=None, **kwargs): - return self._client.get( - self._client.api_host(), "/v1/calls", params or kwargs, auth_type=Voice.auth_type - ) - - # Get a single call record by identifier - def get_call(self, uuid): - return self._client.get( - self._client.api_host(), f"/v1/calls/{uuid}", auth_type=Voice.auth_type - ) - - # Update call data using custom ncco - def update_call(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # Plays audio streaming into call in progress - stream_url parameter is required - def send_audio(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}/stream", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # Play an speech into specified call - text parameter (text to speech) is required - def send_speech(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}/talk", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # plays DTMF tones into the specified call - def send_dtmf(self, uuid, params=None, **kwargs): - return self._client.put( - self._client.api_host(), - f"/v1/calls/{uuid}/dtmf", - params or kwargs, - auth_type=Voice.auth_type, - ) - - # Stops audio recently played into specified call - def stop_audio(self, uuid): - return self._client.delete( - self._client.api_host(), f"/v1/calls/{uuid}/stream", auth_type=Voice.auth_type - ) - - # Stop a speech recently played into specified call - def stop_speech(self, uuid): - return self._client.delete( - self._client.api_host(), f"/v1/calls/{uuid}/talk", auth_type=Voice.auth_type - ) - - def get_recording(self, url): - hostname = urlparse(url).hostname - headers = self._client.headers - headers['Authorization'] = self._client._create_jwt_auth_string() - return self._client.parse(hostname, self._client.session.get(url, headers=headers)) - - def verify_signature(self, token: str, signature: str) -> bool: - return verify_signature(token, signature) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 0290bc72..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,141 +0,0 @@ -import os -import os.path -import platform - -import pytest - - -# Ensure our client isn't being configured with real values! -os.environ.clear() - - -def read_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as input_file: - return input_file.read() - - -class DummyData(object): - def __init__(self): - import vonage - - self.api_key = "nexmo-api-key" - self.api_secret = "nexmo-api-secret" - self.signature_secret = "secret" - self.application_id = "nexmo-application-id" - self.private_key = read_file("data/private_key.txt") - self.public_key = read_file("data/public_key.txt") - self.user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()}" - self.host = "rest.nexmo.com" - self.api_host = "api.nexmo.com" - self.meetings_api_host = "api-eu.vonage.com/beta/meetings" - - -@pytest.fixture(scope="session") -def dummy_data(): - return DummyData() - - -@pytest.fixture -def client(dummy_data): - import vonage - - return vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=dummy_data.private_key, - ) - - -# Represents an instance of the Voice class for testing -@pytest.fixture -def voice(client): - import vonage - - return vonage.Voice(client) - - -# Represents an instance of the Sms class for testing -@pytest.fixture -def sms(client): - import vonage - - return vonage.Sms(client) - - -# Represents an instance of the Verify class for testing -@pytest.fixture -def verify(client): - import vonage - - return vonage.Verify(client) - - -@pytest.fixture -def number_insight(client): - import vonage - - return vonage.NumberInsight(client) - - -@pytest.fixture -def account(client): - import vonage - - return vonage.Account(client) - - -@pytest.fixture -def numbers(client): - import vonage - - return vonage.Numbers(client) - - -@pytest.fixture -def ussd(client): - import vonage - - return vonage.Ussd(client) - - -@pytest.fixture -def short_codes(client): - import vonage - - return vonage.ShortCodes(client) - - -@pytest.fixture -def messages(client): - import vonage - - return vonage.Messages(client) - - -@pytest.fixture -def redact(client): - import vonage - - return vonage.Redact(client) - - -@pytest.fixture -def application_v2(client): - import vonage - - return vonage.ApplicationV2(client) - - -@pytest.fixture -def meetings(client): - import vonage - - return vonage.Meetings(client) - - -@pytest.fixture -def proc(client): - import vonage - - return vonage.ProactiveConnect(client) diff --git a/tests/data/account/secret_management/create-validation.json b/tests/data/account/secret_management/create-validation.json deleted file mode 100644 index c4f50dc4..00000000 --- a/tests/data/account/secret_management/create-validation.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/secret-management#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "invalid_parameters": [ - { - "name": "secret", - "reason": "Does not meet complexity requirements" - } - ], - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/create.json b/tests/data/account/secret_management/create.json deleted file mode 100644 index bf204c7c..00000000 --- a/tests/data/account/secret_management/create.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets/ad6dc56f-07b5-46e1-a527-85530e625800" - } - }, - "id": "ad6dc56f-07b5-46e1-a527-85530e625800", - "created_at": "2017-03-02T16:34:49Z" -} diff --git a/tests/data/account/secret_management/get.json b/tests/data/account/secret_management/get.json deleted file mode 100644 index bf204c7c..00000000 --- a/tests/data/account/secret_management/get.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets/ad6dc56f-07b5-46e1-a527-85530e625800" - } - }, - "id": "ad6dc56f-07b5-46e1-a527-85530e625800", - "created_at": "2017-03-02T16:34:49Z" -} diff --git a/tests/data/account/secret_management/last-secret.json b/tests/data/account/secret_management/last-secret.json deleted file mode 100644 index fe8f85a6..00000000 --- a/tests/data/account/secret_management/last-secret.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret", - "title": "Secret Deletion Forbidden", - "detail": "Can not delete the last secret. The account must always have at least 1 secret active at any time", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/list.json b/tests/data/account/secret_management/list.json deleted file mode 100644 index 3600f601..00000000 --- a/tests/data/account/secret_management/list.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets" - } - }, - "_embedded": { - "secrets": [ - { - "_links": { - "self": { - "href": "/accounts/abcd1234/secrets/ad6dc56f-07b5-46e1-a527-85530e625800" - } - }, - "id": "ad6dc56f-07b5-46e1-a527-85530e625800", - "created_at": "2017-03-02T16:34:49Z" - } - ] - } -} diff --git a/tests/data/account/secret_management/max-secrets.json b/tests/data/account/secret_management/max-secrets.json deleted file mode 100644 index 22916988..00000000 --- a/tests/data/account/secret_management/max-secrets.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed", - "title": "Maxmimum number of secrets already met", - "detail": "This account has reached maximum number of '2' allowed secrets", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/missing.json b/tests/data/account/secret_management/missing.json deleted file mode 100644 index 279920e8..00000000 --- a/tests/data/account/secret_management/missing.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#invalid-api-key", - "title": "Invalid API Key", - "detail": "API key 'ABC123' does not exist, or you do not have access", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/account/secret_management/unauthorized.json b/tests/data/account/secret_management/unauthorized.json deleted file mode 100644 index 8ee31d9a..00000000 --- a/tests/data/account/secret_management/unauthorized.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#unauthorized", - "title": "Invalid credentials supplied", - "detail": "You did not provide correct credentials.", - "instance": "797a8f199c45014ab7b08bfe9cc1c12c" -} diff --git a/tests/data/applications/create_application.json b/tests/data/applications/create_application.json deleted file mode 100644 index 18ab0453..00000000 --- a/tests/data/applications/create_application.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "My Test Application", - "keys": { - "private_key": "-----BEGIN PRIVATE KEY-----\nABCDE\nabcde\n12345\n-----END PRIVATE KEY-----\n", - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": {}, - "_links": { - "self": { - "href": "/v2/applications/680754a2-86b9-11e9-9729-3200112428c0" - } - } -} \ No newline at end of file diff --git a/tests/data/applications/get_application.json b/tests/data/applications/get_application.json deleted file mode 100644 index 8f18afcd..00000000 --- a/tests/data/applications/get_application.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "My Test Application", - "keys": { - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": {}, - "_links": { - "self": { - "href": "/v2/applications/680754a2-86b9-11e9-9729-3200112428c0" - } - } -} \ No newline at end of file diff --git a/tests/data/applications/list_applications.json b/tests/data/applications/list_applications.json deleted file mode 100644 index c2f67537..00000000 --- a/tests/data/applications/list_applications.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "page_size": 1, - "page": 1, - "total_items": 30, - "total_pages": 1, - "_embedded": { - "applications": [ - { - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "My Test Application", - "keys": { - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": { - "voice": { - "webhooks": { - "event_url": { - "address": "https://example.org/event", - "http_method": "POST" - }, - "answer_url": { - "address": "https://example.org/answer", - "http_method": "GET" - } - } - } - } - } - ] - }, - "_links": { - "self": { - "href": "/v2/applications?page_size=10&page=1" - }, - "first": { - "href": "/v2/applications?page_size=10" - }, - "last": { - "href": "/v2/applications?page_size=10&page=3" - }, - "next": { - "href": "/v2/applications?page_size=10&page=2" - } - } -} \ No newline at end of file diff --git a/tests/data/applications/update_application.json b/tests/data/applications/update_application.json deleted file mode 100644 index 8a268167..00000000 --- a/tests/data/applications/update_application.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "680754a2-86b9-11e9-9729-3200112428c0", - "name": "A Better Name", - "keys": { - "public_key": "-----BEGIN PUBLIC KEY-----\nA123BC\n2345ADC\n-----END PUBLIC KEY-----\n" - }, - "capabilities": {}, - "_links": { - "self": { - "href": "/v2/applications/d678d143-e7fa-465a-9ee3-0b59621967d2" - } - } -} \ No newline at end of file diff --git a/tests/data/meetings/delete_recording_not_found.json b/tests/data/meetings/delete_recording_not_found.json deleted file mode 100644 index 0e38eb8f..00000000 --- a/tests/data/meetings/delete_recording_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Could not find recording", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/delete_theme_in_use.json b/tests/data/meetings/delete_theme_in_use.json deleted file mode 100644 index b4ebb38b..00000000 --- a/tests/data/meetings/delete_theme_in_use.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "message": "could not delete theme", - "name": "BadRequestError", - "errors": [ - "Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room" - ], - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/empty_themes.json b/tests/data/meetings/empty_themes.json deleted file mode 100644 index 9e26dfee..00000000 --- a/tests/data/meetings/empty_themes.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/tests/data/meetings/get_recording.json b/tests/data/meetings/get_recording.json deleted file mode 100644 index 3f7c28dd..00000000 --- a/tests/data/meetings/get_recording.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "e5b73c98-c087-4ee5-b61b-0ea08204fc65", - "session_id": "1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4", - "started_at": "2023-01-25T02:38:31.000Z", - "ended_at": "2023-01-25T02:38:40.000Z", - "status": "uploaded", - "_links": { - "url": { - "href": "https://prod-meetings-recordings.s3.amazonaws.com/46339892/e5b73c98-c087-4ee5-b61b-0ea08204fc65/archive.mp4?AWSAccessKeyId=ASIA5NAYMMB6PXEIQICC&Expires=1674687032&Signature=RosB66sKsizUgoRz%2FWlQD7wUUJY%3D&response-content-disposition=attachment%3B%20filename%3D%22test_recording_room_2023-01-25T02%253A38%253A31.000Z.mp4%22&response-content-type=video%2Fmp4&x-amz-security-token=IQoJb3JpZ2luX2VjEKH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJHMEUCIQC5%2FrQRRq%2FzlJCqfgI9MN4Bq9kqmJTMPgZCo2KyaJ79IAIgdVVs9eYiuxB%2Bcc7QYJz7X4XQjSPcofAsves5rrjrsDgqiwQIGhAAGgw5MjEzMjE2Mjc3NzIiDHpbvFCfDDlkhb%2FFCyroA7OrSBCZr7MyZHXnHHPHOB99ctR%2F7XMzr3GAqnWfZTR5DY1zSYLkAKavjbS6Uw%2FMZW1PeETwLUrdwvLcvkpkU6EaXh2PZV07ty8AB9wIEyazRR9%2BqrCP9o23dlN1yMKPDnHGKO%2FGvxNFrHC9xJeFmaKrOz3f4oeRlFzJ%2FcMnXOI3vnuMa5jFf2GHDQGYkCWF7ertH%2FnIrdmj80%2BNOGsCb2O5%2BezLLlbJAd12MNj8C4m4xw%2BY0fNHZKKAjrG4UTE8%2BBdZ%2FQrMfbKfHaz736be3mln4ArCL1vUWRdQOQFP8impDXRDSMGS56qIdKgsYhEr6fcT%2Fy5KpYuVXxL4Z8TzVgrWwfcmlTxAJuvDgA6HxQAzY8BN8eQLxbaBWaiq%2BD0z6hDPIHKhZDYLQ4CSsxDRfL1DN%2FB155Os9kmombG5rZb%2BR1poIYTrlC16LjIN2JWgCovI8fRPgcjJ3JYEIdi0oE6Jq%2B0PdXbjbSZlekpkQRny3eOPuKhheFlmpweiEjwabVPq3kGyUeC03ZvOZ6giN34LdtM3m2gblmcO9t%2F1KvJwm49t2LJYqGMX9JnqOf6pSWqAZXERCoeAaE0SLzsI15pIix07e9fSY8HZJ49OL2%2FYdz%2BlY4rmkTTVo2v2iELhXS57S3ihkz3lMLS6xZ4GOqUB%2BLq1xiXSn3RAfctKCWM6fGctNnh77Tmn9cwKd8LZtXSZUIxGNYESIu4k%2BbgPZh%2B%2BLf%2BNAsgj6DtnpicEwoSxnbeMwrM6PWw8IH7GE%2FeHN8HFZqGwWiWSogeOv1YeFGL%2BWlXVS%2F5mx7uNTJx%2FHryd2JSYu3kn3MpY7cBU67jcTyesZQzR%2BTHIhLnNgTMNhdQ4st7lD4RaCsvTGqFkv6EnOV%2BU17hv" - } - } -} \ No newline at end of file diff --git a/tests/data/meetings/get_recording_not_found.json b/tests/data/meetings/get_recording_not_found.json deleted file mode 100644 index 9c2f8d66..00000000 --- a/tests/data/meetings/get_recording_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Recording not-a-real-recording-id was not found", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/get_session_recordings.json b/tests/data/meetings/get_session_recordings.json deleted file mode 100644 index 782effe3..00000000 --- a/tests/data/meetings/get_session_recordings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "_embedded": { - "recordings": [ - { - "id": "e5b73c98-c087-4ee5-b61b-0ea08204fc65", - "session_id": "1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4", - "started_at": "2023-01-25T02:38:31.000Z", - "ended_at": "2023-01-25T02:38:40.000Z", - "status": "uploaded", - "_links": { - "url": { - "href": "https://prod-meetings-recordings.s3.amazonaws.com/46339892/e5b73c98-c087-4ee5-b61b-0ea08204fc65/archive.mp4?AWSAccessKeyId=ASIA5NAYMMB6JPDOLPNO&Expires=1674688058&Signature=0IzgnyLJFMP1TDkOyoBT4M54Le8%3D&response-content-disposition=attachment%3B%20filename%3D%22test_recording_room_2023-01-25T02%253A38%253A31.000Z.mp4%22&response-content-type=video%2Fmp4&x-amz-security-token=IQoJb3JpZ2luX2VjEKL%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJHMEUCIBE0ejVJPxkEDjAF6cMuDC9nIeOU%2BUnUTSnfhi2prlHtAiEA1wiXNTR96lN%2Bgsb2yeQPM%2BF%2F4e6%2BA6%2B5CylWsM1gW%2BMqiwQIGhAAGgw5MjEzMjE2Mjc3NzIiDHEAZDKegwDhiMhj3CroA1%2B2SNg3m%2B%2FCmq3ELZnnEx8t9oYXmlY0dDRovuKNBdy5n4d%2FUhhR5DaoxOj8cAY7Yu8xZRM1oYQCbO2Qrgiy2Nki7FgHNljLldhbMN6txOnf7%2BP8r2XWD6x0D7ZN8hhA4LAoeTGaF4N7ZT3Oabti%2F6z5qw%2Bp85dak9CMd%2BToeUzqcmKlRhB56SrMgTofr2B8BOXgxxmFfdmrKllmJxsi2og5iLwWWdHNExV87fPout%2FMlQ0u5D1vj3F%2FtQGfAjkPnf1RSol%2BIxwIHPmKEiqpUSu0hwtPai8Ra1l4tml2Zv9SGZ1E8AZEAtmROL2fM4rl%2BtUOEAXTUWGO3G%2BcjGs6cPZB4ihzo6TqIFyGJoQ95pPFu6yiRa%2F31Z5DEHcom5Ux4%2Fxs0TMimuLP2CJ%2BuqKRiAb9w20wchouM9MaGjVYvTXs%2BJsVXQIwdGdJACIkK9CKZXNkYAbKnYfAkz8bi7rfFoJT1mZ6hSxG%2BNGp%2FYr7Vk%2FTSvoLO4%2F4%2FpiSyJs2Y7r6QbohXgmCkZTejJW3KxC4tCRGheVwyVwRC%2F%2FCMQpm34wEb0FL0sEfqxhl7Kbkm0RT1HwOlb3N4GmLERJcEcIpmmegEIRPQcbM2ohGq%2BbMrWvD8lmu1qyfu01cSZkl9xe1NtLtEFpxoxVSMOPFxZ4GOqUB69If14rWCah8hqaxxteZbVoDSmXJo7CrMDc8uaRgFQbNR6tj4WC2t21q%2BhTBRd3C%2F5zYTAAbILP3jkDDRt3SanOamRICcOKqOJFlRa6aCz2G%2F175CWu0Bz1wDGokaGAwz3G1CL%2B2t91JH8aPUHQkX87%2FGxJliywZfL2od%2FbyCR6bM%2FGbVPRuPX8fSXsTQZPjCMCt4GJv5%2Fq%2F3h9t0lf0AfCG9Hx%2F" - } - } - } - ] - } -} \ No newline at end of file diff --git a/tests/data/meetings/get_session_recordings_not_found.json b/tests/data/meetings/get_session_recordings_not_found.json deleted file mode 100644 index 11ebca44..00000000 --- a/tests/data/meetings/get_session_recordings_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Failed to find session recordings by id: not-a-real-session-id", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/list_dial_in_numbers.json b/tests/data/meetings/list_dial_in_numbers.json deleted file mode 100644 index b2d4e165..00000000 --- a/tests/data/meetings/list_dial_in_numbers.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "number": "541139862166", - "locale": "es-AR", - "display_name": "Argentina" - }, - { - "number": "442381924626", - "locale": "en-GB", - "display_name": "United Kingdom" - } -] \ No newline at end of file diff --git a/tests/data/meetings/list_logo_upload_urls.json b/tests/data/meetings/list_logo_upload_urls.json deleted file mode 100644 index 8f4526b5..00000000 --- a/tests/data/meetings/list_logo_upload_urls.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", - "fields": { - "Content-Type": "image/png", - "key": "auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25", - "logoType": "white", - "bucket": "roomservice-whitelabel-logos-prod", - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": "some-credential", - "X-Amz-Date": "20230127T024303Z", - "X-Amz-Security-Token": "some-token", - "Policy": "some-policy", - "X-Amz-Signature": "some-signature" - } - }, - { - "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", - "fields": { - "Content-Type": "image/png", - "key": "auto-expiring-temp/logos/colored/c4e00bac-781b-4bf0-bd5f-b9ff2cbc1b6c", - "logoType": "colored", - "bucket": "roomservice-whitelabel-logos-prod", - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": "some-credential", - "X-Amz-Date": "20230127T024303Z", - "X-Amz-Security-Token": "some-token", - "Policy": "some-policy", - "X-Amz-Signature": "some-signature" - } - }, - { - "url": "https://s3.amazonaws.com/roomservice-whitelabel-logos-prod", - "fields": { - "Content-Type": "image/png", - "key": "auto-expiring-temp/logos/favicon/d7a81477-38f7-460c-b51f-1462b8426df5", - "logoType": "favicon", - "bucket": "roomservice-whitelabel-logos-prod", - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": "some-credential", - "X-Amz-Date": "20230127T024303Z", - "X-Amz-Security-Token": "some-token", - "Policy": "some-policy", - "X-Amz-Signature": "some-signature" - } - } -] \ No newline at end of file diff --git a/tests/data/meetings/list_rooms_theme_id_not_found.json b/tests/data/meetings/list_rooms_theme_id_not_found.json deleted file mode 100644 index 410a75c4..00000000 --- a/tests/data/meetings/list_rooms_theme_id_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Failed to get rooms because theme id 90a21428-b74a-4221-adc3-783935d654dc not found", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/list_rooms_with_theme_id.json b/tests/data/meetings/list_rooms_with_theme_id.json deleted file mode 100644 index 1e791055..00000000 --- a/tests/data/meetings/list_rooms_with_theme_id.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "page_size": 5, - "_embedded": [ - { - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/updated_company_url/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0OTYzODg4fQ.46AYaDgMu_IdNPkmToKFGB_CqWYKM2xFpKU0vc3-E_E" - }, - "guest_url": { - "href": "https://meetings.vonage.com/updated_company_url/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": false, - "is_chat_available": false, - "is_whiteboard_available": false, - "is_locale_switcher_available": false - } - } - ], - "_links": { - "first": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20" - }, - "self": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009870" - }, - "prev": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2009869" - }, - "next": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009871" - } - }, - "total_items": 1 -} \ No newline at end of file diff --git a/tests/data/meetings/list_themes.json b/tests/data/meetings/list_themes.json deleted file mode 100644 index dc8b5f98..00000000 --- a/tests/data/meetings/list_themes.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "theme_id": "1fc39568-bc50-464f-82dc-01e13bed0908", - "theme_name": "my_other_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#FF0000", - "short_company_url": "my-other-company", - "brand_text": "My Other Company", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null - }, - { - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "theme_name": "my_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#12f64e", - "short_company_url": "my-company", - "brand_text": "My Company", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null - } -] \ No newline at end of file diff --git a/tests/data/meetings/logo_key_error.json b/tests/data/meetings/logo_key_error.json deleted file mode 100644 index ea9c9b18..00000000 --- a/tests/data/meetings/logo_key_error.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "message": "could not finalize logos", - "name": "BadRequestError", - "errors": [ - { - "logoKey": "not-a-key", - "code": "key_not_found" - } - ], - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/long_term_room.json b/tests/data/meetings/long_term_room.json deleted file mode 100644 index b73fcb09..00000000 --- a/tests/data/meetings/long_term_room.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0NjA3ODM3fQ.fm7q551LKnZaUcvZ30AmU62jRnvL94Do2sJKU0mHUmE" - }, - "guest_url": { - "href": "https://meetings.vonage.com/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/long_term_room_with_theme.json b/tests/data/meetings/long_term_room_with_theme.json deleted file mode 100644 index eb8ec86e..00000000 --- a/tests/data/meetings/long_term_room_with_theme.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/updated_company_url/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0Nzg2NDYwfQ.XFjcJFNZU9Ez_4x-uGIj079TTvttNHkkfA54JTDqglM" - }, - "guest_url": { - "href": "https://meetings.vonage.com/updated_company_url/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": false, - "is_chat_available": false, - "is_whiteboard_available": false, - "is_locale_switcher_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/meeting_room.json b/tests/data/meetings/meeting_room.json deleted file mode 100644 index a0b134f7..00000000 --- a/tests/data/meetings/meeting_room.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "id": "b3142c46-d1c1-4405-baa6-85683827ed69", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:30:38.629Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "412958792", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=412958792&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiM2ExNWFkZmYtNDFmYy00NWFjLTg3Y2QtZmM2YjYyYjAwMTczIiwiaWF0IjoxNjc0NTMwNDM4fQ.Q0BPbu3ZyISYf1QaW2bLVNOrZ1tjQJCQ7nsOP_0us1E" - }, - "guest_url": { - "href": "https://meetings.vonage.com/412958792" - } - }, - "created_at": "2023-01-24T03:20:38.629Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false, - "is_captions_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/multiple_fewer_rooms.json b/tests/data/meetings/multiple_fewer_rooms.json deleted file mode 100644 index 4a650eb0..00000000 --- a/tests/data/meetings/multiple_fewer_rooms.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "page_size": 2, - "_embedded": [ - { - "id": "4814804d-7c2d-4846-8c7d-4f6fae1f910a", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:25:23.341Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "697975707", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=697975707&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZDIxMzM0YmMtNjljNi00MGI2LWE4NmYtNDVjYzRlNmQ5MDVlIiwiaWF0IjoxNjc0NTcyODg1fQ.qOmyuJL1eVqUzdTlAGKZX-h5Q-dTZnoKG4Jto5AzWHs" - }, - "guest_url": { - "href": "https://meetings.vonage.com/697975707" - } - }, - "created_at": "2023-01-24T03:15:23.342Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "de34416a-2a4c-4a59-a16a-8cd7d3121ea0", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:26:46.521Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "254629696", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=254629696&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZGFhYzQ3YjEtMDZhNS00ZjA0LThjYmEtNDg1Y2VhZDdhYzYxIiwiaWF0IjoxNjc0NTcyODg1fQ.LOyItIhYtKHvhlGNmGFoE6diMH-dODckBVI0OraLB6A" - }, - "guest_url": { - "href": "https://meetings.vonage.com/254629696" - } - }, - "created_at": "2023-01-24T03:16:46.521Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - } - ], - "_links": { - "first": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20" - }, - "self": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006648" - }, - "prev": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2006647" - }, - "next": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006655" - } - }, - "total_items": 2 -} \ No newline at end of file diff --git a/tests/data/meetings/multiple_rooms.json b/tests/data/meetings/multiple_rooms.json deleted file mode 100644 index 66258c3c..00000000 --- a/tests/data/meetings/multiple_rooms.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "page_size": 20, - "_embedded": [ - { - "id": "4814804d-7c2d-4846-8c7d-4f6fae1f910a", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:25:23.341Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "697975707", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=697975707&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZDIxMzM0YmMtNjljNi00MGI2LWE4NmYtNDVjYzRlNmQ5MDVlIiwiaWF0IjoxNjc0NTcyODg1fQ.qOmyuJL1eVqUzdTlAGKZX-h5Q-dTZnoKG4Jto5AzWHs" - }, - "guest_url": { - "href": "https://meetings.vonage.com/697975707" - } - }, - "created_at": "2023-01-24T03:15:23.342Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "de34416a-2a4c-4a59-a16a-8cd7d3121ea0", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:26:46.521Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "254629696", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=254629696&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiZGFhYzQ3YjEtMDZhNS00ZjA0LThjYmEtNDg1Y2VhZDdhYzYxIiwiaWF0IjoxNjc0NTcyODg1fQ.LOyItIhYtKHvhlGNmGFoE6diMH-dODckBVI0OraLB6A" - }, - "guest_url": { - "href": "https://meetings.vonage.com/254629696" - } - }, - "created_at": "2023-01-24T03:16:46.521Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "d44529db-d1fa-48d5-bba0-43034bf91ae4", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:28:32.740Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "659359326", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=659359326&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiNjU5MjZjMTUtMzcwYi00YjlmLWI2MDMtZjZkODFlNzIxNWFkIiwiaWF0IjoxNjc0NTcyODg1fQ.TYjAbWOYdlt7UsjyQh-Y7Qr0hfWElIDrJQTNrOQuLSg" - }, - "guest_url": { - "href": "https://meetings.vonage.com/659359326" - } - }, - "created_at": "2023-01-24T03:18:32.741Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "4f7dc750-6049-42ef-a25f-e7afa4953e32", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:30:21.506Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "752928832", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=752928832&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiMTMzYTY1MTctMDdhYS00NWUxLTg0OGMtNzVhZDM0YzUwODVkIiwiaWF0IjoxNjc0NTcyODg1fQ.afMtFPyLAgZvGsR66pPj0op7sgnNjfj4BHxhU1OP8_w" - }, - "guest_url": { - "href": "https://meetings.vonage.com/752928832" - } - }, - "created_at": "2023-01-24T03:20:21.508Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - }, - { - "id": "b3142c46-d1c1-4405-baa6-85683827ed69", - "display_name": "my_test_room", - "metadata": null, - "type": "instant", - "expires_at": "2023-01-24T03:30:38.629Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "412958792", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=412958792&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiM2ExNWFkZmYtNDFmYy00NWFjLTg3Y2QtZmM2YjYyYjAwMTczIiwiaWF0IjoxNjc0NTcyODg1fQ.hCAmGR3dxnV7LkSyCXYyUXlXYr-LBfAANMjipm6PumM" - }, - "guest_url": { - "href": "https://meetings.vonage.com/412958792" - } - }, - "created_at": "2023-01-24T03:20:38.629Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": true, - "is_chat_available": true, - "is_whiteboard_available": true, - "is_locale_switcher_available": false - } - } - ], - "_links": { - "first": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20" - }, - "self": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006648" - }, - "prev": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&end_id=2006647" - }, - "next": { - "href": "api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2006655" - } - }, - "total_items": 5 -} \ No newline at end of file diff --git a/tests/data/meetings/theme.json b/tests/data/meetings/theme.json deleted file mode 100644 index 63bf8494..00000000 --- a/tests/data/meetings/theme.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "theme_name": "my_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#12f64e", - "short_company_url": "my-company", - "brand_text": "My Company", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null -} \ No newline at end of file diff --git a/tests/data/meetings/theme_name_in_use.json b/tests/data/meetings/theme_name_in_use.json deleted file mode 100644 index f374bb11..00000000 --- a/tests/data/meetings/theme_name_in_use.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "theme_name already exists in application", - "name": "ConflictError", - "status": 409 -} \ No newline at end of file diff --git a/tests/data/meetings/theme_not_found.json b/tests/data/meetings/theme_not_found.json deleted file mode 100644 index d15a28cb..00000000 --- a/tests/data/meetings/theme_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "could not find theme 90a21428-b74a-4221-adc3-783935d654dc", - "name": "NotFoundError", - "status": 404 -} \ No newline at end of file diff --git a/tests/data/meetings/transparent_logo.png b/tests/data/meetings/transparent_logo.png deleted file mode 100644 index 36f9b729a396b95eaa2b9a05fa9f2eba69ca8e09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8843 zcmcIo_dnHt^nW8O*{keqT}f8f^+~_Sezi+*Lfs6(C1+Y|~7_5sJG63ht3{2`DpCnwiWMO5@gIN$Bja`Q2C(OSR zZT8SeO0Y?`u#^A8J6+lk8NRu(aq{h?Z7FZ*^mEt{eGDU$aQg2e1fXF0L|O4WZ<-N| z(AyqE$_ksi#K-Wv|APnlM*!F&3k!`3#HpQ<)c69VfZv#eAXCrJi?|kJVJm1 zTB0F&ZVG&oF#p5?e7XwY%I+B6&eq)g_4yCTx0=rEVhrxo z3;cO6&;rb`B{GB0R{((HX!!lPyxQ}n-wQu~yDuR(_dXV#du{pht6txpJ#TwPdI?~- zH?%qFxVSh@=GQ}l3+nVLw*@9Gfd{ik_R>?-uWT-V2|r2>lRs7GDR^!CNtv7LG7Zg! zkjv|K$zRE;7t%j{=aQrTg3|M;@GaoKT=kqPd~w{_f6hL^)uyRS{g*Uv`y4C@ocBD5 zK1&rFo|k!RC(fF*4Nq(~<~QsKduN)ieZ3bo&$JRPwauG-Mx7Z#3t;1o@b?-i8~Ty> zs6Qm6$#n3MU_d=;_vSMWnsk7{Lq;m>001&OkJ8j3B*5Sc%r5|FTfS;nmc?$+O$7kj zuVQcDG$^mUXTjp>^4@c|;n_)@DYVtE2;!9)G$|HhxGulGnyqoo9IyEOQUi{R;}v7> zPlo#Ux8-S<=9uha*ZkNioURspxY`y^eM#dJ6|?ho#RNu|)bISCsn~N;FY!CWWHMqf zd@BhoCj1{i`C~}SH4*9dFoB(${wxY9cIwB^r#|sXC7mX<>E6zYtu-P0N*bj8u8fx> zsryyHaG(smeN1+d*Dyytk4j8qQR(pA<)AFwhwEU4|6J#}ULfQkLdWcw2@|C2%~-g1`MS_^DLooJFW409 zCdLe-I>O+YXCS=x+`IS30#$7cmhVM+({uXgMj0o)T?D8udw{i?h$VXq~d@`5( zCdHZK@sj?fuLbR30XOE6uX=~%3=)8)D6`Q*@bze)l&ekpEwhh-D z_qQZl6Zd*)=we%Yv~Cbge!>}iE5g7`E1<$1>z?kO|NFl|61wiUb6W}%dHSEPf2RE$ zag*$(+ReA)h${E0sH!`Y;gcPcE*t(EY#VEnn?Zj0>iOdN`-NhP*BBx;60Jb=m8sXY=sZ zHBh5tPD_$3?4uIi5U<#NHw{BWE0Qg(Lnl$yd2Fdr8>#0Imx za@|{o>qi3z20n>TW4~j2*Hab7^1KS1J0{wuPE($|f#!YGPnJvW$)3!9*iW8qn0wI>l7Obf1Upd{q_Dk#67^p#67MQsPxc3FlZnc>DRV~`6<&?PAKnMvGU;bcpZ?x zE6ZMT_jy0li{KaRX5OVqr9Oq>{isEbKP@8-qx;3^R`hh$g!k8(0hN!Dl_!Q0XwF{y zE|pDA3$a?XdG_*bdwFJ#r_K_auuX?XeuO zb!1*-yq9t=y`hvPPa$umxOGL9Rr3lptu?QIrZ1j-hx5o)dfG!gcbK)0DxcMc^H_2~ z*nnr8F*SaOYL+3&x8%rRWuTENVNMsB(9G+3ErRE5Nb|@06wxjsC$NSOn4(>AH??j%luFjtzZ!2;F|c6v!+{y^**;_)bts{1rgD zgv^!BTgWxf4s@+voGyCD{(I$fe?f?{wXF5?hw@KeGQFr(Ta4q=G`@A!XtC7Ckehku zG5U!Sw4*4c$mDHPv%Yf1?|e(tU-=oONUh@s@kTm9-O|)XrjW4oYZ>nZb`34H$A4gl zCjOhasgRYIl~-&^>zQ2-`6k&U+GOMofn?Gyg#GsJDb8v-v7L>wdm^BtY|s?P2?KoN+U*?t62n_4XWpC$yDKAlZyP zMB=u+Y0Ldk&#jTj{s;ZF*=NS{a8rvNMA-CBs%hNFn>SjJM^-YZ@~qx|pXxoEcdo3? zsQVV1h!3<$%BAqY6#W*_F}{=Njogq1KbsqLA2b%S@pzg&ZQWYeq) zUge9bY#%K=%KTM&zqBC#o|?+noawLwgVP)@dlUP153QjBHTJ+i22G9!y}Lh_((Jd| z{Py?{T&9GY?pCQ(tLR4go&AK>*!r%y9rSNlDOU&X8LlY}Z;nPEBk*GZ-wwY?*cXPi zZ+5S9Hu(t8a5W3H>>mdI-Cz&7{KsJ0K0Gb7=Cot-Y?bnuel@!O{H+__d+yK)?=;k9 zO}|M;{j}gb>tgij&k@}L-7x_jft0j-US3XGj`p4VpEG}!)|N64^+j1;gslZsF3q0J zpWGdSpMKw>IZ7VttPU&R_1~;4uTks~=;3Yq9Hz2ea$5CyDsr=Vvt_oYoi|7HRQ=p| zclg+~r}T@_f$Gx3^V3@gk9SXQ>dFs+O(vf!%+wG7!i51K<}m;qgF`XP01zYz0IN;_ zp!gC1*!}WaOq4+ZwI9qn007v8h(8iNQ^74zh}F~9Fb~b!mXWgX6d#EI)|i}>>V`@^!A(Pu25h3ERe>RQL~l+jxI)%#CyRV?>WUdC;;iFNW)W)p0fZ&yDrk^8?hSU3-k#K z9+$TZxoecWd!iY*%AGfI@#^hTjOh=bk-0NpyxZ;*+04~Nz=04ENqCx_)WRp|$i6Qj z+P!T@5odQ1+k}AY3Q9WFAe9%Ep(AyITTI)9>@}yMbMHaPU6SZ~PG5Kwygv;mW0pkc z)gTT>RB+#aGC)K~(l4!z9@~h&oPhZiMHDK8Y`t$9#f<~*LXx3rr%th~40>HWglHY* zx{W!~`cWM4*}!RwC+FUN9d|m5{oPX!0;JQ{m}Jr${)<^2sNuSUWYwNWYMpv?Nk>SO zQEkYHi#Ijwqi3tNr+Vq-_SG!I&mNn#aFAw03 z)ohde-6tIGn@guVa2cI?yoc<@1nJ(;DRrcNjh(&i{oN9(y}B#8T}W1wH(GIZMg}`3 zwnRQ>G+B`VQ~4`&EV6!*S2~y(y8rDpa~2U(a}(;}5IX(?w#2b!3@6zbOzLPaMKtB6_N`Th8>U z+^S&Ve!3{emBHMs-GXNN!>7SCqjy2?OUK6Tf7;--(!oTCFh>tYELu=tMJM%}HgU&> zt6Q@}W7+s`MU>Z}aEe3AJwC2{`nHvQUfahA%Ee`gjdaK}gs@bGYeMI^N}Yzt$CZ7^ zb^HOd{DYcjn-!Y;8?9-FYtxVy!8TmU@%su+?q5hEJoK$tnLLd=AxVF}d&Y`$u;I&7 zDX-a6~T1V#^B^Lu|oip_0VJ{y|DsbvkAfVf5&B&mZ{B_S%AY=~+0qsqvbqk=Yt z!m~rK2?+0q3v}2LTZsEXDSU8vk)G@wn-3ahLWw^dNc~L7lg2}EDx~}&)Pasz@Gs8Q z%h9*l#-x!WRc6KdJ!#1d`8(ttCJ1HjrUjJ=ol)(z_^Kr(3#+clyhGBkMHz5`b)(?< z`PHfUv~vxWjB<1^-%LwqDrjNsVn5QZ;v|9xwQQgLSM0^)Y}bs}aY=RbVxaGYP( zU|?x$;^rToFdq0onfu5?p(;_ue1wT6bC9aDhAesKCC69TLQB{|xA8dh5z8D%*Lp9# zIp}$)p`=_98kspekt0)G9 zJ3@KheFo1kIAMrR3I7d|b3+dmJ8M{bof$}iI}Oni919)Zy)SbwoFBvK=fc8Yn+#&C z8?T>F?hUBQxt++=#FH-?2yPU2RV8DnMiV+E+IOCt491s5fQ}P7d%U={Oae0<$Mnk% zNC#Q&0odt#4-XDLMVL<&CaPOALmgi~Wrf;qKm72>KdQR!lHY?pK0#_%QLnY50N4ZQ zu)|GKWvx?tyui?_!Zj)`!_$W4oB~T0?~f+^#ZCQ;)4{n02O9mr|EO;_61q@H#2#UC=1)JKxO$v(XbwdSmP zRU0`-(%)6N`As^WqZov>otT#1-5Z9bxn95di9B2xNG6?4%6D$F+s9QgtJjhk^&!`A-@o<}gp5*`UK3$8qCLDDQ(8><)7zpi2WB%> z%bD@mKB2Q)x$ceO#3Z{ymGdjJ#9JL!9nR*`dP69Jqoc#S@iKRAy*JL@`lr-eeTH&E znS^H|=aV}FK5}kr1~+JF`dXAW9)Qz(tO<@*4)12c>37SWQF~_>L~?Ujf@-rz+J`M( zMb`#Nya7|zD#fr!7X*E6%wYaZY5QBIl|%P`MaxdZqZ||vw<__N;vz~u;p`Hu-g`6A z2SpDc$s|LJ(d^rwkQIjRLcT=OQo^T7kTZH4)N5oQj20?C*d8CoL+A{5MmpEKtbuX2 zn2Vg>6ebdVqy)+2wA#auFc9X&I3`Ey4&Aicb zt3P#OIr3Afhh9C>dZ(HKW1zNnZhiux>c-m;xEl~h!PnZ^YRpKFH+CH8oG zPEF#U5*+V1DEufvw%>i{Ofs#1+PkBFNMqheWv@HbnSbVA!09t;0RW}1VVN1*Fv0XQQk}t8wFho z&*S>Q&^>_5{*p4IO2RZ2Xwy5L=@zIV>7N7_;W)RyC3pyb&lb`IiUA9@*Wb7gH3Tg`Ew;Wh#8Kdn(7vY%|V|=CYxB0|-YhYo#_7+XoW7n0aLKk}bzM=V|ksTwiJN8*b(IWeVX|`zRhiW`( zPgbn{5M2kDfg!q#TPBf#>tzN;(@cZzHjPX?Y(-i9ps=hJLtnw&++G@&Zbh39S~C~m z03J*(%;wSUtbM_`H<%Es1Ck4Fm4p#gJ^xAozDUlExq|oQkidCcxX_e^x__a(;`hzO z&Z}*vBXkl$5YM9JWWhEt&J2(Azt?0LTD`{BNCt6eS=$a7K_N-Pi;VM|E)Em@W$r>s zxre*UsCV}}I&4M@aIdhXSmZ2J5Q9;3vQyvhXVCiuub3$+-gI}+^HJJ@4Wgq>P`%+cc10s|L~NA!aFrG?w1YB3UPoB zg-;UC{} zo<0qktrxQ`=7yez?evMy(a5>o)ynK-(hM5Ww>fCXq^Pxyy*yCxmZv-n0F#U~#B^MH zgxkAs9M;Md9i?1XZa6VAyeA?a^DV{`tg)C<>`#y|BHjvig`b2+W5;18|dOpT~wXNLTD02iTj_pb8q#4hD zS}r_znM?QdXsb^NUjgDW^E864!c}`&+i%9QWZgsW~+xX?m z^8Ll(?$4ZVLl$mqy@+_3d%IC8k9JCl33qXptjV6fj7k05aY|MG^HjL*_#Ld-XJMvj z3FY0O%nG6Eda!%3S3iZi=O-INvYLboW`FvZnha)G`FA1alQ2pzEkzyIh||pmm?nho zh{s%9KX<9?yV>2)J0V6Wq_0t?6!$S}+(R9XM847+CX@V%ab5XXi+@ivEGv{|mv1|y z*I`#QS9Ef7Gf|D#A&mF1O>a!f*dah7&ulMX;w$jfJFbV3cTQ)hn!8;8j3+U0nlq6FOaka~1G5vR?&s zr;~MFIqNZY3pioXo4I`^vlnl!G#u#Y|e<3_}wJ4zj zP4|L5_66(c5P#UTIQ;! zDdx?Sn4W~r{vWmnvF-~IL}+a!*!yOU?q8V4KdJJku+NA+yG~v#>I)sV0>dWEnkO`~ zxph4mKhnVK#zV(%<}# zq}ZI>YHPnLjre?!Wa3oeVJXiL(#fpx_XxA)(vLSk5-}degPlzB#A|{?(IfyROy}Z} z>zg>~SQK}ffZG)F1>si?HF&aId*J2|^YvSkj-fp2^Ads`iJ^-W#ksNt9F_---RB=> zKJ8PDSWIdUwzza^gncPi8-r$Dn-QuDBR`s$q4@E zQkyj_P56pWDY|M@)+DgQkr6bYf4<3JQgOUnDVkMJ)}+2xY}=3^9Rbz{Wdyv@2XmJE z_nU^R!LZ;wI>G9XbcJ>LR~#~E$ErF%uKQ)KMa)5hub_j%cnQ+Yw5#@jq3V{6Axr+I zHgUECX*QTNb7ZRC|G1`eagUm<6w3+~F}oQjp7HE0-s?*Ai%1H%48ku_A>$t$lJ1GD zD!cFxwRgzmBOXUbv5n0~lRni2?*ruKcOdBtSm^#}^81j9rr05hRESWt)~Q*VM`NRy z?Zn@Zoc1fAz$7@gS904OwK1;w=iRu7>~gxvE$c_)G+Jnfo1dZ6b$dd$rp+ zG~&N4xT(J|h!zsUIc8woRWao=(CH`AO(%5C3#j`Ci-19pmN|#X$01Vu%k#8B;`Y@& zFtam5z~?#a-@y|?T z5+Oc>HC(Q`I%=i4?N7FyLV%2dJ#HNMPW3b&BuT+=_=c=W7Zbe|k=OnWCWH4$zTz-G zj@QABf3L~8Ws;k8Wwh8E5Y^xXd&4f{t_($Q9b!0Hb$v{l(X>9n`B~ zOw=p3-VsLRjxEQiTBwJDE6$%g{4VrmYTe;YJL*pxs@WG96hp|REsi67;h@eIo zM6X=esW*lJH5{o#ClZqgScyl3WW`rLl7MrE9u9&B-XwmK5trI|m`kt3a%2atMG&Rn ztwV0*3Ha%1;q=eFrBhjk;o!UAj68~%WG3+P=vuuR5Utr8@2UMEuvw!4PbybqAf}V6 z|N3+d+PjL|-XIzeI(kB^2MTS zm2;f<8^Wl}Nl@$;1tZRmCC)B)?tQcldq}oD@DcoM0Y9Bw%Y$W7efS-6=)`@E@BH^F zUMSA~Uryx#kt~q2UUy>zb=O+Z6z0)bQ9!& zavfT4QScf9?sh2U7D>BK7JR!rPY_C5d89w3?jnI}0Hq2D_+YRgQI%U{oVn)rGBELD zcAbJm92aF#%wn`9@ng!BzySkjbm?A$jo z;B(2cQM6z|ix%QbLvo=lt0C1A8@edFXKo=bi&ajcNG&?v(z(M>GeCMy4#yts=v9Bk z?bI@jF^@q&$Te-$c~s)A#%e^A$Jr!*DXUBJYN7&S*J9ht3xuIDuW7x0E|akOut=sN zf?~JLIdfAl4x@crUc}gzWqWmSgvtiXXOPKa<&{V;^!6KkQ5qLWHp#R$&TkJYeud_* z&P>tZckyKAyWItt@LDF%DbdJVEa`UQEGhtFdT{yw(=#^lQ9R{>vgF#|G-lV+1n@B* N(1YI7uGVyV^gpd}1$zJh diff --git a/tests/data/meetings/unauthorized.json b/tests/data/meetings/unauthorized.json deleted file mode 100644 index b6813760..00000000 --- a/tests/data/meetings/unauthorized.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Unauthorized", - "detail": "You did not provide correct credentials" -} \ No newline at end of file diff --git a/tests/data/meetings/update_application_theme.json b/tests/data/meetings/update_application_theme.json deleted file mode 100644 index 2fc0df4a..00000000 --- a/tests/data/meetings/update_application_theme.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "application_id": "my-application-id", - "account_id": "my-account-id", - "default_theme_id": "90a21428-b74a-4221-adc3-783935d654db" -} \ No newline at end of file diff --git a/tests/data/meetings/update_application_theme_id_not_found.json b/tests/data/meetings/update_application_theme_id_not_found.json deleted file mode 100644 index 329d8a96..00000000 --- a/tests/data/meetings/update_application_theme_id_not_found.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "Failed to update application because theme id not-a-real-theme-id not found", - "name": "BadRequestError", - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/update_no_keys.json b/tests/data/meetings/update_no_keys.json deleted file mode 100644 index 0b9fc5ce..00000000 --- a/tests/data/meetings/update_no_keys.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "\"update_details\" must have at least 1 key", - "name": "InputValidationError", - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/update_room.json b/tests/data/meetings/update_room.json deleted file mode 100644 index 64926ff5..00000000 --- a/tests/data/meetings/update_room.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "33791484-231c-421b-8349-96e1a44e27d2", - "display_name": "test_long_term_room", - "metadata": null, - "type": "long_term", - "expires_at": "2023-01-30T00:47:04.000Z", - "recording_options": { - "auto_record": false, - "record_only_owner": false - }, - "meeting_code": "613804614", - "_links": { - "host_url": { - "href": "https://meetings.vonage.com/?room_token=613804614&participant_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU5N2NmYTAzLTY3NTQtNGE0ZC1hYjU1LWZiMTdkNzc4NzRjMSJ9.eyJwYXJ0aWNpcGFudElkIjoiN2MwYTQyNWQtMGFhZS00YmUxLWE1Y2UtMDNlMTNmNmYyNThiIiwiaWF0IjoxNjc0NjA3ODM3fQ.fm7q551LKnZaUcvZ30AmU62jRnvL94Do2sJKU0mHUmE" - }, - "guest_url": { - "href": "https://meetings.vonage.com/613804614" - } - }, - "created_at": "2023-01-25T00:50:37.722Z", - "is_available": true, - "expire_after_use": false, - "theme_id": null, - "initial_join_options": { - "microphone_state": "default" - }, - "join_approval_level": "none", - "ui_settings": { - "language": "default" - }, - "available_features": { - "is_recording_available": false, - "is_chat_available": false, - "is_whiteboard_available": false, - "is_locale_switcher_available": false - } -} \ No newline at end of file diff --git a/tests/data/meetings/update_room_type_error.json b/tests/data/meetings/update_room_type_error.json deleted file mode 100644 index d70fb112..00000000 --- a/tests/data/meetings/update_room_type_error.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "The room with id: b3142c46-d1c1-4405-baa6-85683827ed69 could not be updated because of its type: temporary", - "name": "BadRequestError", - "status": 400 -} \ No newline at end of file diff --git a/tests/data/meetings/update_theme_already_exists.json b/tests/data/meetings/update_theme_already_exists.json deleted file mode 100644 index f374bb11..00000000 --- a/tests/data/meetings/update_theme_already_exists.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "message": "theme_name already exists in application", - "name": "ConflictError", - "status": 409 -} \ No newline at end of file diff --git a/tests/data/meetings/updated_theme.json b/tests/data/meetings/updated_theme.json deleted file mode 100644 index 514b7652..00000000 --- a/tests/data/meetings/updated_theme.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "theme_id": "90a21428-b74a-4221-adc3-783935d654db", - "theme_name": "updated_theme", - "domain": "VCP", - "account_id": "1234", - "application_id": "5678", - "main_color": "#FF0000", - "short_company_url": "updated_company_url", - "brand_text": "My Updated Company Name", - "brand_image_colored": null, - "brand_image_white": null, - "branded_favicon": null, - "brand_image_white_url": null, - "brand_image_colored_url": null, - "branded_favicon_url": null -} \ No newline at end of file diff --git a/tests/data/meetings/upload_to_aws_error.xml b/tests/data/meetings/upload_to_aws_error.xml deleted file mode 100644 index 467b8984..00000000 --- a/tests/data/meetings/upload_to_aws_error.xml +++ /dev/null @@ -1 +0,0 @@ -\nSignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.ASIA5NAYMMB6M7A2QEARb2f311449e26692a174ab2c7ca2afab24bd19c509cc611a4cef7cb2c5bb2ea9a5ZS7MSFN46X89NXAf+HV7uSpeawLv5lFvN+QiYP6swbiTMd/XaJeVGC+/pqKHlwlgKZ6vg+qBjV/ufb1e5WS/bxBM/Y= \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_400.json b/tests/data/proactive_connect/create_list_400.json deleted file mode 100644 index 307817dd..00000000 --- a/tests/data/proactive_connect/create_list_400.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "Request data did not validate", - "detail": "Bad Request", - "instance": "b6740287-41ad-41de-b950-f4e2d54cee86", - "errors": [ - "name must be longer than or equal to 1 and shorter than or equal to 255 characters", - "name must be a string" - ] -} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_basic.json b/tests/data/proactive_connect/create_list_basic.json deleted file mode 100644 index ea3e77c2..00000000 --- a/tests/data/proactive_connect/create_list_basic.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "6994fd17-7691-4463-be16-172ab1430d97", - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - }, - "name": "my_list", - "created_at": "2023-04-28T13:42:49.031Z", - "updated_at": "2023-04-28T13:42:49.031Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_manual.json b/tests/data/proactive_connect/create_list_manual.json deleted file mode 100644 index 1241af8e..00000000 --- a/tests/data/proactive_connect/create_list_manual.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - }, - "name": "my_list", - "description": "my description", - "tags": [ - "vip", - "sport" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "created_at": "2023-04-28T13:56:12.920Z", - "updated_at": "2023-04-28T13:56:12.920Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/create_list_salesforce.json b/tests/data/proactive_connect/create_list_salesforce.json deleted file mode 100644 index c7729f78..00000000 --- a/tests/data/proactive_connect/create_list_salesforce.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "salesforce", - "integration_id": "salesforce_credentials", - "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact" - }, - "id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "sync_status": { - "value": "configured", - "metadata_modified": true, - "data_modified": true, - "dirty": true - }, - "name": "my_salesforce_list", - "description": "my salesforce description", - "tags": [ - "vip", - "sport" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "created_at": "2023-04-28T14:16:49.375Z", - "updated_at": "2023-04-28T14:16:49.375Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/csv_to_upload.csv b/tests/data/proactive_connect/csv_to_upload.csv deleted file mode 100644 index 06cbdfbb..00000000 --- a/tests/data/proactive_connect/csv_to_upload.csv +++ /dev/null @@ -1,4 +0,0 @@ -user,phone -alice,1234 -bob,5678 -charlie,9012 diff --git a/tests/data/proactive_connect/fetch_list_400.json b/tests/data/proactive_connect/fetch_list_400.json deleted file mode 100644 index 7e68f7f6..00000000 --- a/tests/data/proactive_connect/fetch_list_400.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "Request data did not validate", - "detail": "Cannot Fetch a manual list", - "instance": "4c34affd-df25-4bdc-b7c0-30076d3df003" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/get_list.json b/tests/data/proactive_connect/get_list.json deleted file mode 100644 index 2920e6f9..00000000 --- a/tests/data/proactive_connect/get_list.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", - "created_at": "2023-04-28T13:56:12.920Z", - "updated_at": "2023-04-28T13:56:12.920Z", - "name": "my_list", - "description": "my description", - "tags": [ - "vip", - "sport" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/item.json b/tests/data/proactive_connect/item.json deleted file mode 100644 index 5c5ecf96..00000000 --- a/tests/data/proactive_connect/item.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "firstName": "John", - "lastName": "Doe", - "phone": "123456789101" - }, - "created_at": "2023-05-02T21:07:25.790Z", - "updated_at": "2023-05-02T21:07:25.790Z" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/item_400.json b/tests/data/proactive_connect/item_400.json deleted file mode 100644 index 778065b0..00000000 --- a/tests/data/proactive_connect/item_400.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "Request data did not validate", - "detail": "Bad Request", - "instance": "8e2dd3f1-1718-48fc-98de-53e1d289d0b4", - "errors": [ - "data must be an object" - ] -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_404.json b/tests/data/proactive_connect/list_404.json deleted file mode 100644 index 80ba7ad7..00000000 --- a/tests/data/proactive_connect/list_404.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "The requested resource does not exist", - "detail": "Not Found", - "instance": "3e661bd2-e429-4887-b0d4-8f37352ab1d3" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_all_items.json b/tests/data/proactive_connect/list_all_items.json deleted file mode 100644 index 28a6a795..00000000 --- a/tests/data/proactive_connect/list_all_items.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "total_items": 2, - "page": 1, - "page_size": 100, - "order": "asc", - "_embedded": { - "items": [ - { - "id": "04c7498c-bae9-40f9-bdcb-c4eabb0418fe", - "created_at": "2023-05-02T21:04:47.507Z", - "updated_at": "2023-05-02T21:04:47.507Z", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "test": 0, - "test2": 1 - } - }, - { - "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", - "created_at": "2023-05-02T21:07:25.790Z", - "updated_at": "2023-05-02T21:07:25.790Z", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "phone": "123456789101", - "lastName": "Doe", - "firstName": "John" - } - } - ] - }, - "total_pages": 1, - "_links": { - "first": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists/246d17c4-79e6-4a25-8b4e-b777a83f6c30/items?page_size=100&order=asc&page=1" - }, - "self": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists/246d17c4-79e6-4a25-8b4e-b777a83f6c30/items?page_size=100&order=asc&page=1" - } - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_events.json b/tests/data/proactive_connect/list_events.json deleted file mode 100644 index b00ef80c..00000000 --- a/tests/data/proactive_connect/list_events.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "total_items": 1, - "page": 1, - "page_size": 100, - "total_pages": 1, - "_links": { - "self": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - }, - "prev": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - }, - "next": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - }, - "first": { - "href": "https://api-eu.vonage.com/v0.1/bulk/events?page_size=100&page=1" - } - }, - "_embedded": { - "events": [ - { - "occurred_at": "2022-08-07T13:18:21.970Z", - "type": "action-call-succeeded", - "id": "e8e1eb4d-61e0-4099-8fa7-c96f1c0764ba", - "job_id": "c68e871a-c239-474d-a905-7b95f4563b7e", - "src_ctx": "et-e4ab4b75-9e7c-4f26-9328-394a5b842648", - "action_id": "26c5bbe2-113e-4201-bd93-f69e0a03d17f", - "data": { - "url": "https://postman-echo.com/post", - "args": {}, - "data": { - "from": "" - }, - "form": {}, - "json": { - "from": "" - }, - "files": {}, - "headers": { - "host": "postman-echo.com", - "user-agent": "got (https://github.com/sindresorhus/got)", - "content-type": "application/json", - "content-length": "11", - "accept-encoding": "gzip, deflate, br", - "x-amzn-trace-id": "Root=1-62efbb9e-53636b7b794accb87a3d662f", - "x-forwarded-port": "443", - "x-nexmo-trace-id": "8a6fed94-7296-4a39-9c52-348f12b4d61a", - "x-forwarded-proto": "https" - } - }, - "run_id": "7d0d4e5f-6453-4c63-87cf-f95b04377324", - "recipient_id": "14806904549" - }, - { - "occurred_at": "2022-08-07T13:18:20.289Z", - "type": "recipient-response", - "id": "8c8e9894-81be-4f6e-88d4-046b6c70ff8c", - "job_id": "c68e871a-c239-474d-a905-7b95f4563b7e", - "src_ctx": "et-e4ab4b75-9e7c-4f26-9328-394a5b842648", - "data": { - "from": "441632960411", - "text": "hello there" - }, - "run_id": "7d0d4e5f-6453-4c63-87cf-f95b04377324", - "recipient_id": "441632960758" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/list_items.csv b/tests/data/proactive_connect/list_items.csv deleted file mode 100644 index 0c167367..00000000 --- a/tests/data/proactive_connect/list_items.csv +++ /dev/null @@ -1,4 +0,0 @@ -"favourite_number","least_favourite_number" -0,1 -1,0 -0,0 diff --git a/tests/data/proactive_connect/list_lists.json b/tests/data/proactive_connect/list_lists.json deleted file mode 100644 index c734e99e..00000000 --- a/tests/data/proactive_connect/list_lists.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "page": 1, - "page_size": 100, - "total_items": 2, - "total_pages": 1, - "_links": { - "self": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - }, - "prev": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - }, - "next": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - }, - "first": { - "href": "https://api-eu.vonage.com/v0.1/bulk/lists?page_size=100&page=1" - } - }, - "_embedded": { - "lists": [ - { - "name": "Recipients for demo", - "description": "List of recipients for demo", - "tags": [ - "vip" - ], - "attributes": [ - { - "name": "firstName" - }, - { - "name": "lastName", - "key": false - }, - { - "name": "number", - "alias": "Phone", - "key": true - } - ], - "datasource": { - "type": "manual" - }, - "items_count": 1000, - "sync_status": { - "value": "configured", - "dirty": false, - "data_modified": false, - "metadata_modified": false - }, - "id": "af8a84b6-c712-4252-ac8d-6e28ac9317ce", - "created_at": "2022-06-23T13:13:16.491Z", - "updated_at": "2022-06-23T13:13:16.491Z" - }, - { - "name": "Salesforce contacts", - "description": "Salesforce contacts for campaign", - "tags": [ - "salesforce" - ], - "attributes": [ - { - "name": "Id", - "key": false - }, - { - "name": "Phone", - "key": true - }, - { - "name": "Email", - "key": false - } - ], - "datasource": { - "type": "salesforce", - "integration_id": "salesforce", - "soql": "SELECT Id, LastName, FirstName, Phone, Email, OtherCountry FROM Contact" - } - } - ] - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/not_found.json b/tests/data/proactive_connect/not_found.json deleted file mode 100644 index 02f8ec01..00000000 --- a/tests/data/proactive_connect/not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.vonage.com/en/api-errors", - "title": "The requested resource does not exist", - "detail": "Not Found", - "instance": "04730b29-c292-4899-9419-f8cad88ec288" -} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_item.json b/tests/data/proactive_connect/update_item.json deleted file mode 100644 index 7166b458..00000000 --- a/tests/data/proactive_connect/update_item.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "d91c39ed-7c34-4803-a139-34bb4b7c6d53", - "created_at": "2023-05-02T21:07:25.790Z", - "updated_at": "2023-05-03T19:50:33.207Z", - "list_id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "data": { - "first_name": "John", - "last_name": "Doe", - "phone": "447007000000" - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_list.json b/tests/data/proactive_connect/update_list.json deleted file mode 100644 index 99df4c42..00000000 --- a/tests/data/proactive_connect/update_list.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "9508e7b8-fe99-4fdf-b022-65d7e461db2d", - "created_at": "2023-04-28T13:56:12.920Z", - "updated_at": "2023-04-28T21:39:17.825Z", - "name": "my_list", - "description": "my updated description", - "tags": [ - "vip", - "sport", - "football" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "dirty": false - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/update_list_salesforce.json b/tests/data/proactive_connect/update_list_salesforce.json deleted file mode 100644 index 7e9eef48..00000000 --- a/tests/data/proactive_connect/update_list_salesforce.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items_count": 0, - "datasource": { - "type": "manual" - }, - "id": "246d17c4-79e6-4a25-8b4e-b777a83f6c30", - "created_at": "2023-04-28T14:16:49.375Z", - "updated_at": "2023-04-28T22:23:37.054Z", - "name": "my_list", - "description": "my updated description", - "tags": [ - "music" - ], - "attributes": [ - { - "key": false, - "name": "phone_number", - "alias": "phone" - } - ], - "sync_status": { - "value": "configured", - "metadata_modified": false, - "data_modified": false, - "details": "failed to get secret: salesforce_credentials", - "dirty": false - } -} \ No newline at end of file diff --git a/tests/data/proactive_connect/upload_from_csv.json b/tests/data/proactive_connect/upload_from_csv.json deleted file mode 100644 index bbdfa8fc..00000000 --- a/tests/data/proactive_connect/upload_from_csv.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "inserted": 3 -} \ No newline at end of file diff --git a/tests/data/subaccounts/balance_transfer.json b/tests/data/subaccounts/balance_transfer.json deleted file mode 100644 index 70fdbd80..00000000 --- a/tests/data/subaccounts/balance_transfer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "masterAccountId": "1234asdf", - "_links": { - "self": { - "href": "/accounts/1234asdf/balance-transfers/83c4da50-9d42-434d-aaa9-76cf3109e9a5" - } - }, - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test balance transfer", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:00.000Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/credit_transfer.json b/tests/data/subaccounts/credit_transfer.json deleted file mode 100644 index 5687a271..00000000 --- a/tests/data/subaccounts/credit_transfer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "masterAccountId": "1234asdf", - "_links": { - "self": { - "href": "/accounts/1234asdf/credit-transfers/83c4da50-9d42-434d-aaa9-76cf3109e9a5" - } - }, - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test credit transfer", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:00.000Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/forbidden.json b/tests/data/subaccounts/forbidden.json deleted file mode 100644 index 39227a21..00000000 --- a/tests/data/subaccounts/forbidden.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#unprovisioned", - "title": "Authorisation error", - "detail": "Account 1234adsf is not provisioned to access Subaccount Provisioning API", - "instance": "158b8f199c45014ab7b08bfe9cc1c12c" -} \ No newline at end of file diff --git a/tests/data/subaccounts/insufficient_credit.json b/tests/data/subaccounts/insufficient_credit.json deleted file mode 100644 index dec73938..00000000 --- a/tests/data/subaccounts/insufficient_credit.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers", - "title": "Transfer amount is invalid", - "detail": "Insufficient Credit", - "instance": "70160200-6424-4fa3-a57d-21ed8be2c0b1" -} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_credentials.json b/tests/data/subaccounts/invalid_credentials.json deleted file mode 100644 index 79ff6d15..00000000 --- a/tests/data/subaccounts/invalid_credentials.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#unauthorized", - "title": "Invalid credentials supplied", - "detail": "You did not provide correct credentials", - "instance": "798b8f199c45014ab7b08bfe9cc1c12c" -} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_number_transfer.json b/tests/data/subaccounts/invalid_number_transfer.json deleted file mode 100644 index f5c29518..00000000 --- a/tests/data/subaccounts/invalid_number_transfer.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#invalid-number-transfer", - "title": "Invalid Number Transfer", - "detail": "Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode is not owned by from account", - "instance": "632768d8-84ea-47e0-91a4-7bda1409a89f" -} \ No newline at end of file diff --git a/tests/data/subaccounts/invalid_transfer.json b/tests/data/subaccounts/invalid_transfer.json deleted file mode 100644 index 9726e28e..00000000 --- a/tests/data/subaccounts/invalid_transfer.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers", - "title": "Invalid Transfer", - "detail": "Transfers are only allowed between a primary account and its subaccount", - "instance": "85a351ee-a180-4b17-a594-fe1df12616d0" -} \ No newline at end of file diff --git a/tests/data/subaccounts/list_balance_transfers.json b/tests/data/subaccounts/list_balance_transfers.json deleted file mode 100644 index 33cadaad..00000000 --- a/tests/data/subaccounts/list_balance_transfers.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/1234asdf/balance-transfers" - } - }, - "_embedded": { - "balance_transfers": [ - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test transfer", - "id": "7380eecb-b82c-46e8-9478-af6b5793af1b", - "created_at": "2023-06-12T17:31:48.000Z" - }, - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:01.000Z" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/subaccounts/list_credit_transfers.json b/tests/data/subaccounts/list_credit_transfers.json deleted file mode 100644 index 8169cfc1..00000000 --- a/tests/data/subaccounts/list_credit_transfers.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/1234asdf/credit-transfers" - } - }, - "_embedded": { - "credit_transfers": [ - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "test credit transfer", - "id": "7380eecb-b82c-46e8-9478-af6b5793af1b", - "created_at": "2023-06-12T17:31:48.000Z" - }, - { - "from": "1234asdf", - "to": "asdfzxcv", - "amount": 0.5, - "reference": "", - "id": "83c4da50-9d42-434d-aaa9-76cf3109e9a5", - "created_at": "2023-06-12T17:20:01.000Z" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/subaccounts/list_subaccounts.json b/tests/data/subaccounts/list_subaccounts.json deleted file mode 100644 index 97836dc7..00000000 --- a/tests/data/subaccounts/list_subaccounts.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "_links": { - "self": { - "href": "/accounts/1234asdf/subaccounts" - } - }, - "total_balance": 9.9999, - "total_credit_limit": 0.0, - "_embedded": { - "primary_account": { - "api_key": "1234asdf", - "name": null, - "balance": 9.9999, - "credit_limit": 0.0, - "suspended": false, - "created_at": "2022-03-28T14:16:56.000Z" - }, - "subaccounts": [ - { - "api_key": "qwerasdf", - "primary_account_api_key": "1234asdf", - "use_primary_account_balance": true, - "name": "test_subaccount", - "balance": null, - "credit_limit": null, - "suspended": false, - "created_at": "2023-06-07T10:50:44.000Z" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/subaccounts/modified_subaccount.json b/tests/data/subaccounts/modified_subaccount.json deleted file mode 100644 index 1fcdac2f..00000000 --- a/tests/data/subaccounts/modified_subaccount.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "api_key": "asdfzxcv", - "primary_account_api_key": "1234asdf", - "use_primary_account_balance": false, - "name": "my modified subaccount", - "balance": 0, - "credit_limit": 0, - "suspended": true, - "created_at": "2023-06-09T14:42:55.000Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/must_be_number.json b/tests/data/subaccounts/must_be_number.json deleted file mode 100644 index ae356b4f..00000000 --- a/tests/data/subaccounts/must_be_number.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "b4fb726d-0a83-4f97-b4b8-30b64d1aeac7", - "invalid_parameters": [ - { - "reason": "Only positive values of data type JSON number are allowed", - "name": "amount" - } - ] -} \ No newline at end of file diff --git a/tests/data/subaccounts/not_found.json b/tests/data/subaccounts/not_found.json deleted file mode 100644 index 7eddfd67..00000000 --- a/tests/data/subaccounts/not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#invalid-api-key", - "title": "Invalid API Key", - "detail": "API key '1234asdf' does not exist, or you do not have access", - "instance": "158b8f199c45014ab7b08bfe9cc1c12c" -} \ No newline at end of file diff --git a/tests/data/subaccounts/number_not_found.json b/tests/data/subaccounts/number_not_found.json deleted file mode 100644 index 31d8c771..00000000 --- a/tests/data/subaccounts/number_not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer", - "title": "Invalid Number Transfer", - "detail": "Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found", - "instance": "37f2f76c-ade3-4f26-a448-a1adee0ff85e" -} \ No newline at end of file diff --git a/tests/data/subaccounts/same_from_and_to_accounts.json b/tests/data/subaccounts/same_from_and_to_accounts.json deleted file mode 100644 index 171a2c1c..00000000 --- a/tests/data/subaccounts/same_from_and_to_accounts.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "5499d21c-35e2-42c9-a50e-e96bdccdb34c", - "invalid_parameters": [ - { - "reason": "Invalid accounts. From and To accounts should be different", - "name": "from" - } - ] -} \ No newline at end of file diff --git a/tests/data/subaccounts/subaccount.json b/tests/data/subaccounts/subaccount.json deleted file mode 100644 index 2ead67ee..00000000 --- a/tests/data/subaccounts/subaccount.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "api_key": "asdfzxcv", - "secret": "Password123", - "primary_account_api_key": "1234asdf", - "use_primary_account_balance": true, - "name": "my subaccount", - "balance": null, - "credit_limit": null, - "suspended": false, - "created_at": "2023-06-09T02:23:21.327Z" -} \ No newline at end of file diff --git a/tests/data/subaccounts/transfer_number.json b/tests/data/subaccounts/transfer_number.json deleted file mode 100644 index de6c818f..00000000 --- a/tests/data/subaccounts/transfer_number.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "from": "1234asdf", - "to": "asdfzxcv", - "number": "12345678901", - "country": "US", - "masterAccountId": "1234asdf" -} \ No newline at end of file diff --git a/tests/data/subaccounts/transfer_validation_error.json b/tests/data/subaccounts/transfer_validation_error.json deleted file mode 100644 index 229a49df..00000000 --- a/tests/data/subaccounts/transfer_validation_error.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "c0b6fae7-7c83-4cdc-9c0b-00672e284da9", - "invalid_parameters": [ - { - "reason": "Malformed", - "name": "start_date" - } - ] -} \ No newline at end of file diff --git a/tests/data/subaccounts/validation_error.json b/tests/data/subaccounts/validation_error.json deleted file mode 100644 index a4b0aad9..00000000 --- a/tests/data/subaccounts/validation_error.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors/account/subaccounts#validation", - "title": "Bad Request", - "detail": "The request failed due to validation errors", - "instance": "fb97a734-7087-4b3a-8ec3-88d271e27fb2", - "invalid_parameters": [ - { - "reason": "Transitioning from 'use_primary_account_balance = false' to 'use_primary_account_balance = true' is not supported", - "name": "use_primary_account_balance" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/invalid_content_type.json b/tests/data/users/invalid_content_type.json deleted file mode 100644 index d818e1a2..00000000 --- a/tests/data/users/invalid_content_type.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Bad request.", - "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", - "code": "http:error:validation-fail", - "detail": "Invalid Content-Type.", - "instance": "9d0e245d-fac0-450e-811f-52343041df61", - "invalid_parameters": [ - { - "name": "content-type", - "reason": "content-type \"application/octet-stream\" is not supported. Supported versions are [application/json]" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/list_users_400.json b/tests/data/users/list_users_400.json deleted file mode 100644 index 10b68249..00000000 --- a/tests/data/users/list_users_400.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Bad request.", - "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", - "code": "http:error:validation-fail", - "detail": "Input validation failure.", - "instance": "04ee4d32-78c9-4acf-bdc1-b7d1fa860c92", - "invalid_parameters": [ - { - "name": "page_size", - "reason": "\"page_size\" must be a number" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/list_users_404.json b/tests/data/users/list_users_404.json deleted file mode 100644 index 7e985e23..00000000 --- a/tests/data/users/list_users_404.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Not found.", - "type": "https://developer.nexmo.com/api/conversation#user:error:not-found", - "code": "user:error:not-found", - "detail": "User does not exist, or you do not have access.", - "instance": "29c78817-eeb9-4de0-b2f9-a5ca816bc907" -} \ No newline at end of file diff --git a/tests/data/users/list_users_500.json b/tests/data/users/list_users_500.json deleted file mode 100644 index 25aa46c5..00000000 --- a/tests/data/users/list_users_500.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Internal Error.", - "type": "https://developer.nexmo.com/api/conversation#system:error:internal-error", - "code": "system:error:internal-error", - "detail": "Something went wrong.", - "instance": "00a5916655d650e920ccf0daf40ef4ee" -} \ No newline at end of file diff --git a/tests/data/users/list_users_basic.json b/tests/data/users/list_users_basic.json deleted file mode 100644 index ec7bf4ad..00000000 --- a/tests/data/users/list_users_basic.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "page_size": 10, - "_embedded": { - "users": [ - { - "id": "USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8", - "name": "NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8" - } - } - }, - { - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - } - }, - { - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "display_name": "My User Name", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - } - } - ] - }, - "_links": { - "first": { - "href": "https://api-us-3.vonage.com/v1/users?order=asc&page_size=10" - }, - "self": { - "href": "https://api-us-3.vonage.com/v1/users?order=asc&page_size=10&cursor=QAuYbTXFALruTxAIRAKiHvdCAqJQjTuYkDNhN9PYWcDajgUTgd9lQPo%3D" - } - } -} \ No newline at end of file diff --git a/tests/data/users/list_users_options.json b/tests/data/users/list_users_options.json deleted file mode 100644 index 3c2e74d8..00000000 --- a/tests/data/users/list_users_options.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "page_size": 2, - "_embedded": { - "users": [ - { - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "display_name": "My User Name", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - } - }, - { - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - } - } - ] - }, - "_links": { - "first": { - "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2" - }, - "self": { - "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2&cursor=Tw2iIH8ISR4SuJRJUrK9xC78rhfI10HHRKOZ20zBN9A8SDiczcOqBj8%3D" - }, - "next": { - "href": "https://api-us-3.vonage.com/v1/users?order=desc&page_size=2&cursor=FBWj1Oxid%2FVkxP6BT%2FCwMZZ2C0uOby0QXCrebkNoNo4A3PU%2FQTOOoD%2BWHib6ewsVLygsQBJy7di8HI9m30A3ujVuv1578w4Lqitgbv6CAnxdzPMeLCcAxNYWxl8%3D" - } - } -} \ No newline at end of file diff --git a/tests/data/users/rate_limit.json b/tests/data/users/rate_limit.json deleted file mode 100644 index 679dc079..00000000 --- a/tests/data/users/rate_limit.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Too Many Requests.", - "type": "https://developer.nexmo.com/api/conversation#http:error:too-many-request", - "code": "http:error:too-many-request", - "detail": "You have exceeded your request limit. You can try again shortly.", - "instance": "00a5916655d650e920ccf0daf40ef4ee" -} \ No newline at end of file diff --git a/tests/data/users/user_400.json b/tests/data/users/user_400.json deleted file mode 100644 index 6269d4f7..00000000 --- a/tests/data/users/user_400.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "Bad request.", - "type": "https://developer.nexmo.com/api/conversation#http:error:validation-fail", - "code": "http:error:validation-fail", - "detail": "Input validation failure.", - "instance": "00a5916655d650e920ccf0daf40ef4ee", - "invalid_parameters": [ - { - "name": "name", - "reason": "\"name\" must be a string" - } - ] -} \ No newline at end of file diff --git a/tests/data/users/user_404.json b/tests/data/users/user_404.json deleted file mode 100644 index cde74ea9..00000000 --- a/tests/data/users/user_404.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Not found.", - "type": "https://developer.nexmo.com/api/conversation#user:error:not-found", - "code": "user:error:not-found", - "detail": "User does not exist, or you do not have access.", - "instance": "9b3b0ea8-987a-4117-b75a-8425e04910c4" -} \ No newline at end of file diff --git a/tests/data/users/user_basic.json b/tests/data/users/user_basic.json deleted file mode 100644 index 794c3102..00000000 --- a/tests/data/users/user_basic.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1", - "properties": { - "custom_data": {} - }, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - }, - "channels": {} -} \ No newline at end of file diff --git a/tests/data/users/user_options.json b/tests/data/users/user_options.json deleted file mode 100644 index 1f1f0ce3..00000000 --- a/tests/data/users/user_options.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "image_url": "https://example.com/image.png", - "display_name": "My User Name", - "properties": { - "custom_data": { - "custom_key": "custom_value" - } - }, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - }, - "channels": { - "pstn": [ - { - "number": 123457 - } - ], - "sip": [ - { - "uri": "sip:4442138907@sip.example.com;transport=tls", - "username": "New SIP", - "password": "Password" - } - ], - "vbc": [ - { - "extension": "403" - } - ], - "websocket": [ - { - "uri": "wss://example.com/socket", - "content-type": "audio/l16;rate=16000", - "headers": { - "customer_id": "ABC123" - } - } - ], - "sms": [ - { - "number": "447700900000" - } - ], - "mms": [ - { - "number": "447700900000" - } - ], - "whatsapp": [ - { - "number": "447700900000" - } - ], - "viber": [ - { - "number": "447700900000" - } - ], - "messenger": [ - { - "id": "12345abcd" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/users/user_updated.json b/tests/data/users/user_updated.json deleted file mode 100644 index be4b884d..00000000 --- a/tests/data/users/user_updated.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5", - "name": "updated_name", - "properties": { - "custom_data": {} - }, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5" - } - }, - "channels": { - "whatsapp": [ - { - "number": "447700900000" - } - ] - } -} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_network.json b/tests/data/verify/blocked_with_network.json deleted file mode 100644 index 06c71609..00000000 --- a/tests/data/verify/blocked_with_network.json +++ /dev/null @@ -1 +0,0 @@ -{"status":"7","error_text":"The number you are trying to verify is blacklisted for verification","network":"25503"} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_network_and_request_id.json b/tests/data/verify/blocked_with_network_and_request_id.json deleted file mode 100644 index 971f276b..00000000 --- a/tests/data/verify/blocked_with_network_and_request_id.json +++ /dev/null @@ -1 +0,0 @@ -{"request_id":"12345678","status":"7","error_text":"The number you are trying to verify is blacklisted for verification","network":"25503"} \ No newline at end of file diff --git a/tests/data/verify/blocked_with_request_id.json b/tests/data/verify/blocked_with_request_id.json deleted file mode 100644 index 61c8446f..00000000 --- a/tests/data/verify/blocked_with_request_id.json +++ /dev/null @@ -1 +0,0 @@ -{"request_id":"12345678","status":"7","error_text":"The number you are trying to verify is blacklisted for verification"} \ No newline at end of file diff --git a/tests/data/verify2/already_verified.json b/tests/data/verify2/already_verified.json deleted file mode 100644 index c1a557ff..00000000 --- a/tests/data/verify2/already_verified.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#not-found", - "title": "Not Found", - "detail": "Request '5fcc26ef-1e54-48a6-83ab-c47546a19824' was not found or it has been verified already.", - "instance": "02cabfcc-2e09-4b5d-b098-1fa7ccef4607" -} \ No newline at end of file diff --git a/tests/data/verify2/check_code.json b/tests/data/verify2/check_code.json deleted file mode 100644 index 2016cb21..00000000 --- a/tests/data/verify2/check_code.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "request_id": "e043d872-459b-4750-a20c-d33f91d6959f", - "status": "completed" -} \ No newline at end of file diff --git a/tests/data/verify2/code_not_supported.json b/tests/data/verify2/code_not_supported.json deleted file mode 100644 index e690eb1e..00000000 --- a/tests/data/verify2/code_not_supported.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Conflict", - "detail": "The current Verify workflow step does not support a code.", - "instance": "690c48de-c5d1-49f2-8712-b3b0a840f911", - "type": "https://developer.nexmo.com/api-errors#conflict" -} \ No newline at end of file diff --git a/tests/data/verify2/create_request.json b/tests/data/verify2/create_request.json deleted file mode 100644 index 106dc1cc..00000000 --- a/tests/data/verify2/create_request.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "request_id": "c11236f4-00bf-4b89-84ba-88b25df97315" -} \ No newline at end of file diff --git a/tests/data/verify2/create_request_silent_auth.json b/tests/data/verify2/create_request_silent_auth.json deleted file mode 100644 index d313581a..00000000 --- a/tests/data/verify2/create_request_silent_auth.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "request_id": "b3a2f4bd-7bda-4e5e-978a-81514702d2ce", - "check_url": "https://api-eu-3.vonage.com/v2/verify/b3a2f4bd-7bda-4e5e-978a-81514702d2ce/silent-auth/redirect" -} \ No newline at end of file diff --git a/tests/data/verify2/error_conflict.json b/tests/data/verify2/error_conflict.json deleted file mode 100644 index 69eebdc7..00000000 --- a/tests/data/verify2/error_conflict.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Conflict", - "type": "https://www.developer.vonage.com/api-errors/verify#conflict", - "detail": "Concurrent verifications to the same number are not allowed.", - "instance": "738f9313-418a-4259-9b0d-6670f06fa82d", - "request_id": "575a2054-aaaf-4405-994e-290be7b9a91f" -} \ No newline at end of file diff --git a/tests/data/verify2/fraud_check_invalid_account.json b/tests/data/verify2/fraud_check_invalid_account.json deleted file mode 100644 index ee5d7053..00000000 --- a/tests/data/verify2/fraud_check_invalid_account.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#forbidden", - "title": "Forbidden", - "detail": "Your account does not have permission to perform this action.", - "instance": "1995bc0d-c850-4bf0-aa1e-6c40da43d3bf" -} \ No newline at end of file diff --git a/tests/data/verify2/invalid_code.json b/tests/data/verify2/invalid_code.json deleted file mode 100644 index 6e6d7b17..00000000 --- a/tests/data/verify2/invalid_code.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#bad-request", - "title": "Invalid Code", - "detail": "The code you provided does not match the expected value.", - "instance": "16d6bca6-c0dc-4add-94b2-0dbc12cba83b" -} \ No newline at end of file diff --git a/tests/data/verify2/invalid_email.json b/tests/data/verify2/invalid_email.json deleted file mode 100644 index 34cb0edc..00000000 --- a/tests/data/verify2/invalid_email.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "title": "Invalid params", - "detail": "The value of one or more parameters is invalid", - "instance": "e151c892-76b2-4486-8a37-b88faa70babd", - "type": "https://www.nexmo.com/messages/Errors#InvalidParams", - "invalid_parameters": [ - { - "name": "workflow[0]", - "reason": "`to` Email address is invalid" - } - ] -} \ No newline at end of file diff --git a/tests/data/verify2/invalid_sender.json b/tests/data/verify2/invalid_sender.json deleted file mode 100644 index dc3cda26..00000000 --- a/tests/data/verify2/invalid_sender.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Invalid sender", - "detail": "The `from` parameter is invalid.", - "instance": "1711258a-12e2-48ad-99a2-43fe3315409c", - "type": "https://developer.nexmo.com/api-errors#invalid-param" -} \ No newline at end of file diff --git a/tests/data/verify2/rate_limit.json b/tests/data/verify2/rate_limit.json deleted file mode 100644 index ddafeb6f..00000000 --- a/tests/data/verify2/rate_limit.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Rate Limit Hit", - "type": "https://www.developer.vonage.com/api-errors#throttled", - "detail": "Please wait, then retry your request", - "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" -} \ No newline at end of file diff --git a/tests/data/verify2/request_not_found.json b/tests/data/verify2/request_not_found.json deleted file mode 100644 index 45abf814..00000000 --- a/tests/data/verify2/request_not_found.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "https://developer.nexmo.com/api-errors#not-found", - "title": "Not Found", - "detail": "Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already.", - "instance": "a5f25ba1-c760-4966-81d4-6bdbb19f29d7" -} \ No newline at end of file diff --git a/tests/data/verify2/too_many_code_attempts.json b/tests/data/verify2/too_many_code_attempts.json deleted file mode 100644 index 50b05c09..00000000 --- a/tests/data/verify2/too_many_code_attempts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Invalid Code", - "detail": "An incorrect code has been provided too many times. Workflow terminated.", - "instance": "060246db-1c9f-4fdf-b9fa-2bd8b772f5d9", - "type": "https://developer.nexmo.com/api-errors#gone" -} \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py deleted file mode 100644 index 142bbbcf..00000000 --- a/tests/test_account.py +++ /dev/null @@ -1,222 +0,0 @@ -import platform - -from util import * - -import vonage -from vonage.errors import PricingTypeError - - -@responses.activate -def test_get_balance(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - assert isinstance(account.get_balance(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_application_info_options(dummy_data): - app_name, app_version = "ExampleApp", "X.Y.Z" - - stub(responses.GET, "https://rest.nexmo.com/account/get-balance") - - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - app_name=app_name, - app_version=app_version, - ) - user_agent = f"vonage-python/{vonage.__version__} python/{platform.python_version()} {app_name}/{app_version}" - - account = client.account - assert isinstance(account.get_balance(), dict) - assert request_user_agent() == user_agent - - -@responses.activate -def test_get_country_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-pricing/outbound/sms") - - assert isinstance(account.get_country_pricing("GB"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=GB" in request_query() - - -@responses.activate -def test_get_all_countries_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-full-pricing/outbound/sms") - - assert isinstance(account.get_all_countries_pricing(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_get_prefix_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-prefix-pricing/outbound/sms") - - assert isinstance(account.get_prefix_pricing(44), dict) - assert request_user_agent() == dummy_data.user_agent - assert "prefix=44" in request_query() - - -@responses.activate -def test_get_sms_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/sms") - - assert isinstance(account.get_sms_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -@responses.activate -def test_get_voice_pricing(account, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/get-phone-pricing/outbound/voice") - - assert isinstance(account.get_voice_pricing("447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "phone=447525856424" in request_query() - - -def test_invalid_pricing_type_throws_error(account): - with pytest.raises(PricingTypeError): - account.get_country_pricing('GB', 'not_a_valid_pricing_type') - - -@responses.activate -def test_update_default_sms_webhook(account, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/settings") - - params = {"moCallBackUrl": "http://example.com/callback"} - - assert isinstance(account.update_default_sms_webhook(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "moCallBackUrl=http%3A%2F%2Fexample.com%2Fcallback" in request_body() - - -@responses.activate -def test_topup(account, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/account/top-up") - - params = {"trx": "00X123456Y7890123Z"} - - assert isinstance(account.topup(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "trx=00X123456Y7890123Z" in request_body() - - -@responses.activate -def test_list_secrets(account): - stub( - responses.GET, - "https://api.nexmo.com/accounts/myaccountid/secrets", - fixture_path="account/secret_management/list.json", - ) - - secrets = account.list_secrets("myaccountid") - assert_basic_auth() - assert secrets["_embedded"]["secrets"][0]["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_list_secrets_missing(account): - stub( - responses.GET, - "https://api.nexmo.com/accounts/myaccountid/secrets", - status_code=404, - fixture_path="account/secret_management/missing.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - account.list_secrets("myaccountid") - assert_basic_auth() - assert ( - str(ce.value) - == """Invalid API Key: API key 'ABC123' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)""" - ) - - -@responses.activate -def test_get_secret(account): - stub( - responses.GET, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - fixture_path="account/secret_management/get.json", - ) - - secret = account.get_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret(account): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - fixture_path="account/secret_management/create.json", - ) - - secret = account.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert secret["id"] == "ad6dc56f-07b5-46e1-a527-85530e625800" - - -@responses.activate -def test_create_secret_max_secrets(account): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=403, - fixture_path="account/secret_management/max-secrets.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - account.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) - == """Maxmimum number of secrets already met: This account has reached maximum number of '2' allowed secrets (https://developer.nexmo.com/api-errors/account/secret-management#maximum-secrets-allowed)""" - ) - - -@responses.activate -def test_create_secret_validation(account): - stub( - responses.POST, - "https://api.nexmo.com/accounts/meaccountid/secrets", - status_code=400, - fixture_path="account/secret_management/create-validation.json", - ) - - with pytest.raises(vonage.ClientError) as ce: - account.create_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) - == """Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/secret-management#validation)""" - ) - - -@responses.activate -def test_delete_secret(account): - stub(responses.DELETE, "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret") - - account.revoke_secret("meaccountid", "mahsecret") - assert_basic_auth() - - -@responses.activate -def test_delete_secret_last_secret(account): - stub( - responses.DELETE, - "https://api.nexmo.com/accounts/meaccountid/secrets/mahsecret", - status_code=403, - fixture_path="account/secret_management/last-secret.json", - ) - with pytest.raises(vonage.ClientError) as ce: - account.revoke_secret("meaccountid", "mahsecret") - assert_basic_auth() - assert ( - str(ce.value) - == """Secret Deletion Forbidden: Can not delete the last secret. The account must always have at least 1 secret active at any time (https://developer.nexmo.com/api-errors/account/secret-management#delete-last-secret)""" - ) diff --git a/tests/test_application.py b/tests/test_application.py deleted file mode 100644 index 101f0513..00000000 --- a/tests/test_application.py +++ /dev/null @@ -1,276 +0,0 @@ -import json -from util import * - -import vonage - - -@responses.activate -def test_deprecated_list_applications(application_v2, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/list_applications.json", - ) - - apps = application_v2.list_applications() - assert_basic_auth() - assert isinstance(apps, dict) - assert apps["total_items"] == 30 - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_get_application(application_v2, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/get_application.json", - ) - - app = application_v2.get_application("xx-xx-xx-xx") - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_create_application(application_v2, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/create_application.json", - ) - - params = {"name": "Example App", "type": "voice"} - - app = application_v2.create_application(params) - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - body_data = json.loads(request_body().decode("utf-8")) - assert body_data["type"] == "voice" - - -@responses.activate -def test_deprecated_update_application(application_v2, dummy_data): - stub( - responses.PUT, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/update_application.json", - ) - - params = {"answer_url": "https://example.com/ncco"} - - app = application_v2.update_application("xx-xx-xx-xx", params) - assert_basic_auth() - assert isinstance(app, dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b'"answer_url": "https://example.com/ncco"' in request_body() - - assert app["name"] == "A Better Name" - - -@responses.activate -def test_deprecated_delete_application(application_v2, dummy_data): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=204, - ) - - assert application_v2.delete_application("xx-xx-xx-xx") is None - assert_basic_auth() - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_deprecated_authentication_error(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=401, - ) - with pytest.raises(vonage.AuthenticationError): - application_v2.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_deprecated_client_error(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body=json.dumps( - { - "type": "nope_error", - "title": "Nope", - "detail": "You really shouldn't have done that", - } - ), - ) - with pytest.raises(vonage.ClientError) as exc_info: - application_v2.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - - -@responses.activate -def test_deprecated_client_error_no_decode(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body="{this: isnot_json", - ) - with pytest.raises(vonage.ClientError) as exc_info: - application_v2.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "430 response from api.nexmo.com" - - -@responses.activate -def test_deprecated_server_error(application_v2): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(vonage.ServerError): - application_v2.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_list_applications(client, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/list_applications.json", - ) - - apps = client.application.list_applications() - assert_basic_auth() - assert isinstance(apps, dict) - assert apps["total_items"] == 30 - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_get_application(client, dummy_data): - stub( - responses.GET, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/get_application.json", - ) - - app = client.application.get_application("xx-xx-xx-xx") - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_create_application(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/v2/applications", - fixture_path="applications/create_application.json", - ) - - params = {"name": "Example App", "type": "voice"} - - app = client.application.create_application(params) - assert_basic_auth() - assert isinstance(app, dict) - assert app["name"] == "My Test Application" - assert request_user_agent() == dummy_data.user_agent - body_data = json.loads(request_body().decode("utf-8")) - assert body_data["type"] == "voice" - - -@responses.activate -def test_update_application(client, dummy_data): - stub( - responses.PUT, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - fixture_path="applications/update_application.json", - ) - - params = {"answer_url": "https://example.com/ncco"} - - app = client.application.update_application("xx-xx-xx-xx", params) - assert_basic_auth() - assert isinstance(app, dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b'"answer_url": "https://example.com/ncco"' in request_body() - - assert app["name"] == "A Better Name" - - -@responses.activate -def test_delete_application(client, dummy_data): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=204, - ) - - assert client.application.delete_application("xx-xx-xx-xx") is None - assert_basic_auth() - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_authentication_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=401, - ) - with pytest.raises(vonage.AuthenticationError): - client.application.delete_application("xx-xx-xx-xx") - - -@responses.activate -def test_client_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body=json.dumps( - { - "type": "nope_error", - "title": "Nope", - "detail": "You really shouldn't have done that", - } - ), - ) - with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "Nope: You really shouldn't have done that (nope_error)" - - -@responses.activate -def test_client_error_no_decode(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=430, - body="{this: isnot_json", - ) - with pytest.raises(vonage.ClientError) as exc_info: - client.application.delete_application("xx-xx-xx-xx") - assert str(exc_info.value) == "430 response from api.nexmo.com" - - -@responses.activate -def test_server_error(client): - responses.add( - responses.DELETE, - "https://api.nexmo.com/v2/applications/xx-xx-xx-xx", - status=500, - ) - with pytest.raises(vonage.ServerError): - client.application.delete_application("xx-xx-xx-xx") diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index cb73ea44..00000000 --- a/tests/test_client.py +++ /dev/null @@ -1,36 +0,0 @@ -import vonage -from util import * -from vonage.errors import InvalidAuthenticationTypeError - - -def test_client_doesnt_require_api_key(dummy_data): - client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) - assert client is not None - assert client.api_key is None - assert client.api_secret is None - - -@responses.activate -def test_client_can_make_application_requests_without_api_key(dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - client = vonage.Client(application_id="myid", private_key=dummy_data.private_key) - voice = vonage.Voice(client) - voice.create_call("123455") - - -def test_invalid_auth_type_raises_error(client): - with pytest.raises(InvalidAuthenticationTypeError): - client.get(client.host(), 'my/request/uri', auth_type='magic') - - -@responses.activate -def test_timeout_is_set_on_client_calls(dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - client = vonage.Client(application_id="myid", private_key=dummy_data.private_key, timeout=1) - voice = vonage.Voice(client) - voice.create_call("123455") - - assert len(responses.calls) == 1 - assert responses.calls[0].request.req_kwargs["timeout"] == 1 diff --git a/tests/test_getters_setters.py b/tests/test_getters_setters.py deleted file mode 100644 index d11caa3b..00000000 --- a/tests/test_getters_setters.py +++ /dev/null @@ -1,13 +0,0 @@ -def test_getters(client, dummy_data): - assert client.host() == dummy_data.host - assert client.api_host() == dummy_data.api_host - - -def test_setters(client, dummy_data): - try: - client.host('host.vonage.com') - client.api_host('host.vonage.com') - assert client.host() != dummy_data.host - assert client.api_host() != dummy_data.api_host - except: - assert False diff --git a/tests/test_jwt.py b/tests/test_jwt.py deleted file mode 100644 index fc51336d..00000000 --- a/tests/test_jwt.py +++ /dev/null @@ -1,54 +0,0 @@ -from time import time -from unittest.mock import patch -from pytest import raises - -from vonage import Client, ClientError - -now = int(time()) - - -def test_auth_sets_claims_from_kwargs(client): - client.auth(jti='asdfzxcv1234', nbf=now + 100, exp=now + 1000) - assert client._jwt_claims['jti'] == 'asdfzxcv1234' - assert client._jwt_claims['nbf'] == now + 100 - assert client._jwt_claims['exp'] == now + 1000 - - -def test_auth_sets_claims_from_dict(client): - custom_jwt_claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100, 'exp': now + 1000} - client.auth(custom_jwt_claims) - assert client._jwt_claims['jti'] == 'asdfzxcv1234' - assert client._jwt_claims['nbf'] == now + 100 - assert client._jwt_claims['exp'] == now + 1000 - - -test_jwt = b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBsaWNhdGlvbl9pZCI6ImFzZGYxMjM0IiwiaWF0IjoxNjg1NzMxMzkxLCJqdGkiOiIwYzE1MDJhZS05YmI5LTQ4YzQtYmQyZC0yOGFhNWUxYjZkMTkiLCJleHAiOjE2ODU3MzIyOTF9.mAkGeVgWOb7Mrzka7DSj32vSM8RaFpYse_2E7jCQ4DuH8i32wq9FxXGgfwdBQDHzgku3RYIjLM1xlVrGjNM3MsnZgR7ymQ6S4bdTTOmSK0dKbk91SrN7ZAC9k2a6JpCC2ZYgXpZ5BzpDTdy9BYu6msHKmkL79_aabFAhrH36Nk26pLvoI0-KiGImEex-aRR4iiaXhOebXBeqiQTRPKoKizREq4-8zBQv_j6yy4AiEYvBatQ8L_sjHsLj9jjITreX8WRvEW-G4TPpPLMaHACHTDMpJSOZAnegAkzTV2frVRmk6DyVXnemm4L0RQD1XZDaH7JPsKk24Hd2WZQyIgHOqQ' - - -def vonage_jwt_mock(self, claims): - return test_jwt - - -def test_generate_application_jwt(client): - with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): - jwt = client._generate_application_jwt() - assert jwt == test_jwt - - -def test_create_jwt_auth_string(client): - headers = client.headers - with patch('vonage.client.JwtClient.generate_application_jwt', vonage_jwt_mock): - headers['Authorization'] = client._create_jwt_auth_string() - assert headers['Accept'] == 'application/json' - assert headers['Authorization'] == b'Bearer ' + test_jwt - - -def test_create_jwt_error_no_application_id_or_private_key(): - empty_client = Client() - - with raises(ClientError) as err: - empty_client._generate_application_jwt() - assert ( - str(err.value) - == 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' - ) diff --git a/tests/test_meetings.py b/tests/test_meetings.py deleted file mode 100644 index 74182836..00000000 --- a/tests/test_meetings.py +++ /dev/null @@ -1,783 +0,0 @@ -from util import * -from vonage.errors import MeetingsError, ClientError - -import responses -import json -from pytest import raises - - -@responses.activate -def test_create_instant_room(meetings, dummy_data): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/rooms", - fixture_path='meetings/meeting_room.json', - ) - - params = {'display_name': 'my_test_room'} - meeting = meetings.create_room(params) - - assert isinstance(meeting, dict) - assert request_user_agent() == dummy_data.user_agent - assert meeting['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' - assert meeting['display_name'] == 'my_test_room' - assert meeting['expires_at'] == '2023-01-24T03:30:38.629Z' - assert meeting['join_approval_level'] == 'none' - - -def test_create_instant_room_error_expiry(meetings, dummy_data): - params = {'display_name': 'my_test_room', 'expires_at': '2023-01-24T03:30:38.629Z'} - with raises(MeetingsError) as err: - meetings.create_room(params) - assert str(err.value) == 'Cannot set "expires_at" for an instant room.' - - -@responses.activate -def test_create_long_term_room(meetings, dummy_data): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/rooms", - fixture_path='meetings/long_term_room.json', - ) - - params = { - 'display_name': 'test_long_term_room', - 'type': 'long_term', - 'expires_at': '2023-01-30T00:47:04+0000', - } - meeting = meetings.create_room(params) - - assert isinstance(meeting, dict) - assert request_user_agent() == dummy_data.user_agent - assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert meeting['display_name'] == 'test_long_term_room' - assert meeting['expires_at'] == '2023-01-30T00:47:04.000Z' - - -def test_create_room_error(meetings): - with raises(MeetingsError) as err: - meetings.create_room() - assert ( - str(err.value) - == 'You must include a value for display_name as a field in the params dict when creating a meeting room.' - ) - - -def test_create_long_term_room_error(meetings): - params = { - 'display_name': 'test_long_term_room', - 'type': 'long_term', - } - with raises(MeetingsError) as err: - meetings.create_room(params) - assert str(err.value) == 'You must set a value for "expires_at" for a long-term room.' - - -@responses.activate -def test_get_room(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', - fixture_path='meetings/meeting_room.json', - ) - meeting = meetings.get_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69') - - assert isinstance(meeting, dict) - assert meeting['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' - assert meeting['display_name'] == 'my_test_room' - assert meeting['expires_at'] == '2023-01-24T03:30:38.629Z' - assert meeting['join_approval_level'] == 'none' - assert meeting['ui_settings']['language'] == 'default' - assert meeting['available_features']['is_locale_switcher_available'] == False - assert meeting['available_features']['is_captions_available'] == False - - -def test_get_room_error_no_room_specified(meetings): - with raises(TypeError): - meetings.get_room() - - -@responses.activate -def test_list_rooms(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms', - fixture_path='meetings/multiple_rooms.json', - ) - response = meetings.list_rooms() - - assert isinstance(response, dict) - assert response['_embedded'][0]['id'] == '4814804d-7c2d-4846-8c7d-4f6fae1f910a' - assert response['_embedded'][1]['id'] == 'de34416a-2a4c-4a59-a16a-8cd7d3121ea0' - assert response['_embedded'][2]['id'] == 'd44529db-d1fa-48d5-bba0-43034bf91ae4' - assert response['_embedded'][3]['id'] == '4f7dc750-6049-42ef-a25f-e7afa4953e32' - assert response['_embedded'][4]['id'] == 'b3142c46-d1c1-4405-baa6-85683827ed69' - assert response['total_items'] == 5 - - -@responses.activate -def test_list_rooms_with_page_size(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms', - fixture_path='meetings/multiple_fewer_rooms.json', - ) - response = meetings.list_rooms(page_size=2) - - assert isinstance(response, dict) - assert response['_embedded'][0]['id'] == '4814804d-7c2d-4846-8c7d-4f6fae1f910a' - assert response['_embedded'][1]['id'] == 'de34416a-2a4c-4a59-a16a-8cd7d3121ea0' - assert response['page_size'] == 2 - assert response['total_items'] == 2 - - -@responses.activate -def test_error_unauthorized(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/rooms', - fixture_path='meetings/unauthorized.json', - status_code=401, - ) - with raises(ClientError) as err: - meetings.list_rooms() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_update_room(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', - fixture_path='meetings/update_room.json', - ) - - params = { - 'update_details': { - "available_features": { - "is_recording_available": False, - "is_chat_available": False, - "is_whiteboard_available": False, - } - } - } - meeting = meetings.update_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69', params=params) - - assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert meeting['available_features']['is_recording_available'] == False - assert meeting['available_features']['is_chat_available'] == False - assert meeting['available_features']['is_whiteboard_available'] == False - - -@responses.activate -def test_add_theme_to_room(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', - fixture_path='meetings/long_term_room_with_theme.json', - ) - - meeting = meetings.add_theme_to_room( - room_id='33791484-231c-421b-8349-96e1a44e27d2', - theme_id='90a21428-b74a-4221-adc3-783935d654db', - ) - - assert meeting['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert meeting['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - - -@responses.activate -def test_update_room_error_no_room_specified(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/b3142c46-d1c1-4405-baa6-85683827ed69', - fixture_path='meetings/update_room_type_error.json', - status_code=400, - ) - with raises(ClientError) as err: - meetings.update_room(room_id='b3142c46-d1c1-4405-baa6-85683827ed69', params={}) - assert ( - str(err.value) - == 'Status Code 400: BadRequestError: The room with id: b3142c46-d1c1-4405-baa6-85683827ed69 could not be updated because of its type: temporary' - ) - - -@responses.activate -def test_update_room_error_no_params_specified(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/rooms/33791484-231c-421b-8349-96e1a44e27d2', - fixture_path='meetings/update_room_type_error.json', - status_code=400, - ) - with raises(TypeError) as err: - meetings.update_room(room_id='33791484-231c-421b-8349-96e1a44e27d2') - assert "update_room() missing 1 required positional argument: 'params'" in str(err.value) - - -@responses.activate -def test_get_recording(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', - fixture_path='meetings/get_recording.json', - ) - - recording = meetings.get_recording(recording_id='e5b73c98-c087-4ee5-b61b-0ea08204fc65') - assert ( - recording['session_id'] - == '1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4' - ) - assert recording['started_at'] == '2023-01-25T02:38:31.000Z' - assert recording['status'] == 'uploaded' - - -@responses.activate -def test_get_recording_not_found(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/recordings/not-a-real-recording-id', - fixture_path='meetings/get_recording_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.get_recording(recording_id='not-a-real-recording-id') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: Recording not-a-real-recording-id was not found' - ) - - -@responses.activate -def test_delete_recording(meetings): - stub( - responses.DELETE, - 'https://api-eu.vonage.com/v1/meetings/recordings/e5b73c98-c087-4ee5-b61b-0ea08204fc65', - fixture_path='no_content.json', - ) - - assert meetings.delete_recording(recording_id='e5b73c98-c087-4ee5-b61b-0ea08204fc65') == None - - -@responses.activate -def test_delete_recording_not_uploaded(meetings, client): - stub( - responses.DELETE, - 'https://api-eu.vonage.com/v1/meetings/recordings/881f0dbe-3d91-4fd6-aeea-0eca4209b512', - fixture_path='meetings/delete_recording_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.delete_recording(recording_id='881f0dbe-3d91-4fd6-aeea-0eca4209b512') - assert str(err.value) == 'Status Code 404: NotFoundError: Could not find recording' - - -@responses.activate -def test_get_session_recordings(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/sessions/1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4/recordings', - fixture_path='meetings/get_session_recordings.json', - ) - - session = meetings.get_session_recordings( - session_id='1_MX40NjMzOTg5Mn5-MTY3NDYxNDI4NjY5M35WM0xaVXBSc1lpT3hKWE1XQ2diM1B3cXB-fn4' - ) - assert session['_embedded']['recordings'][0]['id'] == 'e5b73c98-c087-4ee5-b61b-0ea08204fc65' - assert session['_embedded']['recordings'][0]['started_at'] == '2023-01-25T02:38:31.000Z' - assert session['_embedded']['recordings'][0]['status'] == 'uploaded' - - -@responses.activate -def test_get_session_recordings_not_found(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/sessions/not-a-real-session-id/recordings', - fixture_path='meetings/get_session_recordings_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.get_session_recordings(session_id='not-a-real-session-id') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: Failed to find session recordings by id: not-a-real-session-id' - ) - - -@responses.activate -def test_list_dial_in_numbers(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/dial-in-numbers', - fixture_path='meetings/list_dial_in_numbers.json', - ) - - numbers = meetings.list_dial_in_numbers() - assert numbers[0]['number'] == '541139862166' - assert numbers[0]['display_name'] == 'Argentina' - assert numbers[1]['number'] == '442381924626' - assert numbers[1]['locale'] == 'en-GB' - - -@responses.activate -def test_list_themes(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes', - fixture_path='meetings/list_themes.json', - ) - - themes = meetings.list_themes() - assert themes[0]['theme_id'] == '1fc39568-bc50-464f-82dc-01e13bed0908' - assert themes[0]['main_color'] == '#FF0000' - assert themes[0]['brand_text'] == 'My Other Company' - assert themes[1]['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert themes[1]['main_color'] == '#12f64e' - assert themes[1]['brand_text'] == 'My Company' - - -@responses.activate -def test_list_themes_no_themes(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes', - fixture_path='meetings/empty_themes.json', - ) - - assert meetings.list_themes() == {} - - -@responses.activate -def test_create_theme(meetings): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/themes", - fixture_path='meetings/theme.json', - ) - - params = { - 'theme_name': 'my_theme', - 'main_color': '#12f64e', - 'brand_text': 'My Company', - 'short_company_url': 'my-company', - } - - theme = meetings.create_theme(params) - assert theme['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert theme['main_color'] == '#12f64e' - assert theme['brand_text'] == 'My Company' - assert theme['domain'] == 'VCP' - - -def test_create_theme_missing_required_params(meetings): - with raises(MeetingsError) as err: - meetings.create_theme({}) - assert str(err.value) == 'Values for "main_color" and "brand_text" must be specified' - - -@responses.activate -def test_create_theme_name_already_in_use(meetings): - stub( - responses.POST, - "https://api-eu.vonage.com/v1/meetings/themes", - fixture_path='meetings/theme_name_in_use.json', - status_code=409, - ) - - params = { - 'theme_name': 'my_theme', - 'main_color': '#12f64e', - 'brand_text': 'My Company', - } - - with raises(ClientError) as err: - meetings.create_theme(params) - assert ( - str(err.value) == 'Status Code 409: ConflictError: theme_name already exists in application' - ) - - -@responses.activate -def test_get_theme(meetings): - stub( - responses.GET, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/theme.json', - ) - - theme = meetings.get_theme('90a21428-b74a-4221-adc3-783935d654db') - assert theme['main_color'] == '#12f64e' - assert theme['brand_text'] == 'My Company' - - -@responses.activate -def test_get_theme_not_found(meetings): - stub( - responses.GET, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.get_theme('90a21428-b74a-4221-adc3-783935d654dc') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) - - -@responses.activate -def test_delete_theme(meetings): - stub( - responses.DELETE, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='no_content.json', - ) - - theme = meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') - assert theme == None - - -@responses.activate -def test_delete_theme_not_found(meetings): - stub( - responses.DELETE, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654dc') - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) - - -@responses.activate -def test_delete_theme_in_use(meetings): - stub( - responses.DELETE, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/delete_theme_in_use.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings.delete_theme('90a21428-b74a-4221-adc3-783935d654db') - assert ( - str(err.value) - == 'Status Code 400: BadRequestError: could not delete theme\nError: Theme 90a21428-b74a-4221-adc3-783935d654db is used by 1 room' - ) - - -@responses.activate -def test_update_theme(meetings): - stub( - responses.PATCH, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/updated_theme.json', - ) - - params = { - 'update_details': { - 'theme_name': 'updated_theme', - 'main_color': '#FF0000', - 'brand_text': 'My Updated Company Name', - 'short_company_url': 'updated_company_url', - } - } - - theme = meetings.update_theme('90a21428-b74a-4221-adc3-783935d654db', params) - assert theme['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert theme['main_color'] == '#FF0000' - assert theme['brand_text'] == 'My Updated Company Name' - assert theme['short_company_url'] == 'updated_company_url' - - -@responses.activate -def test_update_theme_no_keys(meetings): - stub( - responses.PATCH, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db", - fixture_path='meetings/update_no_keys.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings.update_theme('90a21428-b74a-4221-adc3-783935d654db', {'update_details': {}}) - assert ( - str(err.value) - == 'Status Code 400: InputValidationError: "update_details" must have at least 1 key' - ) - - -@responses.activate -def test_update_theme_not_found(meetings): - stub( - responses.PATCH, - "https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc", - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.update_theme( - '90a21428-b74a-4221-adc3-783935d654dc', - {'update_details': {'theme_name': 'my_new_name'}}, - ) - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) - - -@responses.activate -def test_update_theme_name_already_exists(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db', - fixture_path='meetings/update_theme_already_exists.json', - status_code=409, - ) - - with raises(ClientError) as err: - meetings.update_theme( - '90a21428-b74a-4221-adc3-783935d654db', - {'update_details': {'theme_name': 'my_other_theme'}}, - ) - assert ( - str(err.value) == 'Status Code 409: ConflictError: theme_name already exists in application' - ) - - -@responses.activate -def test_list_rooms_with_options(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/rooms', - fixture_path='meetings/list_rooms_with_theme_id.json', - ) - - rooms = meetings.list_rooms_with_theme_id( - '90a21428-b74a-4221-adc3-783935d654db', - page_size=5, - start_id=0, - end_id=99999999, - ) - assert rooms['_embedded'][0]['id'] == '33791484-231c-421b-8349-96e1a44e27d2' - assert rooms['_embedded'][0]['display_name'] == 'test_long_term_room' - assert rooms['_embedded'][0]['theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - assert rooms['page_size'] == 5 - assert ( - rooms['_links']['self']['href'] - == 'api-eu.vonage.com/meetings/rooms?page_size=20&start_id=2009870' - ) - - -@responses.activate -def test_list_rooms_with_theme_id_not_found(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/rooms', - fixture_path='meetings/list_rooms_theme_id_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings.list_rooms_with_theme_id( - '90a21428-b74a-4221-adc3-783935d654dc', start_id=0, end_id=99999999 - ) - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: Failed to get rooms because theme id 90a21428-b74a-4221-adc3-783935d654dc not found' - ) - - -@responses.activate -def test_update_application_theme(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/applications', - fixture_path='meetings/update_application_theme.json', - ) - - response = meetings.update_application_theme(theme_id='90a21428-b74a-4221-adc3-783935d654db') - assert response['application_id'] == 'my-application-id' - assert response['account_id'] == 'my-account-id' - assert response['default_theme_id'] == '90a21428-b74a-4221-adc3-783935d654db' - - -@responses.activate -def test_update_application_theme_bad_request(meetings): - stub( - responses.PATCH, - 'https://api-eu.vonage.com/v1/meetings/applications', - fixture_path='meetings/update_application_theme_id_not_found.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings.update_application_theme(theme_id='not-a-real-theme-id') - assert ( - str(err.value) - == 'Status Code 400: BadRequestError: Failed to update application because theme id not-a-real-theme-id not found' - ) - - -@responses.activate -def test_upload_logo_to_theme(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/logos-upload-urls', - fixture_path='meetings/list_logo_upload_urls.json', - ) - stub( - responses.POST, - 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod', - fixture_path='no_content.json', - status_code=204, - ) - stub_bytes( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', - body=b'OK', - ) - - response = meetings.upload_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654db', - path_to_image='tests/data/meetings/transparent_logo.png', - logo_type='white', - ) - assert response == 'Logo upload to theme: 90a21428-b74a-4221-adc3-783935d654db was successful.' - - -@responses.activate -def test_get_logo_upload_url(meetings): - stub( - responses.GET, - 'https://api-eu.vonage.com/v1/meetings/themes/logos-upload-urls', - fixture_path='meetings/list_logo_upload_urls.json', - ) - - url_w = meetings._get_logo_upload_url('white') - assert url_w['url'] == 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod' - assert url_w['fields']['X-Amz-Credential'] == 'some-credential' - assert ( - url_w['fields']['key'] - == 'auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25' - ) - assert url_w['fields']['logoType'] == 'white' - url_c = meetings._get_logo_upload_url('colored') - assert ( - url_c['fields']['key'] - == 'auto-expiring-temp/logos/colored/c4e00bac-781b-4bf0-bd5f-b9ff2cbc1b6c' - ) - assert url_c['fields']['logoType'] == 'colored' - url_f = meetings._get_logo_upload_url('favicon') - assert ( - url_f['fields']['key'] - == 'auto-expiring-temp/logos/favicon/d7a81477-38f7-460c-b51f-1462b8426df5' - ) - assert url_f['fields']['logoType'] == 'favicon' - - with raises(MeetingsError) as err: - meetings._get_logo_upload_url('not-a-valid-option') - assert str(err.value) == 'Cannot find the upload URL for the specified logo type.' - - -@responses.activate -def test_upload_to_aws(meetings): - stub( - responses.POST, - 'https://s3.amazonaws.com/roomservice-whitelabel-logos-prod', - fixture_path='no_content.json', - status_code=204, - ) - - with open('tests/data/meetings/list_logo_upload_urls.json') as file: - urls = json.load(file) - params = urls[0] - meetings._upload_to_aws(params, 'tests/data/meetings/transparent_logo.png') - - -@responses.activate -def test_upload_to_aws_error(meetings): - stub( - responses.POST, - 'https://s3.amazonaws.com/not-a-valid-url', - status_code=403, - fixture_path='meetings/upload_to_aws_error.xml', - ) - - with open('tests/data/meetings/list_logo_upload_urls.json') as file: - urls = json.load(file) - - params = urls[0] - params['url'] = 'https://s3.amazonaws.com/not-a-valid-url' - with raises(MeetingsError) as err: - meetings._upload_to_aws(params, 'tests/data/meetings/transparent_logo.png') - assert ( - str(err.value) - == 'Logo upload process failed. b\'\\\\nSignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.ASIA5NAYMMB6M7A2QEARb2f311449e26692a174ab2c7ca2afab24bd19c509cc611a4cef7cb2c5bb2ea9a5ZS7MSFN46X89NXAf+HV7uSpeawLv5lFvN+QiYP6swbiTMd/XaJeVGC+/pqKHlwlgKZ6vg+qBjV/ufb1e5WS/bxBM/Y=\'' - ) - - -@responses.activate -def test_add_logo_to_theme(meetings): - stub_bytes( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654db/finalizeLogos', - body=b'OK', - ) - - response = meetings._add_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654db', - key='auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25', - ) - assert response == b'OK' - - -@responses.activate -def test_add_logo_to_theme_key_error(meetings): - stub( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', - fixture_path='meetings/logo_key_error.json', - status_code=400, - ) - - with raises(ClientError) as err: - meetings._add_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654dc', - key='an-invalid-key', - ) - assert ( - str(err.value) - == "Status Code 400: BadRequestError: could not finalize logos\nError: {'logoKey': 'not-a-key', 'code': 'key_not_found'}" - ) - - -@responses.activate -def test_add_logo_to_theme_not_found_error(meetings): - stub( - responses.PUT, - 'https://api-eu.vonage.com/v1/meetings/themes/90a21428-b74a-4221-adc3-783935d654dc/finalizeLogos', - fixture_path='meetings/theme_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - meetings._add_logo_to_theme( - theme_id='90a21428-b74a-4221-adc3-783935d654dc', - key='auto-expiring-temp/logos/white/d92b31ae-fbf1-4709-a729-c0fa75368c25', - ) - assert ( - str(err.value) - == 'Status Code 404: NotFoundError: could not find theme 90a21428-b74a-4221-adc3-783935d654dc' - ) diff --git a/tests/test_messages_send_message.py b/tests/test_messages_send_message.py deleted file mode 100644 index 242ccbc7..00000000 --- a/tests/test_messages_send_message.py +++ /dev/null @@ -1,42 +0,0 @@ -from util import * - - -@responses.activate -def test_send_sms_with_messages_api(messages, dummy_data): - stub(responses.POST, 'https://api.nexmo.com/v1/messages') - - params = { - 'channel': 'sms', - 'message_type': 'text', - 'to': '447123456789', - 'from': 'Vonage', - 'text': 'Hello from Vonage', - } - - assert isinstance(messages.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert b'"from": "Vonage"' in request_body() - assert b'"to": "447123456789"' in request_body() - assert b'"text": "Hello from Vonage"' in request_body() - - -@responses.activate -def test_send_whatsapp_image_with_messages_api(messages, dummy_data): - stub(responses.POST, 'https://api.nexmo.com/v1/messages') - - params = { - 'channel': 'whatsapp', - 'message_type': 'image', - 'to': '447123456789', - 'from': '440123456789', - 'image': {'url': 'https://example.com/image.jpg', 'caption': 'fake test image'}, - } - - assert isinstance(messages.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert b'"from": "440123456789"' in request_body() - assert b'"to": "447123456789"' in request_body() - assert ( - b'"image": {"url": "https://example.com/image.jpg", "caption": "fake test image"}' - in request_body() - ) diff --git a/tests/test_messages_validate_input.py b/tests/test_messages_validate_input.py deleted file mode 100644 index 6ee2810d..00000000 --- a/tests/test_messages_validate_input.py +++ /dev/null @@ -1,315 +0,0 @@ -from util import * -from vonage.errors import MessagesError - - -def test_invalid_send_message_params_object(messages): - with pytest.raises(MessagesError) as err: - messages.send_message('hi') - assert ( - str(err.value) == 'Parameters to the send_message method must be specified as a dictionary.' - ) - - -def test_invalid_message_channel(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'carrier_pigeon', - 'message_type': 'text', - 'to': '12345678', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert '"carrier_pigeon" is an invalid message channel.' in str(err.value) - - -def test_invalid_message_type(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'video', - 'to': '12345678', - 'from': 'vonage', - 'video': 'my_url.com', - } - ) - assert '"video" is not a valid message type for channel "sms".' in str(err.value) - - -def test_invalid_recipient_not_string(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': 12345678, - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert str(err.value) == 'Message recipient ("to=12345678") not in a valid format.' - - -def test_invalid_recipient_number(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '+441234567890', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert str(err.value) == 'Message recipient number ("to=+441234567890") not in a valid format.' - - -def test_invalid_messenger_recipient(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'messenger', - 'message_type': 'text', - 'to': '', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert str(err.value) == 'Message recipient ID ("to=") not in a valid format.' - - -def test_invalid_sender(messages): - with pytest.raises(MessagesError) as err: - messages.send_message( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 1234, - 'text': 'my important message', - } - ) - assert ( - str(err.value) - == 'Message sender ("frm=1234") set incorrectly. Set a valid name or number for the sender.' - ) - - -def test_set_client_ref(messages): - messages._check_valid_client_ref( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 'vonage', - 'text': 'my important message', - 'client_ref': 'my client reference', - } - ) - assert messages._client_ref == 'my client reference' - - -def test_invalid_client_ref(messages): - with pytest.raises(MessagesError) as err: - messages._check_valid_client_ref( - { - 'channel': 'sms', - 'message_type': 'text', - 'to': '441234567890', - 'from': 'vonage', - 'text': 'my important message', - 'client_ref': 'my client reference that is, in fact, a small, but significant amount longer than the 100 character limit imposed at this present juncture.', - } - ) - assert str(err.value) == 'client_ref can be a maximum of 100 characters.' - - -def test_whatsapp_template(messages): - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'template', - 'to': '4412345678912', - 'from': 'vonage', - 'template': {'name': 'namespace:mytemplate'}, - 'whatsapp': {'policy': 'deterministic', 'locale': 'en-GB'}, - } - ) - - -def test_set_messenger_optional_attribute(messages): - messages.validate_send_message_input( - { - 'channel': 'messenger', - 'message_type': 'text', - 'to': 'user_messenger_id', - 'from': 'vonage', - 'text': 'my important message', - 'messenger': {'category': 'response', 'tag': 'ACCOUNT_UPDATE'}, - } - ) - - -def test_set_viber_service_optional_attribute(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'text'}, - } - ) - - -def test_viber_service_video(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'video', - 'to': '44123456789', - 'from': 'vonage', - 'video': { - 'url': 'https://example.com/video.mp4', - 'caption': 'Look at this video', - 'thumb_url': 'https://example.com/thumbnail.jpg', - }, - 'viber_service': { - 'category': 'transaction', - 'duration': '120', - 'ttl': 30, - 'type': 'string', - }, - } - ) - - -def test_viber_service_file(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'file', - 'to': '44123456789', - 'from': 'vonage', - 'video': {'url': 'https://example.com/files', 'name': 'example.pdf'}, - 'viber_service': {'category': 'transaction', 'ttl': 30, 'type': 'string'}, - } - ) - - -def test_viber_service_text_action_button(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - 'viber_service': { - 'category': 'transaction', - 'ttl': 30, - 'type': 'string', - 'action': {'url': 'https://example.com/page1.html', 'text': 'Find out more'}, - }, - } - ) - - -def test_viber_service_image_action_button(messages): - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'image', - 'to': '44123456789', - 'from': 'vonage', - 'image': { - 'url': 'https://example.com/image.jpg', - 'caption': 'Check out this new promotion', - }, - 'viber_service': { - 'category': 'transaction', - 'ttl': 30, - 'type': 'string', - 'action': {'url': 'https://example.com/page1.html', 'text': 'Find out more'}, - }, - } - ) - - -def test_incomplete_input(messages): - with pytest.raises(MessagesError) as err: - messages.validate_send_message_input( - { - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '44123456789', - 'from': 'vonage', - 'text': 'my important message', - } - ) - assert ( - str(err.value) - == 'You must specify all required properties for message channel "viber_service".' - ) - - -def test_whatsapp_sticker_id(messages): - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': {'id': '13aaecab-2485-4255-a0a7-97a2be6906b9'}, - 'to': '44123456789', - 'from': 'vonage', - } - ) - - -def test_whatsapp_sticker_url(messages): - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': {'url': 'https://example.com/sticker1.webp'}, - 'to': '44123456789', - 'from': 'vonage', - } - ) - - -def test_whatsapp_sticker_invalid_input_error(messages): - with pytest.raises(MessagesError) as err: - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': {'my_sticker'}, - 'to': '44123456789', - 'from': 'vonage', - } - ) - assert ( - str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' - ) - - -def test_whatsapp_sticker_exclusive_keys_error(messages): - with pytest.raises(MessagesError) as err: - messages.validate_send_message_input( - { - 'channel': 'whatsapp', - 'message_type': 'sticker', - 'sticker': { - 'id': '13aaecab-2485-4255-a0a7-97a2be6906b9', - 'url': 'https://example.com/sticker1.webp', - }, - 'to': '44123456789', - 'from': 'vonage', - } - ) - assert ( - str(err.value) == 'Must specify one, and only one, of "id" or "url" in the "sticker" field.' - ) diff --git a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py deleted file mode 100644 index 85a11568..00000000 --- a/tests/test_ncco_builder/ncco_samples/ncco_action_samples.py +++ /dev/null @@ -1,55 +0,0 @@ -record_full = '{"action": "record", "format": "wav", "split": "conversation", "channels": 4, "endOnSilence": 5, "endOnKey": "*", "timeOut": 100, "beepStart": true, "eventUrl": ["http://example.com"], "eventMethod": "PUT"}' - -record_url_as_str = '{"action": "record", "eventUrl": ["http://example.com/events"]}' - -record_add_split = '{"action": "record", "split": "conversation", "channels": 4}' - -conversation_basic = '{"action": "conversation", "name": "my_conversation"}' - -conversation_full = '{"action": "conversation", "name": "my_conversation", "musicOnHoldUrl": ["http://example.com/music.mp3"], "startOnEnter": true, "endOnExit": true, "record": true, "canSpeak": ["asdf", "qwer"], "canHear": ["asdf"]}' - -conversation_mute_option = '{"action": "conversation", "name": "my_conversation", "mute": true}' - -connect_phone = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000", "dtmfAnswer": "1p2p3p#**903#", "onAnswer": {"url": "https://example.com/answer", "ringbackTone": "http://example.com/ringbackTone.wav"}}]}' - -connect_app = '{"action": "connect", "endpoint": [{"type": "app", "user": "test_user"}]}' - -connect_websocket = '{"action": "connect", "endpoint": [{"type": "websocket", "uri": "ws://example.com/socket", "contentType": "audio/l16;rate=8000", "headers": {"language": "en-GB"}}]}' - -connect_sip = '{"action": "connect", "endpoint": [{"type": "sip", "uri": "sip:rebekka@sip.mcrussell.com", "headers": {"location": "New York City", "occupation": "developer"}}]}' - -connect_vbc = '{"action": "connect", "endpoint": [{"type": "vbc", "extension": "111"}]}' - -connect_full = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "randomFromNumber": false, "eventType": "synchronous", "timeout": 15, "limit": 1000, "machineDetection": "hangup", "eventUrl": ["http://example.com"], "eventMethod": "PUT", "ringbackTone": "http://example.com"}' - -connect_advancedMachineDetection = '{"action": "connect", "endpoint": [{"type": "phone", "number": "447000000000"}], "from": "447400000000", "advancedMachineDetection": {"behavior": "continue", "mode": "detect"}, "eventUrl": ["http://example.com"]}' - -talk_basic = '{"action": "talk", "text": "hello"}' - -talk_full = '{"action": "talk", "text": "hello", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}' - -stream_basic = '{"action": "stream", "streamUrl": ["https://example.com/stream/music.mp3"]}' - -stream_full = '{"action": "stream", "streamUrl": ["https://example.com/stream/music.mp3"], "level": 0.1, "bargeIn": true, "loop": 10}' - -input_basic_dtmf = '{"action": "input", "type": ["dtmf"]}' - -input_basic_dtmf_speech = '{"action": "input", "type": ["dtmf", "speech"]}' - -input_dtmf_and_speech_full = '{"action": "input", "type": ["dtmf", "speech"], "dtmf": {"timeOut": 5, "maxDigits": 12, "submitOnHash": true}, "speech": {"uuid": "my-uuid", "endOnSilence": 2.5, "language": "en-GB", "context": ["sales", "billing"], "startTimeout": 20, "maxDuration": 30, "saveAudio": true}, "eventUrl": ["http://example.com/speech"], "eventMethod": "PUT"}' - -notify_basic = ( - '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}' -) - -notify_full = '{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"], "eventMethod": "POST"}' - -pay_basic = '{"action": "pay", "amount": 10.0}' - -pay_voice_full = '{"action": "pay", "amount": 99.99, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "voice": {"language": "en-GB", "style": 1}}' - -pay_text = '{"action": "pay", "amount": 12.35, "currency": "gbp", "eventUrl": ["https://example.com/payment"], "prompts": {"type": "CardNumber", "text": "Enter your card number.", "errors": {"InvalidCardType": {"text": "The card you are trying to use is not valid for this purchase."}}}}' - -pay_text_multiple_prompts = '{"action": "pay", "amount": 12.0, "prompts": [{"type": "CardNumber", "text": "Enter your card number.", "errors": {"InvalidCardType": {"text": "The card you are trying to use is not valid for this purchase."}}}, {"type": "ExpirationDate", "text": "Enter your card expiration date.", "errors": {"InvalidExpirationDate": {"text": "You have entered an invalid expiration date."}, "Timeout": {"text": "Please enter your card\'s expiration date."}}}, {"type": "SecurityCode", "text": "Enter your 3-digit security code.", "errors": {"InvalidSecurityCode": {"text": "You have entered an invalid security code."}, "Timeout": {"text": "Please enter your card\'s security code."}}}]}' - -two_notify_ncco = '[{"action": "notify", "payload": {"message": "hello"}, "eventUrl": ["http://example.com"]}, {"action": "notify", "payload": {"message": "world"}, "eventUrl": ["http://example.com"], "eventMethod": "PUT"}]' diff --git a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py b/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py deleted file mode 100644 index 7e3c012a..00000000 --- a/tests/test_ncco_builder/ncco_samples/ncco_builder_samples.py +++ /dev/null @@ -1,183 +0,0 @@ -import pytest -from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts - -record = Ncco.Record(eventUrl='http://example.com/events') - -conversation = Ncco.Conversation(name='my_conversation') - -connect = Ncco.Connect( - endpoint=ConnectEndpoints.PhoneEndpoint(number='447000000000'), - from_='447400000000', - randomFromNumber=False, - eventType='synchronous', - timeout=15, - limit=1000, - machineDetection='hangup', - eventUrl='http://example.com', - eventMethod='PUT', - ringbackTone='http://example.com', -) - -connect_advancedMachineDetection = Ncco.Connect( - endpoint=ConnectEndpoints.PhoneEndpoint(number='447000000000'), - advancedMachineDetection={'behavior': 'continue', 'mode': 'detect'}, -) - - -talk_minimal = Ncco.Talk(text='hello') - -talk = Ncco.Talk( - text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True -) - -stream = Ncco.Stream( - streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 -) - -input = Ncco.Input( - type=['dtmf', 'speech'], - dtmf=InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True), - speech=InputTypes.Speech( - uuid='my-uuid', - endOnSilence=2.5, - language='en-GB', - context=['sales', 'billing'], - startTimeout=20, - maxDuration=30, - saveAudio=True, - ), - eventUrl='http://example.com/speech', - eventMethod='put', -) - -notify = Ncco.Notify( - payload={"message": "world"}, eventUrl=["http://example.com"], eventMethod='PUT' -) - - -def get_pay_voice_prompt(): - with pytest.deprecated_call(): - return Ncco.Pay( - amount=99.99, - currency='gbp', - eventUrl='https://example.com/payment', - voice=PayPrompts.VoicePrompt(language='en-GB', style=1), - ) - - -def get_pay_text_prompt(): - with pytest.deprecated_call(): - return Ncco.Pay( - amount=12.345, - currency='gbp', - eventUrl='https://example.com/payment', - prompts=PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ), - ) - - -basic_ncco = [{"action": "talk", "text": "hello"}] - -two_part_ncco = [ - { - 'action': 'record', - 'eventUrl': ['http://example.com/events'], - }, - {'action': 'talk', 'text': 'hello'}, -] - -three_part_advancedMachineDetection_ncco = [ - {'action': 'record', 'eventUrl': ['http://example.com/events']}, - { - 'action': 'connect', - 'endpoint': [{'type': 'phone', 'number': '447000000000'}], - 'advancedMachineDetection': {'behavior': 'continue', 'mode': 'detect'}, - }, - {'action': 'talk', 'text': 'hello'}, -] - -insane_ncco = [ - {'action': 'record', 'eventUrl': ['http://example.com/events']}, - {'action': 'conversation', 'name': 'my_conversation'}, - { - 'action': 'connect', - 'endpoint': [{'number': '447000000000', 'type': 'phone'}], - 'eventMethod': 'PUT', - 'eventType': 'synchronous', - 'eventUrl': ['http://example.com'], - 'from': '447400000000', - 'limit': 1000, - 'machineDetection': 'hangup', - 'randomFromNumber': False, - 'ringbackTone': 'http://example.com', - 'timeout': 15, - }, - { - 'action': 'talk', - 'bargeIn': True, - 'language': 'en-GB', - 'level': 0.5, - 'loop': 3, - 'premium': True, - 'style': 1, - 'text': 'hello', - }, - { - 'action': 'stream', - 'bargeIn': True, - 'level': 0.1, - 'loop': 10, - 'streamUrl': ['https://example.com/stream/music.mp3'], - }, - { - 'action': 'input', - 'dtmf': {'maxDigits': 12, 'submitOnHash': True, 'timeOut': 5}, - 'eventMethod': 'PUT', - 'eventUrl': ['http://example.com/speech'], - 'speech': { - 'context': ['sales', 'billing'], - 'endOnSilence': 2.5, - 'language': 'en-GB', - 'maxDuration': 30, - 'saveAudio': True, - 'startTimeout': 20, - 'uuid': 'my-uuid', - }, - 'type': ['dtmf', 'speech'], - }, - { - 'action': 'notify', - 'eventMethod': 'PUT', - 'eventUrl': ['http://example.com'], - 'payload': {'message': 'world'}, - }, - { - 'action': 'pay', - 'amount': 99.99, - 'currency': 'gbp', - 'eventUrl': ['https://example.com/payment'], - 'voice': {'language': 'en-GB', 'style': 1}, - }, - { - 'action': 'pay', - 'amount': 12.35, - 'currency': 'gbp', - 'eventUrl': ['https://example.com/payment'], - 'prompts': { - 'errors': { - 'InvalidCardType': { - 'text': 'The card you are trying ' 'to use is not valid for ' 'this purchase.' - } - }, - 'text': 'Enter your card number.', - 'type': 'CardNumber', - }, - }, -] diff --git a/tests/test_ncco_builder/test_connect_endpoints.py b/tests/test_ncco_builder/test_connect_endpoints.py deleted file mode 100644 index 6fa378e2..00000000 --- a/tests/test_ncco_builder/test_connect_endpoints.py +++ /dev/null @@ -1,64 +0,0 @@ -from vonage import ConnectEndpoints, Ncco -import ncco_samples.ncco_action_samples as nas - -import json -import pytest -from pydantic import ValidationError - - -def _action_as_dict(action: Ncco.Action): - return action.model_dump(exclude_none=True) - - -def test_connect_all_endpoints_from_model(): - phone = ConnectEndpoints.PhoneEndpoint( - number='447000000000', - dtmfAnswer='1p2p3p#**903#', - onAnswer={ - "url": "https://example.com/answer", - "ringbackTone": "http://example.com/ringbackTone.wav", - }, - ) - connect_phone = Ncco.Connect(endpoint=phone) - assert json.dumps(_action_as_dict(connect_phone)) == nas.connect_phone - - app = ConnectEndpoints.AppEndpoint(user='test_user') - connect_app = Ncco.Connect(endpoint=app) - assert json.dumps(_action_as_dict(connect_app)) == nas.connect_app - - websocket = ConnectEndpoints.WebsocketEndpoint( - uri='ws://example.com/socket', - contentType='audio/l16;rate=8000', - headers={"language": "en-GB"}, - ) - connect_websocket = Ncco.Connect(endpoint=websocket) - assert json.dumps(_action_as_dict(connect_websocket)) == nas.connect_websocket - - sip = ConnectEndpoints.SipEndpoint( - uri='sip:rebekka@sip.mcrussell.com', - headers={"location": "New York City", "occupation": "developer"}, - ) - connect_sip = Ncco.Connect(endpoint=sip) - assert json.dumps(_action_as_dict(connect_sip)) == nas.connect_sip - - vbc = ConnectEndpoints.VbcEndpoint(extension='111') - connect_vbc = Ncco.Connect(endpoint=vbc) - assert json.dumps(_action_as_dict(connect_vbc)) == nas.connect_vbc - - -def test_connect_endpoints_errors(): - with pytest.raises(ValidationError) as err: - ConnectEndpoints.PhoneEndpoint(number='447000000000', onAnswer={'url': 'not-a-valid-url'}) - - with pytest.raises(ValidationError) as err: - ConnectEndpoints.PhoneEndpoint( - number='447000000000', - onAnswer={'url': 'http://example.com/answer', 'ringbackTone': 'not-a-valid-url'}, - ) - - with pytest.raises(ValueError) as err: - ConnectEndpoints.create_endpoint_model_from_dict({'type': 'carrier_pigeon'}) - assert ( - str(err.value) - == 'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.' - ) diff --git a/tests/test_ncco_builder/test_input_types.py b/tests/test_ncco_builder/test_input_types.py deleted file mode 100644 index 592e0518..00000000 --- a/tests/test_ncco_builder/test_input_types.py +++ /dev/null @@ -1,47 +0,0 @@ -from vonage import InputTypes - - -def test_create_dtmf_model(): - dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=2, submitOnHash=True) - assert type(dtmf) == InputTypes.Dtmf - assert dtmf.model_dump() == {'maxDigits': 2, 'submitOnHash': True, 'timeOut': 5} - - -def test_create_dtmf_model_from_dict(): - dtmf_dict = {'timeOut': 3, 'maxDigits': 4, 'submitOnHash': True} - dtmf_model = InputTypes.create_dtmf_model(dtmf_dict) - assert type(dtmf_model) == InputTypes.Dtmf - assert dtmf_model.model_dump() == {'maxDigits': 4, 'submitOnHash': True, 'timeOut': 3} - - -def test_create_speech_model(): - speech = InputTypes.Speech( - uuid='my-uuid', - endOnSilence=2.5, - language='en-GB', - context=['sales', 'billing'], - startTimeout=20, - maxDuration=30, - saveAudio=True, - ) - assert type(speech) == InputTypes.Speech - assert speech.model_dump() == { - 'uuid': 'my-uuid', - 'endOnSilence': 2.5, - 'language': 'en-GB', - 'context': ['sales', 'billing'], - 'startTimeout': 20, - 'maxDuration': 30, - 'saveAudio': True, - } - - -def test_create_speech_model_from_dict(): - speech_dict = {'uuid': 'my-uuid', 'endOnSilence': 2.5, 'maxDuration': 30} - speech_model = InputTypes.create_speech_model(speech_dict) - assert type(speech_model) == InputTypes.Speech - assert speech_model.model_dump(exclude_none=True) == { - 'uuid': 'my-uuid', - 'endOnSilence': 2.5, - 'maxDuration': 30, - } diff --git a/tests/test_ncco_builder/test_ncco_actions.py b/tests/test_ncco_builder/test_ncco_actions.py deleted file mode 100644 index 361e8bbd..00000000 --- a/tests/test_ncco_builder/test_ncco_actions.py +++ /dev/null @@ -1,332 +0,0 @@ -from vonage import Ncco, ConnectEndpoints, InputTypes, PayPrompts -import ncco_samples.ncco_action_samples as nas - -import json -import pytest -from pydantic import ValidationError - - -def _action_as_dict(action: Ncco.Action): - return action.model_dump(exclude_none=True, by_alias=True) - - -def test_record_full(): - record = Ncco.Record( - format='wav', - split='conversation', - channels=4, - endOnSilence=5, - endOnKey='*', - timeOut=100, - beepStart=True, - eventUrl=['http://example.com'], - eventMethod='PUT', - ) - assert type(record) == Ncco.Record - assert json.dumps(_action_as_dict(record)) == nas.record_full - - -def test_record_url_passed_as_str(): - record = Ncco.Record(eventUrl='http://example.com/events') - assert json.dumps(_action_as_dict(record)) == nas.record_url_as_str - - -def test_record_channels_adds_split_parameter(): - record = Ncco.Record(channels=4) - assert json.dumps(_action_as_dict(record)) == nas.record_add_split - - -def test_record_model_errors(): - with pytest.raises(ValidationError): - Ncco.Record(format='mp4') - with pytest.raises(ValidationError): - Ncco.Record(endOnKey='asdf') - - -def test_conversation_basic(): - conversation = Ncco.Conversation(name='my_conversation') - assert type(conversation) == Ncco.Conversation - assert json.dumps(_action_as_dict(conversation)) == nas.conversation_basic - - -def test_conversation_full(): - conversation = Ncco.Conversation( - name='my_conversation', - musicOnHoldUrl='http://example.com/music.mp3', - startOnEnter=True, - endOnExit=True, - record=True, - canSpeak=['asdf', 'qwer'], - canHear=['asdf'], - ) - assert json.dumps(_action_as_dict(conversation)) == nas.conversation_full - - -def test_conversation_field_type_error(): - with pytest.raises(ValidationError): - Ncco.Conversation(name='my_conversation', startOnEnter='asdf') - - -def test_conversation_mute(): - conversation = Ncco.Conversation(name='my_conversation', mute=True) - assert json.dumps(_action_as_dict(conversation)) == nas.conversation_mute_option - - -def test_conversation_incompatible_options_error(): - with pytest.raises(ValidationError) as err: - Ncco.Conversation(name='my_conversation', canSpeak=['asdf', 'qwer'], mute=True) - str(err.value) == 'Cannot use mute option if canSpeak option is specified.+' - - -def test_connect_phone_endpoint_from_dict(): - connect = Ncco.Connect( - endpoint={ - "type": "phone", - "number": "447000000000", - "dtmfAnswer": "1p2p3p#**903#", - "onAnswer": { - "url": "https://example.com/answer", - "ringbackTone": "http://example.com/ringbackTone.wav", - }, - } - ) - assert type(connect) is Ncco.Connect - assert json.dumps(_action_as_dict(connect)) == nas.connect_phone - - -def test_connect_phone_endpoint_from_list(): - connect = Ncco.Connect( - endpoint=[ - { - "type": "phone", - "number": "447000000000", - "dtmfAnswer": "1p2p3p#**903#", - "onAnswer": { - "url": "https://example.com/answer", - "ringbackTone": "http://example.com/ringbackTone.wav", - }, - } - ] - ) - assert json.dumps(_action_as_dict(connect)) == nas.connect_phone - - -def test_connect_options(): - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - connect = Ncco.Connect( - endpoint=endpoint, - from_='447400000000', - randomFromNumber=False, - eventType='synchronous', - timeout=15, - limit=1000, - machineDetection='hangup', - eventUrl='http://example.com', - eventMethod='PUT', - ringbackTone='http://example.com', - ) - assert json.dumps(_action_as_dict(connect)) == nas.connect_full - - -def test_connect_advanced_machine_detection(): - advancedMachineDetectionParams = {'behavior': 'continue', 'mode': 'detect'} - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - connect = Ncco.Connect( - endpoint=endpoint, - from_='447400000000', - advancedMachineDetection=advancedMachineDetectionParams, - eventUrl='http://example.com', - ) - assert json.dumps(_action_as_dict(connect)) == nas.connect_advancedMachineDetection - - -def test_connect_random_from_number_error(): - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - with pytest.raises(ValueError) as err: - Ncco.Connect(endpoint=endpoint, from_='447400000000', randomFromNumber=True) - - assert ( - 'Cannot set a "from" ("from_") field and also the "randomFromNumber" = True option' - in str(err.value) - ) - - -def test_connect_validation_errors(): - endpoint = ConnectEndpoints.PhoneEndpoint(number='447000000000') - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, from_=1234) - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, eventType='asynchronous') - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, limit=7201) - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, machineDetection='do_nothing') - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, advancedMachineDetection={'behavior': 'do_nothing'}) - with pytest.raises(ValidationError): - Ncco.Connect(endpoint=endpoint, advancedMachineDetection={'mode': 'detect_nothing'}) - - -def test_talk_basic(): - talk = Ncco.Talk(text='hello') - assert type(talk) == Ncco.Talk - assert json.dumps(_action_as_dict(talk)) == nas.talk_basic - - -def test_talk_optional_params(): - talk = Ncco.Talk( - text='hello', bargeIn=True, loop=3, level=0.5, language='en-GB', style=1, premium=True - ) - assert json.dumps(_action_as_dict(talk)) == nas.talk_full - - -def test_talk_validation_error(): - with pytest.raises(ValidationError): - Ncco.Talk(text='hello', bargeIn='go ahead') - - -def test_stream_basic(): - stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3') - assert type(stream) == Ncco.Stream - assert json.dumps(_action_as_dict(stream)) == nas.stream_basic - - -def test_stream_full(): - stream = Ncco.Stream( - streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 - ) - assert json.dumps(_action_as_dict(stream)) == nas.stream_full - - -def test_input_basic(): - input = Ncco.Input(type='dtmf') - assert type(input) == Ncco.Input - assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf - - -def test_input_basic_list(): - input = Ncco.Input(type=['dtmf', 'speech']) - assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf_speech - - -def test_input_dtmf_and_speech_options(): - dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True) - speech = InputTypes.Speech( - uuid='my-uuid', - endOnSilence=2.5, - language='en-GB', - context=['sales', 'billing'], - startTimeout=20, - maxDuration=30, - saveAudio=True, - ) - input = Ncco.Input( - type=['dtmf', 'speech'], - dtmf=dtmf, - speech=speech, - eventUrl='http://example.com/speech', - eventMethod='put', - ) - assert json.dumps(_action_as_dict(input)) == nas.input_dtmf_and_speech_full - - -def test_input_validation_error(): - with pytest.raises(ValidationError): - Ncco.Input(type='invalid_type') - - -def test_notify_basic(): - notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com']) - assert type(notify) == Ncco.Notify - assert json.dumps(_action_as_dict(notify)) == nas.notify_basic - - -def test_notify_basic_str_in_event_url(): - notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl='http://example.com') - assert type(notify) == Ncco.Notify - assert json.dumps(_action_as_dict(notify)) == nas.notify_basic - - -def test_notify_full(): - notify = Ncco.Notify( - payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST' - ) - assert type(notify) == Ncco.Notify - assert json.dumps(_action_as_dict(notify)) == nas.notify_full - - -def test_notify_validation_error(): - with pytest.raises(ValidationError): - Ncco.Notify(payload={'message', 'hello'}, eventUrl=['http://example.com']) - - -def test_pay_voice_basic(): - with pytest.deprecated_call(): - pay = Ncco.Pay(amount='10.00') - assert type(pay) == Ncco.Pay - assert json.dumps(_action_as_dict(pay)) == nas.pay_basic - - -def test_pay_voice_full(): - voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) - with pytest.deprecated_call(): - pay = Ncco.Pay( - amount=99.99, currency='gbp', eventUrl='https://example.com/payment', voice=voice_settings - ) - assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full - - -def test_pay_text(): - text_prompts = PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - with pytest.deprecated_call(): - pay = Ncco.Pay( - amount=12.345, currency='gbp', eventUrl='https://example.com/payment', prompts=text_prompts - ) - assert json.dumps(_action_as_dict(pay)) == nas.pay_text - - -def test_pay_text_multiple_prompts(): - card_prompt = PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - expiration_date_prompt = PayPrompts.TextPrompt( - type='ExpirationDate', - text='Enter your card expiration date.', - errors={ - 'InvalidExpirationDate': {'text': 'You have entered an invalid expiration date.'}, - 'Timeout': {'text': 'Please enter your card\'s expiration date.'}, - }, - ) - security_code_prompt = PayPrompts.TextPrompt( - type='SecurityCode', - text='Enter your 3-digit security code.', - errors={ - 'InvalidSecurityCode': {'text': 'You have entered an invalid security code.'}, - 'Timeout': {'text': 'Please enter your card\'s security code.'}, - }, - ) - - text_prompts = [card_prompt, expiration_date_prompt, security_code_prompt] - with pytest.deprecated_call(): - pay = Ncco.Pay(amount=12, prompts=text_prompts) - assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts - - -def test_pay_validation_error(): - with pytest.raises(ValidationError): - with pytest.deprecated_call(): - Ncco.Pay(amount='not-valid') diff --git a/tests/test_ncco_builder/test_ncco_builder.py b/tests/test_ncco_builder/test_ncco_builder.py deleted file mode 100644 index f3850b5d..00000000 --- a/tests/test_ncco_builder/test_ncco_builder.py +++ /dev/null @@ -1,40 +0,0 @@ -import json - -from vonage import Ncco -import ncco_samples.ncco_builder_samples as nbs - - -def test_build_basic_ncco(): - ncco = Ncco.build_ncco(nbs.talk_minimal) - assert ncco == nbs.basic_ncco - - -def test_build_ncco_from_args(): - ncco = Ncco.build_ncco(nbs.record, nbs.talk_minimal) - assert ncco == nbs.two_part_ncco - assert ( - json.dumps(ncco) - == '[{"action": "record", "eventUrl": ["http://example.com/events"]}, {"action": "talk", "text": "hello"}]' - ) - - -def test_build_ncco_from_list(): - action_list = [nbs.record, nbs.connect_advancedMachineDetection, nbs.talk_minimal] - ncco = Ncco.build_ncco(actions=action_list) - assert ncco == nbs.three_part_advancedMachineDetection_ncco - - -def test_build_insane_ncco(): - action_list = [ - nbs.record, - nbs.conversation, - nbs.connect, - nbs.talk, - nbs.stream, - nbs.input, - nbs.notify, - nbs.get_pay_voice_prompt(), - nbs.get_pay_text_prompt(), - ] - ncco = Ncco.build_ncco(actions=action_list) - assert ncco == nbs.insane_ncco diff --git a/tests/test_ncco_builder/test_pay_prompts.py b/tests/test_ncco_builder/test_pay_prompts.py deleted file mode 100644 index abb949ce..00000000 --- a/tests/test_ncco_builder/test_pay_prompts.py +++ /dev/null @@ -1,71 +0,0 @@ -from vonage import PayPrompts - -import pytest -from pydantic import ValidationError - - -def test_create_voice_model(): - voice_prompt = PayPrompts.VoicePrompt(language='en-GB', style=1) - assert (type(voice_prompt)) == PayPrompts.VoicePrompt - - -def test_create_voice_model_from_dict(): - voice_dict = {'language': 'en-GB', 'style': 1} - voice_prompt = PayPrompts.create_voice_model(voice_dict) - assert (type(voice_prompt)) == PayPrompts.VoicePrompt - - -def test_create_text_model(): - text_prompt = PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - assert type(text_prompt) == PayPrompts.TextPrompt - - -def test_create_text_model_from_dict(): - text_dict = { - 'type': 'CardNumber', - 'text': 'Enter your card number.', - 'errors': { - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - } - text_prompt = PayPrompts.create_text_model(text_dict) - assert type(text_prompt) == PayPrompts.TextPrompt - - -def test_error_message_not_in_subdictionary(): - with pytest.raises(ValidationError): - PayPrompts.TextPrompt( - type='CardNumber', - text='Enter your card number.', - errors={ - 'InvalidCardType': 'The card you are trying to use is not valid for this purchase.' - }, - ) - - -def test_invalid_error_type_for_prompt(): - with pytest.raises(ValueError) as err: - PayPrompts.TextPrompt( - type='SecurityCode', - text='Enter your card number.', - errors={ - 'InvalidCardType': { - 'text': 'The card you are trying to use is not valid for this purchase.' - } - }, - ) - - assert ( - 'Value "InvalidCardType" is not a valid error for the "SecurityCode" prompt type.' - in str(err.value) - ) diff --git a/tests/test_number_insight.py b/tests/test_number_insight.py deleted file mode 100644 index 40ab9d19..00000000 --- a/tests/test_number_insight.py +++ /dev/null @@ -1,50 +0,0 @@ -from util import * -from vonage.errors import CallbackRequiredError - - -@responses.activate -def test_get_basic_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/basic/json") - - assert isinstance(number_insight.get_basic_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_standard_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/standard/json") - - assert isinstance(number_insight.get_standard_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_advanced_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/json") - - assert isinstance(number_insight.get_advanced_number_insight(number="447525856424"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - - -@responses.activate -def test_get_async_advanced_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") - - params = {"number": "447525856424", "callback": "https://example.com"} - - assert isinstance(number_insight.get_async_advanced_number_insight(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_query() - assert "callback=https%3A%2F%2Fexample.com" in request_query() - - -def test_callback_required_error_async_advanced_number_insight(number_insight, dummy_data): - stub(responses.GET, "https://api.nexmo.com/ni/advanced/async/json") - - params = {"number": "447525856424", "callback": ""} - - with pytest.raises(CallbackRequiredError): - number_insight.get_async_advanced_number_insight(params) diff --git a/tests/test_number_management.py b/tests/test_number_management.py deleted file mode 100644 index f774be3c..00000000 --- a/tests/test_number_management.py +++ /dev/null @@ -1,57 +0,0 @@ -from util import * - - -@responses.activate -def test_get_account_numbers(numbers, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/account/numbers") - - assert isinstance(numbers.get_account_numbers(size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_params()["size"] == ["25"] - - -@responses.activate -def test_get_available_numbers(numbers, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/number/search") - - assert isinstance(numbers.get_available_numbers("CA", size=25), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=CA" in request_query() - assert "size=25" in request_query() - - -@responses.activate -def test_buy_number(numbers, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/buy") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(numbers.buy_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - - -@responses.activate -def test_cancel_number(numbers, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/cancel") - - params = {"country": "US", "msisdn": "number"} - - assert isinstance(numbers.cancel_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - - -@responses.activate -def test_update_number(numbers, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/number/update") - - params = {"country": "US", "msisdn": "number", "moHttpUrl": "callback"} - - assert isinstance(numbers.update_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "country=US" in request_body() - assert "msisdn=number" in request_body() - assert "moHttpUrl=callback" in request_body() diff --git a/tests/test_packages.py b/tests/test_packages.py deleted file mode 100644 index 49d5dbcf..00000000 --- a/tests/test_packages.py +++ /dev/null @@ -1,14 +0,0 @@ -import os - - -def test_subdirectories_are_python_packages(): - subdirs = [ - os.path.join('src/vonage', o) - for o in os.listdir('src/vonage') - if os.path.isdir(os.path.join('src/vonage', o)) - ] - for subdir in subdirs: - if '__pycache__' in subdir or os.path.isfile(f'{subdir}/__init__.py'): - continue - else: - raise Exception(f'Subfolder {subdir} doesn\'t have an __init__.py file') diff --git a/tests/test_proactive_connect.py b/tests/test_proactive_connect.py deleted file mode 100644 index 9105d31c..00000000 --- a/tests/test_proactive_connect.py +++ /dev/null @@ -1,649 +0,0 @@ -from vonage.errors import ProactiveConnectError, ClientError -from util import * - -import responses -from pytest import raises -import csv - - -@responses.activate -def test_list_all_lists(proc, dummy_data): - stub( - responses.GET, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/list_lists.json', - ) - - lists = proc.list_all_lists() - assert request_user_agent() == dummy_data.user_agent - assert lists['total_items'] == 2 - assert lists['_embedded']['lists'][0]['name'] == 'Recipients for demo' - assert lists['_embedded']['lists'][0]['id'] == 'af8a84b6-c712-4252-ac8d-6e28ac9317ce' - assert lists['_embedded']['lists'][1]['name'] == 'Salesforce contacts' - assert lists['_embedded']['lists'][1]['datasource']['type'] == 'salesforce' - - -@responses.activate -def test_list_all_lists_options(proc): - stub( - responses.GET, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/list_lists.json', - ) - - lists = proc.list_all_lists(page=1, page_size=5) - assert lists['total_items'] == 2 - assert lists['_embedded']['lists'][0]['name'] == 'Recipients for demo' - assert lists['_embedded']['lists'][0]['id'] == 'af8a84b6-c712-4252-ac8d-6e28ac9317ce' - assert lists['_embedded']['lists'][1]['name'] == 'Salesforce contacts' - - -def test_pagination_errors(proc): - with raises(ProactiveConnectError) as err: - proc.list_all_lists(page=-1) - assert str(err.value) == '"page" must be an int > 0.' - - with raises(ProactiveConnectError) as err: - proc.list_all_lists(page_size=-1) - assert str(err.value) == '"page_size" must be an int > 0.' - - -@responses.activate -def test_create_list_basic(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_basic.json', - status_code=201, - ) - - list = proc.create_list({'name': 'my_list'}) - assert list['id'] == '6994fd17-7691-4463-be16-172ab1430d97' - assert list['name'] == 'my_list' - - -@responses.activate -def test_create_list_manual(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_manual.json', - status_code=201, - ) - - params = { - "name": "my name", - "description": "my description", - "tags": ["vip", "sport"], - "attributes": [{"name": "phone_number", "alias": "phone"}], - "datasource": {"type": "manual"}, - } - - list = proc.create_list(params) - assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - assert list['name'] == 'my_list' - assert list['description'] == 'my description' - assert list['tags'] == ['vip', 'sport'] - assert list['attributes'][0]['name'] == 'phone_number' - - -@responses.activate -def test_create_list_salesforce(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_salesforce.json', - status_code=201, - ) - - params = { - "name": "my name", - "description": "my description", - "tags": ["vip", "sport"], - "attributes": [{"name": "phone_number", "alias": "phone"}], - "datasource": { - "type": "salesforce", - "integration_id": "salesforce_credentials", - "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact", - }, - } - - list = proc.create_list(params) - assert list['id'] == '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - assert list['name'] == 'my_salesforce_list' - assert list['description'] == 'my salesforce description' - assert list['datasource']['type'] == 'salesforce' - assert list['datasource']['integration_id'] == 'salesforce_credentials' - assert list['datasource']['soql'] == 'select Id, LastName, FirstName, Phone, Email FROM Contact' - - -def test_create_list_errors(proc): - params = { - "name": "my name", - "datasource": { - "type": "salesforce", - "integration_id": 1234, - "soql": "select Id, LastName, FirstName, Phone, Email FROM Contact", - }, - } - - with raises(ProactiveConnectError) as err: - proc.create_list({}) - assert str(err.value) == 'You must supply a name for the new list.' - - with raises(ProactiveConnectError) as err: - proc.create_list(params) - assert str(err.value) == 'You must supply values for "integration_id" and "soql" as strings.' - - with raises(ProactiveConnectError) as err: - params['datasource'].pop('integration_id') - proc.create_list(params) - assert ( - str(err.value) - == 'You must supply a value for "integration_id" and "soql" when creating a list with Salesforce.' - ) - - -@responses.activate -def test_create_list_invalid_name_error(proc): - stub( - responses.POST, - 'https://api-eu.vonage.com/v0.1/bulk/lists', - fixture_path='proactive_connect/create_list_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.create_list({'name': 1234}) - assert ( - str(err.value) - == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: name must be longer than or equal to 1 and shorter than or equal to 255 characters\nError: name must be a string' - ) - - -@responses.activate -def test_get_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/get_list.json', - ) - - list = proc.get_list(list_id) - assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - assert list['name'] == 'my_list' - assert list['tags'] == ['vip', 'sport'] - - -@responses.activate -def test_get_list_404(proc): - list_id = 'a508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.get_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_update_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/update_list.json', - ) - - params = {'name': 'my_list', 'tags': ['vip', 'sport', 'football']} - list = proc.update_list(list_id, params) - assert list['id'] == '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - assert list['tags'] == ['vip', 'sport', 'football'] - assert list['description'] == 'my updated description' - assert list['updated_at'] == '2023-04-28T21:39:17.825Z' - - -@responses.activate -def test_update_list_salesforce(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/update_list_salesforce.json', - ) - - params = {'name': 'my_list', 'tags': ['music']} - list = proc.update_list(list_id, params) - assert list['id'] == list_id - assert list['tags'] == ['music'] - assert list['updated_at'] == '2023-04-28T22:23:37.054Z' - - -def test_update_list_name_error(proc): - with raises(ProactiveConnectError) as err: - proc.update_list( - '9508e7b8-fe99-4fdf-b022-65d7e461db2d', {'description': 'my new description'} - ) - assert str(err.value) == 'You must supply a name for the new list.' - - -@responses.activate -def test_delete_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='no_content.json', - status_code=204, - ) - - assert proc.delete_list(list_id) == None - - -@responses.activate -def test_delete_list_404(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - proc.delete_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_clear_list(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='no_content.json', - status_code=202, - ) - - assert proc.clear_list(list_id) == None - - -@responses.activate -def test_clear_list_404(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - proc.clear_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_sync_list_from_datasource(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', - fixture_path='no_content.json', - status_code=202, - ) - - assert proc.sync_list_from_datasource(list_id) == None - - -@responses.activate -def test_sync_list_manual_datasource_error(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/fetch', - fixture_path='proactive_connect/fetch_list_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.sync_list_from_datasource(list_id) == None - assert ( - str(err.value) - == 'Request data did not validate: Cannot Fetch a manual list (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_sync_list_from_datasource_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/clear', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - proc.clear_list(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_list_all_items(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/list_all_items.json', - ) - - items = proc.list_all_items(list_id, page=1, page_size=10) - assert items['total_items'] == 2 - assert items['_embedded']['items'][0]['id'] == '04c7498c-bae9-40f9-bdcb-c4eabb0418fe' - assert items['_embedded']['items'][1]['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - - -@responses.activate -def test_list_all_items_error_not_found(proc): - list_id = '9508e7b8-fe99-4fdf-b022-65d7e461db2d' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.list_all_items(list_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_create_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/item.json', - status_code=201, - ) - - data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} - item = proc.create_item(list_id, data) - - assert item['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - assert item['data']['phone'] == '123456789101' - - -@responses.activate -def test_create_item_error_invalid_data(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/item_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.create_item(list_id, {'data': 1234}) - assert ( - str(err.value) - == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: data must be an object' - ) - - -@responses.activate -def test_create_item_error_not_found(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} - with raises(ClientError) as err: - proc.create_item(list_id, data) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_download_list_items(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/download', - fixture_path='proactive_connect/list_items.csv', - ) - - proc.download_list_items( - list_id, os.path.join(os.path.dirname(__file__), 'data/proactive_connect/list_items.csv') - ) - items = _read_csv_file( - os.path.join(os.path.dirname(__file__), 'data/proactive_connect/list_items.csv') - ) - assert items[0]['favourite_number'] == '0' - assert items[1]['least_favourite_number'] == '0' - - -@responses.activate -def test_download_list_items_error_not_found(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/download', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.download_list_items(list_id, 'data/proactive_connect_list_items.csv') - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_get_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/item.json', - ) - - item = proc.get_item(list_id, item_id) - assert item['id'] == 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - assert item['data']['phone'] == '123456789101' - - -@responses.activate -def test_get_item_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.GET, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.get_item(list_id, item_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_update_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - data = {'first_name': 'John', 'last_name': 'Doe', 'phone': '447007000000'} - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/update_item.json', - ) - - updated_item = proc.update_item(list_id, item_id, data) - - assert updated_item['id'] == item_id - assert updated_item['data'] == data - assert updated_item['updated_at'] == '2023-05-03T19:50:33.207Z' - - -@responses.activate -def test_update_item_error_invalid_data(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - data = 'asdf' - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/item_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - proc.update_item(list_id, item_id, data) - assert ( - str(err.value) - == 'Request data did not validate: Bad Request (https://developer.vonage.com/en/api-errors)\nError: data must be an object' - ) - - -@responses.activate -def test_update_item_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - data = {'first_name': 'John', 'last_name': 'Doe', 'phone': '447007000000'} - stub( - responses.PUT, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.update_item(list_id, item_id, data) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_delete_item(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'd91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='no_content.json', - status_code=204, - ) - - response = proc.delete_item(list_id, item_id) - assert response is None - - -@responses.activate -def test_delete_item_404(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - item_id = 'e91c39ed-7c34-4803-a139-34bb4b7c6d53' - stub( - responses.DELETE, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/{item_id}', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.delete_item(list_id, item_id) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_upload_list_items_from_csv(proc): - list_id = '246d17c4-79e6-4a25-8b4e-b777a83f6c30' - file_path = os.path.join(os.path.dirname(__file__), 'data/proactive_connect/csv_to_upload.csv') - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/import', - fixture_path='proactive_connect/upload_from_csv.json', - ) - - response = proc.upload_list_items(list_id, file_path) - assert response['inserted'] == 3 - - -@responses.activate -def test_upload_list_items_from_csv_404(proc): - list_id = '346d17c4-79e6-4a25-8b4e-b777a83f6c30' - file_path = os.path.join(os.path.dirname(__file__), 'data/proactive_connect/csv_to_upload.csv') - stub( - responses.POST, - f'https://api-eu.vonage.com/v0.1/bulk/lists/{list_id}/items/import', - fixture_path='proactive_connect/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - proc.upload_list_items(list_id, file_path) - assert ( - str(err.value) - == 'The requested resource does not exist: Not Found (https://developer.vonage.com/en/api-errors)' - ) - - -@responses.activate -def test_list_events(proc): - stub( - responses.GET, - 'https://api-eu.vonage.com/v0.1/bulk/events', - fixture_path='proactive_connect/list_events.json', - ) - - lists = proc.list_events() - assert lists['total_items'] == 1 - assert lists['_embedded']['events'][0]['occurred_at'] == '2022-08-07T13:18:21.970Z' - assert lists['_embedded']['events'][0]['type'] == 'action-call-succeeded' - assert lists['_embedded']['events'][0]['run_id'] == '7d0d4e5f-6453-4c63-87cf-f95b04377324' - - -def _read_csv_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as csv_file: - reader = csv.DictReader(csv_file) - dict_list = [row for row in reader] - return dict_list diff --git a/tests/test_redact.py b/tests/test_redact.py deleted file mode 100644 index 609e5d9e..00000000 --- a/tests/test_redact.py +++ /dev/null @@ -1,36 +0,0 @@ -from util import * -from vonage.errors import RedactError - - -def test_redact_invalid_product_name(redact): - with pytest.raises(RedactError): - redact.redact_transaction(id='not-a-real-id', product='fake-product') - - -@responses.activate -def test_redact_transaction(redact, dummy_data): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/redact/transaction", - body=None, - status=204, - ) - - assert redact.redact_transaction(id="not-a-real-id", product="sms") is None - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_redact_transaction_with_type(redact, dummy_data): - responses.add( - responses.POST, - "https://api.nexmo.com/v1/redact/transaction", - body=None, - status=204, - ) - - assert redact.redact_transaction(id="some-id", product="sms", type="xyz") is None - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert b"xyz" in request_body() diff --git a/tests/test_rest_calls.py b/tests/test_rest_calls.py deleted file mode 100644 index 56fdb550..00000000 --- a/tests/test_rest_calls.py +++ /dev/null @@ -1,139 +0,0 @@ -from util import * -from vonage.errors import InvalidAuthenticationTypeError - - -@responses.activate -def test_get_with_query_params_auth(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.get(host, request_uri, params=params, auth_type='params') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_query() - assert "bbb=yyy" in request_query() - - -@responses.activate -def test_get_with_header_auth(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.get(host, request_uri, params=params, auth_type='header') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_query() - assert "bbb=yyy" in request_query() - assert_basic_auth() - - -@responses.activate -def test_post_with_query_params_auth(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params, auth_type='params', body_is_json=False) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_body() - assert "bbb=yyy" in request_body() - - -@responses.activate -def test_post_with_header_auth(client, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.post(host, request_uri, params, auth_type='header', body_is_json=False) - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "aaa=xxx" in request_body() - assert "bbb=yyy" in request_body() - assert_basic_auth() - - -@responses.activate -def test_put_with_header_auth(client, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.put(host, request_uri, params=params, auth_type='header') - assert_basic_auth() - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() - - -@responses.activate -def test_delete_with_header_auth(client, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - response = client.delete(host, request_uri, auth_type='header') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert_basic_auth() - - -@responses.activate -def test_patch(client, dummy_data): - stub(responses.PATCH, "https://api.nexmo.com/v1/applications") - host = "api.nexmo.com" - request_uri = "/v1/applications" - params = {"aaa": "xxx", "bbb": "yyy"} - response = client.patch(host, request_uri, params=params, auth_type='jwt') - assert request_headers()['Content-Type'] == 'application/json' - assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert b"aaa" in request_body() - assert b"xxx" in request_body() - assert b"bbb" in request_body() - assert b"yyy" in request_body() - - -@responses.activate -def test_patch_no_content(client, dummy_data): - stub( - responses.PATCH, - f"https://api.nexmo.com/v2/project", - status_code=204, - fixture_path='no_content.json', - ) - host = "api.nexmo.com" - request_uri = "/v2/project" - params = {"test_param_1": "test1", "test_param_2": "test2"} - client.patch(host, request_uri, params=params, auth_type='jwt') - assert request_headers()['Content-Type'] == 'application/json' - assert re.search(b'^Bearer ', request_headers()['Authorization']) is not None - assert request_user_agent() == dummy_data.user_agent - assert b"test_param_1" in request_body() - assert b"test1" in request_body() - assert b"test_param_2" in request_body() - assert b"test2" in request_body() - - -def test_patch_invalid_auth_type(client): - host = "api.nexmo.com" - request_uri = "/v2/project" - params = {"test_param_1": "test1", "test_param_2": "test2"} - with pytest.raises(InvalidAuthenticationTypeError): - client.patch(host, request_uri, params=params, auth_type='params') - - -@responses.activate -def test_get_with_jwt_auth(client, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - host = "api.nexmo.com" - request_uri = "/v1/calls" - response = client.get(host, request_uri, auth_type='jwt') - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent diff --git a/tests/test_short_codes.py b/tests/test_short_codes.py deleted file mode 100644 index 212dfcf6..00000000 --- a/tests/test_short_codes.py +++ /dev/null @@ -1,64 +0,0 @@ -from util import * - - -@responses.activate -def test_send_2fa_message(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/2fa/json") - - params = {"to": "16365553226", "pin": "1234"} - - assert isinstance(short_codes.send_2fa_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "pin=1234" in request_body() - - -@responses.activate -def test_send_event_alert_message(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/json") - - params = {"to": "16365553226", "server": "host", "link": "http://example.com/"} - - assert isinstance(short_codes.send_event_alert_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "to=16365553226" in request_body() - assert "server=host" in request_body() - assert "link=http%3A%2F%2Fexample.com%2F" in request_body() - - -@responses.activate -def test_send_marketing_message(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/marketing/json") - - params = { - "from": "short-code", - "to": "16365553226", - "keyword": "NEXMO", - "text": "Hello", - } - - assert isinstance(short_codes.send_marketing_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=short-code" in request_body() - assert "to=16365553226" in request_body() - assert "keyword=NEXMO" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_get_event_alert_numbers(short_codes, dummy_data): - stub(responses.GET, "https://rest.nexmo.com/sc/us/alert/opt-in/query/json") - - assert isinstance(short_codes.get_event_alert_numbers(), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_resubscribe_event_alert_number(short_codes, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sc/us/alert/opt-in/manage/json") - - params = {"msisdn": "441632960960"} - - assert isinstance(short_codes.resubscribe_event_alert_number(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "msisdn=441632960960" in request_body() diff --git a/tests/test_signature.py b/tests/test_signature.py deleted file mode 100644 index 8ae86468..00000000 --- a/tests/test_signature.py +++ /dev/null @@ -1,86 +0,0 @@ -import vonage -from util import * - - -def test_check_signature(dummy_data): - params = { - "a": "1", - "b": "2", - "timestamp": "1461605396", - "sig": "6af838ef94998832dbfc29020b564830", - } - - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - - assert client.check_signature(params) - - -def test_signature(client, dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - assert client.signature(params) == "6af838ef94998832dbfc29020b564830" - - -def test_signature_adds_timestamp(dummy_data): - params = {"a=7": "1", "b": "2 & 5"} - - client = vonage.Client( - key=dummy_data.api_key, secret=dummy_data.api_secret, signature_secret="secret" - ) - - client.signature(params) - assert params["timestamp"] is not None - - -def test_signature_md5(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="md5", - ) - assert client.signature(params) == "c15c21ced558c93a226c305f58f902f2" - - -def test_signature_sha1(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha1", - ) - assert client.signature(params) == "3e19a4e6880fdc2c1426bfd0587c98b9532f0210" - - -def test_signature_sha256(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha256", - ) - assert ( - client.signature(params) - == "a321e824b9b816be7c3f28859a31749a098713d39f613c80d455bbaffae1cd24" - ) - - -def test_signature_sha512(dummy_data): - params = {"a": "1", "b": "2", "timestamp": "1461605396"} - client = vonage.Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - signature_secret=dummy_data.signature_secret, - signature_method="sha512", - ) - assert ( - client.signature(params) - == "812a18f76680fa0fe1b8bd9ee1625466ceb1bd96242e4d050d2cfd9a7b40166c63ed26ec9702168781b6edcf1633db8ff95af9341701004eec3fcf9550572ee8" - ) diff --git a/tests/test_sms.py b/tests/test_sms.py deleted file mode 100644 index cdb8cb36..00000000 --- a/tests/test_sms.py +++ /dev/null @@ -1,50 +0,0 @@ -import vonage -from util import * - - -@responses.activate -def test_send_message(sms, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/sms/json") - - params = {"from": "Python", "to": "447525856424", "text": "Hey!"} - - assert isinstance(sms.send_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=Python" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hey%21" in request_body() - - -@responses.activate -def test_authentication_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=401) - - with pytest.raises(vonage.AuthenticationError): - sms.send_message({}) - - -@responses.activate -def test_client_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=400) - - with pytest.raises(vonage.ClientError) as excinfo: - sms.send_message({}) - excinfo.match(r"400 response from rest.nexmo.com") - - -@responses.activate -def test_server_error(sms): - responses.add(responses.POST, "https://rest.nexmo.com/sms/json", status=500) - - with pytest.raises(vonage.ServerError) as excinfo: - sms.send_message({}) - excinfo.match(r"500 response from rest.nexmo.com") - - -@responses.activate -def test_submit_sms_conversion(sms): - responses.add(responses.POST, "https://api.nexmo.com/conversions/sms", status=200, body=b"OK") - - sms.submit_sms_conversion("a-message-id") - assert "message-id=a-message-id" in request_body() - assert "timestamp" in request_body() diff --git a/tests/test_subaccounts.py b/tests/test_subaccounts.py deleted file mode 100644 index 1278e7ea..00000000 --- a/tests/test_subaccounts.py +++ /dev/null @@ -1,730 +0,0 @@ -from vonage import Client, ClientError, SubaccountsError -from util import stub - -from pytest import raises -import responses - -api_key = '1234asdf' -api_secret = 'qwerasdfzxcv' -client = Client(key=api_key, secret=api_secret) -subaccount_key = 'asdfzxcv' - - -@responses.activate -def test_list_subaccounts(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/list_subaccounts.json', - ) - subaccounts = client.subaccounts.list_subaccounts() - assert subaccounts['total_balance'] == 9.9999 - assert subaccounts['_embedded']['primary_account']['api_key'] == api_key - assert subaccounts['_embedded']['primary_account']['balance'] == 9.9999 - assert subaccounts['_embedded']['subaccounts'][0]['api_key'] == 'qwerasdf' - assert subaccounts['_embedded']['subaccounts'][0]['name'] == 'test_subaccount' - - -@responses.activate -def test_list_subaccounts_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - with raises(ClientError) as err: - client.subaccounts.list_subaccounts() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_list_subaccounts_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.list_subaccounts() - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_list_subaccounts_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - client.subaccounts.list_subaccounts() - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_create_subaccount(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/subaccount.json', - ) - subaccount = client.subaccounts.create_subaccount( - name='my subaccount', secret='Password123', use_primary_account_balance=True - ) - assert subaccount['api_key'] == 'asdfzxcv' - assert subaccount['secret'] == 'Password123' - assert subaccount['primary_account_api_key'] == api_key - assert subaccount['use_primary_account_balance'] == True - - -@responses.activate -def test_create_subaccount_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.create_subaccount('failed subaccount') - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_create_subaccount_error_forbidden(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - - with raises(ClientError) as err: - client.subaccounts.create_subaccount('failed subaccount') - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_create_subaccount_error_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.create_subaccount('failed subaccount') - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -def test_create_subaccount_error_non_boolean(): - with raises(SubaccountsError) as err: - client.subaccounts.create_subaccount( - 'failed subaccount', use_primary_account_balance='yes please' - ) - assert ( - str(err.value) - == 'If providing a value, it needs to be a boolean. You provided: "yes please"' - ) - - -@responses.activate -def test_get_subaccount(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/subaccount.json', - ) - subaccount = client.subaccounts.get_subaccount(subaccount_key) - assert subaccount['api_key'] == 'asdfzxcv' - assert subaccount['secret'] == 'Password123' - assert subaccount['primary_account_api_key'] == api_key - assert subaccount['use_primary_account_balance'] == True - - -@responses.activate -def test_get_subaccount_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - with raises(ClientError) as err: - client.subaccounts.get_subaccount(subaccount_key) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_get_subaccount_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.get_subaccount(subaccount_key) - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_get_subaccount_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - client.subaccounts.get_subaccount(subaccount_key) - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_modify_subaccount(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/modified_subaccount.json', - ) - subaccount = client.subaccounts.modify_subaccount( - subaccount_key, - suspended=True, - use_primary_account_balance=False, - name='my modified subaccount', - ) - assert subaccount['api_key'] == 'asdfzxcv' - assert subaccount['name'] == 'my modified subaccount' - assert subaccount['suspended'] == True - assert subaccount['primary_account_api_key'] == api_key - assert subaccount['use_primary_account_balance'] == False - - -@responses.activate -def test_modify_subaccount_error_authentication(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, suspended=True) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_modify_subaccount_error_forbidden(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, use_primary_account_balance=False) - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_modify_subaccount_error_not_found(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, name='my modified subaccount name') - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_modify_subaccount_validation_error(): - stub( - responses.PATCH, - f'https://api.nexmo.com/accounts/{api_key}/subaccounts/{subaccount_key}', - fixture_path='subaccounts/validation_error.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.modify_subaccount(subaccount_key, use_primary_account_balance=True) - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_list_credit_transfers(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/list_credit_transfers.json', - ) - transfers = client.subaccounts.list_credit_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount='asdfzxcv', - ) - assert transfers['_embedded']['credit_transfers'][0]['from'] == '1234asdf' - assert transfers['_embedded']['credit_transfers'][0]['reference'] == 'test credit transfer' - assert transfers['_embedded']['credit_transfers'][1]['to'] == 'asdfzxcv' - assert transfers['_embedded']['credit_transfers'][1]['created_at'] == '2023-06-12T17:20:01.000Z' - - -@responses.activate -def test_list_credit_transfers_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_list_credit_transfers_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers() - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_list_credit_transfers_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers() - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_list_credit_transfers_validation_error(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/transfer_validation_error.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.list_credit_transfers(start_date='invalid-date-format') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_transfer_credit(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/credit_transfer.json', - ) - transfer = client.subaccounts.transfer_credit( - from_='1234asdf', to='asdfzxcv', amount=0.50, reference='test credit transfer' - ) - assert transfer['from'] == '1234asdf' - assert transfer['to'] == 'asdfzxcv' - assert transfer['amount'] == 0.5 - assert transfer['reference'] == 'test credit transfer' - - -@responses.activate -def test_transfer_credit_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzxcv', amount=0.1) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_transfer_credit_invalid_transfer(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/invalid_transfer.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='asdfzxcv', to='qwerasdf', amount=1) - assert ( - str(err.value) - == 'Invalid Transfer: Transfers are only allowed between a primary account and its subaccount (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' - ) - - -@responses.activate -def test_transfer_credit_insufficient_credit(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/insufficient_credit.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='asdfzxcv', to='qwerasdf', amount=1) - assert ( - str(err.value) - == 'Transfer amount is invalid: Insufficient Credit (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' - ) - - -@responses.activate -def test_transfer_credit_error_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzcv', amount=0.1) - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_transfer_credit_validation_error(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/credit-transfers', - fixture_path='subaccounts/must_be_number.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_credit(from_='1234asdf', to='asdfzxcv', amount='0.50') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_list_balance_transfers(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/list_balance_transfers.json', - ) - transfers = client.subaccounts.list_balance_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount='asdfzxcv', - ) - assert transfers['_embedded']['balance_transfers'][0]['from'] == '1234asdf' - assert transfers['_embedded']['balance_transfers'][0]['reference'] == 'test transfer' - assert transfers['_embedded']['balance_transfers'][1]['to'] == 'asdfzxcv' - assert ( - transfers['_embedded']['balance_transfers'][1]['created_at'] == '2023-06-12T17:20:01.000Z' - ) - - -@responses.activate -def test_list_balance_transfers_error_authentication(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers() - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_list_balance_transfers_error_forbidden(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/forbidden.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers() - assert ( - str(err.value) - == 'Authorisation error: Account 1234adsf is not provisioned to access Subaccount Provisioning API (https://developer.nexmo.com/api-errors#unprovisioned)' - ) - - -@responses.activate -def test_list_balance_transfers_error_not_found(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers() - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_list_balance_transfers_validation_error(): - stub( - responses.GET, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/transfer_validation_error.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.list_balance_transfers(start_date='invalid-date-format') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_transfer_balance(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/balance_transfer.json', - ) - transfer = client.subaccounts.transfer_balance( - from_='1234asdf', to='asdfzxcv', amount=0.50, reference='test balance transfer' - ) - assert transfer['from'] == '1234asdf' - assert transfer['to'] == 'asdfzxcv' - assert transfer['amount'] == 0.5 - assert transfer['reference'] == 'test balance transfer' - - -@responses.activate -def test_transfer_balance_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzxcv', amount=0.1) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_transfer_balance_invalid_transfer(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/invalid_transfer.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='asdfzxcv', to='qwerasdf', amount=1) - assert ( - str(err.value) - == 'Invalid Transfer: Transfers are only allowed between a primary account and its subaccount (https://developer.nexmo.com/api-errors/account/subaccounts#valid-transfers)' - ) - - -@responses.activate -def test_transfer_balance_error_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzcv', amount=0.1) - assert ( - str(err.value) - == "Invalid API Key: API key '1234asdf' does not exist, or you do not have access (https://developer.nexmo.com/api-errors#invalid-api-key)" - ) - - -@responses.activate -def test_transfer_balance_validation_error(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/balance-transfers', - fixture_path='subaccounts/must_be_number.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_balance(from_='1234asdf', to='asdfzxcv', amount='0.50') - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) - - -@responses.activate -def test_transfer_number(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/transfer_number.json', - ) - transfer = client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert transfer['from'] == '1234asdf' - assert transfer['to'] == 'asdfzxcv' - assert transfer['number'] == '12345678901' - assert transfer['country'] == 'US' - assert transfer['masterAccountId'] == '1234asdf' - - -@responses.activate -def test_transfer_number_error_authentication(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/invalid_credentials.json', - status_code=401, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert str(err.value) == 'Authentication failed.' - - -@responses.activate -def test_transfer_number_invalid_transfer(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/invalid_number_transfer.json', - status_code=403, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode is not owned by from account (https://developer.nexmo.com/api-errors/account/subaccounts#invalid-number-transfer)' - ) - - -@responses.activate -def test_transfer_number_error_number_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/number_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found (https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer)' - ) - - -@responses.activate -def test_transfer_number_error_number_not_found(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/number_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='1234asdf', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Invalid Number Transfer: Could not transfer number 12345678901 from account 1234asdf to asdfzxcv - ShortCode not found (https://developer.nexmo.com/api-errors/account/subaccounts#missing-number-transfer)' - ) - - -@responses.activate -def test_transfer_number_validation_error(): - stub( - responses.POST, - f'https://api.nexmo.com/accounts/{api_key}/transfer-number', - fixture_path='subaccounts/same_from_and_to_accounts.json', - status_code=422, - ) - with raises(ClientError) as err: - client.subaccounts.transfer_number( - from_='asdfzxcv', to='asdfzxcv', number='12345678901', country='US' - ) - assert ( - str(err.value) - == 'Bad Request: The request failed due to validation errors (https://developer.nexmo.com/api-errors/account/subaccounts#validation)' - ) diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 3a468548..00000000 --- a/tests/test_users.py +++ /dev/null @@ -1,369 +0,0 @@ -from vonage import Client, Users -from util import * -from vonage.errors import UsersError, ClientError, ServerError - -from pytest import raises -import responses - -client = Client() -users = Users(client) -host = client.api_host() - - -@responses.activate -def test_list_users_basic(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_basic.json', - ) - - all_users = users.list_users() - assert all_users['page_size'] == 10 - assert all_users['_embedded']['users'][0]['name'] == 'NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c' - assert all_users['_embedded']['users'][1]['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - assert all_users['_embedded']['users'][2]['name'] == 'my_user_name' - - -@responses.activate -def test_list_users_options(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_options.json', - ) - - all_users = users.list_users(page_size=2, order='desc') - assert all_users['page_size'] == 2 - assert all_users['_embedded']['users'][0]['name'] == 'my_user_name' - assert all_users['_embedded']['users'][1]['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - - -def test_list_users_order_error(): - with raises(UsersError) as err: - users.list_users(order='Why, ascending of course!') - assert ( - str(err.value) == 'Invalid order parameter. Must be one of: "asc", "desc", "ASC", "DESC".' - ) - - -@responses.activate -def test_list_users_400(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - users.list_users(page_size='asdf') - assert 'Input validation failure.' in str(err.value) - - -@responses.activate -def test_list_users_404(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_404.json', - status_code=404, - ) - - with raises(ClientError) as err: - users.list_users(name='asdf') - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_list_users_429(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.list_users() - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_list_users_500(): - stub( - responses.GET, - f'https://{host}/v1/users', - fixture_path='users/list_users_500.json', - status_code=500, - ) - - with raises(ServerError) as err: - users.list_users() - assert str(err.value) == '500 response from api.nexmo.com' - - -@responses.activate -def test_create_user_basic(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/user_basic.json', - status_code=201, - ) - - user = users.create_user() - assert user['id'] == 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - assert user['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - ) - - -@responses.activate -def test_create_user_options(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/user_options.json', - status_code=201, - ) - - params = { - "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", - "name": "my_user_name", - "image_url": "https://example.com/image.png", - "display_name": "My User Name", - "properties": {"custom_data": {"custom_key": "custom_value"}}, - "_links": { - "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" - } - }, - "channels": { - "pstn": [{"number": 123457}], - "sip": [ - { - "uri": "sip:4442138907@sip.example.com;transport=tls", - "username": "New SIP", - "password": "Password", - } - ], - "vbc": [{"extension": "403"}], - "websocket": [ - { - "uri": "wss://example.com/socket", - "content-type": "audio/l16;rate=16000", - "headers": {"customer_id": "ABC123"}, - } - ], - "sms": [{"number": "447700900000"}], - "mms": [{"number": "447700900000"}], - "whatsapp": [{"number": "447700900000"}], - "viber": [{"number": "447700900000"}], - "messenger": [{"id": "12345abcd"}], - }, - } - - user = users.create_user(params) - assert user['id'] == 'USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' - assert user['name'] == 'my_user_name' - assert user['display_name'] == 'My User Name' - assert user['properties']['custom_data']['custom_key'] == 'custom_value' - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422' - ) - assert user['channels']['vbc'][0]['extension'] == '403' - - -@responses.activate -def test_create_user_400(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/user_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - users.create_user(params={'name': 1234}) - assert 'Input validation failure.' in str(err.value) - - -@responses.activate -def test_create_user_429(): - stub( - responses.POST, - f'https://{host}/v1/users', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.create_user() - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_get_user(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.GET, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/user_basic.json', - ) - - user = users.get_user(user_id) - assert user['name'] == 'NAM-ecb938f2-13e0-40c1-9d3b-b16ebb4ef3d1' - assert user['properties']['custom_data'] == {} - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - ) - - -@responses.activate -def test_get_user_404(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.GET, - f'https://{host}/v1/users/{user_id}', - status_code=404, - fixture_path='users/user_404.json', - ) - - with raises(ClientError) as err: - users.get_user(user_id) - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_get_user_429(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.GET, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.get_user(user_id) - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_update_user(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/user_updated.json', - ) - - params = { - 'name': 'updated_name', - 'channels': { - 'whatsapp': [ - {'number': '447700900000'}, - ] - }, - } - user = users.update_user(user_id, params) - assert user['name'] == 'updated_name' - assert ( - user['_links']['self']['href'] - == 'https://api-us-3.vonage.com/v1/users/USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - ) - assert user['channels']['whatsapp'][0]['number'] == '447700900000' - - -@responses.activate -def test_update_user_400(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/user_400.json', - status_code=400, - ) - - with raises(ClientError) as err: - users.update_user(user_id, params={'name': 1234}) - assert 'Input validation failure.' in str(err.value) - - -@responses.activate -def test_update_user_404(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - status_code=404, - fixture_path='users/user_404.json', - ) - - with raises(ClientError) as err: - users.update_user(user_id, params={'name': 'updated_user_name'}) - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_update_user_429(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.PATCH, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.update_user(user_id, params={'name': 'updated_user_name'}) - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) - - -@responses.activate -def test_delete_user(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.DELETE, - f'https://{host}/v1/users/{user_id}', - status_code=204, - fixture_path='no_content.json', - ) - - response = users.delete_user(user_id) - assert response == None - - -@responses.activate -def test_delete_user_404(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.DELETE, - f'https://{host}/v1/users/{user_id}', - status_code=404, - fixture_path='users/user_404.json', - ) - - with raises(ClientError) as err: - users.delete_user(user_id) - assert 'User does not exist, or you do not have access.' in str(err.value) - - -@responses.activate -def test_delete_user_429(): - user_id = 'USR-d3cc6a55-aa7b-4916-8244-2fedb554afd5' - stub( - responses.DELETE, - f'https://{host}/v1/users/{user_id}', - fixture_path='users/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - users.delete_user(user_id) - assert 'You have exceeded your request limit. You can try again shortly.' in str(err.value) diff --git a/tests/test_ussd.py b/tests/test_ussd.py deleted file mode 100644 index 27fb41a7..00000000 --- a/tests/test_ussd.py +++ /dev/null @@ -1,27 +0,0 @@ -from util import * - - -@responses.activate -def test_send_ussd_push_message(ussd, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd/json") - - params = {"from": "MyCompany20", "to": "447525856424", "text": "Hello"} - - assert isinstance(ussd.send_ussd_push_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=MyCompany20" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() - - -@responses.activate -def test_send_ussd_prompt_message(ussd, dummy_data): - stub(responses.POST, "https://rest.nexmo.com/ussd-prompt/json") - - params = {"from": "long-virtual-number", "to": "447525856424", "text": "Hello"} - - assert isinstance(ussd.send_ussd_prompt_message(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "from=long-virtual-number" in request_body() - assert "to=447525856424" in request_body() - assert "text=Hello" in request_body() diff --git a/tests/test_verify.py b/tests/test_verify.py deleted file mode 100644 index 576dbf62..00000000 --- a/tests/test_verify.py +++ /dev/null @@ -1,204 +0,0 @@ -from util import * - - -@responses.activate -def test_start_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.start_verification(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_check_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/check/json") - - assert isinstance(verify.check("8g88g88eg8g8gg9g90", code="123445"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "code=123445" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_get_verification(verify, dummy_data): - stub(responses.GET, "https://api.nexmo.com/verify/search/json") - - assert isinstance(verify.search("xxx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "request_id=xxx" in request_query() - - -@responses.activate -def test_cancel_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.cancel("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=cancel" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_trigger_next_verification_event(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/control/json") - - assert isinstance(verify.trigger_next_event("8g88g88eg8g8gg9g90"), dict) - assert request_user_agent() == dummy_data.user_agent - assert "cmd=trigger_next_event" in request_body() - assert "request_id=8g88g88eg8g8gg9g90" in request_body() - - -@responses.activate -def test_start_psd2_verification(verify, dummy_data): - stub(responses.POST, "https://api.nexmo.com/verify/psd2/json") - - params = {"number": "447525856424", "brand": "MyApp"} - - assert isinstance(verify.psd2(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - - -@responses.activate -def test_start_verification_blacklisted_error_with_network(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_network.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.start_verification(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_verification_blacklisted_error_with_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.start_verification(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/json", - fixture_path="verify/blocked_with_network_and_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.start_verification(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_psd2_verification_blacklisted_error_with_network(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_network.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.psd2(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_psd2_verification_blacklisted_error_with_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.psd2(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) - - -@responses.activate -def test_start_psd2_verification_blacklisted_error_with_network_and_request_id(client, dummy_data): - stub( - responses.POST, - "https://api.nexmo.com/verify/psd2/json", - fixture_path="verify/blocked_with_network_and_request_id.json", - ) - - params = {"number": "447525856424", "brand": "MyApp"} - response = client.verify.psd2(params) - - assert isinstance(response, dict) - assert request_user_agent() == dummy_data.user_agent - assert "number=447525856424" in request_body() - assert "brand=MyApp" in request_body() - assert response["status"] == "7" - assert response["network"] == "25503" - assert response["request_id"] == "12345678" - assert ( - response["error_text"] - == "The number you are trying to verify is blacklisted for verification" - ) diff --git a/tests/test_verify2.py b/tests/test_verify2.py deleted file mode 100644 index 601a78ee..00000000 --- a/tests/test_verify2.py +++ /dev/null @@ -1,647 +0,0 @@ -from vonage import Client, Verify2 -from util import * -from vonage.errors import ClientError, Verify2Error - -from pydantic import ValidationError -from pytest import raises -import responses - -verify2 = Verify2(Client()) - - -@responses.activate -def test_new_request_sms_basic(dummy_data): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} - verify_request = verify2.new_request(params) - - assert request_user_agent() == dummy_data.user_agent - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_sms_full(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'locale': 'en-gb', - 'channel_timeout': 120, - 'client_ref': 'my client ref', - 'code_length': 8, - 'fraud_check': False, - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': 'asdfghjklqw'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_sms_custom_code(dummy_data): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'code': 'asdfghjk', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert request_user_agent() == dummy_data.user_agent - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_error_fraud_check_invalid_account(dummy_data): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/fraud_check_invalid_account.json', - status_code=403, - ) - - params = { - 'brand': 'ACME, Inc', - 'fraud_check': False, - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Forbidden: Your account does not have permission to perform this action. (https://developer.nexmo.com/api-errors#forbidden)' - ) - - -def test_new_request_sms_custom_code_length_error(): - params = { - 'code_length': 4, - 'brand': 'ACME, Inc', - 'code': 'a', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ValidationError) as err: - verify2.new_request(params) - assert 'String should have at least 4 characters' in str(err.value) - - -def test_new_request_sms_custom_code_character_error(): - params = { - 'code_length': 4, - 'brand': 'ACME, Inc', - 'code': '?!@%', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ValidationError) as err: - verify2.new_request(params) - assert 'string does not match regex' in str(err.value) - - -def test_new_request_invalid_channel_error(): - params = { - 'code_length': 4, - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'carrier_pigeon', 'to': '447700900000'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'You must specify a valid verify channel inside the "workflow" object, one of: "[\'sms\', \'whatsapp\', \'whatsapp_interactive\', \'voice\', \'email\', \'silent_auth\']"' - ) - - -def test_new_request_code_length_error(): - params = { - 'code_length': 1000, - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - } - - with raises(ValidationError) as err: - verify2.new_request(params) - assert 'Input should be less than or equal to 10' in str(err.value) - - -def test_new_request_to_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '123'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert 'You must specify a valid "to" value for channel "sms"' in str(err.value) - - -def test_new_request_sms_app_hash_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': '00'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert 'Invalid "app_hash" specified.' in str(err.value) - - -def test_new_request_whatsapp_app_hash_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'app_hash': 'asdfqwerzxc'}], - } - - with raises(Verify2Error) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Cannot specify a value for "app_hash" unless using SMS for authentication.' - ) - - -@responses.activate -def test_new_request_whatsapp(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}]} - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_whatsapp_custom_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'code': 'asdfghjk', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_whatsapp_from_field(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447000000000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_whatsapp_invalid_sender_error(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/invalid_sender.json', - status_code=422, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': 'asdfghjkl'}], - } - with pytest.raises(ClientError) as err: - verify2.new_request(params) - assert str(err.value) == 'You must specify a valid "from" value if included.' - - -@responses.activate -def test_new_request_whatsapp_sender_unregistered_error(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/invalid_sender.json', - status_code=422, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp', 'to': '447700900000', 'from': '447999999999'}], - } - with pytest.raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Invalid sender: The `from` parameter is invalid. (https://developer.nexmo.com/api-errors#invalid-param)' - ) - - -@responses.activate -def test_new_request_whatsapp_interactive(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'whatsapp_interactive', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_voice(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'voice', 'to': '447700900000'}]} - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_voice_custom_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'code': 'asdfhjkl', - 'workflow': [{'channel': 'voice', 'to': '447700900000'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_email(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'email', 'to': 'recipient@example.com'}], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_email_additional_fields(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'locale': 'en-gb', - 'channel_timeout': 120, - 'client_ref': 'my client ref', - 'code_length': 8, - 'brand': 'ACME, Inc', - 'code': 'asdfhjkl', - 'workflow': [ - {'channel': 'email', 'to': 'recipient@example.com', 'from': 'sender@example.com'} - ], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -@responses.activate -def test_new_request_email_error(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/invalid_email.json', - status_code=422, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'email', 'to': 'not-an-email-address'}], - } - with pytest.raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == 'Invalid params: The value of one or more parameters is invalid (https://www.nexmo.com/messages/Errors#InvalidParams)' - ) - - -@responses.activate -def test_new_request_silent_auth(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request_silent_auth.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - { - 'channel': 'silent_auth', - 'to': '447000000000', - 'redirect_url': 'https://acme-app.com/sa/redirect', - 'sandbox': False, - } - ], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'b3a2f4bd-7bda-4e5e-978a-81514702d2ce' - assert ( - verify_request['check_url'] - == 'https://api-eu-3.vonage.com/v2/verify/b3a2f4bd-7bda-4e5e-978a-81514702d2ce/silent-auth/redirect' - ) - - -def test_silent_auth_redirect_url_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - { - 'channel': 'silent_auth', - 'to': '447000000000', - 'redirect_url': ['https://acme-app.com/sa/redirect'], - } - ], - } - with raises(Verify2Error) as err: - verify2.new_request(params) - assert str(err.value) == '"redirect_url" must be a string if specified.' - - -def test_silent_auth_sandbox_error(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - { - 'channel': 'silent_auth', - 'to': '447000000000', - 'sandbox': 'true', - } - ], - } - with raises(Verify2Error) as err: - verify2.new_request(params) - assert str(err.value) == '"sandbox" must be a boolean if specified.' - - -@responses.activate -def test_new_request_error_conflict(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/error_conflict.json', - status_code=409, - ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} - - with raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == "Conflict: Concurrent verifications to the same number are not allowed. (https://www.developer.vonage.com/api-errors/verify#conflict)" - ) - - -@responses.activate -def test_new_request_rate_limit(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/rate_limit.json', - status_code=429, - ) - params = {'brand': 'ACME, Inc', 'workflow': [{'channel': 'sms', 'to': '447700900000'}]} - - with raises(ClientError) as err: - verify2.new_request(params) - assert ( - str(err.value) - == "Rate Limit Hit: Please wait, then retry your request (https://www.developer.vonage.com/api-errors#throttled)" - ) - - -@responses.activate -def test_check_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/check_code.json', - ) - - response = verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '1234') - assert response['request_id'] == 'e043d872-459b-4750-a20c-d33f91d6959f' - assert response['status'] == 'completed' - - -@responses.activate -def test_check_code_invalid_code(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/invalid_code.json', - status_code=400, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == 'Invalid Code: The code you provided does not match the expected value. (https://developer.nexmo.com/api-errors#bad-request)' - ) - - -@responses.activate -def test_check_code_already_verified(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/already_verified.json', - status_code=404, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == "Not Found: Request '5fcc26ef-1e54-48a6-83ab-c47546a19824' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" - ) - - -@responses.activate -def test_check_code_workflow_not_supported(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/code_not_supported.json', - status_code=409, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == 'Conflict: The current Verify workflow step does not support a code. (https://developer.nexmo.com/api-errors#conflict)' - ) - - -@responses.activate -def test_check_code_too_many_invalid_code_attempts(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/too_many_code_attempts.json', - status_code=410, - ) - - with pytest.raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - - assert ( - str(err.value) - == 'Invalid Code: An incorrect code has been provided too many times. Workflow terminated. (https://developer.nexmo.com/api-errors#gone)' - ) - - -@responses.activate -def test_check_code_rate_limit(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/rate_limit.json', - status_code=429, - ) - - with raises(ClientError) as err: - verify2.check_code('c11236f4-00bf-4b89-84ba-88b25df97315', '5678') - assert ( - str(err.value) - == "Rate Limit Hit: Please wait, then retry your request (https://www.developer.vonage.com/api-errors#throttled)" - ) - - -@responses.activate -def test_cancel_verification(): - stub( - responses.DELETE, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='no_content.json', - status_code=204, - ) - - assert verify2.cancel_verification('c11236f4-00bf-4b89-84ba-88b25df97315') == None - - -@responses.activate -def test_cancel_verification_error_not_found(): - stub( - responses.DELETE, - 'https://api.nexmo.com/v2/verify/c11236f4-00bf-4b89-84ba-88b25df97315', - fixture_path='verify2/request_not_found.json', - status_code=404, - ) - - with raises(ClientError) as err: - verify2.cancel_verification('c11236f4-00bf-4b89-84ba-88b25df97315') - assert ( - str(err.value) - == "Not Found: Request 'c11236f4-00bf-4b89-84ba-88b25df97315' was not found or it has been verified already. (https://developer.nexmo.com/api-errors#not-found)" - ) - - -@responses.activate -def test_new_request_multiple_workflows(): - stub( - responses.POST, - 'https://api.nexmo.com/v2/verify', - fixture_path='verify2/create_request.json', - status_code=202, - ) - - params = { - 'brand': 'ACME, Inc', - 'workflow': [ - {'channel': 'whatsapp_interactive', 'to': '447700900000'}, - {'channel': 'sms', 'to': '4477009999999'}, - ], - } - verify_request = verify2.new_request(params) - - assert verify_request['request_id'] == 'c11236f4-00bf-4b89-84ba-88b25df97315' - - -def test_remove_unnecessary_fraud_check(): - params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}], - 'fraud_check': True, - } - verify2._remove_unnecessary_fraud_check(params) - - assert 'fraud_check' not in params diff --git a/tests/test_voice.py b/tests/test_voice.py deleted file mode 100644 index cc6dda52..00000000 --- a/tests/test_voice.py +++ /dev/null @@ -1,224 +0,0 @@ -import os.path -import time -import jwt -from unittest.mock import patch - -from vonage import Client, Voice, Ncco -from util import * - - -@responses.activate -def test_create_call(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "from": {"type": "phone", "number": "14843335555"}, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_params_with_random_number(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - params = { - "to": [{"type": "phone", "number": "14843331234"}], - "random_from_number": True, - "answer_url": ["https://example.com/answer"], - } - - assert isinstance(voice.create_call(params), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_create_call_with_ncco_builder(voice, dummy_data): - stub(responses.POST, "https://api.nexmo.com/v1/calls") - - talk = Ncco.Talk( - text='Hello from Vonage!', - bargeIn=True, - loop=3, - level=0.5, - language='en-GB', - style=1, - premium=True, - ) - ncco = Ncco.build_ncco(talk) - voice.create_call( - { - 'to': [{'type': 'phone', 'number': '447449815316'}], - 'from': {'type': 'phone', 'number': '447418370240'}, - 'ncco': ncco, - } - ) - assert ( - request_body() - == b'{"to": [{"type": "phone", "number": "447449815316"}], "from": {"type": "phone", "number": "447418370240"}, "ncco": [{"action": "talk", "text": "Hello from Vonage!", "bargeIn": true, "loop": 3, "level": 0.5, "language": "en-GB", "style": 1, "premium": true}]}' - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - - -@responses.activate -def test_get_calls(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls") - - assert isinstance(voice.get_calls(), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_get_call(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.get_call("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - assert_re(r"\ABearer ", request_authorization()) - - -@responses.activate -def test_update_call(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - assert isinstance(voice.update_call("xx-xx-xx-xx", action="hangup"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"action": "hangup"}' - - -@responses.activate -def test_send_audio(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance( - voice.send_audio("xx-xx-xx-xx", stream_url="http://example.com/audio.mp3"), - dict, - ) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"stream_url": "http://example.com/audio.mp3"}' - - -@responses.activate -def test_stop_audio(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/stream") - - assert isinstance(voice.stop_audio("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_speech(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.send_speech("xx-xx-xx-xx", text="Hello"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"text": "Hello"}' - - -@responses.activate -def test_stop_speech(voice, dummy_data): - stub(responses.DELETE, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/talk") - - assert isinstance(voice.stop_speech("xx-xx-xx-xx"), dict) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_send_dtmf(voice, dummy_data): - stub(responses.PUT, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx/dtmf") - - assert isinstance(voice.send_dtmf("xx-xx-xx-xx", digits="1234"), dict) - assert request_user_agent() == dummy_data.user_agent - assert request_content_type() == "application/json" - assert request_body() == b'{"digits": "1234"}' - - -@responses.activate -def test_user_provided_authorization(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - application_id = "different-application-id" - client = Client(application_id=application_id, private_key=dummy_data.private_key) - - nbf = int(time.time()) - exp = nbf + 3600 - - client.auth(nbf=nbf, exp=exp) - client.voice.get_call("xx-xx-xx-xx") - - token = request_authorization().split()[1] - - token = jwt.decode(token, dummy_data.public_key, algorithms="RS256") - assert token["application_id"] == application_id - assert token["nbf"] == nbf - assert token["exp"] == exp - - -@responses.activate -def test_authorization_with_private_key_path(dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - private_key = os.path.join(os.path.dirname(__file__), "data/private_key.txt") - - client = Client( - key=dummy_data.api_key, - secret=dummy_data.api_secret, - application_id=dummy_data.application_id, - private_key=private_key, - ) - voice = Voice(client) - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_authorization_with_private_key_object(voice, dummy_data): - stub(responses.GET, "https://api.nexmo.com/v1/calls/xx-xx-xx-xx") - - voice.get_call("xx-xx-xx-xx") - - token = jwt.decode( - request_authorization().split()[1], dummy_data.public_key, algorithms="RS256" - ) - assert token["application_id"] == dummy_data.application_id - - -@responses.activate -def test_get_recording(voice, dummy_data): - stub_bytes( - responses.GET, - "https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957", - body=b'THISISANMP3', - ) - - assert isinstance( - voice.get_recording("https://api.nexmo.com/v1/files/d6e47a2e-3414-11e8-8c2c-2f8b643ed957"), - bytes, - ) - assert request_user_agent() == dummy_data.user_agent - - -def test_verify_jwt_signature(voice: Voice): - with patch('vonage.Voice.verify_signature') as mocked_verify_signature: - mocked_verify_signature.return_value = True - assert voice.verify_signature('valid_token', 'valid_signature') - - -def test_verify_jwt_invalid_signature(voice: Voice): - with patch('vonage.Voice.verify_signature') as mocked_verify_signature: - mocked_verify_signature.return_value = False - assert voice.verify_signature('token', 'invalid_signature') is False diff --git a/tests/util.py b/tests/util.py deleted file mode 100644 index e9c8f289..00000000 --- a/tests/util.py +++ /dev/null @@ -1,63 +0,0 @@ -import os.path -import re - -import pytest - -from urllib.parse import urlparse, parse_qs - -import responses - - -def request_body(): - return responses.calls[0].request.body - - -def request_query(): - return urlparse(responses.calls[0].request.url).query - - -def request_params(): - """Obtain the query params, as a dict.""" - return parse_qs(request_query()) - - -def request_headers(): - return responses.calls[0].request.headers - - -def request_user_agent(): - return responses.calls[0].request.headers["User-Agent"] - - -def request_authorization(): - return responses.calls[0].request.headers["Authorization"].decode("utf-8") - - -def request_content_type(): - return responses.calls[0].request.headers["Content-Type"] - - -def stub(method, url, fixture_path=None, status_code=200): - body = load_fixture(fixture_path) if fixture_path else '{"key":"value"}' - responses.add(method, url, body=body, status=status_code, content_type="application/json") - - -def stub_bytes(method, url, body): - responses.add(method, url, body, status=200) - - -def assert_re(pattern, string): - __tracebackhide__ = True - if not re.search(pattern, string): - pytest.fail(f"Cannot find pattern {repr(pattern)} in {repr(string)}") - - -def assert_basic_auth(): - params = request_params() - assert "api_key" not in params - assert "api_secret" not in params - assert request_headers()["Authorization"] == "Basic bmV4bW8tYXBpLWtleTpuZXhtby1hcGktc2VjcmV0" - - -def load_fixture(fixture_path): - return open(os.path.join(os.path.dirname(__file__), "data", fixture_path)).read() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index efeb1ff9..00000000 --- a/tox.ini +++ /dev/null @@ -1,13 +0,0 @@ -[tox] -envlist = py3.8, py3.11, coverage-report - -[testenv] -deps = -r requirements.txt -commands = coverage run --parallel -m pytest tests - -[testenv:coverage-report] -deps = coverage -skip_install = true -commands = - coverage combine - coverage report diff --git a/vonage/BUILD b/vonage/BUILD new file mode 100644 index 00000000..02ab75f9 --- /dev/null +++ b/vonage/BUILD @@ -0,0 +1,9 @@ +resource(name='pyproject', source='pyproject.toml') + +python_distribution( + name='vonage', + dependencies=[':pyproject', 'vonage/src/vonage'], + provides=python_artifact(), + generate_setup=False, + repositories=['https://test.pypi.org/legacy/'], +) diff --git a/CHANGES.md b/vonage/CHANGES.md similarity index 97% rename from CHANGES.md rename to vonage/CHANGES.md index 3cc7a200..205eb1b4 100644 --- a/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,11 +1,5 @@ -# 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` +# 4.0.0 +Created new monorepo structure - this package `vonage` is now a way to depend on the functionality of all Vonage APIs, which has been moved into separate packages. Additionally, there are many breaking changes. # 3.11.0 - Add method to check JWT signatures of Voice API webhooks: `vonage.Voice.verify_signature` diff --git a/vonage/README.md b/vonage/README.md new file mode 100644 index 00000000..6f26339d --- /dev/null +++ b/vonage/README.md @@ -0,0 +1,15 @@ +# 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 +``` \ No newline at end of file diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml new file mode 100644 index 00000000..632f3d84 --- /dev/null +++ b/vonage/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = 'vonage' +description = 'Python Server SDK for using Vonage APIs' +version = '4.0.0b1' + + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" 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..e69de29b diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py new file mode 100644 index 00000000..e2d7ab77 --- /dev/null +++ b/vonage/src/vonage/vonage.py @@ -0,0 +1,26 @@ +from typing import Optional + +from http_client.auth import Auth + +from http_client.http_client import HttpClient, HttpClientOptions +from number_insight_v2.number_insight_v2 import NumberInsightv2 + + +class Vonage: + """Main Server SDK class for using Vonage APIs. + + 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) + + self.number_insight_v2 = NumberInsightv2(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..024d07c0 --- /dev/null +++ b/vonage/tests/test_vonage.py @@ -0,0 +1,13 @@ +from http_client.http_client import HttpClient +from vonage.vonage import Auth, Vonage + + +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 From 59c7f190b2055512bfd91cb9600147610533f7ed Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 7 Feb 2024 03:22:11 +0000 Subject: [PATCH 02/98] remove unused tests --- http_client/tests/data/video/broadcast.json | 39 - .../tests/data/video/create_archive.json | 17 - .../tests/data/video/create_session.json | 19 - .../tests/data/video/create_sip_call.json | 5 - .../video/disable_mute_multiple_streams.json | 7 - http_client/tests/data/video/get_archive.json | 18 - http_client/tests/data/video/get_stream.json | 8 - .../tests/data/video/list_archives.json | 28 - .../tests/data/video/list_broadcasts.json | 44 -- .../tests/data/video/list_streams.json | 13 - .../data/video/mute_multiple_streams.json | 7 - .../data/video/mute_specific_stream.json | 7 - http_client/tests/data/video/null.json | 1 - .../data/video/play_dtmf_invalid_error.json | 4 - .../tests/data/video/stop_archive.json | 15 - http_client/tests/test_video.py | 678 ------------------ 16 files changed, 910 deletions(-) delete mode 100644 http_client/tests/data/video/broadcast.json delete mode 100644 http_client/tests/data/video/create_archive.json delete mode 100644 http_client/tests/data/video/create_session.json delete mode 100644 http_client/tests/data/video/create_sip_call.json delete mode 100644 http_client/tests/data/video/disable_mute_multiple_streams.json delete mode 100644 http_client/tests/data/video/get_archive.json delete mode 100644 http_client/tests/data/video/get_stream.json delete mode 100644 http_client/tests/data/video/list_archives.json delete mode 100644 http_client/tests/data/video/list_broadcasts.json delete mode 100644 http_client/tests/data/video/list_streams.json delete mode 100644 http_client/tests/data/video/mute_multiple_streams.json delete mode 100644 http_client/tests/data/video/mute_specific_stream.json delete mode 100644 http_client/tests/data/video/null.json delete mode 100644 http_client/tests/data/video/play_dtmf_invalid_error.json delete mode 100644 http_client/tests/data/video/stop_archive.json delete mode 100644 http_client/tests/test_video.py diff --git a/http_client/tests/data/video/broadcast.json b/http_client/tests/data/video/broadcast.json deleted file mode 100644 index 25122cf5..00000000 --- a/http_client/tests/data/video/broadcast.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "1748b7070a81464c9759c46ad10d3734", - "sessionId": "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4", - "multiBroadcastTag": "broadcast_tag_provided", - "applicationId": "abc123", - "createdAt": 1437676551000, - "updatedAt": 1437676551000, - "maxDuration": 5400, - "maxBitrate": 2000000, - "broadcastUrls": { - "hls": "hlsurl", - "rtmp": [ - { - "id": "abc123", - "status": "abc123", - "serverUrl": "abc123", - "streamName": "abc123" - } - ] - }, - "settings": { - "hls": { - "lowLatency": false, - "dvr": false - } - }, - "resolution": "640x480", - "hasAudio": true, - "hasVideo": true, - "streamMode": "auto", - "status": "started", - "streams": [ - { - "streamId": "70a81464c9759c46ad10d3734", - "hasAudio": true, - "hasVideo": true - } - ] -} \ No newline at end of file diff --git a/http_client/tests/data/video/create_archive.json b/http_client/tests/data/video/create_archive.json deleted file mode 100644 index 725c8eb3..00000000 --- a/http_client/tests/data/video/create_archive.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "createdAt" : 1384221730555, - "duration" : 0, - "hasAudio" : true, - "hasVideo" : true, - "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name" : "my_new_archive", - "outputMode" : "composed", - "projectId" : 234567, - "reason" : "", - "resolution" : "640x480", - "sessionId" : "my_session_id", - "size" : 0, - "status" : "started", - "streamMode" : "auto", - "url" : null -} \ No newline at end of file diff --git a/http_client/tests/data/video/create_session.json b/http_client/tests/data/video/create_session.json deleted file mode 100644 index 7c6f4b35..00000000 --- a/http_client/tests/data/video/create_session.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - { - "session_id": "my_session_id", - "project_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", - "partner_id": "29f760f8-7ce1-46c9-ade3-f2dedee4ed5f", - "create_dt": "Tue Aug 09 09:10:17 PDT 2022", - "session_status": null, - "status_invalid": null, - "media_server_hostname": null, - "messaging_server_url": null, - "messaging_url": null, - "symphony_address": null, - "properties": null, - "ice_server": null, - "session_segment_id": "b8c32a6d-faf9-4ec4-a648-a6d382cd650b", - "ice_servers": null, - "ice_credential_expiration": 86100 - } -] diff --git a/http_client/tests/data/video/create_sip_call.json b/http_client/tests/data/video/create_sip_call.json deleted file mode 100644 index 29cbbab6..00000000 --- a/http_client/tests/data/video/create_sip_call.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "b0a5a8c7-dc38-459f-a48d-a7f2008da853", - "connectionId": "e9f8c166-6c67-440d-994a-04fb6dfed007", - "streamId": "482bce73-f882-40fd-8ca5-cb74ff416036" -} \ No newline at end of file diff --git a/http_client/tests/data/video/disable_mute_multiple_streams.json b/http_client/tests/data/video/disable_mute_multiple_streams.json deleted file mode 100644 index 878f6eaa..00000000 --- a/http_client/tests/data/video/disable_mute_multiple_streams.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "status": "ACTIVE", - "name": "Joe Montana", - "environment": "standard", - "createdAt": 1414642898000 -} \ No newline at end of file diff --git a/http_client/tests/data/video/get_archive.json b/http_client/tests/data/video/get_archive.json deleted file mode 100644 index be0ece31..00000000 --- a/http_client/tests/data/video/get_archive.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "createdAt" : 1384221730000, - "duration" : 5049, - "hasAudio" : true, - "hasVideo" : true, - "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name" : "Foo", - "outputMode" : "composed", - "projectId" : 123456, - "reason" : "", - "resolution" : "640x480", - "sessionId" : "2_MX40NzIwMzJ-flR1ZSBPY3QgMjkgMTI6MTM6MjMgUERUIDIwMTN-MC45NDQ2MzE2NH4", - "size" : 247748791, - "status" : "available", - "streamMode" : "auto", - "streams" : [], - "url" : "https://tokbox.com.archive2.s3.amazonaws.com/123456/09141e29-8770-439b-b180-337d7e637545/archive.mp4" -} \ No newline at end of file diff --git a/http_client/tests/data/video/get_stream.json b/http_client/tests/data/video/get_stream.json deleted file mode 100644 index 5e8fb98d..00000000 --- a/http_client/tests/data/video/get_stream.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "8b732909-0a06-46a2-8ea8-074e64d43422", - "videoType": "camera", - "name": "", - "layoutClassList": [ - "full" - ] -} \ No newline at end of file diff --git a/http_client/tests/data/video/list_archives.json b/http_client/tests/data/video/list_archives.json deleted file mode 100644 index 140d59c9..00000000 --- a/http_client/tests/data/video/list_archives.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "count": 1, - "items": [ - { - "createdAt": 1384221730000, - "duration": 5049, - "hasAudio": true, - "hasVideo": true, - "id": "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name": "Foo", - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "reason": "", - "resolution": "abc123", - "sessionId": "my_session_id", - "size": 247748791, - "status": "available", - "streamMode": "manual", - "streams": [ - { - "streamId": "abc123", - "hasAudio": true, - "hasVideo": true - } - ], - "url": "https://tokbox.com.archive2.s3.amazonaws.com/123456/09141e29-8770-439b-b180-337d7e637545/archive.mp4" - } - ] -} \ No newline at end of file diff --git a/http_client/tests/data/video/list_broadcasts.json b/http_client/tests/data/video/list_broadcasts.json deleted file mode 100644 index 2d82a315..00000000 --- a/http_client/tests/data/video/list_broadcasts.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "count": "1", - "items": [ - { - "id": "1748b7070a81464c9759c46ad10d3734", - "sessionId": "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4", - "multiBroadcastTag": "broadcast_tag_provided", - "applicationId": "abc123", - "createdAt": 1437676551000, - "updatedAt": 1437676551000, - "maxDuration": 5400, - "maxBitrate": 2000000, - "broadcastUrls": { - "hls": "hlsurl", - "rtmp": [ - { - "id": "abc123", - "status": "abc123", - "serverUrl": "abc123", - "streamName": "abc123" - } - ] - }, - "settings": { - "hls": { - "lowLatency": false, - "dvr": false - } - }, - "resolution": "abc123", - "hasAudio": false, - "hasVideo": false, - "streamMode": "manual", - "status": "abc123", - "streams": [ - { - "streamId": "abc123", - "hasAudio": "abc123", - "hasVideo": "abc123" - } - ] - } - ] -} \ No newline at end of file diff --git a/http_client/tests/data/video/list_streams.json b/http_client/tests/data/video/list_streams.json deleted file mode 100644 index fe50c8d0..00000000 --- a/http_client/tests/data/video/list_streams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "count": 1, - "items": [ - { - "id": "8b732909-0a06-46a2-8ea8-074e64d43422", - "videoType": "camera", - "name": "", - "layoutClassList": [ - "full" - ] - } - ] - } \ No newline at end of file diff --git a/http_client/tests/data/video/mute_multiple_streams.json b/http_client/tests/data/video/mute_multiple_streams.json deleted file mode 100644 index 878f6eaa..00000000 --- a/http_client/tests/data/video/mute_multiple_streams.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "status": "ACTIVE", - "name": "Joe Montana", - "environment": "standard", - "createdAt": 1414642898000 -} \ No newline at end of file diff --git a/http_client/tests/data/video/mute_specific_stream.json b/http_client/tests/data/video/mute_specific_stream.json deleted file mode 100644 index 878f6eaa..00000000 --- a/http_client/tests/data/video/mute_specific_stream.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "applicationId": "78d335fa-323d-0114-9c3d-d6f0d48968cf", - "status": "ACTIVE", - "name": "Joe Montana", - "environment": "standard", - "createdAt": 1414642898000 -} \ No newline at end of file diff --git a/http_client/tests/data/video/null.json b/http_client/tests/data/video/null.json deleted file mode 100644 index ec747fa4..00000000 --- a/http_client/tests/data/video/null.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/http_client/tests/data/video/play_dtmf_invalid_error.json b/http_client/tests/data/video/play_dtmf_invalid_error.json deleted file mode 100644 index b783100c..00000000 --- a/http_client/tests/data/video/play_dtmf_invalid_error.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "code": 400, - "message": "One of the properties digits or sessionId is invalid." -} \ No newline at end of file diff --git a/http_client/tests/data/video/stop_archive.json b/http_client/tests/data/video/stop_archive.json deleted file mode 100644 index 630b1d8b..00000000 --- a/http_client/tests/data/video/stop_archive.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "createdAt" : 1384221730555, - "duration" : 60, - "hasAudio" : true, - "hasVideo" : true, - "id" : "b40ef09b-3811-4726-b508-e41a0f96c68f", - "name" : "my_new_archive", - "projectId" : 234567, - "reason" : "", - "resolution" : "640x480", - "sessionId" : "flR1ZSBPY3QgMjkgMTI6MTM6MjMgUERUIDIwMTN", - "size" : 0, - "status" : "stopped", - "url" : null -} \ No newline at end of file diff --git a/http_client/tests/test_video.py b/http_client/tests/test_video.py deleted file mode 100644 index d1c98945..00000000 --- a/http_client/tests/test_video.py +++ /dev/null @@ -1,678 +0,0 @@ -from util import * -from vonage import Client -from vonage.errors import ( - ClientError, - VideoError, - InvalidRoleError, - TokenExpiryError, - SipError, -) - -import jwt -from time import time - - -session_id = 'my_session_id' -stream_id = 'my_stream_id' -connection_id = '1234-5678' -archive_id = '1234-abcd' -broadcast_id = '1748b7070a81464c9759c46ad10d3734' - - -@responses.activate -def test_create_default_session(client: Client, dummy_data): - stub( - responses.POST, - "https://video.api.vonage.com/session/create", - fixture_path="video/create_session.json", - ) - - session_info = client.video.create_session() - assert isinstance(session_info, dict) - assert request_user_agent() == dummy_data.user_agent - assert session_info['session_id'] == session_id - assert session_info['archive_mode'] == 'manual' - assert session_info['media_mode'] == 'routed' - assert session_info['location'] == None - - -@responses.activate -def test_create_session_custom_archive_mode_and_location(client: Client): - stub( - responses.POST, - "https://video.api.vonage.com/session/create", - fixture_path="video/create_session.json", - ) - - session_options = {'archive_mode': 'always', 'location': '192.0.1.1', 'media_mode': 'routed'} - session_info = client.video.create_session(session_options) - assert isinstance(session_info, dict) - assert session_info['session_id'] == session_id - assert session_info['archive_mode'] == 'always' - assert session_info['media_mode'] == 'routed' - assert session_info['location'] == '192.0.1.1' - - -@responses.activate -def test_create_session_custom_media_mode(client: Client): - stub( - responses.POST, - "https://video.api.vonage.com/session/create", - fixture_path="video/create_session.json", - ) - - session_options = {'media_mode': 'relayed'} - session_info = client.video.create_session(session_options) - assert isinstance(session_info, dict) - assert session_info['session_id'] == session_id - assert session_info['archive_mode'] == 'manual' - assert session_info['media_mode'] == 'relayed' - assert session_info['location'] == None - - -def test_create_session_invalid_archive_mode(client: Client): - session_options = {'archive_mode': 'invalid_option'} - with pytest.raises(VideoError) as excinfo: - client.video.create_session(session_options) - assert 'Invalid archive_mode value. Must be one of ' in str(excinfo.value) - - -def test_create_session_invalid_media_mode(client: Client): - session_options = {'media_mode': 'invalid_option'} - with pytest.raises(VideoError) as excinfo: - client.video.create_session(session_options) - assert 'Invalid media_mode value. Must be one of ' in str(excinfo.value) - - -def test_create_session_invalid_mode_combination(client: Client): - session_options = {'archive_mode': 'always', 'media_mode': 'relayed'} - with pytest.raises(VideoError) as excinfo: - client.video.create_session(session_options) - assert ( - str(excinfo.value) - == 'Invalid combination: cannot specify "archive_mode": "always" and "media_mode": "relayed".' - ) - - -def test_generate_client_token_all_defaults(client: Client): - token = client.video.generate_client_token(session_id) - decoded_token = jwt.decode(token, algorithms='RS256', options={'verify_signature': False}) - assert decoded_token['application_id'] == 'nexmo-application-id' - assert decoded_token['scope'] == 'session.connect' - assert decoded_token['session_id'] == 'my_session_id' - assert decoded_token['role'] == 'publisher' - assert decoded_token['initial_layout_class_list'] == '' - - -def test_generate_client_token_custom_options(client: Client): - now = int(time()) - token_options = { - 'role': 'moderator', - 'data': 'some token data', - 'initialLayoutClassList': ['1234', '5678', '9123'], - 'expireTime': now + 60, - 'jti': 1234, - 'iat': now, - 'subject': 'test_subject', - 'acl': ['1', '2', '3'], - } - - token = client.video.generate_client_token(session_id, token_options) - decoded_token = jwt.decode(token, algorithms='RS256', options={'verify_signature': False}) - assert decoded_token['application_id'] == 'nexmo-application-id' - assert decoded_token['scope'] == 'session.connect' - assert decoded_token['session_id'] == 'my_session_id' - assert decoded_token['role'] == 'moderator' - assert decoded_token['initial_layout_class_list'] == ['1234', '5678', '9123'] - assert decoded_token['data'] == 'some token data' - assert decoded_token['jti'] == 1234 - assert decoded_token['subject'] == 'test_subject' - assert decoded_token['acl'] == ['1', '2', '3'] - - -def test_check_client_token_headers(client: Client): - token = client.video.generate_client_token(session_id) - headers = jwt.get_unverified_header(token) - assert headers['alg'] == 'RS256' - assert headers['typ'] == 'JWT' - - -def test_generate_client_token_invalid_role(client: Client): - with pytest.raises(InvalidRoleError): - client.video.generate_client_token(session_id, {'role': 'observer'}) - - -def test_generate_client_token_invalid_expire_time(client: Client): - now = int(time()) - with pytest.raises(TokenExpiryError): - client.video.generate_client_token(session_id, {'expireTime': now + 3600 * 24 * 30 + 1}) - - -@responses.activate -def test_get_stream(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream/{stream_id}", - fixture_path="video/get_stream.json", - ) - - stream = client.video.get_stream(session_id, stream_id) - assert isinstance(stream, dict) - assert stream['videoType'] == 'camera' - - -@responses.activate -def test_list_streams( - client: Client, -): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream", - fixture_path="video/list_streams.json", - ) - - stream_list = client.video.list_streams(session_id) - assert isinstance(stream_list, dict) - assert stream_list['items'][0]['videoType'] == 'camera' - - -@responses.activate -def test_change_stream_layout(client: Client): - stub( - responses.PUT, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream", - ) - - items = [{'id': 'stream-1234', 'layoutClassList': ["full"]}] - - assert isinstance(client.video.set_stream_layout(session_id, items), dict) - assert request_content_type() == "application/json" - - -@responses.activate -def test_send_signal_to_all_participants(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/signal", - ) - - assert isinstance( - client.video.send_signal(session_id, type='chat', data='hello from a test case'), dict - ) - assert request_content_type() == "application/json" - - -@responses.activate -def test_send_signal_to_single_participant(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/{connection_id}/signal", - ) - - assert isinstance( - client.video.send_signal( - session_id, type='chat', data='hello from a test case', connection_id=connection_id - ), - dict, - ) - assert request_content_type() == "application/json" - - -@responses.activate -def test_disconnect_client(client: Client): - stub( - responses.DELETE, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/{connection_id}", - ) - - assert isinstance(client.video.disconnect_client(session_id, connection_id=connection_id), dict) - - -@responses.activate -def test_mute_specific_stream(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/stream/{stream_id}/mute", - fixture_path="video/mute_specific_stream.json", - ) - - response = client.video.mute_stream(session_id, stream_id) - assert isinstance(response, dict) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_mute_all_streams(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", - fixture_path="video/mute_multiple_streams.json", - ) - - response = client.video.mute_all_streams(session_id) - assert isinstance(response, dict) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_mute_all_streams_except_excluded_list(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", - fixture_path="video/mute_multiple_streams.json", - ) - - response = client.video.mute_all_streams( - session_id, excluded_stream_ids=['excluded_stream_id_1', 'excluded_stream_id_2'] - ) - assert isinstance(response, dict) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_disable_mute_all_streams(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/mute", - fixture_path="video/disable_mute_multiple_streams.json", - ) - - response = client.video.disable_mute_all_streams( - session_id, excluded_stream_ids=['excluded_stream_id_1', 'excluded_stream_id_2'] - ) - assert isinstance(response, dict) - assert ( - request_body() - == b'{"active": false, "excludedStreamIds": ["excluded_stream_id_1", "excluded_stream_id_2"]}' - ) - assert response['createdAt'] == 1414642898000 - - -@responses.activate -def test_list_archives_with_filters_applied(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive", - fixture_path="video/list_archives.json", - ) - - response = client.video.list_archives(offset=0, count=1, session_id=session_id) - assert isinstance(response, dict) - assert response['items'][0]['createdAt'] == 1384221730000 - assert response['items'][0]['streams'][0]['streamId'] == 'abc123' - - -@responses.activate -def test_create_new_archive(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive", - fixture_path="video/create_archive.json", - ) - - response = client.video.create_archive( - session_id=session_id, name='my_new_archive', outputMode='individual' - ) - assert isinstance(response, dict) - assert response['name'] == 'my_new_archive' - assert response['createdAt'] == 1384221730555 - - -@responses.activate -def test_get_archive(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}", - fixture_path="video/get_archive.json", - ) - - response = client.video.get_archive(archive_id=archive_id) - assert isinstance(response, dict) - assert response['duration'] == 5049 - assert response['size'] == 247748791 - assert response['streams'] == [] - - -@responses.activate -def test_delete_archive(client: Client): - stub( - responses.GET, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}", - status_code=204, - fixture_path='no_content.json', - ) - - assert client.video.delete_archive(archive_id=archive_id) == None - - -@responses.activate -def test_add_stream_to_archive(client: Client): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert ( - client.video.add_stream_to_archive( - archive_id=archive_id, stream_id='1234', has_audio=True, has_video=True - ) - == None - ) - - -@responses.activate -def test_remove_stream_from_archive(client: Client): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert client.video.remove_stream_from_archive(archive_id=archive_id, stream_id='1234') == None - - -@responses.activate -def test_stop_archive(client: Client): - stub( - responses.POST, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/stop", - fixture_path="video/stop_archive.json", - ) - - response = client.video.stop_archive(archive_id=archive_id) - assert response['name'] == 'my_new_archive' - assert response['createdAt'] == 1384221730555 - assert response['status'] == 'stopped' - - -@responses.activate -def test_change_archive_layout(client: Client): - stub( - responses.PUT, - f"https://video.api.vonage.com/v2/project/{client.application_id}/archive/{archive_id}/layout", - ) - - params = {'type': 'bestFit', 'screenshareType': 'horizontalPresentation'} - - assert isinstance(client.video.change_archive_layout(archive_id, params), dict) - assert request_content_type() == "application/json" - - -@responses.activate -def test_create_sip_call(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/dial', - fixture_path='video/create_sip_call.json', - ) - - sip = {'uri': 'sip:user@sip.partner.com;transport=tls'} - - sip_call = client.video.create_sip_call(session_id, 'my_token', sip) - assert sip_call['id'] == 'b0a5a8c7-dc38-459f-a48d-a7f2008da853' - assert sip_call['connectionId'] == 'e9f8c166-6c67-440d-994a-04fb6dfed007' - assert sip_call['streamId'] == '482bce73-f882-40fd-8ca5-cb74ff416036' - - -@responses.activate -def test_create_sip_call_not_found_error(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/dial', - status_code=404, - ) - sip = {'uri': 'sip:user@sip.partner.com;transport=tls'} - with pytest.raises(ClientError): - client.video.create_sip_call('an-invalid-session-id', 'my_token', sip) - - -def test_create_sip_call_no_uri_error(client): - sip = {} - with pytest.raises(SipError) as err: - client.video.create_sip_call(session_id, 'my_token', sip) - - assert str(err.value) == 'You must specify a uri when creating a SIP call.' - - -@responses.activate -def test_play_dtmf(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/play-dtmf', - fixture_path='no_content.json', - ) - - assert client.video.play_dtmf(session_id, '1234') == None - - -@responses.activate -def test_play_dtmf_specific_connection(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/connection/my-connection-id/play-dtmf', - fixture_path='no_content.json', - ) - - assert client.video.play_dtmf(session_id, '1234', connection_id='my-connection-id') == None - - -@responses.activate -def test_play_dtmf_invalid_session_id_error(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/session/{session_id}/play-dtmf', - fixture_path='video/play_dtmf_invalid_error.json', - status_code=400, - ) - - with pytest.raises(ClientError) as err: - client.video.play_dtmf(session_id, '1234') - assert 'One of the properties digits or sessionId is invalid.' in str(err.value) - - -def test_play_dtmf_invalid_input_error(client): - with pytest.raises(VideoError) as err: - client.video.play_dtmf(session_id, '!@£$%^&()asdfghjkl;') - - assert str(err.value) == 'Only digits 0-9, *, #, and "p" are allowed.' - - -@responses.activate -def test_list_broadcasts(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/list_broadcasts.json', - ) - - broadcasts = client.video.list_broadcasts() - assert broadcasts['count'] == '1' - assert broadcasts['items'][0]['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcasts['items'][0]['applicationId'] == 'abc123' - - -@responses.activate -def test_list_broadcasts_options(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/list_broadcasts.json', - ) - - broadcasts = client.video.list_broadcasts( - count=1, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - ) - assert broadcasts['count'] == '1' - assert broadcasts['items'][0]['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - assert broadcasts['items'][0]['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcasts['items'][0]['applicationId'] == 'abc123' - - -@responses.activate -def test_list_broadcasts_invalid_options_errors(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/list_broadcasts.json', - ) - - with pytest.raises(VideoError) as err: - client.video.list_broadcasts(offset=-2, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') - assert str(err.value) == 'Offset must be an int >= 0.' - - with pytest.raises(VideoError) as err: - client.video.list_broadcasts(count=9999, session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') - assert str(err.value) == 'Count must be an int between 0 and 1000.' - - with pytest.raises(VideoError) as err: - client.video.list_broadcasts(offset='10', session_id='2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4') - assert str(err.value) == 'Offset must be an int >= 0.' - - -@responses.activate -def test_start_broadcast_required_params(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/broadcast.json', - ) - - params = { - "sessionId": "2_MX40NTMyODc3Mn5-fg", - "outputs": { - "rtmp": [ - { - "id": "foo", - "serverUrl": "rtmps://myfooserver/myfooapp", - "streamName": "myfoostream", - } - ] - }, - } - - broadcast = client.video.start_broadcast(params) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['createdAt'] == 1437676551000 - assert broadcast['maxBitrate'] == 2000000 - assert broadcast['broadcastUrls']['rtmp'][0]['id'] == 'abc123' - - -@responses.activate -def test_start_broadcast_all_params(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast', - fixture_path='video/broadcast.json', - ) - - params = { - "sessionId": "2_MX40NTMyODc3Mn5-fg", - "layout": { - "type": "custom", - "stylesheet": "the layout stylesheet (only used with type == custom)", - "screenshareType": "horizontalPresentation", - }, - "maxDuration": 5400, - "outputs": { - "rtmp": [ - { - "id": "foo", - "serverUrl": "rtmps://myfooserver/myfooapp", - "streamName": "myfoostream", - } - ] - }, - "resolution": "1920x1080", - "streamMode": "manual", - "multiBroadcastTag": "foo", - } - - broadcast = client.video.start_broadcast(params) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['createdAt'] == 1437676551000 - assert broadcast['maxBitrate'] == 2000000 - assert broadcast['broadcastUrls']['rtmp'][0]['id'] == 'abc123' - - -@responses.activate -def test_get_broadcast(client): - stub( - responses.GET, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}', - fixture_path='video/broadcast.json', - ) - - broadcast = client.video.get_broadcast(broadcast_id) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - assert broadcast['updatedAt'] == 1437676551000 - assert broadcast['resolution'] == '640x480' - - -@responses.activate -def test_stop_broadcast(client): - stub( - responses.POST, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}', - fixture_path='video/broadcast.json', - ) - - broadcast = client.video.stop_broadcast(broadcast_id) - assert broadcast['id'] == '1748b7070a81464c9759c46ad10d3734' - assert broadcast['sessionId'] == '2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4' - assert broadcast['updatedAt'] == 1437676551000 - assert broadcast['resolution'] == '640x480' - - -@responses.activate -def test_change_broadcast_layout(client): - stub( - responses.PUT, - f'https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/layout', - fixture_path='no_content.json', - ) - - params = { - "type": "bestFit", - "stylesheet": "stream.instructor {position: absolute; width: 100%; height:50%;}", - "screenshareType": "pip", - } - - assert client.video.change_broadcast_layout(broadcast_id, params) == None - - -@responses.activate -def test_add_stream_to_broadcast(client: Client, dummy_data): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert ( - client.video.add_stream_to_broadcast( - broadcast_id=broadcast_id, stream_id='1234', has_audio=True, has_video=True - ) - == None - ) - assert request_user_agent() == dummy_data.user_agent - - -@responses.activate -def test_remove_stream_from_broadcast(client: Client, dummy_data): - stub( - responses.PATCH, - f"https://video.api.vonage.com/v2/project/{client.application_id}/broadcast/{broadcast_id}/streams", - status_code=204, - fixture_path='no_content.json', - ) - - assert ( - client.video.remove_stream_from_broadcast(broadcast_id=broadcast_id, stream_id='1234') - == None - ) - assert request_user_agent() == dummy_data.user_agent From 095fb7b5ab1493f3abc024df0fb8e7087ab95122 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 14 Feb 2024 03:29:08 +0000 Subject: [PATCH 03/98] add NI v2, refactoring, new tests --- Makefile | 3 + http_client/src/http_client/http_client.py | 49 +++++++++-- http_client/tests/data/401.json | 7 +- http_client/tests/data/429.json | 7 +- http_client/tests/data/500.json | 6 +- http_client/tests/test_http_client.py | 43 +++++++--- libs/testing_utils.py | 5 +- libs/tests/test_format_phone_number.py | 2 +- libs/tests/test_remove_none_values.py | 16 ++++ libs/{format_phone_number.py => utils.py} | 14 +++ number_insight_v2/BUILD | 4 +- .../number_insight_v2/number_insight_v2.py | 72 ++++++++++++---- number_insight_v2/tests/BUILD | 2 +- number_insight_v2/tests/data/default.json | 19 ++++ number_insight_v2/tests/data/fraud_score.json | 15 ++++ number_insight_v2/tests/data/sim_swap.json | 11 +++ number_insight_v2/tests/test_ni2.py | 7 -- .../tests/test_number_insight_v2.py | 86 +++++++++++++++++++ requirements.txt | 4 +- vonage/pyproject.toml | 2 +- vonage/src/vonage/__init__.py | 1 + vonage/src/vonage/vonage.py | 7 +- vonage/tests/test_vonage.py | 3 +- 23 files changed, 321 insertions(+), 64 deletions(-) create mode 100644 libs/tests/test_remove_none_values.py rename libs/{format_phone_number.py => utils.py} (73%) create mode 100644 number_insight_v2/tests/data/default.json create mode 100644 number_insight_v2/tests/data/fraud_score.json create mode 100644 number_insight_v2/tests/data/sim_swap.json delete mode 100644 number_insight_v2/tests/test_ni2.py create mode 100644 number_insight_v2/tests/test_number_insight_v2.py diff --git a/Makefile b/Makefile index a0d095d7..a5858a29 100644 --- a/Makefile +++ b/Makefile @@ -4,4 +4,7 @@ test: pants test :: coverage: + pants test --use-coverage :: + +coverage-report: pants test --use-coverage --open-coverage :: \ No newline at end of file diff --git a/http_client/src/http_client/http_client.py b/http_client/src/http_client/http_client.py index 3fe68cfd..0ef3b370 100644 --- a/http_client/src/http_client/http_client.py +++ b/http_client/src/http_client/http_client.py @@ -1,6 +1,6 @@ from logging import getLogger from platform import python_version -from typing import Literal, Optional +from typing import Literal, Optional, Union from http_client.auth import Auth from http_client.errors import ( @@ -32,8 +32,9 @@ class HttpClient: """A synchronous HTTP client used to send authenticated requests to Vonage APIs. Args: - auth (:class: Auth): An instance of the Auth class containing credentials to use when making HTTP requests. + 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'. @@ -44,7 +45,12 @@ class HttpClient: 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): + 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: @@ -70,7 +76,7 @@ def __init__(self, auth: Auth, http_client_options: HttpClientOptions = None): ) self._session.mount('https://', self._adapter) - self._user_agent = f'vonage-python-sdk python/{python_version()}' + self._user_agent = f'vonage-python-sdk/{sdk_version} python/{python_version()}' self._headers = {'User-Agent': self._user_agent, 'Accept': 'application/json'} @property @@ -89,11 +95,27 @@ def api_host(self): def rest_host(self): return self._rest_host - def post(self, host: str, request_path: str = '', params: dict = None): - return self.make_request('POST', host, request_path, params) + @property + def user_agent(self): + return self._user_agent + + def post( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: str = 'jwt', + ): + return self.make_request('POST', host, request_path, params, auth_type) - def get(self, host: str, request_path: str = '', params: dict = None): - return self.make_request('GET', host, request_path, params) + def get( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: str = 'jwt', + ): + return self.make_request('GET', host, request_path, params, auth_type) @validate_call def make_request( @@ -102,11 +124,17 @@ def make_request( host: str, request_path: str = '', params: Optional[dict] = None, + auth_type: Literal['jwt', 'basic'] = 'jwt', ): 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() + with self._session.request( request_type, url, @@ -116,7 +144,10 @@ def make_request( ) as response: return self._parse_response(response) - def _parse_response(self, response: Response): + def append_to_user_agent(self, string: str): + 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}' ) diff --git a/http_client/tests/data/401.json b/http_client/tests/data/401.json index a98d881f..c068aec7 100644 --- a/http_client/tests/data/401.json +++ b/http_client/tests/data/401.json @@ -1 +1,6 @@ -{"Error": "Authentication Failed"} \ No newline at end of file +{ + "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/429.json b/http_client/tests/data/429.json index d6a0546b..cc29b165 100644 --- a/http_client/tests/data/429.json +++ b/http_client/tests/data/429.json @@ -1 +1,6 @@ -{"Error": "Too Many Requests"} \ No newline at end of file +{ + "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 index 35039b4c..82af672d 100644 --- a/http_client/tests/data/500.json +++ b/http_client/tests/data/500.json @@ -1 +1,5 @@ -{"Error": "Internal Server Error"} \ No newline at end of file +{ + "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/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 1e2b4eb4..7f637fc4 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -1,5 +1,5 @@ from json import loads -from os.path import abspath +from os.path import abspath, dirname, join import responses from http_client.auth import Auth @@ -19,6 +19,15 @@ 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 @@ -48,12 +57,12 @@ def test_create_http_client_invalid_options_error(): def test_make_get_request(): build_response(path, 'GET', 'https://example.com/get_json', 'example_get.json') client = HttpClient( - Auth('asdfqwer', 'asdfqwer1234'), + 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') - assert res['hello'] == 'world' + assert res['hello'] == 'world' assert responses.calls[0].request.headers['User-Agent'] == client._user_agent @@ -64,7 +73,7 @@ def test_make_get_request_no_content(): Auth('asdfqwer', 'asdfqwer1234'), http_client_options={'api_host': 'example.com'}, ) - res = client.get(host='example.com', request_path='/get_json') + res = client.get(host='example.com', request_path='/get_json', auth_type='basic') assert res == None @@ -72,7 +81,7 @@ def test_make_get_request_no_content(): def test_make_post_request(): build_response(path, 'POST', 'https://example.com/post_json', 'example_post.json') client = HttpClient( - Auth('asdfqwer', 'asdfqwer1234'), + Auth(application_id=application_id, private_key=private_key), http_client_options={'api_host': 'example.com'}, ) params = { @@ -92,7 +101,7 @@ def test_http_response_general_error(): client = HttpClient(Auth()) try: - client.get(host='example.com', request_path='/get_json') + 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 @@ -104,7 +113,7 @@ def test_http_response_general_text_error(): client = HttpClient(Auth()) try: - client.get(host='example.com', request_path='/get') + 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 @@ -114,11 +123,11 @@ def test_http_response_general_text_error(): def test_authentication_error(): build_response(path, 'GET', 'https://example.com/get_json', '401.json', 401) - client = HttpClient(Auth()) + 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()['Error'] == 'Authentication Failed' + assert err.response.json()['title'] == 'Unauthorized' @responses.activate @@ -127,7 +136,7 @@ def test_authentication_error_no_content(): client = HttpClient(Auth()) try: - client.get(host='example.com', request_path='/get_json') + client.get(host='example.com', request_path='/get_json', auth_type='basic') except AuthenticationError as err: assert type(err.response) == Response @@ -138,17 +147,23 @@ def test_rate_limited_error(): client = HttpClient(Auth()) try: - client.get(host='example.com', request_path='/get_json') + client.get(host='example.com', request_path='/get_json', auth_type='basic') except RateLimitedError as err: - assert err.response.json()['Error'] == 'Too Many Requests' + 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()) + 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()['Error'] == 'Internal Server Error' + assert err.response.json()['title'] == 'Internal Server Error' + + +def test_append_to_user_agent(): + client = HttpClient(Auth()) + client.append_to_user_agent('TestAgent') + assert 'TestAgent' in client.user_agent diff --git a/libs/testing_utils.py b/libs/testing_utils.py index d463e7a8..d5e285c1 100644 --- a/libs/testing_utils.py +++ b/libs/testing_utils.py @@ -1,4 +1,4 @@ -import os +from os.path import dirname, join from typing import Literal import responses @@ -6,7 +6,7 @@ def _load_mock_data(caller_file_path: str, mock_path: str): - with open(os.path.join(os.path.dirname(caller_file_path), 'data', mock_path)) as file: + with open(join(dirname(caller_file_path), 'data', mock_path)) as file: return file.read() @@ -19,5 +19,6 @@ def build_response( status_code: int = 200, content_type: str = 'application/json', ): + print('file_path', file_path) body = _load_mock_data(file_path, mock_path) if mock_path else None responses.add(method, url, body=body, status=status_code, content_type=content_type) diff --git a/libs/tests/test_format_phone_number.py b/libs/tests/test_format_phone_number.py index be51f527..6f2108e2 100644 --- a/libs/tests/test_format_phone_number.py +++ b/libs/tests/test_format_phone_number.py @@ -1,6 +1,6 @@ from errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError -from format_phone_number import format_phone_number from pytest import raises +from utils import format_phone_number def test_format_phone_numbers(): diff --git a/libs/tests/test_remove_none_values.py b/libs/tests/test_remove_none_values.py new file mode 100644 index 00000000..708c3d87 --- /dev/null +++ b/libs/tests/test_remove_none_values.py @@ -0,0 +1,16 @@ +from dataclasses import asdict, dataclass + +from 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} diff --git a/libs/format_phone_number.py b/libs/utils.py similarity index 73% rename from libs/format_phone_number.py rename to libs/utils.py index d4fb7f93..2e647504 100644 --- a/libs/format_phone_number.py +++ b/libs/utils.py @@ -34,3 +34,17 @@ def format_phone_number(number: Union[str, int]) -> str: 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/number_insight_v2/BUILD b/number_insight_v2/BUILD index 7c7325b9..77e093a7 100644 --- a/number_insight_v2/BUILD +++ b/number_insight_v2/BUILD @@ -1,8 +1,10 @@ resource(name='pyproject', source='pyproject.toml') +files(sources=['tests/data/*']) + python_distribution( name='vonage-number-insight', - dependencies=[':pyproject', 'ni2/src/ni2'], + dependencies=[':pyproject', 'number_insight_v2/src/number_insight_v2'], provides=python_artifact(), generate_setup=False, repositories=['https://test.pypi.org/legacy/'], diff --git a/number_insight_v2/src/number_insight_v2/number_insight_v2.py b/number_insight_v2/src/number_insight_v2/number_insight_v2.py index d784cce3..e5f9445a 100644 --- a/number_insight_v2/src/number_insight_v2/number_insight_v2.py +++ b/number_insight_v2/src/number_insight_v2/number_insight_v2.py @@ -1,6 +1,6 @@ from copy import deepcopy from dataclasses import dataclass -from typing import List, Union +from typing import List, Literal, Optional, Union from pydantic import BaseModel @@ -8,36 +8,70 @@ class FraudCheckRequest(BaseModel): - """""" + phone: Union[str, int] + insights: Union[ + Literal['fraud_score', 'sim_swap'], List[Literal['fraud_score', 'sim_swap']] + ] = ['fraud_score', 'sim_swap'] + type: Literal['phone'] = 'phone' - number: str - insights: Union[str, List[str]] + +@dataclass +class Phone: + phone: str + carrier: Optional[str] = None + type: Optional[str] = None @dataclass -class FraudCheckResponse: - ... +class FraudScore: + risk_score: str + risk_recommendation: str + label: str + status: str + +@dataclass() +class SimSwap: + status: str + swapped: Optional[bool] = None + reason: Optional[str] = None -# phone: Phone -# sim: SimSwap + +@dataclass() +class FraudCheckResponse: + request_id: str + type: str + phone: Phone + fraud_score: Optional[FraudScore] + sim_swap: Optional[SimSwap] -class NumberInsightv2: +class NumberInsightV2: """Number Insight API V2.""" def __init__(self, http_client: HttpClient) -> None: self._http_client = deepcopy(http_client) - self._http_client._parse_response = self.response_parser - self._auth_type = 'header' - - def fraud_check(self, number: str, insights: Union[str, List[str]]): - """""" + self._auth_type = 'basic' def fraud_check(self, request: FraudCheckRequest) -> FraudCheckResponse: - """""" - response = self._http_client.post('/ni/fraud', request.model_dump()) - return FraudCheckResponse(response) + """Initiate a fraud check request.""" + response = self._http_client.post( + self._http_client.api_host, + '/v2/ni', + request.model_dump(), + self._auth_type, + ) + + phone = Phone(**response['phone']) + fraud_score = ( + FraudScore(**response['fraud_score']) if 'fraud_score' in response else None + ) + sim_swap = SimSwap(**response['sim_swap']) if 'sim_swap' in response else None - def response_parser(self, response): - """""" + return FraudCheckResponse( + request_id=response['request_id'], + type=response['type'], + phone=phone, + fraud_score=fraud_score, + sim_swap=sim_swap, + ) diff --git a/number_insight_v2/tests/BUILD b/number_insight_v2/tests/BUILD index dabf212d..a5151f92 100644 --- a/number_insight_v2/tests/BUILD +++ b/number_insight_v2/tests/BUILD @@ -1 +1 @@ -python_tests() +python_tests(dependencies=['number_insight_v2']) diff --git a/number_insight_v2/tests/data/default.json b/number_insight_v2/tests/data/default.json new file mode 100644 index 00000000..cb808dd7 --- /dev/null +++ b/number_insight_v2/tests/data/default.json @@ -0,0 +1,19 @@ +{ + "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", + "type": "phone", + "phone": { + "phone": "1234567890", + "carrier": "Verizon Wireless", + "type": "MOBILE" + }, + "fraud_score": { + "risk_score": "0", + "risk_recommendation": "allow", + "label": "low", + "status": "completed" + }, + "sim_swap": { + "status": "completed", + "swapped": false + } +} \ No newline at end of file diff --git a/number_insight_v2/tests/data/fraud_score.json b/number_insight_v2/tests/data/fraud_score.json new file mode 100644 index 00000000..be7cc267 --- /dev/null +++ b/number_insight_v2/tests/data/fraud_score.json @@ -0,0 +1,15 @@ +{ + "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", + "type": "phone", + "phone": { + "phone": "1234567890", + "carrier": "Verizon Wireless", + "type": "MOBILE" + }, + "fraud_score": { + "risk_score": "0", + "risk_recommendation": "allow", + "label": "low", + "status": "completed" + } +} \ No newline at end of file diff --git a/number_insight_v2/tests/data/sim_swap.json b/number_insight_v2/tests/data/sim_swap.json new file mode 100644 index 00000000..594028c8 --- /dev/null +++ b/number_insight_v2/tests/data/sim_swap.json @@ -0,0 +1,11 @@ +{ + "request_id": "db5282b6-8046-4217-9c0e-d9c55d8696e9", + "type": "phone", + "phone": { + "phone": "1234567890" + }, + "sim_swap": { + "status": "completed", + "swapped": false + } +} \ No newline at end of file diff --git a/number_insight_v2/tests/test_ni2.py b/number_insight_v2/tests/test_ni2.py deleted file mode 100644 index 8bb43f93..00000000 --- a/number_insight_v2/tests/test_ni2.py +++ /dev/null @@ -1,7 +0,0 @@ -import pytest - - -def test_raise_exception(): - with pytest.raises(Exception): - raise Exception - assert True diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py new file mode 100644 index 00000000..17e9dce9 --- /dev/null +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -0,0 +1,86 @@ +from dataclasses import asdict +from os.path import abspath + +import responses +from http_client.auth import Auth +from pydantic import ValidationError +from pytest import raises +from testing_utils import build_response +from utils import remove_none_values + +from http_client.http_client import HttpClient +from number_insight_v2.number_insight_v2 import ( + FraudCheckRequest, + FraudCheckResponse, + NumberInsightV2, +) + +path = abspath(__file__) + +ni2 = NumberInsightV2(HttpClient(Auth('key', 'secret'))) + + +def test_fraud_check_request_defaults(): + request = FraudCheckRequest(phone='1234567890') + assert request.type == 'phone' + assert request.phone == '1234567890' + assert request.insights == ['fraud_score', 'sim_swap'] + + +def test_fraud_check_request_custom_insights(): + request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) + assert request.type == 'phone' + assert request.phone == '1234567890' + assert request.insights == ['fraud_score'] + + +def test_fraud_check_request_invalid_insights(): + with raises(ValidationError): + FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) + + +@responses.activate +def test_ni2_defaults(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') + request = FraudCheckRequest(phone='1234567890') + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' + assert response.phone.carrier == 'Verizon Wireless' + assert response.fraud_score.risk_score == '0' + assert response.sim_swap.status == 'completed' + + +@responses.activate +def test_ni2_fraud_score_only(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') + request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' + assert response.phone.carrier == 'Verizon Wireless' + assert response.fraud_score.risk_score == '0' + assert response.sim_swap is None + + clear_response = asdict(response, dict_factory=remove_none_values) + print(clear_response) + assert 'fraud_score' in clear_response + assert 'sim_swap' not in clear_response + + +@responses.activate +def test_ni2_sim_swap_only(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') + request = FraudCheckRequest(phone='1234567890', insights='sim_swap') + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' + assert response.phone.phone == '1234567890' + assert response.fraud_score is None + assert response.sim_swap.status == 'completed' + assert response.sim_swap.swapped is False + + clear_response = asdict(response, dict_factory=remove_none_values) + assert 'fraud_score' not in clear_response + assert 'sim_swap' in clear_response + assert 'reason' not in clear_response['sim_swap'] diff --git a/requirements.txt b/requirements.txt index cc0e7360..458067d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ pytest>=8.0.0 requests>=2.31.0 responses>=0.24.1 -pydantic>=2.5.2 -typing_extensions>=4.8.0 +pydantic>=2.6.1 +typing_extensions>=4.9.0 vonage-jwt>=1.1.0 \ No newline at end of file diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 632f3d84..f28c3380 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -1,7 +1,7 @@ [project] name = 'vonage' description = 'Python Server SDK for using Vonage APIs' -version = '4.0.0b1' +version = '4.0.1a0' [build-system] diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index e69de29b..04c3ba4b 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -0,0 +1 @@ +__version__ = '4.0.1a0' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index e2d7ab77..16c89c2f 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -3,7 +3,8 @@ from http_client.auth import Auth from http_client.http_client import HttpClient, HttpClientOptions -from number_insight_v2.number_insight_v2 import NumberInsightv2 +from number_insight_v2.number_insight_v2 import NumberInsightV2 +from vonage import __version__ class Vonage: @@ -17,9 +18,9 @@ class Vonage: def __init__( self, auth: Auth, http_client_options: Optional[HttpClientOptions] = None ): - self._http_client = HttpClient(auth, http_client_options) + self._http_client = HttpClient(auth, http_client_options, __version__) - self.number_insight_v2 = NumberInsightv2(self._http_client) + self.number_insight_v2 = NumberInsightV2(self._http_client) @property def http_client(self): diff --git a/vonage/tests/test_vonage.py b/vonage/tests/test_vonage.py index 024d07c0..2b51f184 100644 --- a/vonage/tests/test_vonage.py +++ b/vonage/tests/test_vonage.py @@ -1,5 +1,5 @@ from http_client.http_client import HttpClient -from vonage.vonage import Auth, Vonage +from vonage.vonage import Auth, Vonage, __version__ def test_create_vonage_class_instance(): @@ -11,3 +11,4 @@ def test_create_vonage_class_instance(): 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 From 3f6d7bb89d1f5202c5e3de1864a5e1a195fa847f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 14 Feb 2024 20:32:52 +0000 Subject: [PATCH 04/98] restructuring utils directory --- .gitignore | 1 + LICENSE.txt => LICENSE | 0 http_client/src/http_client/errors.py | 2 +- http_client/tests/BUILD | 2 +- http_client/tests/test_http_client.py | 2 +- libs/pyproject.toml | 8 ------ libs/tests/BUILD | 1 - number_insight_v2/BUILD | 4 +-- number_insight_v2/pyproject.toml | 17 ++++++++++++- .../tests/test_number_insight_v2.py | 4 +-- pants.toml | 9 +++++-- {libs => testing_utils}/BUILD | 0 testing_utils/__init__.py | 0 {libs => testing_utils}/testing_utils.py | 0 utils/BUILD | 9 +++++++ utils/README.md | 3 +++ utils/pyproject.toml | 25 +++++++++++++++++++ utils/src/utils/BUILD | 1 + utils/src/utils/__init__.py | 0 {libs => utils/src/utils}/errors.py | 0 {libs => utils/src/utils}/utils.py | 0 utils/tests/BUILD | 1 + .../tests/test_format_phone_number.py | 1 + .../tests/test_remove_none_values.py | 2 +- 24 files changed, 72 insertions(+), 20 deletions(-) rename LICENSE.txt => LICENSE (100%) delete mode 100644 libs/pyproject.toml delete mode 100644 libs/tests/BUILD rename {libs => testing_utils}/BUILD (100%) create mode 100644 testing_utils/__init__.py rename {libs => testing_utils}/testing_utils.py (100%) create mode 100644 utils/BUILD create mode 100644 utils/README.md create mode 100644 utils/pyproject.toml create mode 100644 utils/src/utils/BUILD create mode 100644 utils/src/utils/__init__.py rename {libs => utils/src/utils}/errors.py (100%) rename {libs => utils/src/utils}/utils.py (100%) create mode 100644 utils/tests/BUILD rename {libs => utils}/tests/test_format_phone_number.py (99%) rename {libs => utils}/tests/test_remove_none_values.py (87%) diff --git a/.gitignore b/.gitignore index e7513b24..fce8e163 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ ENV* .pytest_cache html/ .mutmut-cache +_test_scripts/ # Pants workspace files /.pants.* diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/http_client/src/http_client/errors.py b/http_client/src/http_client/errors.py index 77921c09..e5119c33 100644 --- a/http_client/src/http_client/errors.py +++ b/http_client/src/http_client/errors.py @@ -1,7 +1,7 @@ from json import JSONDecodeError -from errors import VonageError from requests import Response +from utils.errors import VonageError class JWTGenerationError(VonageError): diff --git a/http_client/tests/BUILD b/http_client/tests/BUILD index c9a9fd7b..7e8efd28 100644 --- a/http_client/tests/BUILD +++ b/http_client/tests/BUILD @@ -1 +1 @@ -python_tests(dependencies=['http_client', 'libs']) +python_tests(dependencies=['http_client', 'utils']) diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 7f637fc4..99c4360c 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -12,9 +12,9 @@ ) from pytest import raises from requests import Response -from testing_utils import build_response from http_client.http_client import HttpClient +from testing_utils import build_response path = abspath(__file__) diff --git a/libs/pyproject.toml b/libs/pyproject.toml deleted file mode 100644 index 4faf4d18..00000000 --- a/libs/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = 'vonage-lib' -description = 'lib functions for use with Vonage APIs' -version = '0.1.0' - -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" diff --git a/libs/tests/BUILD b/libs/tests/BUILD deleted file mode 100644 index acaef3ba..00000000 --- a/libs/tests/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_tests(dependencies=['libs']) diff --git a/number_insight_v2/BUILD b/number_insight_v2/BUILD index 77e093a7..3fabca68 100644 --- a/number_insight_v2/BUILD +++ b/number_insight_v2/BUILD @@ -3,8 +3,8 @@ resource(name='pyproject', source='pyproject.toml') files(sources=['tests/data/*']) python_distribution( - name='vonage-number-insight', - dependencies=[':pyproject', 'number_insight_v2/src/number_insight_v2'], + name='vonage-number-insight-v2', + dependencies=[':pyproject', 'number_insight_v2/src/number_insight_v2', 'utils'], provides=python_artifact(), generate_setup=False, repositories=['https://test.pypi.org/legacy/'], diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml index a41cd0ee..7d50887b 100644 --- a/number_insight_v2/pyproject.toml +++ b/number_insight_v2/pyproject.toml @@ -2,7 +2,22 @@ name = 'vonage-number-insight-v2' description = 'Vonage Number Insight v2 package' version = '0.1.0' -dependencies = ['vonage-http-client'] +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +license = "Apache" +homepage = "https://github.com/Vonage/vonage-python-sdk" +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", +] + +[project.python-requires] +python_version = ">=3.8" [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py index 17e9dce9..e4d127f3 100644 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -5,8 +5,6 @@ from http_client.auth import Auth from pydantic import ValidationError from pytest import raises -from testing_utils import build_response -from utils import remove_none_values from http_client.http_client import HttpClient from number_insight_v2.number_insight_v2 import ( @@ -14,6 +12,8 @@ FraudCheckResponse, NumberInsightV2, ) +from testing_utils import build_response +from utils import remove_none_values path = abspath(__file__) diff --git a/pants.toml b/pants.toml index 0acc97b1..5cee4fe1 100644 --- a/pants.toml +++ b/pants.toml @@ -16,7 +16,7 @@ backend_packages = [ enabled = false [source] -root_patterns = ['/', 'src/', 'tests/', 'libs/'] +root_patterns = ['/', 'src/', 'tests/', 'testing_utils/'] [python] interpreter_constraints = ['==3.11.*'] @@ -27,7 +27,12 @@ args = ['-vv', '--no-header'] [coverage-py] interpreter_constraints = ['>=3.8'] report = ['html', 'console'] -filter = ['vonage/src', 'http_client/src', 'ni2/src', 'libs'] +filter = [ + 'vonage/src', + 'http_client/src', + 'number_insight_v2/src', + 'testing_utils', +] [black] args = ['--line-length=90', '--skip-string-normalization'] diff --git a/libs/BUILD b/testing_utils/BUILD similarity index 100% rename from libs/BUILD rename to testing_utils/BUILD diff --git a/testing_utils/__init__.py b/testing_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/testing_utils.py b/testing_utils/testing_utils.py similarity index 100% rename from libs/testing_utils.py rename to testing_utils/testing_utils.py diff --git a/utils/BUILD b/utils/BUILD new file mode 100644 index 00000000..9ded8be2 --- /dev/null +++ b/utils/BUILD @@ -0,0 +1,9 @@ +resource(name='pyproject', source='pyproject.toml') + +python_distribution( + name='vonage-utils', + dependencies=[':pyproject'], + provides=python_artifact(), + generate_setup=False, + repositories=['https://test.pypi.org/legacy/'], +) diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 00000000..0adcc9c7 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,3 @@ +# Vonage Utils Package + +This package contains utility code that is used by the Vonage Python SDK and other related packages. \ No newline at end of file diff --git a/utils/pyproject.toml b/utils/pyproject.toml new file mode 100644 index 00000000..5e466a36 --- /dev/null +++ b/utils/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = 'vonage-utils' +version = '0.1.0' +description = 'Utils package containing objects for use with Vonage APIs' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +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" +documentation = "https://developer.vonage.com" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/utils/src/utils/BUILD b/utils/src/utils/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/utils/src/utils/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/utils/src/utils/__init__.py b/utils/src/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/errors.py b/utils/src/utils/errors.py similarity index 100% rename from libs/errors.py rename to utils/src/utils/errors.py diff --git a/libs/utils.py b/utils/src/utils/utils.py similarity index 100% rename from libs/utils.py rename to utils/src/utils/utils.py diff --git a/utils/tests/BUILD b/utils/tests/BUILD new file mode 100644 index 00000000..c9cbcdc5 --- /dev/null +++ b/utils/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['utils']) diff --git a/libs/tests/test_format_phone_number.py b/utils/tests/test_format_phone_number.py similarity index 99% rename from libs/tests/test_format_phone_number.py rename to utils/tests/test_format_phone_number.py index 6f2108e2..fd5592fa 100644 --- a/libs/tests/test_format_phone_number.py +++ b/utils/tests/test_format_phone_number.py @@ -1,5 +1,6 @@ from errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError from pytest import raises + from utils import format_phone_number diff --git a/libs/tests/test_remove_none_values.py b/utils/tests/test_remove_none_values.py similarity index 87% rename from libs/tests/test_remove_none_values.py rename to utils/tests/test_remove_none_values.py index 708c3d87..2cc3b6d3 100644 --- a/libs/tests/test_remove_none_values.py +++ b/utils/tests/test_remove_none_values.py @@ -1,6 +1,6 @@ from dataclasses import asdict, dataclass -from utils import remove_none_values +from utils.utils import remove_none_values @dataclass From 7736da039f24d0bed0219a8e7bc3fdc471548221 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 15 Feb 2024 04:20:24 +0000 Subject: [PATCH 05/98] preparing packages for release --- http_client/BUILD | 2 +- http_client/pyproject.toml | 26 +++++++++++++++++-- http_client/src/http_client/http_client.py | 2 +- http_client/tests/BUILD | 2 +- number_insight_v2/BUILD | 6 ++++- number_insight_v2/pyproject.toml | 15 +++++++---- .../src/number_insight_v2/errors.py | 2 +- .../tests/test_number_insight_v2.py | 2 +- pants.toml | 1 + testing_utils/BUILD | 2 +- utils/BUILD | 2 +- utils/pyproject.toml | 1 - utils/src/utils/BUILD | 2 +- utils/src/utils/utils.py | 2 +- utils/tests/BUILD | 2 +- utils/tests/test_format_phone_number.py | 4 +-- vonage/pyproject.toml | 22 +++++++++++++++- 17 files changed, 73 insertions(+), 22 deletions(-) diff --git a/http_client/BUILD b/http_client/BUILD index d9eea45b..a30c6228 100644 --- a/http_client/BUILD +++ b/http_client/BUILD @@ -4,7 +4,7 @@ files(sources=['tests/data/*']) python_distribution( name='vonage-http-client', - dependencies=[':pyproject', 'http_client/src/http_client'], + dependencies=[':pyproject', 'http_client/src/http_client', 'utils/src/utils'], provides=python_artifact(), generate_setup=False, repositories=['https://test.pypi.org/legacy/'], diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index be9f8a88..ac49e3b9 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,8 +1,30 @@ [project] name = 'vonage-http-client' -description = 'An asynchronous HTTP client for making requests to Vonage APIs.' version = '0.1.0' -dependencies = ['vonage-jwt', 'requests'] +description = 'An HTTP client for making requests to Vonage APIs.' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-utils>=0.1.0", + "vonage-jwt>=1.1.0", + "requests==2.*", + "pydantic>=2.6.1", + "typing_extensions>=4.9.0", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/http_client/src/http_client/http_client.py b/http_client/src/http_client/http_client.py index 0ef3b370..98654fff 100644 --- a/http_client/src/http_client/http_client.py +++ b/http_client/src/http_client/http_client.py @@ -16,7 +16,7 @@ from requests.sessions import Session from typing_extensions import Annotated -logger = getLogger('vonage-http-client-v2') +logger = getLogger('vonage') class HttpClientOptions(BaseModel): diff --git a/http_client/tests/BUILD b/http_client/tests/BUILD index 7e8efd28..55e58f97 100644 --- a/http_client/tests/BUILD +++ b/http_client/tests/BUILD @@ -1 +1 @@ -python_tests(dependencies=['http_client', 'utils']) +python_tests(dependencies=['http_client']) diff --git a/number_insight_v2/BUILD b/number_insight_v2/BUILD index 3fabca68..02b9d573 100644 --- a/number_insight_v2/BUILD +++ b/number_insight_v2/BUILD @@ -4,7 +4,11 @@ files(sources=['tests/data/*']) python_distribution( name='vonage-number-insight-v2', - dependencies=[':pyproject', 'number_insight_v2/src/number_insight_v2', 'utils'], + dependencies=[ + ':pyproject', + 'number_insight_v2/src/number_insight_v2', + 'utils/src/utils', + ], provides=python_artifact(), generate_setup=False, repositories=['https://test.pypi.org/legacy/'], diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml index 7d50887b..d38b49a6 100644 --- a/number_insight_v2/pyproject.toml +++ b/number_insight_v2/pyproject.toml @@ -1,11 +1,15 @@ [project] name = 'vonage-number-insight-v2' -description = 'Vonage Number Insight v2 package' version = '0.1.0' +description = 'Vonage Number Insight v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -license = "Apache" -homepage = "https://github.com/Vonage/vonage-python-sdk" +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=0.1.0", + "vonage-utils>=0.1.0", + "pydantic>=2.6.1", +] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", @@ -14,10 +18,11 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", ] -[project.python-requires] -python_version = ">=3.8" +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/number_insight_v2/src/number_insight_v2/errors.py b/number_insight_v2/src/number_insight_v2/errors.py index ca8a4d7f..9ad6a18e 100644 --- a/number_insight_v2/src/number_insight_v2/errors.py +++ b/number_insight_v2/src/number_insight_v2/errors.py @@ -1,4 +1,4 @@ -from errors import VonageError +from utils.errors import VonageError class NumberInsightV2Error(VonageError): diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py index e4d127f3..64130c03 100644 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -13,7 +13,7 @@ NumberInsightV2, ) from testing_utils import build_response -from utils import remove_none_values +from utils.utils import remove_none_values path = abspath(__file__) diff --git a/pants.toml b/pants.toml index 5cee4fe1..b26b195c 100644 --- a/pants.toml +++ b/pants.toml @@ -31,6 +31,7 @@ filter = [ 'vonage/src', 'http_client/src', 'number_insight_v2/src', + 'utils/src', 'testing_utils', ] diff --git a/testing_utils/BUILD b/testing_utils/BUILD index db46e8d6..e0f85930 100644 --- a/testing_utils/BUILD +++ b/testing_utils/BUILD @@ -1 +1 @@ -python_sources() +python_sources(name='testing_utils') diff --git a/utils/BUILD b/utils/BUILD index 9ded8be2..97b936fa 100644 --- a/utils/BUILD +++ b/utils/BUILD @@ -2,7 +2,7 @@ resource(name='pyproject', source='pyproject.toml') python_distribution( name='vonage-utils', - dependencies=[':pyproject'], + dependencies=[':pyproject', 'utils/src/utils'], provides=python_artifact(), generate_setup=False, repositories=['https://test.pypi.org/legacy/'], diff --git a/utils/pyproject.toml b/utils/pyproject.toml index 5e466a36..442c67e7 100644 --- a/utils/pyproject.toml +++ b/utils/pyproject.toml @@ -18,7 +18,6 @@ classifiers = [ [project.urls] homepage = "https://github.com/Vonage/vonage-python-sdk" -documentation = "https://developer.vonage.com" [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/utils/src/utils/BUILD b/utils/src/utils/BUILD index db46e8d6..c570c9ff 100644 --- a/utils/src/utils/BUILD +++ b/utils/src/utils/BUILD @@ -1 +1 @@ -python_sources() +python_sources(name='utils') diff --git a/utils/src/utils/utils.py b/utils/src/utils/utils.py index 2e647504..24fdc088 100644 --- a/utils/src/utils/utils.py +++ b/utils/src/utils/utils.py @@ -1,7 +1,7 @@ from re import search from typing import Union -from errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError +from utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError def format_phone_number(number: Union[str, int]) -> str: diff --git a/utils/tests/BUILD b/utils/tests/BUILD index c9cbcdc5..27c0cc6f 100644 --- a/utils/tests/BUILD +++ b/utils/tests/BUILD @@ -1 +1 @@ -python_tests(dependencies=['utils']) +python_tests(dependencies=['utils/src/utils']) diff --git a/utils/tests/test_format_phone_number.py b/utils/tests/test_format_phone_number.py index fd5592fa..7caf07ef 100644 --- a/utils/tests/test_format_phone_number.py +++ b/utils/tests/test_format_phone_number.py @@ -1,7 +1,7 @@ -from errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError from pytest import raises +from utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError -from utils import format_phone_number +from utils.utils import format_phone_number def test_format_phone_numbers(): diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index f28c3380..d4497d7f 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -1,8 +1,28 @@ [project] name = 'vonage' -description = 'Python Server SDK for using Vonage APIs' version = '4.0.1a0' +description = 'Python Server SDK for using Vonage APIs' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-utils>=0.1.0", + "vonage-http-client>=0.1.0", + "vonage-number-insight-v2>=0.1.0", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" [build-system] requires = ["setuptools>=61.0", "wheel"] From 715e9f65c168440d1c5bd9d3fbe067587a672a8f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 22 Feb 2024 04:19:32 +0000 Subject: [PATCH 06/98] preparing for alpha release --- http_client/BUILD | 9 ++- http_client/README.md | 55 +++++++++++++++++-- http_client/pyproject.toml | 12 ++-- .../{http_client => vonage_http_client}/BUILD | 0 .../__init__.py | 0 .../auth.py | 0 .../errors.py | 2 +- .../http_client.py | 14 ++--- http_client/tests/test_auth.py | 8 +-- http_client/tests/test_http_client.py | 12 ++-- number_insight_v2/BUILD | 7 ++- number_insight_v2/README.md | 22 +++++++- number_insight_v2/pyproject.toml | 4 +- .../src/number_insight_v2/__init__.py | 0 .../BUILD | 0 .../src/vonage_number_insight_v2/__init__.py | 7 +++ .../errors.py | 2 +- .../number_insight_v2.py | 15 +++-- number_insight_v2/tests/BUILD | 2 +- .../tests/test_number_insight_v2.py | 22 ++++++-- pants.toml | 4 +- testing_utils/BUILD | 1 - testing_utils/__init__.py | 0 testutils/BUILD | 1 + testutils/__init__.py | 3 + .../testutils.py | 0 utils/README.md | 3 - utils/src/utils/BUILD | 1 - utils/src/utils/__init__.py | 0 utils/tests/BUILD | 1 - vonage/BUILD | 6 +- vonage/README.md | 30 ++++++++++ vonage/pyproject.toml | 13 +++-- vonage/src/vonage/__init__.py | 6 +- vonage/src/vonage/_version.py | 1 + vonage/src/vonage/vonage.py | 8 +-- vonage/tests/test_vonage.py | 3 +- {utils => vonage_utils}/BUILD | 6 +- vonage_utils/README.md | 25 +++++++++ {utils => vonage_utils}/pyproject.toml | 4 +- vonage_utils/src/vonage_utils/BUILD | 1 + vonage_utils/src/vonage_utils/__init__.py | 4 ++ .../src/vonage_utils}/errors.py | 0 .../src/vonage_utils}/utils.py | 2 +- vonage_utils/tests/BUILD | 1 + .../tests/test_format_phone_number.py | 5 +- .../tests/test_remove_none_values.py | 2 +- 47 files changed, 244 insertions(+), 80 deletions(-) rename http_client/src/{http_client => vonage_http_client}/BUILD (100%) rename http_client/src/{http_client => vonage_http_client}/__init__.py (100%) rename http_client/src/{http_client => vonage_http_client}/auth.py (100%) rename http_client/src/{http_client => vonage_http_client}/errors.py (98%) rename http_client/src/{http_client => vonage_http_client}/http_client.py (98%) delete mode 100644 number_insight_v2/src/number_insight_v2/__init__.py rename number_insight_v2/src/{number_insight_v2 => vonage_number_insight_v2}/BUILD (100%) create mode 100644 number_insight_v2/src/vonage_number_insight_v2/__init__.py rename number_insight_v2/src/{number_insight_v2 => vonage_number_insight_v2}/errors.py (71%) rename number_insight_v2/src/{number_insight_v2 => vonage_number_insight_v2}/number_insight_v2.py (84%) delete mode 100644 testing_utils/BUILD delete mode 100644 testing_utils/__init__.py create mode 100644 testutils/BUILD create mode 100644 testutils/__init__.py rename testing_utils/testing_utils.py => testutils/testutils.py (100%) delete mode 100644 utils/README.md delete mode 100644 utils/src/utils/BUILD delete mode 100644 utils/src/utils/__init__.py delete mode 100644 utils/tests/BUILD create mode 100644 vonage/src/vonage/_version.py rename {utils => vonage_utils}/BUILD (52%) create mode 100644 vonage_utils/README.md rename {utils => vonage_utils}/pyproject.toml (90%) create mode 100644 vonage_utils/src/vonage_utils/BUILD create mode 100644 vonage_utils/src/vonage_utils/__init__.py rename {utils/src/utils => vonage_utils/src/vonage_utils}/errors.py (100%) rename {utils/src/utils => vonage_utils/src/vonage_utils}/utils.py (95%) create mode 100644 vonage_utils/tests/BUILD rename {utils => vonage_utils}/tests/test_format_phone_number.py (88%) rename {utils => vonage_utils}/tests/test_remove_none_values.py (86%) diff --git a/http_client/BUILD b/http_client/BUILD index a30c6228..f07bfef7 100644 --- a/http_client/BUILD +++ b/http_client/BUILD @@ -1,11 +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', 'http_client/src/http_client', 'utils/src/utils'], + dependencies=[ + ':pyproject', + ':readme', + 'http_client/src/vonage_http_client:http_client', + ], provides=python_artifact(), generate_setup=False, - repositories=['https://test.pypi.org/legacy/'], + repositories=['@pypi'], ) diff --git a/http_client/README.md b/http_client/README.md index 6671dd88..d5d39673 100644 --- a/http_client/README.md +++ b/http_client/README.md @@ -1,17 +1,60 @@ -# Need to redo this to be about the client! +# Vonage HTTP Client Package -Vonage Authentication Package +This Python package provides a synchronous HTTP client for sending authenticated requests to Vonage APIs. -`vonage-auth` provides a convenient way to handle authentication related to Vonage APIs in your Python projects. This package includes an `Auth` class that allows you to manage API key- and secret-based authentication as well as JSON Web Token (JWT) authentication. +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. -This package (`vonage-auth`) is used by the `vonage` Python package so doesn't require manual installation unless you're not using that. +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 +## Installation (if not using via an SDK) You can install the package using pip: ```bash -pip install vonage-auth +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'}) +``` + +### 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') +``` \ No newline at end of file diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index ac49e3b9..b78a5dba 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,14 +1,14 @@ [project] -name = 'vonage-http-client' -version = '0.1.0' -description = 'An HTTP client for making requests to Vonage APIs.' +name = "vonage-http-client" +version = "1.0.0" +description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=0.1.0", + "vonage-utils>=1.0.0", "vonage-jwt>=1.1.0", - "requests==2.*", + "requests>=2.27.0", "pydantic>=2.6.1", "typing_extensions>=4.9.0", ] @@ -24,7 +24,7 @@ classifiers = [ ] [project.urls] -homepage = "https://github.com/Vonage/vonage-python-sdk" +Homepage = "https://github.com/Vonage/vonage-python-sdk" [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/http_client/src/http_client/BUILD b/http_client/src/vonage_http_client/BUILD similarity index 100% rename from http_client/src/http_client/BUILD rename to http_client/src/vonage_http_client/BUILD diff --git a/http_client/src/http_client/__init__.py b/http_client/src/vonage_http_client/__init__.py similarity index 100% rename from http_client/src/http_client/__init__.py rename to http_client/src/vonage_http_client/__init__.py diff --git a/http_client/src/http_client/auth.py b/http_client/src/vonage_http_client/auth.py similarity index 100% rename from http_client/src/http_client/auth.py rename to http_client/src/vonage_http_client/auth.py diff --git a/http_client/src/http_client/errors.py b/http_client/src/vonage_http_client/errors.py similarity index 98% rename from http_client/src/http_client/errors.py rename to http_client/src/vonage_http_client/errors.py index e5119c33..ca235565 100644 --- a/http_client/src/http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -1,7 +1,7 @@ from json import JSONDecodeError from requests import Response -from utils.errors import VonageError +from vonage_utils.errors import VonageError class JWTGenerationError(VonageError): diff --git a/http_client/src/http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py similarity index 98% rename from http_client/src/http_client/http_client.py rename to http_client/src/vonage_http_client/http_client.py index 98654fff..2ceb3ec3 100644 --- a/http_client/src/http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -2,19 +2,19 @@ from platform import python_version from typing import Literal, Optional, Union -from http_client.auth import Auth -from http_client.errors import ( +from pydantic import BaseModel, Field, ValidationError, validate_call +from requests import Response +from requests.adapters import HTTPAdapter +from requests.sessions import Session +from typing_extensions import Annotated +from vonage_http_client.auth import Auth +from vonage_http_client.errors import ( AuthenticationError, HttpRequestError, InvalidHttpClientOptionsError, RateLimitedError, ServerError, ) -from pydantic import BaseModel, Field, ValidationError, validate_call -from requests import Response -from requests.adapters import HTTPAdapter -from requests.sessions import Session -from typing_extensions import Annotated logger = getLogger('vonage') diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py index 24d4a26f..2fbb2f18 100644 --- a/http_client/tests/test_auth.py +++ b/http_client/tests/test_auth.py @@ -1,10 +1,10 @@ from os.path import dirname, join from unittest.mock import patch -from http_client.auth import Auth -from http_client.errors import InvalidAuthError, JWTGenerationError 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 @@ -81,14 +81,14 @@ def vonage_jwt_mock(self): def test_generate_application_jwt(): auth = Auth(application_id=application_id, private_key=private_key) - with patch('http_client.auth.Auth.generate_application_jwt', vonage_jwt_mock): + 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('http_client.auth.Auth.generate_application_jwt', vonage_jwt_mock): + 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 diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 99c4360c..94bcbf99 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -2,19 +2,19 @@ from os.path import abspath, dirname, join import responses -from http_client.auth import Auth -from http_client.errors import ( +from pytest import raises +from requests import Response +from vonage_http_client.auth import Auth +from vonage_http_client.errors import ( AuthenticationError, HttpRequestError, InvalidHttpClientOptionsError, RateLimitedError, ServerError, ) -from pytest import raises -from requests import Response +from vonage_http_client.http_client import HttpClient -from http_client.http_client import HttpClient -from testing_utils import build_response +from testutils import build_response path = abspath(__file__) diff --git a/number_insight_v2/BUILD b/number_insight_v2/BUILD index 02b9d573..6fa1a4f5 100644 --- a/number_insight_v2/BUILD +++ b/number_insight_v2/BUILD @@ -1,4 +1,5 @@ resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') files(sources=['tests/data/*']) @@ -6,10 +7,10 @@ python_distribution( name='vonage-number-insight-v2', dependencies=[ ':pyproject', - 'number_insight_v2/src/number_insight_v2', - 'utils/src/utils', + ':readme', + 'number_insight_v2/src/vonage_number_insight_v2', ], provides=python_artifact(), generate_setup=False, - repositories=['https://test.pypi.org/legacy/'], + repositories=['@pypi'], ) diff --git a/number_insight_v2/README.md b/number_insight_v2/README.md index fb308f6d..f11a0144 100644 --- a/number_insight_v2/README.md +++ b/number_insight_v2/README.md @@ -1,3 +1,23 @@ # Vonage Number Insight Python SDK package -This package contains the code to use Vonage's Number Insight API in Python. \ No newline at end of file +This package contains the code to use v2 of Vonage's Number Insight API (currently in beta) in Python. + +It includes classes for making fraud check requests and handling the responses. + +## Usage +First, import the necessary classes and create an instance of the `NumberInsightV2` class: + +```python +from vonage_http_client.http_client import HttpClient +from number_insight_v2 import NumberInsightV2, FraudCheckRequest + +http_client = HttpClient(api_host='your_api_host', api_key='your_api_key', api_secret='your_api_secret') +number_insight = NumberInsightV2(http_client) +``` + +You can then create a `FraudCheckRequest` object and use the `fraud_check` method to initiate a fraud check request: + +```python +request = FraudCheckRequest(phone='1234567890') +response = number_insight.fraud_check(request) +``` \ No newline at end of file diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml index d38b49a6..73f68e4b 100644 --- a/number_insight_v2/pyproject.toml +++ b/number_insight_v2/pyproject.toml @@ -6,8 +6,8 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=0.1.0", - "vonage-utils>=0.1.0", + "vonage-http-client>=1.0.0", + "vonage-utils>=1.0.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/number_insight_v2/src/number_insight_v2/__init__.py b/number_insight_v2/src/number_insight_v2/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/number_insight_v2/src/number_insight_v2/BUILD b/number_insight_v2/src/vonage_number_insight_v2/BUILD similarity index 100% rename from number_insight_v2/src/number_insight_v2/BUILD rename to number_insight_v2/src/vonage_number_insight_v2/BUILD diff --git a/number_insight_v2/src/vonage_number_insight_v2/__init__.py b/number_insight_v2/src/vonage_number_insight_v2/__init__.py new file mode 100644 index 00000000..8998fbc0 --- /dev/null +++ b/number_insight_v2/src/vonage_number_insight_v2/__init__.py @@ -0,0 +1,7 @@ +from .number_insight_v2 import FraudCheckRequest, FraudCheckResponse, NumberInsightV2 + +__all__ = [ + 'NumberInsightV2', + 'FraudCheckRequest', + 'FraudCheckResponse', +] diff --git a/number_insight_v2/src/number_insight_v2/errors.py b/number_insight_v2/src/vonage_number_insight_v2/errors.py similarity index 71% rename from number_insight_v2/src/number_insight_v2/errors.py rename to number_insight_v2/src/vonage_number_insight_v2/errors.py index 9ad6a18e..35061a33 100644 --- a/number_insight_v2/src/number_insight_v2/errors.py +++ b/number_insight_v2/src/vonage_number_insight_v2/errors.py @@ -1,4 +1,4 @@ -from utils.errors import VonageError +from vonage_utils.errors import VonageError class NumberInsightV2Error(VonageError): diff --git a/number_insight_v2/src/number_insight_v2/number_insight_v2.py b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py similarity index 84% rename from number_insight_v2/src/number_insight_v2/number_insight_v2.py rename to number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py index e5f9445a..8836f802 100644 --- a/number_insight_v2/src/number_insight_v2/number_insight_v2.py +++ b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py @@ -2,9 +2,10 @@ from dataclasses import dataclass from typing import List, Literal, Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, field_validator, validate_call +from vonage_http_client.http_client import HttpClient -from http_client.http_client import HttpClient +from vonage_utils import format_phone_number class FraudCheckRequest(BaseModel): @@ -14,6 +15,11 @@ class FraudCheckRequest(BaseModel): ] = ['fraud_score', 'sim_swap'] type: Literal['phone'] = 'phone' + @field_validator('phone') + @classmethod + def format_phone_number(cls, value): + return format_phone_number(value) + @dataclass class Phone: @@ -30,14 +36,14 @@ class FraudScore: status: str -@dataclass() +@dataclass class SimSwap: status: str swapped: Optional[bool] = None reason: Optional[str] = None -@dataclass() +@dataclass class FraudCheckResponse: request_id: str type: str @@ -53,6 +59,7 @@ def __init__(self, http_client: HttpClient) -> None: self._http_client = deepcopy(http_client) self._auth_type = 'basic' + @validate_call def fraud_check(self, request: FraudCheckRequest) -> FraudCheckResponse: """Initiate a fraud check request.""" response = self._http_client.post( diff --git a/number_insight_v2/tests/BUILD b/number_insight_v2/tests/BUILD index a5151f92..0b73afe7 100644 --- a/number_insight_v2/tests/BUILD +++ b/number_insight_v2/tests/BUILD @@ -1 +1 @@ -python_tests(dependencies=['number_insight_v2']) +python_tests(dependencies=['number_insight_v2', 'testutils']) diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py index 64130c03..31a81166 100644 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -2,18 +2,19 @@ from os.path import abspath import responses -from http_client.auth import Auth from pydantic import ValidationError from pytest import raises - -from http_client.http_client import HttpClient -from number_insight_v2.number_insight_v2 import ( +from vonage_http_client.auth import Auth +from vonage_http_client.http_client import HttpClient +from vonage_number_insight_v2.number_insight_v2 import ( FraudCheckRequest, FraudCheckResponse, NumberInsightV2, ) -from testing_utils import build_response -from utils.utils import remove_none_values +from vonage_utils.errors import InvalidPhoneNumberError +from vonage_utils.utils import remove_none_values + +from testutils import build_response path = abspath(__file__) @@ -34,6 +35,15 @@ def test_fraud_check_request_custom_insights(): assert request.insights == ['fraud_score'] +def test_fraud_check_request_invalid_phone(): + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='invalid_phone') + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='123') + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='12345678901234567890') + + def test_fraud_check_request_invalid_insights(): with raises(ValidationError): FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) diff --git a/pants.toml b/pants.toml index b26b195c..1634ebe4 100644 --- a/pants.toml +++ b/pants.toml @@ -16,7 +16,7 @@ backend_packages = [ enabled = false [source] -root_patterns = ['/', 'src/', 'tests/', 'testing_utils/'] +root_patterns = ['/', 'src/', 'tests/'] [python] interpreter_constraints = ['==3.11.*'] @@ -32,7 +32,7 @@ filter = [ 'http_client/src', 'number_insight_v2/src', 'utils/src', - 'testing_utils', + 'testutils', ] [black] diff --git a/testing_utils/BUILD b/testing_utils/BUILD deleted file mode 100644 index e0f85930..00000000 --- a/testing_utils/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_sources(name='testing_utils') diff --git a/testing_utils/__init__.py b/testing_utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/testutils/BUILD b/testutils/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/testutils/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/testutils/__init__.py b/testutils/__init__.py new file mode 100644 index 00000000..e4aa14c2 --- /dev/null +++ b/testutils/__init__.py @@ -0,0 +1,3 @@ +from .testutils import build_response + +__all__ = ['build_response'] diff --git a/testing_utils/testing_utils.py b/testutils/testutils.py similarity index 100% rename from testing_utils/testing_utils.py rename to testutils/testutils.py diff --git a/utils/README.md b/utils/README.md deleted file mode 100644 index 0adcc9c7..00000000 --- a/utils/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Vonage Utils Package - -This package contains utility code that is used by the Vonage Python SDK and other related packages. \ No newline at end of file diff --git a/utils/src/utils/BUILD b/utils/src/utils/BUILD deleted file mode 100644 index c570c9ff..00000000 --- a/utils/src/utils/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_sources(name='utils') diff --git a/utils/src/utils/__init__.py b/utils/src/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/utils/tests/BUILD b/utils/tests/BUILD deleted file mode 100644 index 27c0cc6f..00000000 --- a/utils/tests/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_tests(dependencies=['utils/src/utils']) diff --git a/vonage/BUILD b/vonage/BUILD index 02ab75f9..fc42ca8d 100644 --- a/vonage/BUILD +++ b/vonage/BUILD @@ -1,9 +1,11 @@ resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + python_distribution( name='vonage', - dependencies=[':pyproject', 'vonage/src/vonage'], + dependencies=[':pyproject', ':readme', 'vonage/src/vonage'], provides=python_artifact(), generate_setup=False, - repositories=['https://test.pypi.org/legacy/'], + repositories=['@pypi'], ) diff --git a/vonage/README.md b/vonage/README.md index 6f26339d..8552f886 100644 --- a/vonage/README.md +++ b/vonage/README.md @@ -12,4 +12,34 @@ 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 Number Insight API v2: + +```python +from vonage_number_insight_v2 import FraudCheckRequest + +vonage.number_insight_v2.fraud_check(FraudCheckRequest(phone='1234567890')) +``` + +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 index d4497d7f..78490797 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -1,13 +1,13 @@ [project] -name = 'vonage' -version = '4.0.1a0' -description = 'Python Server SDK for using Vonage APIs' +name = "vonage" +dynamic = ["version"] +description = "Python Server SDK for using Vonage APIs" readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=0.1.0", - "vonage-http-client>=0.1.0", + "vonage-utils>=1.0.0", + "vonage-http-client>=1.0.0", "vonage-number-insight-v2>=0.1.0", ] classifiers = [ @@ -24,6 +24,9 @@ classifiers = [ [project.urls] homepage = "https://github.com/Vonage/vonage-python-sdk" +[tool.setuptools.dynamic] +version = { attr = "vonage._version.__version__" } + [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 04c3ba4b..95b9e74b 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1 +1,5 @@ -__version__ = '4.0.1a0' +from vonage_utils import VonageError + +from .vonage import Auth, HttpClientOptions, NumberInsightV2, Vonage + +__all__ = ['Vonage', 'Auth', 'HttpClientOptions', 'NumberInsightV2', 'VonageError'] diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py new file mode 100644 index 00000000..fba5a81b --- /dev/null +++ b/vonage/src/vonage/_version.py @@ -0,0 +1 @@ +__version__ = '3.99.0a0' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 16c89c2f..c08a4dd9 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -1,10 +1,10 @@ from typing import Optional -from http_client.auth import Auth +from vonage_http_client.auth import Auth +from vonage_http_client.http_client import HttpClient, HttpClientOptions +from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 -from http_client.http_client import HttpClient, HttpClientOptions -from number_insight_v2.number_insight_v2 import NumberInsightV2 -from vonage import __version__ +from ._version import __version__ class Vonage: diff --git a/vonage/tests/test_vonage.py b/vonage/tests/test_vonage.py index 2b51f184..707eca0d 100644 --- a/vonage/tests/test_vonage.py +++ b/vonage/tests/test_vonage.py @@ -1,4 +1,5 @@ -from http_client.http_client import HttpClient +from vonage_http_client.http_client import HttpClient + from vonage.vonage import Auth, Vonage, __version__ diff --git a/utils/BUILD b/vonage_utils/BUILD similarity index 52% rename from utils/BUILD rename to vonage_utils/BUILD index 97b936fa..8aacf7e5 100644 --- a/utils/BUILD +++ b/vonage_utils/BUILD @@ -1,9 +1,11 @@ resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + python_distribution( name='vonage-utils', - dependencies=[':pyproject', 'utils/src/utils'], + dependencies=[':pyproject', ':readme', 'vonage_utils/src/vonage_utils'], provides=python_artifact(), generate_setup=False, - repositories=['https://test.pypi.org/legacy/'], + repositories=['@pypi'], ) 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/utils/pyproject.toml b/vonage_utils/pyproject.toml similarity index 90% rename from utils/pyproject.toml rename to vonage_utils/pyproject.toml index 442c67e7..0362521c 100644 --- a/utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-utils' -version = '0.1.0' +version = '1.0.0' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -17,7 +17,7 @@ classifiers = [ ] [project.urls] -homepage = "https://github.com/Vonage/vonage-python-sdk" +Homepage = "https://github.com/Vonage/vonage-python-sdk" [build-system] requires = ["setuptools>=61.0", "wheel"] 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..7e04e7f1 --- /dev/null +++ b/vonage_utils/src/vonage_utils/__init__.py @@ -0,0 +1,4 @@ +from .errors import VonageError +from .utils import format_phone_number, remove_none_values + +__all__ = ['VonageError', 'format_phone_number', 'remove_none_values'] diff --git a/utils/src/utils/errors.py b/vonage_utils/src/vonage_utils/errors.py similarity index 100% rename from utils/src/utils/errors.py rename to vonage_utils/src/vonage_utils/errors.py diff --git a/utils/src/utils/utils.py b/vonage_utils/src/vonage_utils/utils.py similarity index 95% rename from utils/src/utils/utils.py rename to vonage_utils/src/vonage_utils/utils.py index 24fdc088..8e02c0b7 100644 --- a/utils/src/utils/utils.py +++ b/vonage_utils/src/vonage_utils/utils.py @@ -1,7 +1,7 @@ from re import search from typing import Union -from utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError +from vonage_utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError def format_phone_number(number: Union[str, int]) -> str: 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/utils/tests/test_format_phone_number.py b/vonage_utils/tests/test_format_phone_number.py similarity index 88% rename from utils/tests/test_format_phone_number.py rename to vonage_utils/tests/test_format_phone_number.py index 7caf07ef..1a831256 100644 --- a/utils/tests/test_format_phone_number.py +++ b/vonage_utils/tests/test_format_phone_number.py @@ -1,7 +1,6 @@ from pytest import raises -from utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError - -from utils.utils import format_phone_number +from vonage_utils.errors import InvalidPhoneNumberError, InvalidPhoneNumberTypeError +from vonage_utils.utils import format_phone_number def test_format_phone_numbers(): diff --git a/utils/tests/test_remove_none_values.py b/vonage_utils/tests/test_remove_none_values.py similarity index 86% rename from utils/tests/test_remove_none_values.py rename to vonage_utils/tests/test_remove_none_values.py index 2cc3b6d3..cc31e656 100644 --- a/utils/tests/test_remove_none_values.py +++ b/vonage_utils/tests/test_remove_none_values.py @@ -1,6 +1,6 @@ from dataclasses import asdict, dataclass -from utils.utils import remove_none_values +from vonage_utils.utils import remove_none_values @dataclass From 38843b4ff63fa5a57ba056c312822cef8ec5920d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sun, 3 Mar 2024 16:35:59 +0000 Subject: [PATCH 07/98] add sms --- http_client/src/vonage_http_client/auth.py | 2 + sms/BUILD | 16 ++++ sms/README.md | 5 ++ sms/pyproject.toml | 29 +++++++ sms/src/vonage_sms/BUILD | 1 + sms/src/vonage_sms/__init__.py | 5 ++ sms/src/vonage_sms/errors.py | 5 ++ sms/src/vonage_sms/sms.py | 51 ++++++++++++ sms/tests/BUILD | 1 + sms/tests/data/default.json | 19 +++++ sms/tests/data/fraud_score.json | 15 ++++ sms/tests/data/sim_swap.json | 11 +++ sms/tests/test_sms.py | 93 ++++++++++++++++++++++ vonage/src/vonage/vonage.py | 2 + 14 files changed, 255 insertions(+) create mode 100644 sms/BUILD create mode 100644 sms/README.md create mode 100644 sms/pyproject.toml create mode 100644 sms/src/vonage_sms/BUILD create mode 100644 sms/src/vonage_sms/__init__.py create mode 100644 sms/src/vonage_sms/errors.py create mode 100644 sms/src/vonage_sms/sms.py create mode 100644 sms/tests/BUILD create mode 100644 sms/tests/data/default.json create mode 100644 sms/tests/data/fraud_score.json create mode 100644 sms/tests/data/sim_swap.json create mode 100644 sms/tests/test_sms.py diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 99ac7cc5..7bb311d2 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -27,6 +27,8 @@ def __init__( api_secret: Optional[str] = None, application_id: Optional[str] = None, private_key: Optional[str] = None, + signature: Optional[str] = None, + signature_method: Optional[str] = None, ) -> None: self._validate_input_combinations( api_key, api_secret, application_id, private_key diff --git a/sms/BUILD b/sms/BUILD new file mode 100644 index 00000000..4ab3bdfe --- /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=['@testpypi'], +) diff --git a/sms/README.md b/sms/README.md new file mode 100644 index 00000000..18c630b6 --- /dev/null +++ b/sms/README.md @@ -0,0 +1,5 @@ +# Vonage SMS Package + +This package contains the code to use Vonage's SMS API with Python. + +## Usage diff --git a/sms/pyproject.toml b/sms/pyproject.toml new file mode 100644 index 00000000..d4ece51f --- /dev/null +++ b/sms/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-sms' +version = '0.1.0' +description = 'Vonage SMS package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.0.0", + "vonage-utils>=1.0.0", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..24089b7d --- /dev/null +++ b/sms/src/vonage_sms/__init__.py @@ -0,0 +1,5 @@ +from .sms import Sms + +__all__ = [ + 'Sms', +] diff --git a/sms/src/vonage_sms/errors.py b/sms/src/vonage_sms/errors.py new file mode 100644 index 00000000..7d387658 --- /dev/null +++ b/sms/src/vonage_sms/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class SmsError(VonageError): + """Indicates an error with the Vonage SMS Package.""" diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py new file mode 100644 index 00000000..b95b83ec --- /dev/null +++ b/sms/src/vonage_sms/sms.py @@ -0,0 +1,51 @@ +from copy import deepcopy +from dataclasses import dataclass +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, Field, field_validator, validate_call +from vonage_http_client.http_client import HttpClient +from vonage_sms.errors import SmsError + + +class SmsMessage(BaseModel): + to: str + from_: str = Field(..., alias="from") + text: str + type: Optional[str] = None + sig: Optional[str] = Field(None, min_length=16, max_length=60) + status_report_req: Optional[int] = Field( + None, + alias="status-report-req", + description="Set to 1 to receive a Delivery Receipt", + ) + client_ref: Optional[str] = Field( + None, alias="client-ref", description="Your own reference. Up to 40 characters." + ) + network_code: Optional[str] = Field( + None, + alias="network-code", + description="A 4-5 digit number that represents the mobile carrier network code", + ) + + +@dataclass +class SmsResponse: + id: str + + +class Sms: + """Calls Vonage's SMS API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = deepcopy(http_client) + self._auth_type = 'basic' + + @validate_call + def send(self, message: SmsMessage) -> SmsResponse: + """Send an SMS message.""" + response = self._http_client.post( + self._http_client.api_host, + '/v2/ni', + message.model_dump(), + self._auth_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/default.json b/sms/tests/data/default.json new file mode 100644 index 00000000..cb808dd7 --- /dev/null +++ b/sms/tests/data/default.json @@ -0,0 +1,19 @@ +{ + "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", + "type": "phone", + "phone": { + "phone": "1234567890", + "carrier": "Verizon Wireless", + "type": "MOBILE" + }, + "fraud_score": { + "risk_score": "0", + "risk_recommendation": "allow", + "label": "low", + "status": "completed" + }, + "sim_swap": { + "status": "completed", + "swapped": false + } +} \ No newline at end of file diff --git a/sms/tests/data/fraud_score.json b/sms/tests/data/fraud_score.json new file mode 100644 index 00000000..be7cc267 --- /dev/null +++ b/sms/tests/data/fraud_score.json @@ -0,0 +1,15 @@ +{ + "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", + "type": "phone", + "phone": { + "phone": "1234567890", + "carrier": "Verizon Wireless", + "type": "MOBILE" + }, + "fraud_score": { + "risk_score": "0", + "risk_recommendation": "allow", + "label": "low", + "status": "completed" + } +} \ No newline at end of file diff --git a/sms/tests/data/sim_swap.json b/sms/tests/data/sim_swap.json new file mode 100644 index 00000000..594028c8 --- /dev/null +++ b/sms/tests/data/sim_swap.json @@ -0,0 +1,11 @@ +{ + "request_id": "db5282b6-8046-4217-9c0e-d9c55d8696e9", + "type": "phone", + "phone": { + "phone": "1234567890" + }, + "sim_swap": { + "status": "completed", + "swapped": false + } +} \ No newline at end of file diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py new file mode 100644 index 00000000..45cb4995 --- /dev/null +++ b/sms/tests/test_sms.py @@ -0,0 +1,93 @@ +from dataclasses import asdict +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.http_client import HttpClient + +from vonage_utils.errors import InvalidPhoneNumberError +from vonage_utils.utils import remove_none_values + +from vonage_sms.sms import Sms +from testutils import build_response + +path = abspath(__file__) + +sms = Sms(HttpClient(Auth('key', 'secret'))) + + +def test_fraud_check_request_defaults(): + request = FraudCheckRequest(phone='1234567890') + assert request.type == 'phone' + assert request.phone == '1234567890' + assert request.insights == ['fraud_score', 'sim_swap'] + + +def test_fraud_check_request_custom_insights(): + request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) + assert request.type == 'phone' + assert request.phone == '1234567890' + assert request.insights == ['fraud_score'] + + +def test_fraud_check_request_invalid_phone(): + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='invalid_phone') + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='123') + with raises(InvalidPhoneNumberError): + FraudCheckRequest(phone='12345678901234567890') + + +def test_fraud_check_request_invalid_insights(): + with raises(ValidationError): + FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) + + +@responses.activate +def test_ni2_defaults(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') + request = FraudCheckRequest(phone='1234567890') + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' + assert response.phone.carrier == 'Verizon Wireless' + assert response.fraud_score.risk_score == '0' + assert response.sim_swap.status == 'completed' + + +@responses.activate +def test_ni2_fraud_score_only(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') + request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' + assert response.phone.carrier == 'Verizon Wireless' + assert response.fraud_score.risk_score == '0' + assert response.sim_swap is None + + clear_response = asdict(response, dict_factory=remove_none_values) + print(clear_response) + assert 'fraud_score' in clear_response + assert 'sim_swap' not in clear_response + + +@responses.activate +def test_ni2_sim_swap_only(): + build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') + request = FraudCheckRequest(phone='1234567890', insights='sim_swap') + response = ni2.fraud_check(request) + assert type(response) == FraudCheckResponse + assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' + assert response.phone.phone == '1234567890' + assert response.fraud_score is None + assert response.sim_swap.status == 'completed' + assert response.sim_swap.swapped is False + + clear_response = asdict(response, dict_factory=remove_none_values) + assert 'fraud_score' not in clear_response + assert 'sim_swap' in clear_response + assert 'reason' not in clear_response['sim_swap'] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index c08a4dd9..9d9caf6b 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -3,6 +3,7 @@ from vonage_http_client.auth import Auth from vonage_http_client.http_client import HttpClient, HttpClientOptions from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 +from vonage_sms import Sms from ._version import __version__ @@ -21,6 +22,7 @@ def __init__( self._http_client = HttpClient(auth, http_client_options, __version__) self.number_insight_v2 = NumberInsightV2(self._http_client) + self.sms = Sms(self._http_client) @property def http_client(self): From 7d6eafd9ffc85cf062e649fd35e4402be06bdbc5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 16 Mar 2024 01:40:06 +0000 Subject: [PATCH 08/98] add signature and error handling --- http_client/src/vonage_http_client/auth.py | 54 ++++- .../src/vonage_http_client/http_client.py | 16 +- http_client/tests/test_auth.py | 54 +++++ sms/src/vonage_sms/errors.py | 12 ++ sms/src/vonage_sms/sms.py | 95 +++++++-- sms/tests/test_sms.py | 200 +++++++++++------- 6 files changed, 324 insertions(+), 107 deletions(-) diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 7bb311d2..9c9fc641 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -1,5 +1,8 @@ from base64 import b64encode -from typing import Optional +from typing import Literal, Optional +import hashlib +import hmac +from time import time from pydantic import validate_call from vonage_jwt.jwt import JwtClient @@ -10,11 +13,17 @@ 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`. @@ -27,8 +36,8 @@ def __init__( api_secret: Optional[str] = None, application_id: Optional[str] = None, private_key: Optional[str] = None, - signature: Optional[str] = None, - signature_method: Optional[str] = None, + signature_secret: Optional[str] = None, + signature_method: Optional[Literal['md5', 'sha1', 'sha256', 'sha512']] = None, ) -> None: self._validate_input_combinations( api_key, api_secret, application_id, private_key @@ -40,6 +49,9 @@ def __init__( 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 @@ -65,6 +77,42 @@ def create_basic_auth_string(self): ) return f'Basic {hash}' + def sign_params(self, params: dict) -> dict: + """ + 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: + dict: The signed message parameters. + """ + + if self._signature_method: + hasher = hmac.new( + self._signature_secret.encode(), + digestmod=self._signature_method, + ) + else: + hasher = hashlib.md5() + + 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")) + + if self._signature_method is None: + hasher.update(self._signature_secret.encode()) + + return hasher.hexdigest() + def _validate_input_combinations( self, api_key, api_secret, application_id, private_key ): diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 2ceb3ec3..8a5d3d58 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -105,7 +105,7 @@ def post( request_path: str = '', params: dict = None, auth_type: str = 'jwt', - ): + ) -> Union[dict, None]: return self.make_request('POST', host, request_path, params, auth_type) def get( @@ -114,7 +114,7 @@ def get( request_path: str = '', params: dict = None, auth_type: str = 'jwt', - ): + ) -> Union[dict, None]: return self.make_request('GET', host, request_path, params, auth_type) @validate_call @@ -124,7 +124,7 @@ def make_request( host: str, request_path: str = '', params: Optional[dict] = None, - auth_type: Literal['jwt', 'basic'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', ): url = f'https://{host}{request_path}' logger.debug( @@ -134,6 +134,16 @@ def make_request( self._headers['Authorization'] = self._auth.create_jwt_auth_string() elif auth_type == 'basic': self._headers['Authorization'] = self._auth.create_basic_auth_string() + elif auth_type == 'signature': + params = self._auth.sign_params(params) + with self._session.request( + request_type, + url, + params=params, + headers=self._headers, + timeout=self._timeout, + ) as response: + return self._parse_response(response) with self._session.request( request_type, diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py index 2fbb2f18..be805604 100644 --- a/http_client/tests/test_auth.py +++ b/http_client/tests/test_auth.py @@ -1,5 +1,7 @@ from os.path import dirname, join from unittest.mock import patch +import hashlib + from pydantic import ValidationError from pytest import raises @@ -102,3 +104,55 @@ def test_create_jwt_error_no_application_id_or_private_key(): 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_auth_init_with_valid_combinations(): + api_key = 'qwerasdf' + api_secret = '1234qwerasdfzxcv' + application_id = 'asdfzxcv' + private_key = 'dummy_private_key' + signature_secret = 'signature_secret' + signature_method = 'sha256' + + 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 auth._jwt_client.application_id == application_id + assert auth._jwt_client.private_key == private_key + assert auth._signature_secret == signature_secret + assert auth._signature_method == hashlib.sha256 + + +def test_auth_init_with_invalid_combinations(): + api_key = 'qwerasdf' + api_secret = '1234qwerasdfzxcv' + application_id = 'asdfzxcv' + private_key = 'dummy_private_key' + signature_secret = 'signature_secret' + signature_method = 'invalid_method' + + with patch('vonage_http_client.auth.hashlib') as mock_hashlib: + mock_hashlib.sha256.side_effect = AttributeError + + 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 auth._jwt_client is None + assert auth._signature_secret == signature_secret + assert auth._signature_method is None diff --git a/sms/src/vonage_sms/errors.py b/sms/src/vonage_sms/errors.py index 7d387658..fa399c14 100644 --- a/sms/src/vonage_sms/errors.py +++ b/sms/src/vonage_sms/errors.py @@ -1,5 +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/sms.py b/sms/src/vonage_sms/sms.py index b95b83ec..15594a47 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -1,36 +1,58 @@ from copy import deepcopy from dataclasses import dataclass -from typing import List, Literal, Optional, Union +from typing import List, Literal, Optional from pydantic import BaseModel, Field, field_validator, validate_call from vonage_http_client.http_client import HttpClient -from vonage_sms.errors import SmsError + +from .errors import SmsError, PartialFailureError class SmsMessage(BaseModel): to: str from_: str = Field(..., alias="from") text: str - type: Optional[str] = None sig: Optional[str] = Field(None, min_length=16, max_length=60) - status_report_req: Optional[int] = Field( - None, - alias="status-report-req", - description="Set to 1 to receive a Delivery Receipt", - ) - client_ref: Optional[str] = Field( - None, alias="client-ref", description="Your own reference. Up to 40 characters." - ) - network_code: Optional[str] = Field( - None, - alias="network-code", - description="A 4-5 digit number that represents the mobile carrier network code", - ) + client_ref: Optional[str] = Field(None, 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, alias='status-report-req') + callback: Optional[str] = Field(None, max_length=100) + message_class: Optional[int] = Field(None, alias='message-class', ge=0, le=3) + body: Optional[str] = None + udh: Optional[str] = None + protocol_id: Optional[int] = Field(None, alias='protocol-id', ge=0, le=255) + account_ref: Optional[str] = Field(None, alias='account-ref') + entity_id: Optional[str] = Field(None, alias='entity-id') + content_id: Optional[str] = Field(None, alias='content-id') + + @field_validator('body', 'udh') + @classmethod + def validate_body(cls, value, values): + if 'type' not in values or not values['type'] == 'binary': + raise ValueError( + 'This parameter can only be set when the "type" parameter is set to "binary".' + ) + if values['type'] == 'binary' and not value: + raise ValueError('This parameter is required for binary messages.') + + +@dataclass +class MessageResponse: + to: str + message_id: str + status: str + remaining_balance: str + message_price: str + network: str + client_ref: Optional[str] = None + account_ref: Optional[str] = None @dataclass class SmsResponse: - id: str + message_count: str + messages: List[MessageResponse] class Sms: @@ -38,14 +60,45 @@ class Sms: def __init__(self, http_client: HttpClient) -> None: self._http_client = deepcopy(http_client) - self._auth_type = 'basic' + if self._http_client._auth._signature_secret: + self._auth_type = 'signature' + else: + self._auth_type = 'basic' @validate_call def send(self, message: SmsMessage) -> SmsResponse: """Send an SMS message.""" response = self._http_client.post( - self._http_client.api_host, - '/v2/ni', - message.model_dump(), + self._http_client.rest_host, + '/sms/json', + message.model_dump(by_alias=True), self._auth_type, ) + + if int(response['message-count']) > 1: + self.check_for_partial_failure(response) + else: + self.check_for_error(response) + + messages = [] + for message in response['messages']: + messages.append(MessageResponse(**message)) + + return SmsResponse(message_count=response['message-count'], messages=messages) + + 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"]}' + ) diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index 45cb4995..d6030705 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -1,93 +1,133 @@ -from dataclasses import asdict 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.http_client import HttpClient - -from vonage_utils.errors import InvalidPhoneNumberError -from vonage_utils.utils import remove_none_values - -from vonage_sms.sms import Sms -from testutils import build_response +from vonage_sms.sms import Sms, SmsMessage path = abspath(__file__) sms = Sms(HttpClient(Auth('key', 'secret'))) -def test_fraud_check_request_defaults(): - request = FraudCheckRequest(phone='1234567890') - assert request.type == 'phone' - assert request.phone == '1234567890' - assert request.insights == ['fraud_score', 'sim_swap'] - - -def test_fraud_check_request_custom_insights(): - request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) - assert request.type == 'phone' - assert request.phone == '1234567890' - assert request.insights == ['fraud_score'] - - -def test_fraud_check_request_invalid_phone(): - with raises(InvalidPhoneNumberError): - FraudCheckRequest(phone='invalid_phone') - with raises(InvalidPhoneNumberError): - FraudCheckRequest(phone='123') - with raises(InvalidPhoneNumberError): - FraudCheckRequest(phone='12345678901234567890') - - -def test_fraud_check_request_invalid_insights(): +# def test_fraud_check_request_invalid_phone(): +# with raises(InvalidPhoneNumberError): +# FraudCheckRequest(phone='invalid_phone') +# with raises(InvalidPhoneNumberError): +# FraudCheckRequest(phone='123') +# with raises(InvalidPhoneNumberError): +# FraudCheckRequest(phone='12345678901234567890') + + +# def test_fraud_check_request_invalid_insights(): +# with raises(ValidationError): +# FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) + + +# @responses.activate +# def test_ni2_defaults(): +# build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') +# request = FraudCheckRequest(phone='1234567890') +# response = ni2.fraud_check(request) +# assert type(response) == FraudCheckResponse +# assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' +# assert response.phone.carrier == 'Verizon Wireless' +# assert response.fraud_score.risk_score == '0' +# assert response.sim_swap.status == 'completed' + + +# @responses.activate +# def test_ni2_fraud_score_only(): +# build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') +# request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) +# response = ni2.fraud_check(request) +# assert type(response) == FraudCheckResponse +# assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' +# assert response.phone.carrier == 'Verizon Wireless' +# assert response.fraud_score.risk_score == '0' +# assert response.sim_swap is None + +# clear_response = asdict(response, dict_factory=remove_none_values) +# print(clear_response) +# assert 'fraud_score' in clear_response +# assert 'sim_swap' not in clear_response + + +# @responses.activate +# def test_ni2_sim_swap_only(): +# build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') +# request = FraudCheckRequest(phone='1234567890', insights='sim_swap') +# response = ni2.fraud_check(request) +# assert type(response) == FraudCheckResponse +# assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' +# assert response.phone.phone == '1234567890' +# assert response.fraud_score is None +# assert response.sim_swap.status == 'completed' +# assert response.sim_swap.swapped is False + +# clear_response = asdict(response, dict_factory=remove_none_values) +# assert 'fraud_score' not in clear_response +# assert 'sim_swap' in clear_response +# assert 'reason' not in clear_response['sim_swap'] + + +def test_validate_full_message(): + valid_message = { + 'to': '1234567890', + 'from_': '9876543210', + 'text': 'Hello, World!', + 'sig': 'asdfqwerzxcv12345678', + 'client_ref': 'ref123', + 'type': 'text', + 'ttl': 3000000, + 'status_report_req': True, + 'callback': 'https://example.com/callback', + 'message_class': 0, + 'body': 'some binary data', + 'udh': 'udh123', + 'protocol_id': 127, + 'account_ref': 'account123', + 'entity_id': 'entity123', + 'content_id': 'content123', + } + try: + SmsMessage(**valid_message) + except ValidationError: + assert False, 'Validation error occurred for a valid SmsMessage' + + # Invalid SmsMessage - missing required fields + invalid_message = {'to': '+1234567890', 'text': 'Hello, World!'} + with raises(ValidationError): + SmsMessage(**invalid_message) + + # Invalid SmsMessage - invalid type value + invalid_message = { + 'to': '+1234567890', + 'from_': '+9876543210', + 'text': 'Hello, World!', + 'type': 'invalid_type', + } + with raises(ValidationError): + SmsMessage(**invalid_message) + + # Invalid SmsMessage - invalid body for non-binary type + invalid_message = { + 'to': '+1234567890', + 'from_': '+9876543210', + 'text': 'Hello, World!', + 'type': 'text', + 'body': 'binary data', + } + with raises(ValidationError): + SmsMessage(**invalid_message) + + # Invalid SmsMessage - missing body for binary type + invalid_message = { + 'to': '+1234567890', + 'from_': '+9876543210', + 'text': 'Hello, World!', + 'type': 'binary', + } with raises(ValidationError): - FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) - - -@responses.activate -def test_ni2_defaults(): - build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') - request = FraudCheckRequest(phone='1234567890') - response = ni2.fraud_check(request) - assert type(response) == FraudCheckResponse - assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' - assert response.phone.carrier == 'Verizon Wireless' - assert response.fraud_score.risk_score == '0' - assert response.sim_swap.status == 'completed' - - -@responses.activate -def test_ni2_fraud_score_only(): - build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') - request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) - response = ni2.fraud_check(request) - assert type(response) == FraudCheckResponse - assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' - assert response.phone.carrier == 'Verizon Wireless' - assert response.fraud_score.risk_score == '0' - assert response.sim_swap is None - - clear_response = asdict(response, dict_factory=remove_none_values) - print(clear_response) - assert 'fraud_score' in clear_response - assert 'sim_swap' not in clear_response - - -@responses.activate -def test_ni2_sim_swap_only(): - build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') - request = FraudCheckRequest(phone='1234567890', insights='sim_swap') - response = ni2.fraud_check(request) - assert type(response) == FraudCheckResponse - assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' - assert response.phone.phone == '1234567890' - assert response.fraud_score is None - assert response.sim_swap.status == 'completed' - assert response.sim_swap.swapped is False - - clear_response = asdict(response, dict_factory=remove_none_values) - assert 'fraud_score' not in clear_response - assert 'sim_swap' in clear_response - assert 'reason' not in clear_response['sim_swap'] + SmsMessage(**invalid_message) From 9788ca403f30bc8d25f1db8a56a162dd8084e1a9 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 19 Mar 2024 04:22:31 +0000 Subject: [PATCH 09/98] working on signing requests --- http_client/src/vonage_http_client/auth.py | 23 +++++++++++----- .../src/vonage_http_client/http_client.py | 5 ++++ http_client/tests/test_auth.py | 4 +++ http_client/tests/test_http_client.py | 27 +++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 9c9fc641..c1125a44 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -37,10 +37,10 @@ def __init__( application_id: Optional[str] = None, private_key: Optional[str] = None, signature_secret: Optional[str] = None, - signature_method: Optional[Literal['md5', 'sha1', 'sha256', 'sha512']] = None, + signature_method: Optional[Literal['md5', 'sha1', 'sha256', 'sha512']] = 'md5', ) -> None: self._validate_input_combinations( - api_key, api_secret, application_id, private_key + api_key, api_secret, application_id, private_key, signature_secret ) self._api_key = api_key @@ -110,18 +110,27 @@ def sign_params(self, params: dict) -> dict: if self._signature_method is None: hasher.update(self._signature_secret.encode()) - return hasher.hexdigest() + def check_signature(self, params) -> bool: + params = dict(params) + signature = params.pop('sig', '').lower() + return hmac.compare_digest(signature, self.signature(params)) + def _validate_input_combinations( - self, api_key, api_secret, application_id, private_key + self, api_key, api_secret, application_id, private_key, signature_secret ): - if (api_key and not api_secret) or (not api_key and api_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( - 'Both api_key and api_secret must be set or both must be None.' + '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.' + 'Both `application_id` and `private_key` must be set or both must be None.' ) diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 8a5d3d58..66c85807 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -136,6 +136,10 @@ def make_request( self._headers['Authorization'] = self._auth.create_basic_auth_string() elif auth_type == 'signature': params = self._auth.sign_params(params) + + print(params) + print(self._auth.check_signature(params)) + with self._session.request( request_type, url, @@ -161,6 +165,7 @@ 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}' ) + print(response.request.headers) content_type = response.headers['Content-Type'].split(';', 1)[0] if 200 <= response.status_code < 300: if response.status_code == 204: diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py index be805604..4303b469 100644 --- a/http_client/tests/test_auth.py +++ b/http_client/tests/test_auth.py @@ -19,6 +19,8 @@ def read_file(path): 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(): @@ -27,6 +29,8 @@ def test_create_auth_class_and_get_objects(): 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 diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 94bcbf99..11483336 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -95,6 +95,33 @@ def test_make_post_request(): assert loads(responses.calls[0].request.body) == params +@responses.activate +def test_make_post_request_with_signature(): + build_response( + path, 'POST', 'https://example.com/post_signed_params', 'example_post.json' + ) + client = HttpClient( + Auth( + api_key='asdfzxcv', signature_secret='qwerasdfzxcv', signature_method='sha256' + ), + http_client_options={'api_host': 'example.com'}, + ) + params = { + 'test': 'post request', + 'testing': 'http client', + } + + res = client.post( + host='example.com', + request_path='/post_signed_params', + params=params, + auth_type='signature', + ) + assert res['hello'] == 'world!' + + assert loads(responses.calls[0].request.body) == params + + @responses.activate def test_http_response_general_error(): build_response(path, 'GET', 'https://example.com/get_json', '400.json', 400) From 691835e84ff49db5e9d421540b4b592a057c5274 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 20 Mar 2024 04:54:22 +0000 Subject: [PATCH 10/98] update signing and add tests --- http_client/src/vonage_http_client/auth.py | 25 ++++---- .../src/vonage_http_client/http_client.py | 8 +-- http_client/tests/test_auth.py | 62 ++++++++++++++++++- http_client/tests/test_http_client.py | 4 +- 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index c1125a44..29576600 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -89,33 +89,30 @@ def sign_params(self, params: dict) -> dict: dict: The signed message parameters. """ - if self._signature_method: - hasher = hmac.new( - self._signature_secret.encode(), - digestmod=self._signature_method, - ) - else: - hasher = hashlib.md5() + hasher = hmac.new( + self._signature_secret.encode(), + digestmod=self._signature_method, + ) - if not params.get("timestamp"): - params["timestamp"] = int(time()) + 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("=", "_") + value = value.replace('&', '_').replace('=', '_') - hasher.update(f"&{key}={value}".encode("utf-8")) + hasher.update(f'&{key}={value}'.encode('utf-8')) if self._signature_method is None: hasher.update(self._signature_secret.encode()) return hasher.hexdigest() - def check_signature(self, params) -> bool: - params = dict(params) + @validate_call + def check_signature(self, params: dict) -> bool: signature = params.pop('sig', '').lower() - return hmac.compare_digest(signature, self.signature(params)) + return hmac.compare_digest(signature, self._signature_secret(params)) def _validate_input_combinations( self, api_key, api_secret, application_id, private_key, signature_secret diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 66c85807..8f925b7b 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -135,11 +135,8 @@ def make_request( elif auth_type == 'basic': self._headers['Authorization'] = self._auth.create_basic_auth_string() elif auth_type == 'signature': - params = self._auth.sign_params(params) - - print(params) - print(self._auth.check_signature(params)) - + params['api_key'] = self._auth.api_key + params['sig'] = self._auth.sign_params(params) with self._session.request( request_type, url, @@ -165,7 +162,6 @@ 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}' ) - print(response.request.headers) content_type = response.headers['Content-Type'].split(';', 1)[0] if 200 <= response.status_code < 300: if response.status_code == 204: diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py index 4303b469..cc59c4b6 100644 --- a/http_client/tests/test_auth.py +++ b/http_client/tests/test_auth.py @@ -1,7 +1,7 @@ from os.path import dirname, join from unittest.mock import patch import hashlib - +import hmac from pydantic import ValidationError from pytest import raises @@ -160,3 +160,63 @@ def test_auth_init_with_invalid_combinations(): assert auth._jwt_client is None assert auth._signature_secret == signature_secret assert auth._signature_method is None + + +def test_sign_params(): + auth = Auth(signature_secret='signature_secret', signature_method='sha256') + + params = {'param1': 'value1', 'param2': 'value2', 'timestamp': 1234567890} + + signed_params = auth.sign_params(params) + + assert signed_params == 'asdf' + + +def test_sign_params_default_sig_method(): + auth = Auth() + + params = {'param1': 'value1', 'param2': 'value2', 'timestamp': 1234567890} + + signed_params = auth.sign_params(params) + + assert signed_params == 'asdf' + + +def test_sign_params_with_special_characters(): + auth = Auth(signature_secret='signature_secret', signature_method='sha1') + + params = {'param1': 'value&1', 'param2': 'value=2', 'timestamp': 1234567890} + + signed_params = auth.sign_params(params) + + assert signed_params == 'asdf' + + +# def test_check_signature_with_valid_signature(): +# auth = Auth(signature_secret='signature_secret') +# params = {'param1': 'value1', 'param2': 'value2', 'sig': 'valid_signature'} +# expected_signature = hmac.new( +# b'signature_secret', b'param1value1param2value2', hashlib.sha256 +# ).hexdigest() + +# assert auth.check_signature(params) == True + + +# def test_check_signature_with_invalid_signature(): +# auth = Auth(signature_secret='signature_secret') +# params = {'param1': 'value1', 'param2': 'value2', 'sig': 'invalid_signature'} +# expected_signature = hmac.new( +# b'signature_secret', b'param1value1param2value2', hashlib.sha256 +# ).hexdigest() + +# assert auth.check_signature(params) == False + + +# def test_check_signature_with_empty_signature(): +# auth = Auth(signature_secret='signature_secret') +# params = {'param1': 'value1', 'param2': 'value2', 'sig': ''} +# expected_signature = hmac.new( +# b'signature_secret', b'param1value1param2value2', hashlib.sha256 +# ).hexdigest() + +# assert auth.check_signature(params) == False diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 11483336..4b2a8a8e 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -118,8 +118,8 @@ def test_make_post_request_with_signature(): auth_type='signature', ) assert res['hello'] == 'world!' - - assert loads(responses.calls[0].request.body) == params + print(responses.calls[0].request.url) + assert responses.calls[0].request.body == params @responses.activate From 52e94c494b878226b6ad8fc68b99a880e2644243 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 21 Mar 2024 05:31:27 +0000 Subject: [PATCH 11/98] fix mocks, signatures and sig auth, add tests --- http_client/src/vonage_http_client/auth.py | 31 ++++-- .../src/vonage_http_client/http_client.py | 2 +- http_client/tests/test_auth.py | 99 +++++++------------ http_client/tests/test_http_client.py | 27 +++-- pants.toml | 1 + sms/src/vonage_sms/sms.py | 2 +- testutils/testutils.py | 19 +++- 7 files changed, 97 insertions(+), 84 deletions(-) diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 29576600..a2358c8b 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -1,8 +1,9 @@ -from base64 import b64encode -from typing import Literal, Optional 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 @@ -77,16 +78,16 @@ def create_basic_auth_string(self): ) return f'Basic {hash}' - def sign_params(self, params: dict) -> dict: - """ - 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. + 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: - dict: The signed message parameters. + str: A hexadecimal digest of the signed message parameters. """ hasher = hmac.new( @@ -96,6 +97,7 @@ def sign_params(self, params: dict) -> dict: if not params.get('timestamp'): params['timestamp'] = int(time()) + print(params['timestamp']) for key in sorted(params): value = params[key] @@ -105,14 +107,23 @@ def sign_params(self, params: dict) -> dict: hasher.update(f'&{key}={value}'.encode('utf-8')) - if self._signature_method is None: - hasher.update(self._signature_secret.encode()) 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._signature_secret(params)) + return hmac.compare_digest(signature, self.sign_params(params)) def _validate_input_combinations( self, api_key, api_secret, application_id, private_key, signature_secret diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 8f925b7b..6326a55b 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -140,7 +140,7 @@ def make_request( with self._session.request( request_type, url, - params=params, + data=params, headers=self._headers, timeout=self._timeout, ) as response: diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py index cc59c4b6..b5fd021d 100644 --- a/http_client/tests/test_auth.py +++ b/http_client/tests/test_auth.py @@ -1,7 +1,6 @@ +import hashlib from os.path import dirname, join from unittest.mock import patch -import hashlib -import hmac from pydantic import ValidationError from pytest import raises @@ -36,6 +35,8 @@ def test_create_auth_class_and_get_objects(): 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(): @@ -63,6 +64,10 @@ def test_auth_init_with_invalid_combinations(): 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(): @@ -110,96 +115,64 @@ def test_create_basic_auth_string(): assert auth.create_basic_auth_string() == 'Basic cXdlcmFzZGY6MTIzNHF3ZXJhc2Rmenhjdg==' -def test_auth_init_with_valid_combinations(): - api_key = 'qwerasdf' - api_secret = '1234qwerasdfzxcv' - application_id = 'asdfzxcv' - private_key = 'dummy_private_key' - signature_secret = 'signature_secret' - signature_method = 'sha256' - +def test_sign_params(): 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 auth._jwt_client.application_id == application_id - assert auth._jwt_client.private_key == private_key - assert auth._signature_secret == signature_secret - assert auth._signature_method == hashlib.sha256 + params = {'param1': 'value1', 'param2': 'value2', 'timestamp': 1234567890} + signed_params_hash = auth.sign_params(params) -def test_auth_init_with_invalid_combinations(): - api_key = 'qwerasdf' - api_secret = '1234qwerasdfzxcv' - application_id = 'asdfzxcv' - private_key = 'dummy_private_key' - signature_secret = 'signature_secret' - signature_method = 'invalid_method' - - with patch('vonage_http_client.auth.hashlib') as mock_hashlib: - mock_hashlib.sha256.side_effect = AttributeError - - 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 auth._jwt_client is None - assert auth._signature_secret == signature_secret - assert auth._signature_method is None + assert ( + signed_params_hash + == '280c4320703dbc98bfa22db676655ed2acfbfe8792b062ff7622e67f1183c287' + ) -def test_sign_params(): - auth = Auth(signature_secret='signature_secret', signature_method='sha256') +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 = auth.sign_params(params) + signed_params_hash = auth.sign_params(params) - assert signed_params == 'asdf' + assert signed_params_hash == '724c2bf6ca423c36e20631b11d1c5753' -def test_sign_params_default_sig_method(): - auth = Auth() +def test_sign_params_with_special_characters(): + auth = Auth(api_key=api_key, signature_secret=signature_secret) - params = {'param1': 'value1', 'param2': 'value2', 'timestamp': 1234567890} + params = {'param1': 'value&1', 'param2': 'value=2', 'timestamp': 1234567890} signed_params = auth.sign_params(params) - assert signed_params == 'asdf' + assert signed_params == '2bbf0abafb2c55e5af6231513896a2ac' -def test_sign_params_with_special_characters(): - auth = Auth(signature_secret='signature_secret', signature_method='sha1') +@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': 'value&1', 'param2': 'value=2', 'timestamp': 1234567890} + params = {'param1': 'value1', 'param2': 'value2'} signed_params = auth.sign_params(params) - assert signed_params == 'asdf' + assert signed_params == 'bc7e95bb4e341090b3a202a2885903a5' -# def test_check_signature_with_valid_signature(): -# auth = Auth(signature_secret='signature_secret') -# params = {'param1': 'value1', 'param2': 'value2', 'sig': 'valid_signature'} -# expected_signature = hmac.new( -# b'signature_secret', b'param1value1param2value2', hashlib.sha256 -# ).hexdigest() +def test_check_signature_with_valid_signature(): + auth = Auth(api_key=api_key, signature_secret=signature_secret) + params = { + 'param1': 'value1', + 'param2': 'value2', + 'sig': 'valid_signature', + 'timestamp': 1234567890, + } -# assert auth.check_signature(params) == True + assert auth.check_signature(params) == True # def test_check_signature_with_invalid_signature(): diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 4b2a8a8e..0eaab49e 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -4,6 +4,7 @@ import responses from pytest import raises from requests import Response +from responses import matchers from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, @@ -97,8 +98,26 @@ def test_make_post_request(): @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' + 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( @@ -106,10 +125,6 @@ def test_make_post_request_with_signature(): ), http_client_options={'api_host': 'example.com'}, ) - params = { - 'test': 'post request', - 'testing': 'http client', - } res = client.post( host='example.com', @@ -118,8 +133,6 @@ def test_make_post_request_with_signature(): auth_type='signature', ) assert res['hello'] == 'world!' - print(responses.calls[0].request.url) - assert responses.calls[0].request.body == params @responses.activate diff --git a/pants.toml b/pants.toml index 1634ebe4..6f617a91 100644 --- a/pants.toml +++ b/pants.toml @@ -31,6 +31,7 @@ filter = [ 'vonage/src', 'http_client/src', 'number_insight_v2/src', + 'sms/src', 'utils/src', 'testutils', ] diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py index 15594a47..305691fe 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator, validate_call from vonage_http_client.http_client import HttpClient -from .errors import SmsError, PartialFailureError +from .errors import PartialFailureError, SmsError class SmsMessage(BaseModel): diff --git a/testutils/testutils.py b/testutils/testutils.py index d5e285c1..5e565df2 100644 --- a/testutils/testutils.py +++ b/testutils/testutils.py @@ -10,6 +10,10 @@ def _load_mock_data(caller_file_path: str, mock_path: str): return file.read() +def _filter_none_values(data: dict) -> dict: + return {k: v for (k, v) in data.items() if v is not None} + + @validate_call def build_response( file_path: str, @@ -18,7 +22,18 @@ def build_response( mock_path: str = None, status_code: int = 200, content_type: str = 'application/json', + match: list = None, ): - print('file_path', file_path) body = _load_mock_data(file_path, mock_path) if mock_path else None - responses.add(method, url, body=body, status=status_code, content_type=content_type) + responses.add( + **_filter_none_values( + { + 'method': method, + 'url': url, + 'body': body, + 'status': status_code, + 'content_type': content_type, + 'match': match, + } + ) + ) From 01145a914167d07cfce5bc20f06f96a216466c9f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 22 Mar 2024 05:35:11 +0000 Subject: [PATCH 12/98] Sms structuring and tests --- .../src/vonage_http_client/http_client.py | 41 +++--- http_client/tests/test_auth.py | 34 ++--- http_client/tests/test_http_client.py | 1 + sms/src/vonage_sms/models.py | 54 +++++++ sms/src/vonage_sms/responses.py | 20 +++ sms/src/vonage_sms/sms.py | 55 +------- sms/tests/data/default.json | 19 --- sms/tests/data/fraud_score.json | 15 -- sms/tests/data/send_sms.json | 1 + sms/tests/data/sim_swap.json | 11 -- sms/tests/test_sms.py | 133 +++++++----------- 11 files changed, 161 insertions(+), 223 deletions(-) create mode 100644 sms/src/vonage_sms/models.py create mode 100644 sms/src/vonage_sms/responses.py delete mode 100644 sms/tests/data/default.json delete mode 100644 sms/tests/data/fraud_score.json create mode 100644 sms/tests/data/send_sms.json delete mode 100644 sms/tests/data/sim_swap.json diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 6326a55b..d135fdd2 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -104,18 +104,20 @@ def post( host: str, request_path: str = '', params: dict = None, - auth_type: str = 'jwt', + auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + body_type: Literal['json', 'data'] = 'json', ) -> Union[dict, None]: - return self.make_request('POST', host, request_path, params, auth_type) + return self.make_request('POST', host, request_path, params, auth_type, body_type) def get( self, host: str, request_path: str = '', params: dict = None, - auth_type: str = 'jwt', + auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + body_type: Literal['json', 'data'] = 'json', ) -> Union[dict, None]: - return self.make_request('GET', host, request_path, params, auth_type) + return self.make_request('GET', host, request_path, params, auth_type, body_type) @validate_call def make_request( @@ -125,6 +127,7 @@ def make_request( request_path: str = '', params: Optional[dict] = None, auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + body_type: Literal['json', 'data'] = 'json', ): url = f'https://{host}{request_path}' logger.debug( @@ -137,22 +140,20 @@ def make_request( elif auth_type == 'signature': params['api_key'] = self._auth.api_key params['sig'] = self._auth.sign_params(params) - with self._session.request( - request_type, - url, - data=params, - headers=self._headers, - timeout=self._timeout, - ) as response: - return self._parse_response(response) - - with self._session.request( - request_type, - url, - json=params, - headers=self._headers, - timeout=self._timeout, - ) as response: + + request_params = { + 'method': request_type, + 'url': url, + 'headers': self._headers, + 'timeout': self._timeout, + } + + if body_type == 'json': + request_params['json'] = params + else: + request_params['data'] = params + + with self._session.request(**request_params) as response: return self._parse_response(response) def append_to_user_agent(self, string: str): diff --git a/http_client/tests/test_auth.py b/http_client/tests/test_auth.py index b5fd021d..3ff48e91 100644 --- a/http_client/tests/test_auth.py +++ b/http_client/tests/test_auth.py @@ -163,33 +163,21 @@ def test_sign_params_with_dynamic_timestamp(mock_time): assert signed_params == 'bc7e95bb4e341090b3a202a2885903a5' -def test_check_signature_with_valid_signature(): +def test_check_signature_valid_signature(): auth = Auth(api_key=api_key, signature_secret=signature_secret) params = { - 'param1': 'value1', - 'param2': 'value2', - 'sig': 'valid_signature', + 'param': 'value', 'timestamp': 1234567890, + 'sig': '655a4d0b7f064dff438defc52b012cf5', } - assert auth.check_signature(params) == True -# def test_check_signature_with_invalid_signature(): -# auth = Auth(signature_secret='signature_secret') -# params = {'param1': 'value1', 'param2': 'value2', 'sig': 'invalid_signature'} -# expected_signature = hmac.new( -# b'signature_secret', b'param1value1param2value2', hashlib.sha256 -# ).hexdigest() - -# assert auth.check_signature(params) == False - - -# def test_check_signature_with_empty_signature(): -# auth = Auth(signature_secret='signature_secret') -# params = {'param1': 'value1', 'param2': 'value2', 'sig': ''} -# expected_signature = hmac.new( -# b'signature_secret', b'param1value1param2value2', hashlib.sha256 -# ).hexdigest() - -# assert auth.check_signature(params) == False +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 index 0eaab49e..5f3447b1 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -131,6 +131,7 @@ def test_make_post_request_with_signature(): request_path='/post_signed_params', params=params, auth_type='signature', + body_type='data', ) assert res['hello'] == 'world!' diff --git a/sms/src/vonage_sms/models.py b/sms/src/vonage_sms/models.py new file mode 100644 index 00000000..a90970b3 --- /dev/null +++ b/sms/src/vonage_sms/models.py @@ -0,0 +1,54 @@ +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.""" + + to: str + from_: str = Field(..., serialization_alias='from') + text: str + sig: Optional[str] = Field(None, min_length=16, max_length=60) + client_ref: Optional[str] = Field( + None, serialization_alias='client-ref', max_length=100 + ) + type: Optional[Literal['text', 'binary', 'unicode']] = None + ttl: Optional[int] = Field(None, ge=20000, le=604800000) + status_report_req: Optional[bool] = Field( + None, serialization_alias='status-report-req' + ) + callback: Optional[str] = Field(None, max_length=100) + message_class: Optional[int] = Field( + None, serialization_alias='message-class', ge=0, le=3 + ) + body: Optional[str] = None + udh: Optional[str] = None + protocol_id: Optional[int] = Field( + None, serialization_alias='protocol-id', ge=0, le=255 + ) + account_ref: Optional[str] = Field(None, serialization_alias='account-ref') + entity_id: Optional[str] = Field(None, serialization_alias='entity-id') + content_id: Optional[str] = Field(None, serialization_alias='content-id') + + @field_validator('body', 'udh') + @classmethod + def validate_body(cls, value, info: ValidationInfo): + data = info.data + if 'type' not in data or not data['type'] == 'binary': + raise ValueError( + 'This parameter can only be set when the "type" parameter is set to "binary".' + ) + return value + + @model_validator(mode='after') + def validate_type(self) -> 'SmsMessage': + if self.type == 'binary' and self.body is None and self.udh is None: + raise ValueError('This parameter is required for binary messages.') + return self diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py new file mode 100644 index 00000000..b9e4a820 --- /dev/null +++ b/sms/src/vonage_sms/responses.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class MessageResponse: + to: str + message_id: str + status: str + remaining_balance: str + message_price: str + network: str + client_ref: Optional[str] = None + account_ref: Optional[str] = None + + +@dataclass +class SmsResponse: + message_count: str + messages: List[MessageResponse] diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py index 305691fe..dbbfe250 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -1,58 +1,11 @@ from copy import deepcopy -from dataclasses import dataclass -from typing import List, Literal, Optional -from pydantic import BaseModel, Field, field_validator, validate_call +from pydantic import validate_call from vonage_http_client.http_client import HttpClient from .errors import PartialFailureError, SmsError - - -class SmsMessage(BaseModel): - to: str - from_: str = Field(..., alias="from") - text: str - sig: Optional[str] = Field(None, min_length=16, max_length=60) - client_ref: Optional[str] = Field(None, 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, alias='status-report-req') - callback: Optional[str] = Field(None, max_length=100) - message_class: Optional[int] = Field(None, alias='message-class', ge=0, le=3) - body: Optional[str] = None - udh: Optional[str] = None - protocol_id: Optional[int] = Field(None, alias='protocol-id', ge=0, le=255) - account_ref: Optional[str] = Field(None, alias='account-ref') - entity_id: Optional[str] = Field(None, alias='entity-id') - content_id: Optional[str] = Field(None, alias='content-id') - - @field_validator('body', 'udh') - @classmethod - def validate_body(cls, value, values): - if 'type' not in values or not values['type'] == 'binary': - raise ValueError( - 'This parameter can only be set when the "type" parameter is set to "binary".' - ) - if values['type'] == 'binary' and not value: - raise ValueError('This parameter is required for binary messages.') - - -@dataclass -class MessageResponse: - to: str - message_id: str - status: str - remaining_balance: str - message_price: str - network: str - client_ref: Optional[str] = None - account_ref: Optional[str] = None - - -@dataclass -class SmsResponse: - message_count: str - messages: List[MessageResponse] +from .models import SmsMessage +from .responses import MessageResponse, SmsResponse class Sms: @@ -60,6 +13,7 @@ class Sms: def __init__(self, http_client: HttpClient) -> None: self._http_client = deepcopy(http_client) + self._body_type = 'data' if self._http_client._auth._signature_secret: self._auth_type = 'signature' else: @@ -73,6 +27,7 @@ def send(self, message: SmsMessage) -> SmsResponse: '/sms/json', message.model_dump(by_alias=True), self._auth_type, + self._body_type, ) if int(response['message-count']) > 1: diff --git a/sms/tests/data/default.json b/sms/tests/data/default.json deleted file mode 100644 index cb808dd7..00000000 --- a/sms/tests/data/default.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", - "type": "phone", - "phone": { - "phone": "1234567890", - "carrier": "Verizon Wireless", - "type": "MOBILE" - }, - "fraud_score": { - "risk_score": "0", - "risk_recommendation": "allow", - "label": "low", - "status": "completed" - }, - "sim_swap": { - "status": "completed", - "swapped": false - } -} \ No newline at end of file diff --git a/sms/tests/data/fraud_score.json b/sms/tests/data/fraud_score.json deleted file mode 100644 index be7cc267..00000000 --- a/sms/tests/data/fraud_score.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "request_id": "2c2f5d3f-93ac-42b1-9083-4b14f0d583d3", - "type": "phone", - "phone": { - "phone": "1234567890", - "carrier": "Verizon Wireless", - "type": "MOBILE" - }, - "fraud_score": { - "risk_score": "0", - "risk_recommendation": "allow", - "label": "low", - "status": "completed" - } -} \ No newline at end of file diff --git a/sms/tests/data/send_sms.json b/sms/tests/data/send_sms.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/sms/tests/data/send_sms.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/sms/tests/data/sim_swap.json b/sms/tests/data/sim_swap.json deleted file mode 100644 index 594028c8..00000000 --- a/sms/tests/data/sim_swap.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "request_id": "db5282b6-8046-4217-9c0e-d9c55d8696e9", - "type": "phone", - "phone": { - "phone": "1234567890" - }, - "sim_swap": { - "status": "completed", - "swapped": false - } -} \ No newline at end of file diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index d6030705..3602015b 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -2,84 +2,40 @@ from pydantic import ValidationError from pytest import raises +import responses + +from testutils import build_response from vonage_http_client.auth import Auth from vonage_http_client.http_client import HttpClient -from vonage_sms.sms import Sms, SmsMessage - -path = abspath(__file__) - -sms = Sms(HttpClient(Auth('key', 'secret'))) - - -# def test_fraud_check_request_invalid_phone(): -# with raises(InvalidPhoneNumberError): -# FraudCheckRequest(phone='invalid_phone') -# with raises(InvalidPhoneNumberError): -# FraudCheckRequest(phone='123') -# with raises(InvalidPhoneNumberError): -# FraudCheckRequest(phone='12345678901234567890') - +from vonage_sms import Sms +from vonage_sms.models import SmsMessage -# def test_fraud_check_request_invalid_insights(): -# with raises(ValidationError): -# FraudCheckRequest(phone='1234567890', insights=['invalid_insight']) +path = abspath(__file__) -# @responses.activate -# def test_ni2_defaults(): -# build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'default.json') -# request = FraudCheckRequest(phone='1234567890') -# response = ni2.fraud_check(request) -# assert type(response) == FraudCheckResponse -# assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' -# assert response.phone.carrier == 'Verizon Wireless' -# assert response.fraud_score.risk_score == '0' -# assert response.sim_swap.status == 'completed' +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))) -# @responses.activate -# def test_ni2_fraud_score_only(): -# build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'fraud_score.json') -# request = FraudCheckRequest(phone='1234567890', insights=['fraud_score']) -# response = ni2.fraud_check(request) -# assert type(response) == FraudCheckResponse -# assert response.request_id == '2c2f5d3f-93ac-42b1-9083-4b14f0d583d3' -# assert response.phone.carrier == 'Verizon Wireless' -# assert response.fraud_score.risk_score == '0' -# assert response.sim_swap is None - -# clear_response = asdict(response, dict_factory=remove_none_values) -# print(clear_response) -# assert 'fraud_score' in clear_response -# assert 'sim_swap' not in clear_response +def test_create_valid_SmsMessage(): + valid_message = { + 'to': '1234567890', + 'from_': 'Acme Inc.', + 'text': 'Hello, World!', + } + SmsMessage(**valid_message) -# @responses.activate -# def test_ni2_sim_swap_only(): -# build_response(path, 'POST', 'https://api.nexmo.com/v2/ni', 'sim_swap.json') -# request = FraudCheckRequest(phone='1234567890', insights='sim_swap') -# response = ni2.fraud_check(request) -# assert type(response) == FraudCheckResponse -# assert response.request_id == 'db5282b6-8046-4217-9c0e-d9c55d8696e9' -# assert response.phone.phone == '1234567890' -# assert response.fraud_score is None -# assert response.sim_swap.status == 'completed' -# assert response.sim_swap.swapped is False - -# clear_response = asdict(response, dict_factory=remove_none_values) -# assert 'fraud_score' not in clear_response -# assert 'sim_swap' in clear_response -# assert 'reason' not in clear_response['sim_swap'] - - -def test_validate_full_message(): valid_message = { 'to': '1234567890', - 'from_': '9876543210', + 'from_': 'Acme Inc.', 'text': 'Hello, World!', 'sig': 'asdfqwerzxcv12345678', 'client_ref': 'ref123', - 'type': 'text', + 'type': 'binary', 'ttl': 3000000, 'status_report_req': True, 'callback': 'https://example.com/callback', @@ -91,30 +47,19 @@ def test_validate_full_message(): 'entity_id': 'entity123', 'content_id': 'content123', } - try: - SmsMessage(**valid_message) - except ValidationError: - assert False, 'Validation error occurred for a valid SmsMessage' + SmsMessage(**valid_message) - # Invalid SmsMessage - missing required fields - invalid_message = {'to': '+1234567890', 'text': 'Hello, World!'} - with raises(ValidationError): - SmsMessage(**invalid_message) - # Invalid SmsMessage - invalid type value - invalid_message = { - 'to': '+1234567890', - 'from_': '+9876543210', - 'text': 'Hello, World!', - 'type': 'invalid_type', - } +def test_create_invalid_SmsMessage(): + # Missing required fields + invalid_message = {'to': '1234567890', 'text': 'Hello, World!'} with raises(ValidationError): SmsMessage(**invalid_message) - # Invalid SmsMessage - invalid body for non-binary type + # Invalid body for non-binary type invalid_message = { - 'to': '+1234567890', - 'from_': '+9876543210', + 'to': '1234567890', + 'from_': 'Acme Inc.', 'text': 'Hello, World!', 'type': 'text', 'body': 'binary data', @@ -122,12 +67,30 @@ def test_validate_full_message(): with raises(ValidationError): SmsMessage(**invalid_message) - # Invalid SmsMessage - missing body for binary type + # Missing body and udh for binary type invalid_message = { - 'to': '+1234567890', - 'from_': '+9876543210', + 'to': '1234567890', + 'from_': 'Acme Inc.', 'text': 'Hello, World!', 'type': 'binary', } with raises(ValidationError): SmsMessage(**invalid_message) + + +# @responses.activate +def test_send_message(): + sms = Sms(HttpClient(Auth(api_key='', api_secret=''))) + build_response(path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_sms.json') + message = SmsMessage(to='', from_='Acme Inc.', text='Hello, World!') + response = sms.send(message) + print(response) + assert response.message_count == 1 + assert response.messages[0].status == '0' + + # assert response.status == '0' + # assert response.to == '1234567890' + # assert response.message_id + # assert response.remaining_balance + # assert response.message_price + # assert response.network From b547a957bec3a6935fad91b3296a83fcbf247281 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 23 Mar 2024 05:06:39 +0000 Subject: [PATCH 13/98] add conversion method, SMS tests, refactoring --- http_client/CHANGES.md | 7 +- http_client/pyproject.toml | 2 +- http_client/src/vonage_http_client/auth.py | 4 +- .../src/vonage_http_client/http_client.py | 6 +- number_insight_v2/README.md | 4 +- pants.toml | 2 + sms/BUILD | 2 +- sms/CHANGES.md | 2 + sms/README.md | 21 +++- sms/pyproject.toml | 2 +- sms/src/vonage_sms/__init__.py | 8 ++ sms/src/vonage_sms/{models.py => requests.py} | 8 +- sms/src/vonage_sms/responses.py | 31 +++--- sms/src/vonage_sms/sms.py | 55 ++++++++--- sms/tests/data/conversion_not_enabled.html | 12 +++ sms/tests/data/null | 0 sms/tests/data/send_sms.json | 14 ++- sms/tests/data/send_sms_error.json | 9 ++ sms/tests/data/send_sms_partial_error.json | 17 ++++ sms/tests/test_sms.py | 98 ++++++++++++++++--- vonage/CHANGES.md | 5 +- vonage/src/vonage/__init__.py | 4 +- vonage/src/vonage/_version.py | 2 +- vonage/src/vonage/vonage.py | 2 +- 24 files changed, 251 insertions(+), 66 deletions(-) create mode 100644 sms/CHANGES.md rename sms/src/vonage_sms/{models.py => requests.py} (94%) create mode 100644 sms/tests/data/conversion_not_enabled.html create mode 100644 sms/tests/data/null create mode 100644 sms/tests/data/send_sms_error.json create mode 100644 sms/tests/data/send_sms_partial_error.json diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 17cd0da7..23d089a0 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,2 +1,5 @@ -# 0.1.0 -- Initial upload \ No newline at end of file +# 1.0.0 +- Initial upload + +# 1.1.0 +- Add support for signature authentication \ No newline at end of file diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index b78a5dba..f44e66ea 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vonage-http-client" -version = "1.0.0" +version = "1.1.0" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index a2358c8b..43e453f3 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -1,7 +1,6 @@ import hashlib import hmac from base64 import b64encode - from time import time from typing import Literal, Optional @@ -111,8 +110,7 @@ def sign_params(self, params: dict) -> str: @validate_call def check_signature(self, params: dict) -> bool: - """ - Checks the signature hash of the given parameters. + """Checks the signature hash of the given parameters. Args: params (dict): The parameters to check the signature for. diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index d135fdd2..d7ad9ced 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -1,3 +1,4 @@ +from json import JSONDecodeError from logging import getLogger from platform import python_version from typing import Literal, Optional, Union @@ -167,7 +168,10 @@ def _parse_response(self, response: Response) -> Union[dict, None]: if 200 <= response.status_code < 300: if response.status_code == 204: return None - return response.json() + 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}' diff --git a/number_insight_v2/README.md b/number_insight_v2/README.md index f11a0144..43b9ac1c 100644 --- a/number_insight_v2/README.md +++ b/number_insight_v2/README.md @@ -8,10 +8,10 @@ It includes classes for making fraud check requests and handling the responses. First, import the necessary classes and create an instance of the `NumberInsightV2` class: ```python -from vonage_http_client.http_client import HttpClient +from vonage_http_client.http_client import HttpClient, Auth from number_insight_v2 import NumberInsightV2, FraudCheckRequest -http_client = HttpClient(api_host='your_api_host', api_key='your_api_key', api_secret='your_api_secret') +http_client = HttpClient(Auth(api_key='your_api_key', api_secret='your_api_secret')) number_insight = NumberInsightV2(http_client) ``` diff --git a/pants.toml b/pants.toml index 6f617a91..4d79b5c9 100644 --- a/pants.toml +++ b/pants.toml @@ -12,6 +12,8 @@ backend_packages = [ "pants.backend.experimental.python", ] +pants_ignore.add = ['!_test_scripts/'] + [anonymous-telemetry] enabled = false diff --git a/sms/BUILD b/sms/BUILD index 4ab3bdfe..0f896959 100644 --- a/sms/BUILD +++ b/sms/BUILD @@ -12,5 +12,5 @@ python_distribution( ], provides=python_artifact(), generate_setup=False, - repositories=['@testpypi'], + repositories=['@pypi'], ) diff --git a/sms/CHANGES.md b/sms/CHANGES.md new file mode 100644 index 00000000..be516a55 --- /dev/null +++ b/sms/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/sms/README.md b/sms/README.md index 18c630b6..66ac2662 100644 --- a/sms/README.md +++ b/sms/README.md @@ -1,5 +1,24 @@ # Vonage SMS Package -This package contains the code to use Vonage's SMS API with Python. +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 index d4ece51f..bc6f10ea 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-sms' -version = '0.1.0' +version = '1.0.0' description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/sms/src/vonage_sms/__init__.py b/sms/src/vonage_sms/__init__.py index 24089b7d..b38bc93d 100644 --- a/sms/src/vonage_sms/__init__.py +++ b/sms/src/vonage_sms/__init__.py @@ -1,5 +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/models.py b/sms/src/vonage_sms/requests.py similarity index 94% rename from sms/src/vonage_sms/models.py rename to sms/src/vonage_sms/requests.py index a90970b3..b1a5a777 100644 --- a/sms/src/vonage_sms/models.py +++ b/sms/src/vonage_sms/requests.py @@ -1,12 +1,6 @@ from typing import Literal, Optional -from pydantic import ( - BaseModel, - Field, - ValidationInfo, - field_validator, - model_validator, -) +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator class SmsMessage(BaseModel): diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py index b9e4a820..7a727698 100644 --- a/sms/src/vonage_sms/responses.py +++ b/sms/src/vonage_sms/responses.py @@ -1,20 +1,27 @@ -from dataclasses import dataclass from typing import List, Optional +from pydantic import BaseModel, Field, field_validator -@dataclass -class MessageResponse: + +class MessageResponse(BaseModel): to: str - message_id: str + message_id: str = Field(..., validation_alias='message-id') status: str - remaining_balance: str - message_price: str + remaining_balance: str = Field(..., validation_alias='remaining-balance') + message_price: str = Field(..., validation_alias='message-price') network: str - client_ref: Optional[str] = None - account_ref: 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): + message_count: str = Field(..., validation_alias='message-count') + messages: List[dict] -@dataclass -class SmsResponse: - message_count: str - messages: List[MessageResponse] + @field_validator('messages') + @classmethod + def create_message_response(cls, value): + messages = [] + for message in value: + messages.append(MessageResponse(**message)) + return messages diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py index dbbfe250..c1a8779f 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -1,18 +1,18 @@ -from copy import deepcopy +from datetime import datetime, timezone from pydantic import validate_call from vonage_http_client.http_client import HttpClient from .errors import PartialFailureError, SmsError -from .models import SmsMessage -from .responses import MessageResponse, SmsResponse +from .requests import SmsMessage +from .responses import SmsResponse class Sms: """Calls Vonage's SMS API.""" def __init__(self, http_client: HttpClient) -> None: - self._http_client = deepcopy(http_client) + self._http_client = http_client self._body_type = 'data' if self._http_client._auth._signature_secret: self._auth_type = 'signature' @@ -31,17 +31,12 @@ def send(self, message: SmsMessage) -> SmsResponse: ) if int(response['message-count']) > 1: - self.check_for_partial_failure(response) + self._check_for_partial_failure(response) else: - self.check_for_error(response) + self._check_for_error(response) + return SmsResponse(**response) - messages = [] - for message in response['messages']: - messages.append(MessageResponse(**message)) - - return SmsResponse(message_count=response['message-count'], messages=messages) - - def check_for_partial_failure(self, response_data): + def _check_for_partial_failure(self, response_data): successful_messages = 0 total_messages = int(response_data['message-count']) @@ -51,9 +46,41 @@ def check_for_partial_failure(self, response_data): if successful_messages < total_messages: raise PartialFailureError(response_data) - def check_for_error(self, response_data): + def _check_for_error(self, response_data): message = response_data['messages'][0] if int(message['status']) != 0: raise SmsError( f'Sms.send_message method failed with error code {message["status"]}: {message["error-text"]}' ) + + @validate_call + def submit_sms_conversion( + self, message_id: str, delivered: bool = True, timestamp: datetime = None + ): + """ + Note: Not available without having this feature manually enabled on your account. + + Notifies Vonage that an SMS was successfully received. + + This method is used to submit conversion data about SMS messages that were successfully delivered. + If you are using the Verify API for two-factor authentication (2FA), this information is sent to Vonage automatically, + so you do not need to use this method for 2FA messages. + + Args: + message_id (str): The `message-id` returned by the `Sms.send` call. + delivered (bool, optional): Set to `True` if the user replied to the message you sent. Otherwise, set to `False`. + timestamp (datetime, optional): A `datetime` object containing the time the SMS arrived. + """ + params = { + 'message-id': message_id, + 'delivered': delivered, + 'timestamp': (timestamp or datetime.now(timezone.utc)).strftime( + '%Y-%m-%d %H:%M:%S' + ), + } + self._http_client.get( + self._http_client.api_host, + '/conversions/sms', + params, + self._auth_type, + ) 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/sms/tests/data/null b/sms/tests/data/null new file mode 100644 index 00000000..e69de29b diff --git a/sms/tests/data/send_sms.json b/sms/tests/data/send_sms.json index 9e26dfee..dce30b3e 100644 --- a/sms/tests/data/send_sms.json +++ b/sms/tests/data/send_sms.json @@ -1 +1,13 @@ -{} \ No newline at end of file +{ + "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 index 3602015b..23eb1dba 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -1,15 +1,17 @@ from os.path import abspath +import stat +import responses from pydantic import ValidationError from pytest import raises -import responses - -from testutils import build_response 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.models import SmsMessage +from vonage_sms.errors import PartialFailureError, SmsError +from vonage_sms.requests import SmsMessage +from testutils import build_response path = abspath(__file__) @@ -78,19 +80,85 @@ def test_create_invalid_SmsMessage(): SmsMessage(**invalid_message) -# @responses.activate +@responses.activate def test_send_message(): - sms = Sms(HttpClient(Auth(api_key='', api_secret=''))) build_response(path, 'POST', 'https://rest.nexmo.com/sms/json', 'send_sms.json') - message = SmsMessage(to='', from_='Acme Inc.', text='Hello, World!') + message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') response = sms.send(message) - print(response) - assert response.message_count == 1 + 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_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' + - # assert response.status == '0' - # assert response.to == '1234567890' - # assert response.message_id - # assert response.remaining_balance - # assert response.message_price - # assert response.network +@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, + 'GET', + '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, + 'GET', + 'https://api.nexmo.com/conversions/sms', + 'conversion_not_enabled.html', + status_code=402, + ) + try: + sms.submit_sms_conversion('3295d748-4e14-4681-af78-166dca3c5aab') + except HttpRequestError as err: + assert err.message == '402 response from https://api.nexmo.com/conversions/sms.' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 205eb1b4..37536509 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,4 +1,7 @@ -# 4.0.0 +# 3.99.0a1 +- Add support for the Vonage SMS API + +# 3.99.0a0 Created new monorepo structure - this package `vonage` is now a way to depend on the functionality of all Vonage APIs, which has been moved into separate packages. Additionally, there are many breaking changes. # 3.11.0 diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 95b9e74b..a61419cb 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,5 +1,5 @@ from vonage_utils import VonageError -from .vonage import Auth, HttpClientOptions, NumberInsightV2, Vonage +from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Vonage -__all__ = ['Vonage', 'Auth', 'HttpClientOptions', 'NumberInsightV2', 'VonageError'] +__all__ = ['Vonage', 'Auth', 'HttpClientOptions', 'NumberInsightV2', 'Sms', 'VonageError'] diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index fba5a81b..d25447bf 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a0' +__version__ = '3.99.0a1' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 9d9caf6b..4cae6fd9 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -3,7 +3,7 @@ from vonage_http_client.auth import Auth from vonage_http_client.http_client import HttpClient, HttpClientOptions from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 -from vonage_sms import Sms +from vonage_sms.sms import Sms from ._version import __version__ From 35e9fa915686b673b3756ab332876c31c88e016e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 23 Mar 2024 05:11:44 +0000 Subject: [PATCH 14/98] linting --- sms/tests/test_sms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index 23eb1dba..6337f665 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -1,5 +1,4 @@ from os.path import abspath -import stat import responses from pydantic import ValidationError From b4657c06188ee35afe98639c17857b31564921d2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 23 Mar 2024 05:25:44 +0000 Subject: [PATCH 15/98] update dependency versions --- sms/pyproject.toml | 4 ++-- vonage/pyproject.toml | 3 ++- vonage/src/vonage/_version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sms/pyproject.toml b/sms/pyproject.toml index bc6f10ea..8e4b1277 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -1,12 +1,12 @@ [project] name = 'vonage-sms' -version = '1.0.0' +version = '1.0.1' description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.0.0", + "vonage-http-client>=1.1.0", "vonage-utils>=1.0.0", "pydantic>=2.6.1", ] diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 78490797..9f82272a 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -7,8 +7,9 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.0.0", - "vonage-http-client>=1.0.0", + "vonage-http-client>=1.1.0", "vonage-number-insight-v2>=0.1.0", + "vonage-sms>=1.0.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index d25447bf..99e1d55c 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a1' +__version__ = '3.99.0a2' From 14237bbe9bb03e13f617fcb9e5d61ee2a322dcfd Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 26 Mar 2024 05:20:01 +0000 Subject: [PATCH 16/98] adding users api structure and list endpoint, refactoring and testing --- pants.toml | 3 +- sms/src/vonage_sms/responses.py | 12 +----- sms/tests/data/send_long_sms.json | 23 +++++++++++ sms/tests/test_sms.py | 10 +++++ testutils/BUILD | 4 +- testutils/__init__.py | 3 +- testutils/data/fake_private_key.txt | 28 ++++++++++++++ testutils/mock_auth.py | 19 +++++++++ users/BUILD | 16 ++++++++ users/CHANGES.md | 2 + users/README.md | 31 +++++++++++++++ users/pyproject.toml | 29 ++++++++++++++ users/src/vonage_users/BUILD | 1 + users/src/vonage_users/__init__.py | 8 ++++ users/src/vonage_users/errors.py | 5 +++ users/src/vonage_users/requests.py | 60 +++++++++++++++++++++++++++++ users/src/vonage_users/responses.py | 59 ++++++++++++++++++++++++++++ users/src/vonage_users/users.py | 30 +++++++++++++++ users/tests/BUILD | 1 + users/tests/data/list_users.json | 31 +++++++++++++++ users/tests/test_users.py | 50 ++++++++++++++++++++++++ 21 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 sms/tests/data/send_long_sms.json create mode 100644 testutils/data/fake_private_key.txt create mode 100644 testutils/mock_auth.py create mode 100644 users/BUILD create mode 100644 users/CHANGES.md create mode 100644 users/README.md create mode 100644 users/pyproject.toml create mode 100644 users/src/vonage_users/BUILD create mode 100644 users/src/vonage_users/__init__.py create mode 100644 users/src/vonage_users/errors.py create mode 100644 users/src/vonage_users/requests.py create mode 100644 users/src/vonage_users/responses.py create mode 100644 users/src/vonage_users/users.py create mode 100644 users/tests/BUILD create mode 100644 users/tests/data/list_users.json create mode 100644 users/tests/test_users.py diff --git a/pants.toml b/pants.toml index 4d79b5c9..14f4ac99 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.19.0' +pants_version = '2.19.1' backend_packages = [ 'pants.backend.python', @@ -34,6 +34,7 @@ filter = [ 'http_client/src', 'number_insight_v2/src', 'sms/src', + 'users/src', 'utils/src', 'testutils', ] diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py index 7a727698..d6428c07 100644 --- a/sms/src/vonage_sms/responses.py +++ b/sms/src/vonage_sms/responses.py @@ -1,6 +1,6 @@ from typing import List, Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field class MessageResponse(BaseModel): @@ -16,12 +16,4 @@ class MessageResponse(BaseModel): class SmsResponse(BaseModel): message_count: str = Field(..., validation_alias='message-count') - messages: List[dict] - - @field_validator('messages') - @classmethod - def create_message_response(cls, value): - messages = [] - for message in value: - messages.append(MessageResponse(**message)) - return messages + messages: List[MessageResponse] 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/test_sms.py b/sms/tests/test_sms.py index 6337f665..2ce9da0b 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -93,6 +93,16 @@ def test_send_message(): 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( diff --git a/testutils/BUILD b/testutils/BUILD index db46e8d6..ec72fc27 100644 --- a/testutils/BUILD +++ b/testutils/BUILD @@ -1 +1,3 @@ -python_sources() +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 index e4aa14c2..a52da6ae 100644 --- a/testutils/__init__.py +++ b/testutils/__init__.py @@ -1,3 +1,4 @@ from .testutils import build_response +from .mock_auth import get_mock_api_key_auth, get_mock_jwt_auth -__all__ = ['build_response'] +__all__ = ['build_response', 'get_mock_api_key_auth', 'get_mock_jwt_auth'] diff --git a/testutils/data/fake_private_key.txt b/testutils/data/fake_private_key.txt new file mode 100644 index 00000000..163ff367 --- /dev/null +++ b/testutils/data/fake_private_key.txt @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQdAHqJHs/a+Ra +2ubvSd1vz/aWlJ9BqnMUtB7guTlyggdENAbleIkzep6mUHepDJdQh8Qv6zS3lpUe +K0UkDfr1/FvsvxurGw/YYPagUEhP/HxMbs2rnQTiAdWOT+Ux9vPABoyNYvZB90xN +IVhBDRWgkz1HPQBRNjFcm3NOol83h5Uwp5YroGTWx+rpmIiRhQj3mv6luk102d95 +4ulpPpzcYWKIpJNdclJrEkBZaghDZTOpbv79qd+ds9AVp1j8i9cG/owBJpsJWxfw +StMDpNeEZqopeQWmA121sSEsxpAbKJ5DA7F/lmckx74sulKHX1fDWT76cRhloaEQ +VmETdj0VAgMBAAECggEAZ+SBtchz8vKbsBqtAbM/XcR5Iqi1TR2eWMHDJ/65HpSm ++XuyujjerN0e6EZvtT4Uxmq8QaPJNP0kmhI31hXvsB0UVcUUDa4hshb1pIYO3Gq7 +Kr8I29EZB2mhndm9Ii9yYhEBiVA66zrNeR225kkWr97iqjhBibhoVr8Vc6oiqcIP +nFy5zSFtQSkhucaPge6rW00JSOD3wg2GM+rgS6r22t8YmqTzAwvwfil5pQfUngal +oywqLOf6CUYXPBleJc1KgaIIP/cSvqh6b/t25o2VXnI4rpRhtleORvYBbH6K6xLa +OWgg6B58T+0/QEqtZIAn4miYtVCkYLB78Ormc7Q9ewKBgQDuSytuYqxdZh/L/RDU +CErFcNO5I1e9fkLAs5dQEBvvdQC74+oA1MsDEVv0xehFa1JwPKSepmvB2UznZg9L +CtR7QKMDZWvS5xx4j0E/b+PiNQ/tlcFZB2UZ0JwviSxdd7omOTscq9c3RIhFHar1 +Y38Fixkfm44Ij/K3JqIi2v2QMwKBgQDf8TYOOmAr9UuipUDxMsRSqTGVIY8B+aEJ +W+2aLrqJVkLGTRfrbjzXWYo3+n7kNJjFgNkltDq6HYtufHMYRs/0PPtNR0w0cDPS +Xr7m2LNHTDcBalC/AS4yKZJLNLm+kXA84vkw4qiTjc0LSFxJkouTQzkea0l8EWHt +zRMv/qYVlwKBgBaJOWRJJK/4lo0+M7c5yYh+sSdTNlsPc9Sxp1/FBj9RO26JkXne +pgx2OdIeXWcjTTqcIZ13c71zhZhkyJF6RroZVNFfaCEcBk9IjQ0o0c504jq/7Pc0 +gdU9K2g7etykFBDFXNfLUKFDc/fFZIOskzi8/PVGStp4cqXrm23cdBqNAoGBAKtf +A2bP9ViuVjsZCyGJIAPBxlfBXpa8WSe4WZNrvwPqJx9pT6yyp4yE0OkVoJUyStaZ +S5M24NocUd8zDUC+r9TP9d+leAOI+Z87MgumOUuOX2mN2kzQsnFgrrsulhXnZmSx +rNBkI20HTqobrcP/iSAgiU1l/M4c3zwDe3N3A9HxAoGBAM2hYu0Ij6htSNgo/WWr +IEYYXuwf8hPkiuwzlaiWhD3eocgd4S8SsBu/bTCY19hQ2QbBPaYyFlNem+ynQyXx +IOacrgIHCrYnRCxjPfFF/MxgUHJb8ZoiexprP/FME5p0PoRQIEFYa+jVht3hT5wC +9aedWufq4JJb+akO6MVUjTvs +-----END PRIVATE KEY----- diff --git a/testutils/mock_auth.py b/testutils/mock_auth.py new file mode 100644 index 00000000..5221b6a2 --- /dev/null +++ b/testutils/mock_auth.py @@ -0,0 +1,19 @@ +from os.path import join, dirname + +from vonage_http_client.auth import Auth + + +def read_file(path): + with open(join(dirname(__file__), path)) as input_file: + return input_file.read() + + +def get_mock_api_key_auth(): + return Auth(api_key='test_api_key', api_secret='test_api_secret') + + +def get_mock_jwt_auth(): + return Auth( + application_id='test_application_id', + private_key=read_file('data/fake_private_key.txt'), + ) 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..be516a55 --- /dev/null +++ b/users/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/users/README.md b/users/README.md new file mode 100644 index 00000000..794fc5d0 --- /dev/null +++ b/users/README.md @@ -0,0 +1,31 @@ +# 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 + +### Create a New User + +### Get a User + +### Update a User + +### Delete a User + + diff --git a/users/pyproject.toml b/users/pyproject.toml new file mode 100644 index 00000000..300164db --- /dev/null +++ b/users/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-users' +version = '1.0.0' +description = 'Vonage SMS package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.1.0", + "vonage-utils>=1.0.0", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..d5c5d774 --- /dev/null +++ b/users/src/vonage_users/__init__.py @@ -0,0 +1,8 @@ +# from .errors import PartialFailureError, SmsError +# from .requests import SmsMessage +# from .responses import MessageResponse, SmsResponse +from .users import Users + +__all__ = [ + 'Users', +] diff --git a/users/src/vonage_users/errors.py b/users/src/vonage_users/errors.py new file mode 100644 index 00000000..91041192 --- /dev/null +++ b/users/src/vonage_users/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class UsersError(VonageError): + """Indicates an error with the Vonage Users Package.""" diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py new file mode 100644 index 00000000..b431abf7 --- /dev/null +++ b/users/src/vonage_users/requests.py @@ -0,0 +1,60 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator + + +class ListUsersRequest(BaseModel): + """Request object for listing users.""" + + page_size: Optional[int] = Field(None, 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 + + +# class SmsMessage(BaseModel): +# """Message object containing the data and options for an SMS message.""" + +# to: str +# from_: str = Field(..., serialization_alias='from') +# text: str +# sig: Optional[str] = Field(None, min_length=16, max_length=60) +# client_ref: Optional[str] = Field( +# None, serialization_alias='client-ref', max_length=100 +# ) +# type: Optional[Literal['text', 'binary', 'unicode']] = None +# ttl: Optional[int] = Field(None, ge=20000, le=604800000) +# status_report_req: Optional[bool] = Field( +# None, serialization_alias='status-report-req' +# ) +# callback: Optional[str] = Field(None, max_length=100) +# message_class: Optional[int] = Field( +# None, serialization_alias='message-class', ge=0, le=3 +# ) +# body: Optional[str] = None +# udh: Optional[str] = None +# protocol_id: Optional[int] = Field( +# None, serialization_alias='protocol-id', ge=0, le=255 +# ) +# account_ref: Optional[str] = Field(None, serialization_alias='account-ref') +# entity_id: Optional[str] = Field(None, serialization_alias='entity-id') +# content_id: Optional[str] = Field(None, serialization_alias='content-id') + +# @field_validator('body', 'udh') +# @classmethod +# def validate_body(cls, value, info: ValidationInfo): +# data = info.data +# if 'type' not in data or not data['type'] == 'binary': +# raise ValueError( +# 'This parameter can only be set when the "type" parameter is set to "binary".' +# ) +# return value + +# @model_validator(mode='after') +# def validate_type(self) -> 'SmsMessage': +# if self.type == 'binary' and self.body is None and self.udh is None: +# raise ValueError('This parameter is required for binary messages.') +# return self diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py new file mode 100644 index 00000000..67ac71d0 --- /dev/null +++ b/users/src/vonage_users/responses.py @@ -0,0 +1,59 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class Link(BaseModel): + href: str + + +class UserLinks(BaseModel): + self: Link + + +class Links(BaseModel): + self: Link + first: Link + next: Optional[Link] = None + prev: Optional[Link] = None + + +class User(BaseModel): + id: Optional[str] + name: Optional[str] + display_name: Optional[str] + links: Optional[UserLinks] = Field(..., validation_alias='_links') + + +class Embedded(BaseModel): + users: List[User] = [] + + +class ListUsersResponse(BaseModel): + page_size: int + embedded: Embedded = Field(..., validation_alias='_embedded') + links: Links = Field(..., validation_alias='_links') + + +# class MessageResponse(BaseModel): +# to: str +# message_id: str = Field(..., validation_alias='message-id') +# status: str +# remaining_balance: str = Field(..., validation_alias='remaining-balance') +# message_price: str = Field(..., validation_alias='message-price') +# network: str +# client_ref: Optional[str] = Field(None, validation_alias='client-ref') +# account_ref: Optional[str] = Field(None, validation_alias='account-ref') + + +# class SmsResponse(BaseModel): +# message_count: str = Field(..., validation_alias='message-count') +# messages: List[dict] + +# @field_validator('messages') +# @classmethod +# def create_message_response(cls, value): +# messages = [] +# for message in value: +# messages.append(MessageResponse(**message)) +# return messages diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py new file mode 100644 index 00000000..c6f78c44 --- /dev/null +++ b/users/src/vonage_users/users.py @@ -0,0 +1,30 @@ +from typing import Optional +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import UsersError +from .requests import ListUsersRequest +from .responses import ListUsersResponse + + +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' + + @validate_call + def list_users(self, params: Optional[ListUsersRequest] = None) -> ListUsersResponse: + """List all users.""" + response = self._http_client.get( + self._http_client.api_host, + '/v1/users', + params.model_dump() if params is not None else None, + self._auth_type, + ) + return ListUsersResponse(**response) 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..6939f43d --- /dev/null +++ b/users/tests/data/list_users.json @@ -0,0 +1,31 @@ +{ + "page_size": 10, + "_embedded": { + "users": [ + { + "id": "USR-82e028d9-5201-4f1e-8188-604b2d3471fd", + "name": "my_user_name", + "display_name": "My User Name", + "_links": { + "self": { + "href": "https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd" + } + } + } + ] + }, + "_links": { + "first": { + "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10" + }, + "self": { + "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=" + }, + "next": { + "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=" + }, + "prev": { + "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=" + } + } +} \ 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..60191f4f --- /dev/null +++ b/users/tests/test_users.py @@ -0,0 +1,50 @@ +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_users import Users +from vonage_users.errors import UsersError +from vonage_users.requests import ListUsersRequest +from vonage_users.responses import ListUsersResponse + +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 = ListUsersRequest(**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') + response: ListUsersResponse = users.list_users() + assert response.page_size == 10 + assert response.embedded.users[0].id == 'USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + assert response.embedded.users[0].name == 'my_user_name' + assert response.embedded.users[0].display_name == 'My User Name' + assert ( + response.embedded.users[0].links.self.href + == 'https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + ) + assert ( + response.links.self.href + == 'https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=' + ) + + # print(response.model_dump_json(indent=2)) From a4c6e5ac23ea69e19df796bf78064bead904a662 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 27 Mar 2024 03:12:49 +0000 Subject: [PATCH 17/98] add new models, change structure, start implementing pagination --- testutils/__init__.py | 2 +- testutils/mock_auth.py | 2 +- users/src/vonage_users/common.py | 70 +++++++++++++++++++++++++++++ users/src/vonage_users/requests.py | 4 +- users/src/vonage_users/responses.py | 7 +++ users/src/vonage_users/users.py | 59 +++++++++++++++++++----- users/tests/test_users.py | 37 ++++++++------- 7 files changed, 148 insertions(+), 33 deletions(-) create mode 100644 users/src/vonage_users/common.py diff --git a/testutils/__init__.py b/testutils/__init__.py index a52da6ae..4cfb4d9d 100644 --- a/testutils/__init__.py +++ b/testutils/__init__.py @@ -1,4 +1,4 @@ -from .testutils import build_response from .mock_auth import get_mock_api_key_auth, get_mock_jwt_auth +from .testutils import build_response __all__ = ['build_response', 'get_mock_api_key_auth', 'get_mock_jwt_auth'] diff --git a/testutils/mock_auth.py b/testutils/mock_auth.py index 5221b6a2..3237c801 100644 --- a/testutils/mock_auth.py +++ b/testutils/mock_auth.py @@ -1,4 +1,4 @@ -from os.path import join, dirname +from os.path import dirname, join from vonage_http_client.auth import Auth diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py new file mode 100644 index 00000000..7be06966 --- /dev/null +++ b/users/src/vonage_users/common.py @@ -0,0 +1,70 @@ +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field, HttpUrl +from typing_extensions import Annotated + +PhoneNumber = Annotated[str, Field(pattern='^[1-9]\d{6,14}$')] + + +class PstnChannel(BaseModel): + number: int + + +class SipChannel(BaseModel): + uri: str = Field(..., pattern='^(sip|sips):\+?([\w|:.\-@;,=%&]+)') + username: str = None + password: str = None + + +class VbcChannel(BaseModel): + extension: str + + +class WebsocketChannel(BaseModel): + uri: str = Field(pattern='^(ws|wss)://[a-zA-Z0-9~#%@&-_?\/.,:;)(][]*$') + content_type: str = Field(pattern="^audio/l16;rate=(8000|16000)$") + headers: Optional[Dict[str, str]] = None + + +class SmsChannel(BaseModel): + number: PhoneNumber + + +class MmsChannel(BaseModel): + number: PhoneNumber + + +class WhatsappChannel(BaseModel): + number: PhoneNumber + + +class ViberChannel(BaseModel): + number: PhoneNumber + + +class MessengerChannel(BaseModel): + id: str + + +class Channels(BaseModel): + pstn: Optional[List[PstnChannel]] = None + sip: Optional[List[SipChannel]] = None + vbc: Optional[List[VbcChannel]] = None + websocket: Optional[List[WebsocketChannel]] = None + 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 + + +class Properties(BaseModel): + custom_data: Optional[Dict[str, str]] + + +class User(BaseModel): + name: Optional[str] = Field(None, example="my_user_name") + display_name: Optional[str] = Field(None, example="My User Name") + image_url: Optional[HttpUrl] = Field(None, example="https://example.com/image.png") + properties: Optional[Properties] + channels: Optional[Channels] diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py index b431abf7..3445aeea 100644 --- a/users/src/vonage_users/requests.py +++ b/users/src/vonage_users/requests.py @@ -1,12 +1,12 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, Field class ListUsersRequest(BaseModel): """Request object for listing users.""" - page_size: Optional[int] = Field(None, ge=1, le=100) + page_size: Optional[int] = Field(10, ge=1, le=100) order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None cursor: Optional[str] = Field( None, diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 67ac71d0..7bfbb777 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -35,6 +35,13 @@ class ListUsersResponse(BaseModel): links: Links = Field(..., validation_alias='_links') +class CreateUserResponse(BaseModel): + id: str + name: str + display_name: str + links: UserLinks = Field(..., validation_alias='_links') + + # class MessageResponse(BaseModel): # to: str # message_id: str = Field(..., validation_alias='message-id') diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index c6f78c44..fca844b0 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -1,17 +1,32 @@ -from typing import Optional -from pydantic import validate_call +from typing import Generator, Literal, Optional + +from pydantic import BaseModel, validate_call from vonage_http_client.http_client import HttpClient -from .errors import UsersError +from .common import User from .requests import ListUsersRequest -from .responses import ListUsersResponse +from .responses import CreateUserResponse, ListUsersResponse + + +class Filters(BaseModel): + order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None + + +import urllib.parse + + +def parse_cursor_from_url(url: str) -> Optional[str]: + """Extract the cursor from the "next" URL.""" + query_string = urllib.parse.urlparse(url).query + params = urllib.parse.parse_qs(query_string) + return params.get('cursor', [None])[0] 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. + 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: @@ -19,12 +34,36 @@ def __init__(self, http_client: HttpClient) -> None: self._auth_type = 'jwt' @validate_call - def list_users(self, params: Optional[ListUsersRequest] = None) -> ListUsersResponse: - """List all users.""" - response = self._http_client.get( + def list_users( + self, + order: Literal['asc', 'desc', 'ASC', 'DESC'] = None, + name: str = None, + ) -> Generator: + """List all users with pagination handled by a generator.""" + cursor = None + while True: + params = ListUsersRequest(order=order, cursor=cursor, name=name) + response = self._http_client.get( + self._http_client.api_host, + '/v1/users', + # need to send the right stuff to the api + params.model_dump() if params is not None else None, + self._auth_type, + ) + users = ListUsersResponse(**response) + for user in users.embedded.users: + yield user + if not users.links.next: + break + cursor = parse_cursor_from_url(users.links.next.href) + + @validate_call + def create_user(self, params: Optional[User]): + """Create a user.""" + response = self._http_client.post( self._http_client.api_host, '/v1/users', params.model_dump() if params is not None else None, self._auth_type, ) - return ListUsersResponse(**response) + return CreateUserResponse(**response) diff --git a/users/tests/test_users.py b/users/tests/test_users.py index 60191f4f..2bcaf30b 100644 --- a/users/tests/test_users.py +++ b/users/tests/test_users.py @@ -1,15 +1,9 @@ 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_users import Users -from vonage_users.errors import UsersError from vonage_users.requests import ListUsersRequest -from vonage_users.responses import ListUsersResponse from testutils import build_response, get_mock_jwt_auth @@ -33,18 +27,23 @@ def test_create_list_users_request(): @responses.activate def test_list_users(): build_response(path, 'GET', 'https://api.nexmo.com/v1/users', 'list_users.json') - response: ListUsersResponse = users.list_users() - assert response.page_size == 10 - assert response.embedded.users[0].id == 'USR-82e028d9-5201-4f1e-8188-604b2d3471fd' - assert response.embedded.users[0].name == 'my_user_name' - assert response.embedded.users[0].display_name == 'My User Name' - assert ( - response.embedded.users[0].links.self.href - == 'https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd' - ) - assert ( - response.links.self.href - == 'https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=' - ) + users_generator = users.list_users() + print(next(users_generator)) + # for user in users_generator: + # print(user) + assert 0 + + # assert response.page_size == 10 + # assert response.embedded.users[0].id == 'USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + # assert response.embedded.users[0].name == 'my_user_name' + # assert response.embedded.users[0].display_name == 'My User Name' + # assert ( + # response.embedded.users[0].links.self.href + # == 'https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + # ) + # assert ( + # response.links.self.href + # == 'https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=' + # ) # print(response.model_dump_json(indent=2)) From 4c9b303604e51ab038f770dc16e2657567ed7d48 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 27 Mar 2024 20:18:25 +0000 Subject: [PATCH 18/98] add users api endpoints and tests --- http_client/src/vonage_http_client/errors.py | 24 +- .../src/vonage_http_client/http_client.py | 32 ++- http_client/tests/data/404.json | 6 + http_client/tests/test_http_client.py | 11 + testutils/testutils.py | 2 +- users/src/vonage_users/common.py | 65 ++++-- users/src/vonage_users/requests.py | 47 +--- users/src/vonage_users/responses.py | 57 ++--- users/src/vonage_users/users.py | 130 +++++++---- users/tests/data/list_users.json | 79 +++++-- users/tests/data/updated_user.json | 23 ++ users/tests/data/user.json | 20 ++ users/tests/data/user_not_found.json | 6 + users/tests/test_users.py | 211 ++++++++++++++++-- vonage/src/vonage/__init__.py | 12 +- vonage/src/vonage/vonage.py | 2 + 16 files changed, 536 insertions(+), 191 deletions(-) create mode 100644 http_client/tests/data/404.json create mode 100644 users/tests/data/updated_user.json create mode 100644 users/tests/data/user.json create mode 100644 users/tests/data/user_not_found.json diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index ca235565..ffa05b99 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -1,4 +1,4 @@ -from json import JSONDecodeError +from json import dumps, JSONDecodeError from requests import Response from vonage_utils.errors import VonageError @@ -37,14 +37,14 @@ def set_error_message(self, response: Response, content_type: str): body = None if content_type == 'application/json': try: - body = response.json() + body = dumps(response.json(), indent=4) except JSONDecodeError: pass else: body = response.text if body: - self.message = f'{response.status_code} response from {response.url}. Error response body: {body}' + self.message = f'{response.status_code} response from {response.url}. Error response body: \n{body}' else: self.message = f'{response.status_code} response from {response.url}.' @@ -67,6 +67,24 @@ def __init__(self, response: Response, content_type: str): super().__init__(response, content_type) +class NotFoundError(HttpRequestError): + """Exception indicating a resource was not found in a Vonage SDK request. + + This error is raised when the HTTP response status code is 404 (Not Found). + + Args: + response (requests.Response): The HTTP response object. + content_type (str): The response content type. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response, content_type: str): + super().__init__(response, content_type) + + class RateLimitedError(HttpRequestError): """Exception indicating a rate limit was hit when making too many requests to a Vonage endpoint. diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index d7ad9ced..cf4bd68f 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -4,7 +4,7 @@ from typing import Literal, Optional, Union from pydantic import BaseModel, Field, ValidationError, validate_call -from requests import Response +from requests import Response, delete from requests.adapters import HTTPAdapter from requests.sessions import Session from typing_extensions import Annotated @@ -13,6 +13,7 @@ AuthenticationError, HttpRequestError, InvalidHttpClientOptionsError, + NotFoundError, RateLimitedError, ServerError, ) @@ -120,10 +121,34 @@ def get( ) -> Union[dict, None]: return self.make_request('GET', host, request_path, params, auth_type, body_type) + def patch( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + body_type: Literal['json', 'data'] = 'json', + ) -> Union[dict, None]: + return self.make_request( + 'PATCH', host, request_path, params, auth_type, body_type + ) + + def delete( + self, + host: str, + request_path: str = '', + params: dict = None, + auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + body_type: Literal['json', 'data'] = 'json', + ) -> Union[dict, None]: + return self.make_request( + 'DELETE', host, request_path, params, auth_type, body_type + ) + @validate_call def make_request( self, - request_type: Literal['GET', 'POST'], + request_type: Literal['GET', 'POST', 'PATCH', 'DELETE'], host: str, request_path: str = '', params: Optional[dict] = None, @@ -150,6 +175,7 @@ def make_request( } if body_type == 'json': + self._headers['Content-Type'] = 'application/json' request_params['json'] = params else: request_params['data'] = params @@ -178,6 +204,8 @@ def _parse_response(self, response: Response) -> Union[dict, None]: ) if response.status_code == 401 or response.status_code == 403: raise AuthenticationError(response, content_type) + elif response.status_code == 404: + raise NotFoundError(response, content_type) elif response.status_code == 429: raise RateLimitedError(response, content_type) elif response.status_code == 500: 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/test_http_client.py b/http_client/tests/test_http_client.py index 5f3447b1..abf0e7ae 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -182,6 +182,17 @@ def test_authentication_error_no_content(): assert type(err.response) == Response +@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) diff --git a/testutils/testutils.py b/testutils/testutils.py index 5e565df2..8bbfec60 100644 --- a/testutils/testutils.py +++ b/testutils/testutils.py @@ -17,7 +17,7 @@ def _filter_none_values(data: dict) -> dict: @validate_call def build_response( file_path: str, - method: Literal['GET', 'POST'], + method: Literal['GET', 'POST', 'PATCH', 'DELETE'], url: str, mock_path: str = None, status_code: int = 200, diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 7be06966..b4f98f68 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,9 +1,26 @@ -from typing import Dict, List, Optional - -from pydantic import BaseModel, Field, HttpUrl +from dataclasses import field +from typing import List, Optional + +from pydantic import ( + BaseModel, + Field, + ValidationInfo, + field_validator, + model_validator, + root_validator, +) from typing_extensions import Annotated -PhoneNumber = Annotated[str, Field(pattern='^[1-9]\d{6,14}$')] + +PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] + + +class Link(BaseModel): + href: str + + +class ResourceLink(BaseModel): + self: Link class PstnChannel(BaseModel): @@ -11,7 +28,7 @@ class PstnChannel(BaseModel): class SipChannel(BaseModel): - uri: str = Field(..., pattern='^(sip|sips):\+?([\w|:.\-@;,=%&]+)') + uri: str = Field(..., pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)') username: str = None password: str = None @@ -21,9 +38,11 @@ class VbcChannel(BaseModel): class WebsocketChannel(BaseModel): - uri: str = Field(pattern='^(ws|wss)://[a-zA-Z0-9~#%@&-_?\/.,:;)(][]*$') - content_type: str = Field(pattern="^audio/l16;rate=(8000|16000)$") - headers: Optional[Dict[str, str]] = None + uri: str = Field(pattern=r'^(ws|wss):\/\/[a-zA-Z0-9~#%@&-_?\/.,:;)(\]\[]*$') + content_type: Optional[str] = Field( + None, alias='content-type', pattern='^audio/l16;rate=(8000|16000)$' + ) + headers: Optional[dict] = None class SmsChannel(BaseModel): @@ -47,24 +66,34 @@ class MessengerChannel(BaseModel): class Channels(BaseModel): - pstn: Optional[List[PstnChannel]] = None - sip: Optional[List[SipChannel]] = None - vbc: Optional[List[VbcChannel]] = None - websocket: Optional[List[WebsocketChannel]] = None 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): - custom_data: Optional[Dict[str, str]] + custom_data: Optional[dict] = None class User(BaseModel): - name: Optional[str] = Field(None, example="my_user_name") - display_name: Optional[str] = Field(None, example="My User Name") - image_url: Optional[HttpUrl] = Field(None, example="https://example.com/image.png") - properties: Optional[Properties] - channels: Optional[Channels] + 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') + @classmethod + def get_link(cls, data): + if data.links is not None: + data.link = data.links.self.href + return data diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py index 3445aeea..77f68f3d 100644 --- a/users/src/vonage_users/requests.py +++ b/users/src/vonage_users/requests.py @@ -6,55 +6,10 @@ class ListUsersRequest(BaseModel): """Request object for listing users.""" - page_size: Optional[int] = Field(10, ge=1, le=100) + page_size: Optional[int] = Field(2, 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 - - -# class SmsMessage(BaseModel): -# """Message object containing the data and options for an SMS message.""" - -# to: str -# from_: str = Field(..., serialization_alias='from') -# text: str -# sig: Optional[str] = Field(None, min_length=16, max_length=60) -# client_ref: Optional[str] = Field( -# None, serialization_alias='client-ref', max_length=100 -# ) -# type: Optional[Literal['text', 'binary', 'unicode']] = None -# ttl: Optional[int] = Field(None, ge=20000, le=604800000) -# status_report_req: Optional[bool] = Field( -# None, serialization_alias='status-report-req' -# ) -# callback: Optional[str] = Field(None, max_length=100) -# message_class: Optional[int] = Field( -# None, serialization_alias='message-class', ge=0, le=3 -# ) -# body: Optional[str] = None -# udh: Optional[str] = None -# protocol_id: Optional[int] = Field( -# None, serialization_alias='protocol-id', ge=0, le=255 -# ) -# account_ref: Optional[str] = Field(None, serialization_alias='account-ref') -# entity_id: Optional[str] = Field(None, serialization_alias='entity-id') -# content_id: Optional[str] = Field(None, serialization_alias='content-id') - -# @field_validator('body', 'udh') -# @classmethod -# def validate_body(cls, value, info: ValidationInfo): -# data = info.data -# if 'type' not in data or not data['type'] == 'binary': -# raise ValueError( -# 'This parameter can only be set when the "type" parameter is set to "binary".' -# ) -# return value - -# @model_validator(mode='after') -# def validate_type(self) -> 'SmsMessage': -# if self.type == 'binary' and self.body is None and self.udh is None: -# raise ValueError('This parameter is required for binary messages.') -# return self diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 7bfbb777..73236945 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -1,14 +1,8 @@ from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator - -class Link(BaseModel): - href: str - - -class UserLinks(BaseModel): - self: Link +from vonage_users.common import Link, ResourceLink class Links(BaseModel): @@ -18,49 +12,26 @@ class Links(BaseModel): prev: Optional[Link] = None -class User(BaseModel): +class UserSummary(BaseModel): id: Optional[str] name: Optional[str] - display_name: Optional[str] - links: Optional[UserLinks] = Field(..., validation_alias='_links') + display_name: Optional[str] = None + links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True) + link: Optional[str] = None + + @model_validator(mode='after') + @classmethod + def get_link(cls, data): + if data.links is not None: + data.link = data.links.self.href + return data class Embedded(BaseModel): - users: List[User] = [] + users: List[UserSummary] = [] class ListUsersResponse(BaseModel): page_size: int embedded: Embedded = Field(..., validation_alias='_embedded') links: Links = Field(..., validation_alias='_links') - - -class CreateUserResponse(BaseModel): - id: str - name: str - display_name: str - links: UserLinks = Field(..., validation_alias='_links') - - -# class MessageResponse(BaseModel): -# to: str -# message_id: str = Field(..., validation_alias='message-id') -# status: str -# remaining_balance: str = Field(..., validation_alias='remaining-balance') -# message_price: str = Field(..., validation_alias='message-price') -# network: str -# client_ref: Optional[str] = Field(None, validation_alias='client-ref') -# account_ref: Optional[str] = Field(None, validation_alias='account-ref') - - -# class SmsResponse(BaseModel): -# message_count: str = Field(..., validation_alias='message-count') -# messages: List[dict] - -# @field_validator('messages') -# @classmethod -# def create_message_response(cls, value): -# messages = [] -# for message in value: -# messages.append(MessageResponse(**message)) -# return messages diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index fca844b0..454e92a5 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -1,25 +1,12 @@ -from typing import Generator, Literal, Optional +from typing import List, Optional, Tuple -from pydantic import BaseModel, validate_call +from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from urllib.parse import urlparse, parse_qs from .common import User from .requests import ListUsersRequest -from .responses import CreateUserResponse, ListUsersResponse - - -class Filters(BaseModel): - order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None - - -import urllib.parse - - -def parse_cursor_from_url(url: str) -> Optional[str]: - """Extract the cursor from the "next" URL.""" - query_string = urllib.parse.urlparse(url).query - params = urllib.parse.parse_qs(query_string) - return params.get('cursor', [None])[0] +from .responses import ListUsersResponse, UserSummary class Users: @@ -35,35 +22,94 @@ def __init__(self, http_client: HttpClient) -> None: @validate_call def list_users( - self, - order: Literal['asc', 'desc', 'ASC', 'DESC'] = None, - name: str = None, - ) -> Generator: - """List all users with pagination handled by a generator.""" - cursor = None - while True: - params = ListUsersRequest(order=order, cursor=cursor, name=name) - response = self._http_client.get( - self._http_client.api_host, - '/v1/users', - # need to send the right stuff to the api - params.model_dump() if params is not None else None, - self._auth_type, - ) - users = ListUsersResponse(**response) - for user in users.embedded.users: - yield user - if not users.links.next: - break - cursor = parse_cursor_from_url(users.links.next.href) + self, params: ListUsersRequest = ListUsersRequest() + ) -> Tuple[List[UserSummary], str]: + """List all users. + + If you want to see more information about a specific user, you can use the `Users.get_user` method. + Gets the first 100 users by default.""" + response = self._http_client.get( + self._http_client.api_host, + '/v1/users', + params.model_dump(exclude_none=True), + self._auth_type, + ) + print(params.model_dump(exclude_none=True)) + + 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]): - """Create a user.""" + 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() if params is not None else None, + 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 CreateUserResponse(**response) + 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. + + Returns: + None + """ + self._http_client.delete( + self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type + ) + return None diff --git a/users/tests/data/list_users.json b/users/tests/data/list_users.json index 6939f43d..7b2958e3 100644 --- a/users/tests/data/list_users.json +++ b/users/tests/data/list_users.json @@ -1,31 +1,82 @@ { - "page_size": 10, "_embedded": { "users": [ { - "id": "USR-82e028d9-5201-4f1e-8188-604b2d3471fd", - "name": "my_user_name", + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8" + } + }, + "id": "USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8", + "name": "NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-d3a1b6cd-15b1-48e5-bef6-457c447adff4" + } + }, + "id": "USR-d3a1b6cd-15b1-48e5-bef6-457c447adff4", + "name": "NAM-9c31641c-03c3-476b-827a-9b0dd1570eec" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-37a8299f-eaad-417c-a0b3-431b6555c4be" + } + }, + "display_name": "My Other User Name", + "id": "USR-37a8299f-eaad-417c-a0b3-431b6555c4be", + "name": "my_other_user_name" + }, + { + "_links": { + "self": { + "href": "https://api-us-3.vonage.com/v1/users/USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422" + } + }, "display_name": "My User Name", + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef422", + "name": "my_user_name" + }, + { "_links": { "self": { - "href": "https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd" + "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-a70d56d074e1" + } + }, + "id": "USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e1", + "name": "update_name" } ] }, "_links": { "first": { - "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10" + "href": "https://api-us-3.vonage.com/v1/users?page_size=10" }, "self": { - "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=" - }, - "next": { - "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=" - }, - "prev": { - "href": "https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=" + "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/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 index 2bcaf30b..d5b27f6b 100644 --- a/users/tests/test_users.py +++ b/users/tests/test_users.py @@ -1,9 +1,11 @@ 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.requests import ListUsersRequest +from vonage_users.common import * from testutils import build_response, get_mock_jwt_auth @@ -27,23 +29,192 @@ def test_create_list_users_request(): @responses.activate def test_list_users(): build_response(path, 'GET', 'https://api.nexmo.com/v1/users', 'list_users.json') - users_generator = users.list_users() - print(next(users_generator)) - # for user in users_generator: - # print(user) - assert 0 - - # assert response.page_size == 10 - # assert response.embedded.users[0].id == 'USR-82e028d9-5201-4f1e-8188-604b2d3471fd' - # assert response.embedded.users[0].name == 'my_user_name' - # assert response.embedded.users[0].display_name == 'My User Name' - # assert ( - # response.embedded.users[0].links.self.href - # == 'https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd' - # ) - # assert ( - # response.links.self.href - # == 'https://api.nexmo.com/v1/users?order=desc&page_size=10&cursor=7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=' - # ) - - # print(response.model_dump_json(indent=2)) + users_list, _ = users.list_users() + assert len(users_list) == 1 + assert users_list[0].id == 'USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + assert users_list[0].name == 'my_user_name' + assert users_list[0].display_name == 'My User Name' + assert ( + users_list[0].links.self.href + == 'https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + ) + + +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 diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index a61419cb..b6ea14ea 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,5 +1,13 @@ from vonage_utils import VonageError -from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Vonage +from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Vonage, Users -__all__ = ['Vonage', 'Auth', 'HttpClientOptions', 'NumberInsightV2', 'Sms', 'VonageError'] +__all__ = [ + 'Vonage', + 'Auth', + 'HttpClientOptions', + 'NumberInsightV2', + 'Sms', + 'Users', + 'VonageError', +] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 4cae6fd9..21141aab 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -4,6 +4,7 @@ from vonage_http_client.http_client import HttpClient, HttpClientOptions from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 from vonage_sms.sms import Sms +from vonage_users.users import Users from ._version import __version__ @@ -23,6 +24,7 @@ def __init__( self.number_insight_v2 = NumberInsightV2(self._http_client) self.sms = Sms(self._http_client) + self.users = Users(self._http_client) @property def http_client(self): From ff65eac2ff3a21665d8c4dc475777c38152081a9 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 28 Mar 2024 03:15:25 +0000 Subject: [PATCH 19/98] finish Users API implementation --- http_client/CHANGES.md | 10 ++-- http_client/pyproject.toml | 2 +- http_client/src/vonage_http_client/errors.py | 2 +- .../src/vonage_http_client/http_client.py | 30 ++++++----- http_client/tests/test_http_client.py | 13 +++-- .../tests/test_number_insight_v2.py | 5 +- sms/CHANGES.md | 6 +++ sms/pyproject.toml | 4 +- sms/src/vonage_sms/sms.py | 7 +-- sms/tests/test_sms.py | 4 +- users/README.md | 54 +++++++++++++++---- users/pyproject.toml | 2 +- users/src/vonage_users/__init__.py | 9 ++-- users/src/vonage_users/common.py | 11 +--- users/src/vonage_users/errors.py | 5 -- users/src/vonage_users/responses.py | 1 - users/src/vonage_users/users.py | 20 ++++--- users/tests/data/list_users.json | 24 ++++----- users/tests/data/list_users_options.json | 41 ++++++++++++++ users/tests/test_users.py | 47 +++++++++++++--- vonage/CHANGES.md | 8 ++- vonage/pyproject.toml | 5 +- vonage/src/vonage/__init__.py | 2 +- vonage_utils/CHANGES.md | 2 + 24 files changed, 223 insertions(+), 91 deletions(-) delete mode 100644 users/src/vonage_users/errors.py create mode 100644 users/tests/data/list_users_options.json create mode 100644 vonage_utils/CHANGES.md diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 23d089a0..1b047f57 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,5 +1,9 @@ -# 1.0.0 -- Initial upload +# 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 \ No newline at end of file +- Add support for signature authentication + +# 1.0.0 +- Initial upload diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index f44e66ea..ffd4545f 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vonage-http-client" -version = "1.1.0" +version = "1.1.1" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index ffa05b99..a44b00b4 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -1,4 +1,4 @@ -from json import dumps, JSONDecodeError +from json import JSONDecodeError, dumps from requests import Response from vonage_utils.errors import VonageError diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index cf4bd68f..9b780e29 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -4,7 +4,7 @@ from typing import Literal, Optional, Union from pydantic import BaseModel, Field, ValidationError, validate_call -from requests import Response, delete +from requests import Response from requests.adapters import HTTPAdapter from requests.sessions import Session from typing_extensions import Annotated @@ -107,9 +107,11 @@ def post( request_path: str = '', params: dict = None, auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', - body_type: Literal['json', 'data'] = 'json', + sent_data_type: Literal['json', 'data'] = 'json', ) -> Union[dict, None]: - return self.make_request('POST', host, request_path, params, auth_type, body_type) + return self.make_request( + 'POST', host, request_path, params, auth_type, sent_data_type + ) def get( self, @@ -117,9 +119,11 @@ def get( request_path: str = '', params: dict = None, auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', - body_type: Literal['json', 'data'] = 'json', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: - return self.make_request('GET', host, request_path, params, auth_type, body_type) + return self.make_request( + 'GET', host, request_path, params, auth_type, sent_data_type + ) def patch( self, @@ -127,10 +131,10 @@ def patch( request_path: str = '', params: dict = None, auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', - body_type: Literal['json', 'data'] = 'json', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( - 'PATCH', host, request_path, params, auth_type, body_type + 'PATCH', host, request_path, params, auth_type, sent_data_type ) def delete( @@ -139,10 +143,10 @@ def delete( request_path: str = '', params: dict = None, auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', - body_type: Literal['json', 'data'] = 'json', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( - 'DELETE', host, request_path, params, auth_type, body_type + 'DELETE', host, request_path, params, auth_type, sent_data_type ) @validate_call @@ -153,7 +157,7 @@ def make_request( request_path: str = '', params: Optional[dict] = None, auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', - body_type: Literal['json', 'data'] = 'json', + sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ): url = f'https://{host}{request_path}' logger.debug( @@ -174,10 +178,12 @@ def make_request( 'timeout': self._timeout, } - if body_type == 'json': + if sent_data_type == 'json': self._headers['Content-Type'] = 'application/json' request_params['json'] = params - else: + elif sent_data_type == 'query_params': + request_params['params'] = params + elif sent_data_type == 'form': request_params['data'] = params with self._session.request(**request_params) as response: diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index abf0e7ae..89cfaec6 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -56,12 +56,19 @@ def test_create_http_client_invalid_options_error(): @responses.activate def test_make_get_request(): - build_response(path, 'GET', 'https://example.com/get_json', 'example_get.json') + 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') + res = client.get( + host='example.com', + request_path='/get_json', + params={'key': 'value'}, + sent_data_type='query_params', + ) assert res['hello'] == 'world' assert responses.calls[0].request.headers['User-Agent'] == client._user_agent @@ -131,7 +138,7 @@ def test_make_post_request_with_signature(): request_path='/post_signed_params', params=params, auth_type='signature', - body_type='data', + sent_data_type='form', ) assert res['hello'] == 'world!' diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py index 31a81166..26c63f16 100644 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -4,7 +4,6 @@ import responses from pydantic import ValidationError from pytest import raises -from vonage_http_client.auth import Auth from vonage_http_client.http_client import HttpClient from vonage_number_insight_v2.number_insight_v2 import ( FraudCheckRequest, @@ -14,11 +13,11 @@ from vonage_utils.errors import InvalidPhoneNumberError from vonage_utils.utils import remove_none_values -from testutils import build_response +from testutils import build_response, get_mock_api_key_auth path = abspath(__file__) -ni2 = NumberInsightV2(HttpClient(Auth('key', 'secret'))) +ni2 = NumberInsightV2(HttpClient(get_mock_api_key_auth())) def test_fraud_check_request_defaults(): diff --git a/sms/CHANGES.md b/sms/CHANGES.md index be516a55..336bbca8 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,2 +1,8 @@ +# 1.0.2 +- Internal refactoring + +# 1.0.1 +- Internal refactoring + # 1.0.0 - Initial upload diff --git a/sms/pyproject.toml b/sms/pyproject.toml index 8e4b1277..1e20fdac 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -1,12 +1,12 @@ [project] name = 'vonage-sms' -version = '1.0.1' +version = '1.0.2' description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.1.0", + "vonage-http-client>=1.1.1", "vonage-utils>=1.0.0", "pydantic>=2.6.1", ] diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py index c1a8779f..be5839ca 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -13,7 +13,7 @@ class Sms: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client - self._body_type = 'data' + self._sent_data_type = 'form' if self._http_client._auth._signature_secret: self._auth_type = 'signature' else: @@ -27,7 +27,7 @@ def send(self, message: SmsMessage) -> SmsResponse: '/sms/json', message.model_dump(by_alias=True), self._auth_type, - self._body_type, + self._sent_data_type, ) if int(response['message-count']) > 1: @@ -78,9 +78,10 @@ def submit_sms_conversion( '%Y-%m-%d %H:%M:%S' ), } - self._http_client.get( + self._http_client.post( self._http_client.api_host, '/conversions/sms', params, self._auth_type, + self._sent_data_type, ) diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index 2ce9da0b..c6cb21ac 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -150,7 +150,7 @@ def test_send_message_error(): def test_submit_sms_conversion(): build_response( path, - 'GET', + 'POST', 'https://api.nexmo.com/conversions/sms', 'null', ) @@ -162,7 +162,7 @@ def test_submit_sms_conversion(): def test_submit_sms_conversion_402(): build_response( path, - 'GET', + 'POST', 'https://api.nexmo.com/conversions/sms', 'conversion_not_enabled.html', status_code=402, diff --git a/users/README.md b/users/README.md index 794fc5d0..48aa6469 100644 --- a/users/README.md +++ b/users/README.md @@ -10,22 +10,56 @@ It is recommended to use this as part of the main `vonage` package. The examples ### List Users -### Create a New User +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. -### Get a User +```python +from vonage_users import ListUsersRequest -### Update a User +users, _ = vonage_client.users.list_users() -### Delete a User +# 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 - +### 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 index 300164db..c42c2ba4 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.1.0", + "vonage-http-client>=1.1.1", "vonage-utils>=1.0.0", "pydantic>=2.6.1", ] diff --git a/users/src/vonage_users/__init__.py b/users/src/vonage_users/__init__.py index d5c5d774..bb33e3c5 100644 --- a/users/src/vonage_users/__init__.py +++ b/users/src/vonage_users/__init__.py @@ -1,8 +1,11 @@ -# from .errors import PartialFailureError, SmsError -# from .requests import SmsMessage -# from .responses import MessageResponse, SmsResponse +from .common import User +from .requests import ListUsersRequest +from .responses import UserSummary from .users import Users __all__ = [ 'Users', + 'User', + 'ListUsersRequest', + 'UserSummary', ] diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index b4f98f68..7172cc4c 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,17 +1,8 @@ -from dataclasses import field from typing import List, Optional -from pydantic import ( - BaseModel, - Field, - ValidationInfo, - field_validator, - model_validator, - root_validator, -) +from pydantic import BaseModel, Field, model_validator from typing_extensions import Annotated - PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] diff --git a/users/src/vonage_users/errors.py b/users/src/vonage_users/errors.py deleted file mode 100644 index 91041192..00000000 --- a/users/src/vonage_users/errors.py +++ /dev/null @@ -1,5 +0,0 @@ -from vonage_utils.errors import VonageError - - -class UsersError(VonageError): - """Indicates an error with the Vonage Users Package.""" diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 73236945..ba16b18f 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -1,7 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator - from vonage_users.common import Link, ResourceLink diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index 454e92a5..661dc941 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -1,8 +1,8 @@ from typing import List, Optional, Tuple +from urllib.parse import parse_qs, urlparse from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from urllib.parse import urlparse, parse_qs from .common import User from .requests import ListUsersRequest @@ -26,15 +26,22 @@ def list_users( ) -> Tuple[List[UserSummary], 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. - Gets the first 100 users by default.""" + + Args: + params (ListUsersRequest, optional): An instance of the ListUsersRequest class that allows you to specify additional parameters for the user listing. + + Returns: + Tuple[List[UserSummary], str]: A tuple containing a list of UserSummary objects representing the users and a string representing the next cursor for pagination. + """ response = self._http_client.get( self._http_client.api_host, '/v1/users', params.model_dump(exclude_none=True), self._auth_type, + 'query_params', ) - print(params.model_dump(exclude_none=True)) users_response = ListUsersResponse(**response) if users_response.links.next is None: @@ -47,8 +54,7 @@ def list_users( @validate_call def create_user(self, params: Optional[User] = None) -> User: - """ - Create a new user. + """Create a new user. Args: params (Optional[User]): An optional `User` object containing the parameters for creating a new user. @@ -66,8 +72,7 @@ def create_user(self, params: Optional[User] = None) -> User: @validate_call def get_user(self, id: str) -> User: - """ - Get a user by ID. + """Get a user by ID. Args: id (str): The ID of the user to retrieve. @@ -112,4 +117,3 @@ def delete_user(self, id: str) -> None: self._http_client.delete( self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type ) - return None diff --git a/users/tests/data/list_users.json b/users/tests/data/list_users.json index 7b2958e3..4faf2cc9 100644 --- a/users/tests/data/list_users.json +++ b/users/tests/data/list_users.json @@ -4,39 +4,39 @@ { "_links": { "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8" + "href": "https://api-us-3.vonage.com/v1/users/USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af9" } }, - "id": "USR-2af4d3c5-ec49-4c4a-b74c-ec13ab560af8", - "name": "NAM-6dd4ea1f-3841-47cb-a3d3-e271f5c1e33c" + "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-457c447adff4" + "href": "https://api-us-3.vonage.com/v1/users/USR-d3a1b6cd-15b1-48e5-bef6-457c447adff5" } }, - "id": "USR-d3a1b6cd-15b1-48e5-bef6-457c447adff4", - "name": "NAM-9c31641c-03c3-476b-827a-9b0dd1570eec" + "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-431b6555c4be" + "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-431b6555c4be", + "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-c31dab7ef422" + "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-c31dab7ef422", + "id": "USR-5ab17d58-b8b3-427d-ac42-c31dab7ef423", "name": "my_user_name" }, { @@ -62,10 +62,10 @@ { "_links": { "self": { - "href": "https://api-us-3.vonage.com/v1/users/USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e1" + "href": "https://api-us-3.vonage.com/v1/users/USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e2" } }, - "id": "USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e1", + "id": "USR-c4ef37cd-26d3-4b05-bbf3-a70d56d074e2", "name": "update_name" } ] 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/test_users.py b/users/tests/test_users.py index d5b27f6b..1138191b 100644 --- a/users/tests/test_users.py +++ b/users/tests/test_users.py @@ -4,8 +4,8 @@ from vonage_http_client.errors import NotFoundError from vonage_http_client.http_client import HttpClient from vonage_users import Users -from vonage_users.requests import ListUsersRequest from vonage_users.common import * +from vonage_users.requests import ListUsersRequest from testutils import build_response, get_mock_jwt_auth @@ -30,13 +30,46 @@ def test_create_list_users_request(): 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) == 1 - assert users_list[0].id == 'USR-82e028d9-5201-4f1e-8188-604b2d3471fd' - assert users_list[0].name == 'my_user_name' - assert users_list[0].display_name == 'My User Name' + 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 = ListUsersRequest( + 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 ( - users_list[0].links.self.href - == 'https://api.nexmo.com/v1/users/USR-82e028d9-5201-4f1e-8188-604b2d3471fd' + next + == 'Rv1d7qE3lDuOuwSFjRGHJ2JpKG28CdI1iNjSKNwy0NIr7uicrn7SGpIyaDtvkEEBfyH5xyjSonpeoYNLdw19SQ==' ) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 37536509..62e093f5 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,11 @@ +# 3.99.0a3 +- Add support for the [Vonage Users API](https://developer.vonage.com/en/api/application.v2#User). + +# 3.99.0a2 +- Internal refactoring + # 3.99.0a1 -- Add support for the Vonage SMS API +- Add support for the [Vonage SMS API](https://developer.vonage.com/en/messaging/sms/overview). # 3.99.0a0 Created new monorepo structure - this package `vonage` is now a way to depend on the functionality of all Vonage APIs, which has been moved into separate packages. Additionally, there are many breaking changes. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 9f82272a..f45f3e9c 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -7,9 +7,10 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.0.0", - "vonage-http-client>=1.1.0", + "vonage-http-client>=1.1.1", "vonage-number-insight-v2>=0.1.0", - "vonage-sms>=1.0.0", + "vonage-sms>=1.0.2", + "vonage-users>=1.0.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index b6ea14ea..fbf8b59b 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,6 +1,6 @@ from vonage_utils import VonageError -from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Vonage, Users +from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Users, Vonage __all__ = [ 'Vonage', diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md new file mode 100644 index 00000000..a376cb52 --- /dev/null +++ b/vonage_utils/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload \ No newline at end of file From 2173273898624c2e6bc777fdb20aab4e74558e81 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 28 Mar 2024 03:39:58 +0000 Subject: [PATCH 20/98] finish Users API implementation --- vonage/src/vonage/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 99e1d55c..cbad4bb3 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a2' +__version__ = '3.99.0a3' From b1767870d6bcbf6a9e1f5ac923c159e3b84b9db5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 29 Mar 2024 04:35:54 +0000 Subject: [PATCH 21/98] start working on verify api --- .../src/vonage_http_client/http_client.py | 13 ++- pants.toml | 1 + users/src/vonage_users/common.py | 4 +- users/src/vonage_users/requests.py | 2 +- verify/BUILD | 16 +++ verify/CHANGES.md | 2 + verify/README.md | 27 +++++ verify/pyproject.toml | 29 +++++ verify/src/vonage_verify/BUILD | 1 + verify/src/vonage_verify/__init__.py | 11 ++ verify/src/vonage_verify/errors.py | 16 +++ verify/src/vonage_verify/language_codes.py | 67 +++++++++++ verify/src/vonage_verify/requests.py | 56 +++++++++ verify/src/vonage_verify/responses.py | 22 ++++ verify/src/vonage_verify/verify.py | 107 ++++++++++++++++++ verify/tests/BUILD | 1 + verify/tests/data/verify_request.json | 4 + verify/tests/test_verify.py | 63 +++++++++++ vonage/CHANGES.md | 3 + vonage/src/vonage/__init__.py | 3 +- vonage/src/vonage/vonage.py | 7 ++ vonage_utils/pyproject.toml | 1 + vonage_utils/src/vonage_utils/types/BUILD | 1 + .../src/vonage_utils/types/phone_number.py | 5 + 24 files changed, 452 insertions(+), 10 deletions(-) create mode 100644 verify/BUILD create mode 100644 verify/CHANGES.md create mode 100644 verify/README.md create mode 100644 verify/pyproject.toml create mode 100644 verify/src/vonage_verify/BUILD create mode 100644 verify/src/vonage_verify/__init__.py create mode 100644 verify/src/vonage_verify/errors.py create mode 100644 verify/src/vonage_verify/language_codes.py create mode 100644 verify/src/vonage_verify/requests.py create mode 100644 verify/src/vonage_verify/responses.py create mode 100644 verify/src/vonage_verify/verify.py create mode 100644 verify/tests/BUILD create mode 100644 verify/tests/data/verify_request.json create mode 100644 verify/tests/test_verify.py create mode 100644 vonage_utils/src/vonage_utils/types/BUILD create mode 100644 vonage_utils/src/vonage_utils/types/phone_number.py diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 9b780e29..4d6a7e08 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -106,7 +106,7 @@ def post( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'data'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -118,7 +118,7 @@ def get( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -130,7 +130,7 @@ def patch( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -142,7 +142,7 @@ def delete( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ) -> Union[dict, None]: return self.make_request( @@ -156,7 +156,7 @@ def make_request( host: str, request_path: str = '', params: Optional[dict] = None, - auth_type: Literal['jwt', 'basic', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', ): url = f'https://{host}{request_path}' @@ -167,6 +167,9 @@ def make_request( self._headers['Authorization'] = self._auth.create_jwt_auth_string() elif auth_type == 'basic': self._headers['Authorization'] = self._auth.create_basic_auth_string() + elif auth_type == 'body': + params['api_key'] = self._auth.api_key + params['api_secret'] = self._auth.api_secret elif auth_type == 'signature': params['api_key'] = self._auth.api_key params['sig'] = self._auth.sign_params(params) diff --git a/pants.toml b/pants.toml index 14f4ac99..a9c671a5 100644 --- a/pants.toml +++ b/pants.toml @@ -37,6 +37,7 @@ filter = [ 'users/src', 'utils/src', 'testutils', + 'verify/src', ] [black] diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 7172cc4c..763a7b9b 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,9 +1,7 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from typing_extensions import Annotated - -PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] +from vonage_utils.types.phone_number import PhoneNumber class Link(BaseModel): diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py index 77f68f3d..d6943487 100644 --- a/users/src/vonage_users/requests.py +++ b/users/src/vonage_users/requests.py @@ -6,7 +6,7 @@ class ListUsersRequest(BaseModel): """Request object for listing users.""" - page_size: Optional[int] = Field(2, ge=1, le=100) + page_size: Optional[int] = Field(100, ge=1, le=100) order: Optional[Literal['asc', 'desc', 'ASC', 'DESC']] = None cursor: Optional[str] = Field( None, diff --git a/verify/BUILD b/verify/BUILD new file mode 100644 index 00000000..910bc085 --- /dev/null +++ b/verify/BUILD @@ -0,0 +1,16 @@ +resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + +files(sources=['tests/data/*']) + +python_distribution( + name='vonage-verify', + dependencies=[ + ':pyproject', + ':readme', + 'verify/src/vonage_verify', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/verify/CHANGES.md b/verify/CHANGES.md new file mode 100644 index 00000000..be516a55 --- /dev/null +++ b/verify/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/verify/README.md b/verify/README.md new file mode 100644 index 00000000..6dccb42d --- /dev/null +++ b/verify/README.md @@ -0,0 +1,27 @@ +# Vonage Verify Package + +This package contains the code to use Vonage's Verify API in Python. There is a more current package to user Vonage's Verify v2 API which is recommended to use for most use cases. The v2 API lets you send messages via multiple channels, including Email, SMS, MMS, WhatsApp, Messenger and others. You can also make Silent Authentication requests with Verify v2 to give an end user a more seamless experience. + +This package includes methods for sending 2-factor authentication (2FA) messages and returns... + + +asdf +asdf + + +## Usage + +It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. + +### Make a Verify Request + + diff --git a/verify/pyproject.toml b/verify/pyproject.toml new file mode 100644 index 00000000..43d2ab4d --- /dev/null +++ b/verify/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-verify' +version = '1.0.0' +description = 'Vonage verify package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.1.1", + "vonage-utils>=1.0.0", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/verify/src/vonage_verify/BUILD b/verify/src/vonage_verify/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/verify/src/vonage_verify/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/verify/src/vonage_verify/__init__.py b/verify/src/vonage_verify/__init__.py new file mode 100644 index 00000000..442b7842 --- /dev/null +++ b/verify/src/vonage_verify/__init__.py @@ -0,0 +1,11 @@ +# from .errors import PartialFailureError, SmsError +from .requests import Psd2Request, VerifyRequest + +# from .responses import MessageResponse, SmsResponse +from .verify import Verify + +__all__ = [ + 'Verify', + 'VerifyRequest', + 'Psd2Request', +] diff --git a/verify/src/vonage_verify/errors.py b/verify/src/vonage_verify/errors.py new file mode 100644 index 00000000..547633bf --- /dev/null +++ b/verify/src/vonage_verify/errors.py @@ -0,0 +1,16 @@ +from vonage_utils.errors import VonageError + + +class VerifyError(VonageError): + """Indicates an error with the Vonage Verify Package.""" + + +# class PartialFailureError(SmsError): +# """Indicates that a request was partially successful.""" + +# def __init__(self, response: Response): +# self.message = ( +# 'Sms.send_message method partially failed. Not all of the message(s) sent successfully.', +# ) +# super().__init__(self.message) +# self.response = response diff --git a/verify/src/vonage_verify/language_codes.py b/verify/src/vonage_verify/language_codes.py new file mode 100644 index 00000000..1dcdcef5 --- /dev/null +++ b/verify/src/vonage_verify/language_codes.py @@ -0,0 +1,67 @@ +from enum import Enum + + +class LanguageCode(str, Enum): + ar_xa = 'ar-xa' + cs_cz = 'cs-cz' + cy_cy = 'cy-cy' + cy_gb = 'cy-gb' + da_dk = 'da-dk' + de_de = 'de-de' + el_gr = 'el-gr' + en_au = 'en-au' + en_gb = 'en-gb' + en_in = 'en-in' + en_us = 'en-us' + es_es = 'es-es' + es_mx = 'es-mx' + es_us = 'es-us' + fi_fi = 'fi-fi' + fil_ph = 'fil-ph' + fr_ca = 'fr-ca' + fr_fr = 'fr-fr' + hi_in = 'hi-in' + hu_hu = 'hu-hu' + id_id = 'id-id' + is_is = 'is-is' + it_it = 'it-it' + ja_jp = 'ja-jp' + ko_kr = 'ko-kr' + nb_no = 'nb-no' + nl_nl = 'nl-nl' + pl_pl = 'pl-pl' + pt_br = 'pt-br' + pt_pt = 'pt-pt' + ro_ro = 'ro-ro' + ru_ru = 'ru-ru' + sv_se = 'sv-se' + th_th = 'th-th' + tr_tr = 'tr-tr' + vi_vn = 'vi-vn' + yue_cn = 'yue-cn' + zh_cn = 'zh-cn' + zh_tw = 'zh-tw' + + +class Psd2LanguageCode(Enum): + en_gb = 'en-gb' + bg_bg = 'bg-bg' + cs_cz = 'cs-cz' + da_dk = 'da-dk' + de_de = 'de-de' + ee_et = 'ee-et' + el_gr = 'el-gr' + es_es = 'es-es' + fi_fi = 'fi-fi' + fr_fr = 'fr-fr' + ga_ie = 'ga-ie' + hu_hu = 'hu-hu' + it_it = 'it-it' + lv_lv = 'lv-lv' + lt_lt = 'lt-lt' + mt_mt = 'mt-mt' + nl_nl = 'nl-nl' + pl_pl = 'pl-pl' + sk_sk = 'sk-sk' + sl_si = 'sl-si' + sv_se = 'sv-se' diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py new file mode 100644 index 00000000..86d1fc6a --- /dev/null +++ b/verify/src/vonage_verify/requests.py @@ -0,0 +1,56 @@ +from logging import getLogger +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types.phone_number import PhoneNumber + +from .language_codes import LanguageCode, Psd2LanguageCode + +logger = getLogger('vonage_verify') + + +class BaseVerifyRequest(BaseModel): + """Base request object containing the data and options for a verification request.""" + + number: PhoneNumber + country: Optional[str] = Field(None, max_length=2) + code_length: Optional[Literal[4, 6]] = 4 + pin_expiry: Optional[int] = Field(None, ge=60, le=3600) + next_event_wait: Optional[int] = Field(None, ge=60, le=900) + workflow_id: Optional[Literal[1, 2, 3, 4, 5, 6, 7]] = None + + @model_validator(mode='after') + def check_expiry_and_next_event_timing(self): + if self.pin_expiry is None or self.next_event_wait is None: + return self + if self.pin_expiry % self.next_event_wait != 0: + logger.debug( + f'The pin_expiry should be a multiple of next_event_wait.' + f'The current values are: pin_expiry={self.pin_expiry}, next_event_wait={self.next_event_wait}.' + f'The value of pin_expiry will be set to next_event_wait.' + ) + self.pin_expiry = self.next_event_wait + return self + + +class VerifyRequest(BaseVerifyRequest): + """Request object for a verification request. + + You must set the `number` and `brand` fields. + """ + + brand: str = Field(..., max_length=18) + sender_id: Optional[str] = Field('VERIFY', max_length=11) + lg: Optional[LanguageCode] = None + pin_code: Optional[str] = Field(None, min_length=4, max_length=10) + + +class Psd2Request(BaseVerifyRequest): + """Request object for a PSD2 verification request. + + You must set the `number`, `payee` and `amount` fields. + """ + + payee: str = Field(..., max_length=18) + amount: float + lg: Optional[Psd2LanguageCode] = None diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py new file mode 100644 index 00000000..837e1180 --- /dev/null +++ b/verify/src/vonage_verify/responses.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +class VerifyResponse(BaseModel): + request_id: str + status: str + + +# class MessageResponse(BaseModel): +# to: str +# message_id: str = Field(..., validation_alias='message-id') +# status: str +# remaining_balance: str = Field(..., validation_alias='remaining-balance') +# message_price: str = Field(..., validation_alias='message-price') +# network: str +# client_ref: Optional[str] = Field(None, validation_alias='client-ref') +# account_ref: Optional[str] = Field(None, validation_alias='account-ref') + + +# class SmsResponse(BaseModel): +# message_count: str = Field(..., validation_alias='message-count') +# messages: List[MessageResponse] diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py new file mode 100644 index 00000000..a39d78ec --- /dev/null +++ b/verify/src/vonage_verify/verify.py @@ -0,0 +1,107 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .requests import BaseVerifyRequest, Psd2Request, VerifyRequest +from .responses import VerifyResponse + + +class Verify: + """Calls Vonage's Verify API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._sent_post_data_type = 'form' + self._sent_get_data_type = 'query_params' + self._auth_type = 'body' + + @validate_call + def start_verification(self, verify_request: VerifyRequest) -> VerifyResponse: + """Start a verification process.""" + return self._make_verify_request(verify_request) + + @validate_call + def start_psd2_verification(self, verify_request: Psd2Request) -> VerifyResponse: + """Start a PSD2 verification process.""" + return self._make_verify_request(verify_request) + + def _make_verify_request(self, verify_request: BaseVerifyRequest) -> VerifyResponse: + if type(verify_request) == VerifyRequest: + request_path = '/verify/json' + elif type(verify_request) == Psd2Request: + request_path = '/verify/psd2/json' + + response = self._http_client.post( + self._http_client.api_host, + request_path, + verify_request.model_dump(by_alias=True), + self._auth_type, + self._sent_post_data_type, + ) + return VerifyResponse(**response) + + # @validate_call + # def send(self, message: SmsMessage) -> SmsResponse: + # """Send an SMS message.""" + # response = self._http_client.post( + # self._http_client.rest_host, + # '/sms/json', + # message.model_dump(by_alias=True), + # self._auth_type, + # self._sent_data_type, + # ) + + # if int(response['message-count']) > 1: + # self._check_for_partial_failure(response) + # else: + # self._check_for_error(response) + # return SmsResponse(**response) + + # def _check_for_partial_failure(self, response_data): + # successful_messages = 0 + # total_messages = int(response_data['message-count']) + + # for message in response_data['messages']: + # if message['status'] == '0': + # successful_messages += 1 + # if successful_messages < total_messages: + # raise PartialFailureError(response_data) + + # def _check_for_error(self, response_data): + # message = response_data['messages'][0] + # if int(message['status']) != 0: + # raise SmsError( + # f'Sms.send_message method failed with error code {message["status"]}: {message["error-text"]}' + # ) + + # @validate_call + # def submit_sms_conversion( + # self, message_id: str, delivered: bool = True, timestamp: datetime = None + # ): + # """ + # Note: Not available without having this feature manually enabled on your account. + + # Notifies Vonage that an SMS was successfully received. + + # This method is used to submit conversion data about SMS messages that were successfully delivered. + # If you are using the Verify API for two-factor authentication (2FA), this information is sent to Vonage automatically, + # so you do not need to use this method for 2FA messages. + + # Args: + # message_id (str): The `message-id` returned by the `Sms.send` call. + # delivered (bool, optional): Set to `True` if the user replied to the message you sent. Otherwise, set to `False`. + # timestamp (datetime, optional): A `datetime` object containing the time the SMS arrived. + # """ + # params = { + # 'message-id': message_id, + # 'delivered': delivered, + # 'timestamp': (timestamp or datetime.now(timezone.utc)).strftime( + # '%Y-%m-%d %H:%M:%S' + # ), + # } + # self._http_client.post( + # self._http_client.api_host, + # '/conversions/sms', + # params, + # self._auth_type, + # self._sent_data_type, + # ) diff --git a/verify/tests/BUILD b/verify/tests/BUILD new file mode 100644 index 00000000..4b3ba9ae --- /dev/null +++ b/verify/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['verify', 'testutils']) diff --git a/verify/tests/data/verify_request.json b/verify/tests/data/verify_request.json new file mode 100644 index 00000000..74136a5b --- /dev/null +++ b/verify/tests/data/verify_request.json @@ -0,0 +1,4 @@ +{ + "request_id": "abcdef0123456789abcdef0123456789", + "status": "0" +} \ No newline at end of file diff --git a/verify/tests/test_verify.py b/verify/tests/test_verify.py new file mode 100644 index 00000000..c676372d --- /dev/null +++ b/verify/tests/test_verify.py @@ -0,0 +1,63 @@ +from os.path import abspath + +import responses +from vonage_http_client.http_client import HttpClient +from vonage_verify.language_codes import LanguageCode, Psd2LanguageCode +from vonage_verify.requests import Psd2Request, VerifyRequest +from vonage_verify.verify import Verify + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + + +verify = Verify(HttpClient(get_mock_api_key_auth())) + +data = { + 'number': '1234567890', + 'country': 'US', + 'code_length': 6, + 'pin_expiry': 600, + 'next_event_wait': 150, + 'workflow_id': 2, +} + + +def test_create_valid_verify_request_model(): + params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', 'lg': LanguageCode.en_us, **data} + request = VerifyRequest(**params) + + assert request.model_dump(exclude_none=True) == params + + +def test_create_valid_psd2_request_model(): + params = {'payee': 'Acme Inc.', 'amount': 99.99, 'lg': Psd2LanguageCode.en_gb, **data} + request = Psd2Request(**params) + + assert request.model_dump(exclude_none=True) == params + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request.json' + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' + + +@responses.activate +def test_make_psd2_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/psd2/json', 'verify_request.json' + ) + params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} + request = Psd2Request(**params) + + response = verify.start_psd2_verification(request) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 62e093f5..6bf57af6 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.0a4 +- Add support for the [Vonage Verify API](https://developer.vonage.com/en/api/verify). + # 3.99.0a3 - Add support for the [Vonage Users API](https://developer.vonage.com/en/api/application.v2#User). diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index fbf8b59b..feab796b 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,6 +1,6 @@ from vonage_utils import VonageError -from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Users, Vonage +from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Users, Verify, Vonage __all__ = [ 'Vonage', @@ -9,5 +9,6 @@ 'NumberInsightV2', 'Sms', 'Users', + 'Verify', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 21141aab..cd56f8d1 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -5,6 +5,7 @@ from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 from vonage_sms.sms import Sms from vonage_users.users import Users +from vonage_verify.verify import Verify from ._version import __version__ @@ -12,6 +13,11 @@ class Vonage: """Main Server SDK class for using Vonage APIs. + When creating an instance, it will create the authentication objects and + an HTTP Client needed for using Vonage APIs. + Use an instance of this class to access the Vonage APIs, e.g. to access + methods associated with the Vonage SMS API, call `vonage.sms.method_name()`. + Args: auth (Auth): Class dealing with authentication objects and methods. http_client_options (HttpClientOptions, optional): Options for the HTTP client. @@ -25,6 +31,7 @@ def __init__( self.number_insight_v2 = NumberInsightV2(self._http_client) self.sms = Sms(self._http_client) self.users = Users(self._http_client) + self.verify = Verify(self._http_client) @property def http_client(self): diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 0362521c..9303d98e 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -4,6 +4,7 @@ version = '1.0.0' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +dependencies = ["pydantic>=2.6.1"] requires-python = ">=3.8" classifiers = [ "Programming Language :: Python", diff --git a/vonage_utils/src/vonage_utils/types/BUILD b/vonage_utils/src/vonage_utils/types/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/vonage_utils/src/vonage_utils/types/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/vonage_utils/src/vonage_utils/types/phone_number.py b/vonage_utils/src/vonage_utils/types/phone_number.py new file mode 100644 index 00000000..6d43856e --- /dev/null +++ b/vonage_utils/src/vonage_utils/types/phone_number.py @@ -0,0 +1,5 @@ +from typing import Annotated + +from pydantic import Field + +PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] From dae72329097a70f46a981d687965c990788ee2bf Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 3 Apr 2024 02:55:59 +0100 Subject: [PATCH 22/98] adding start verification methods and starting check_code --- http_client/CHANGES.md | 3 + .../src/vonage_http_client/http_client.py | 27 ++- http_client/tests/test_http_client.py | 19 +- users/CHANGES.md | 3 + users/src/vonage_users/users.py | 1 - verify/src/vonage_verify/errors.py | 13 +- verify/src/vonage_verify/requests.py | 12 +- verify/src/vonage_verify/responses.py | 13 +- verify/src/vonage_verify/verify.py | 168 +++++++++--------- verify/tests/data/check_code.json | 0 verify/tests/data/verify_request_error.json | 5 + .../verify_request_error_with_network.json | 6 + verify/tests/test_verify.py | 63 ++++++- .../src/vonage_utils/types/phone_number.py | 3 +- .../tests/test_format_phone_number.py | 5 +- 15 files changed, 227 insertions(+), 114 deletions(-) create mode 100644 verify/tests/data/check_code.json create mode 100644 verify/tests/data/verify_request_error.json create mode 100644 verify/tests/data/verify_request_error_with_network.json diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 1b047f57..0e35f1be 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.0 +- Add `last_request` and `last_response` properties + # 1.1.1 - Add new Patch method - New input fields for different ways to pass data in a request diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 4d6a7e08..f6006496 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -4,7 +4,7 @@ from typing import Literal, Optional, Union from pydantic import BaseModel, Field, ValidationError, validate_call -from requests import Response +from requests import PreparedRequest, Response from requests.adapters import HTTPAdapter from requests.sessions import Session from typing_extensions import Annotated @@ -81,6 +81,9 @@ def __init__( 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 @@ -101,6 +104,24 @@ def rest_host(self): 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. + """ + 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. + """ + return self._last_response + def post( self, host: str, @@ -119,7 +140,7 @@ def get( request_path: str = '', params: dict = None, auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', - sent_data_type: Literal['json', 'form', 'query_params'] = 'json', + 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 @@ -199,6 +220,8 @@ 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 + content_type = response.headers['Content-Type'].split(';', 1)[0] if 200 <= response.status_code < 300: if response.status_code == 204: diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 89cfaec6..c13487b1 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -3,7 +3,7 @@ import responses from pytest import raises -from requests import Response +from requests import PreparedRequest, Response from responses import matchers from vonage_http_client.auth import Auth from vonage_http_client.errors import ( @@ -55,7 +55,7 @@ def test_create_http_client_invalid_options_error(): @responses.activate -def test_make_get_request(): +def test_make_get_request_and_last_request_and_response(): build_response( path, 'GET', 'https://example.com/get_json?key=value', 'example_get.json' ) @@ -64,15 +64,22 @@ def test_make_get_request(): http_client_options={'api_host': 'example.com'}, ) res = client.get( - host='example.com', - request_path='/get_json', - params={'key': 'value'}, - sent_data_type='query_params', + 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(): diff --git a/users/CHANGES.md b/users/CHANGES.md index be516a55..a28aa741 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Internal refactoring + # 1.0.0 - Initial upload diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index 661dc941..5b87db64 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -40,7 +40,6 @@ def list_users( '/v1/users', params.model_dump(exclude_none=True), self._auth_type, - 'query_params', ) users_response = ListUsersResponse(**response) diff --git a/verify/src/vonage_verify/errors.py b/verify/src/vonage_verify/errors.py index 547633bf..b2832d2f 100644 --- a/verify/src/vonage_verify/errors.py +++ b/verify/src/vonage_verify/errors.py @@ -2,15 +2,4 @@ class VerifyError(VonageError): - """Indicates an error with the Vonage Verify Package.""" - - -# class PartialFailureError(SmsError): -# """Indicates that a request was partially successful.""" - -# def __init__(self, response: Response): -# self.message = ( -# 'Sms.send_message method partially failed. Not all of the message(s) sent successfully.', -# ) -# super().__init__(self.message) -# self.response = response + """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 index 86d1fc6a..834bdee5 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -14,20 +14,20 @@ class BaseVerifyRequest(BaseModel): number: PhoneNumber country: Optional[str] = Field(None, max_length=2) - code_length: Optional[Literal[4, 6]] = 4 + 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[Literal[1, 2, 3, 4, 5, 6, 7]] = None + 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.debug( + logger.warning( f'The pin_expiry should be a multiple of next_event_wait.' - f'The current values are: pin_expiry={self.pin_expiry}, next_event_wait={self.next_event_wait}.' - f'The value of pin_expiry will be set to next_event_wait.' + 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 @@ -40,7 +40,7 @@ class VerifyRequest(BaseVerifyRequest): """ brand: str = Field(..., max_length=18) - sender_id: Optional[str] = Field('VERIFY', max_length=11) + 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) diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py index 837e1180..56c16300 100644 --- a/verify/src/vonage_verify/responses.py +++ b/verify/src/vonage_verify/responses.py @@ -1,9 +1,20 @@ +from typing import Optional + from pydantic import BaseModel -class VerifyResponse(BaseModel): +class StartVerificationResponse(BaseModel): + request_id: str + status: str + + +class CheckCodeResponse(BaseModel): request_id: str status: str + event_id: str + price: str + currency: str + estimated_price_messages_sent: Optional[str] = None # class MessageResponse(BaseModel): diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index a39d78ec..0ea10b2a 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -1,107 +1,115 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from .errors import VerifyError from .requests import BaseVerifyRequest, Psd2Request, VerifyRequest -from .responses import VerifyResponse +from .responses import CheckCodeResponse, StartVerificationResponse class Verify: - """Calls Vonage's Verify API.""" + """Calls Vonage's Verify API. + + This class provides methods to interact with Vonage's Verify API for starting verification + processes. + """ def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client - self._sent_post_data_type = 'form' - self._sent_get_data_type = 'query_params' + self._sent_data_type = 'form' self._auth_type = 'body' @validate_call - def start_verification(self, verify_request: VerifyRequest) -> VerifyResponse: - """Start a verification process.""" + 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) -> VerifyResponse: - """Start a PSD2 verification process.""" + 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) - def _make_verify_request(self, verify_request: BaseVerifyRequest) -> VerifyResponse: + @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) + + 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), + verify_request.model_dump(by_alias=True, exclude_none=True), self._auth_type, - self._sent_post_data_type, + self._sent_data_type, ) - return VerifyResponse(**response) - - # @validate_call - # def send(self, message: SmsMessage) -> SmsResponse: - # """Send an SMS message.""" - # response = self._http_client.post( - # self._http_client.rest_host, - # '/sms/json', - # message.model_dump(by_alias=True), - # self._auth_type, - # self._sent_data_type, - # ) - - # if int(response['message-count']) > 1: - # self._check_for_partial_failure(response) - # else: - # self._check_for_error(response) - # return SmsResponse(**response) - - # def _check_for_partial_failure(self, response_data): - # successful_messages = 0 - # total_messages = int(response_data['message-count']) - - # for message in response_data['messages']: - # if message['status'] == '0': - # successful_messages += 1 - # if successful_messages < total_messages: - # raise PartialFailureError(response_data) - - # def _check_for_error(self, response_data): - # message = response_data['messages'][0] - # if int(message['status']) != 0: - # raise SmsError( - # f'Sms.send_message method failed with error code {message["status"]}: {message["error-text"]}' - # ) - - # @validate_call - # def submit_sms_conversion( - # self, message_id: str, delivered: bool = True, timestamp: datetime = None - # ): - # """ - # Note: Not available without having this feature manually enabled on your account. - - # Notifies Vonage that an SMS was successfully received. - - # This method is used to submit conversion data about SMS messages that were successfully delivered. - # If you are using the Verify API for two-factor authentication (2FA), this information is sent to Vonage automatically, - # so you do not need to use this method for 2FA messages. - - # Args: - # message_id (str): The `message-id` returned by the `Sms.send` call. - # delivered (bool, optional): Set to `True` if the user replied to the message you sent. Otherwise, set to `False`. - # timestamp (datetime, optional): A `datetime` object containing the time the SMS arrived. - # """ - # params = { - # 'message-id': message_id, - # 'delivered': delivered, - # 'timestamp': (timestamp or datetime.now(timezone.utc)).strftime( - # '%Y-%m-%d %H:%M:%S' - # ), - # } - # self._http_client.post( - # self._http_client.api_host, - # '/conversions/sms', - # params, - # self._auth_type, - # self._sent_data_type, - # ) + self._check_for_error(response) + parsed_response = StartVerificationResponse(**response) + + return parsed_response + + def _check_for_error(self, response: dict) -> None: + """Check for error in the response. + + This method checks if the response contains an error and raises a VerifyError if an error is found. + + Args: + response (dict): The response object. + + Raises: + VerifyError: If an error is found in the response. + """ + print(self._http_client.last_request.body) + if int(response['status']) != 0: + error_message = f'Error with Vonage status code {response["status"]}: {response["error_text"]}.' + if 'network' in response: + error_message += f' Network ID: {response["network"]}' + raise VerifyError(error_message) diff --git a/verify/tests/data/check_code.json b/verify/tests/data/check_code.json new file mode 100644 index 00000000..e69de29b diff --git a/verify/tests/data/verify_request_error.json b/verify/tests/data/verify_request_error.json new file mode 100644 index 00000000..934d0fad --- /dev/null +++ b/verify/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/tests/data/verify_request_error_with_network.json b/verify/tests/data/verify_request_error_with_network.json new file mode 100644 index 00000000..7714083b --- /dev/null +++ b/verify/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/tests/test_verify.py b/verify/tests/test_verify.py index c676372d..2bcecbc4 100644 --- a/verify/tests/test_verify.py +++ b/verify/tests/test_verify.py @@ -1,7 +1,9 @@ from os.path import abspath import responses +from pytest import raises from vonage_http_client.http_client import HttpClient +from vonage_verify.errors import VerifyError from vonage_verify.language_codes import LanguageCode, Psd2LanguageCode from vonage_verify.requests import Psd2Request, VerifyRequest from vonage_verify.verify import Verify @@ -23,20 +25,29 @@ } -def test_create_valid_verify_request_model(): +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_valid_psd2_request_model(): +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( @@ -61,3 +72,51 @@ def test_make_psd2_request(): response = verify.start_psd2_verification(request) assert response.request_id == 'abcdef0123456789abcdef0123456789' assert response.status == '0' + + +@responses.activate +def test_verify_request_error(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request_error.json' + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + with raises(VerifyError) as e: + verify.start_verification(request) + + assert e.match( + 'Error with Vonage status code 10: 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 ID: 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='abcdef0123456789abcdef0123456789', code='1234' + ) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' + assert response.event_id == 'abcdef0123456789abcdef0123456789' + assert response.price == '0.10000000' + assert response.currency == 'EUR' diff --git a/vonage_utils/src/vonage_utils/types/phone_number.py b/vonage_utils/src/vonage_utils/types/phone_number.py index 6d43856e..88e4da45 100644 --- a/vonage_utils/src/vonage_utils/types/phone_number.py +++ b/vonage_utils/src/vonage_utils/types/phone_number.py @@ -1,5 +1,4 @@ -from typing import Annotated - from pydantic import Field +from typing_extensions import Annotated PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] diff --git a/vonage_utils/tests/test_format_phone_number.py b/vonage_utils/tests/test_format_phone_number.py index 1a831256..e0a3328e 100644 --- a/vonage_utils/tests/test_format_phone_number.py +++ b/vonage_utils/tests/test_format_phone_number.py @@ -20,11 +20,12 @@ def test_format_phone_number_invalid_type(): number = ['1234567890'] with raises(InvalidPhoneNumberTypeError) as e: format_phone_number(number) - assert '""' in str(e.value) + + 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 '"not a phone number"' in str(e.value) + assert e.match('"not a phone number"') From 8a22b14f4e19807b39d462832b3d8b6c50d4ab68 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 4 Apr 2024 04:45:56 +0100 Subject: [PATCH 23/98] add verify controls --- http_client/src/vonage_http_client/auth.py | 1 - .../src/vonage_http_client/http_client.py | 2 +- .../tests/test_number_insight_v2.py | 1 - verify/src/vonage_verify/responses.py | 49 ++++++-- verify/src/vonage_verify/verify.py | 103 +++++++++++++-- verify/tests/data/cancel_verification.json | 4 + .../tests/data/cancel_verification_error.json | 4 + verify/tests/data/check_code.json | 8 ++ verify/tests/data/check_code_error.json | 5 + verify/tests/data/search_request.json | 32 +++++ verify/tests/data/search_request_error.json | 4 + verify/tests/data/search_request_list.json | 64 ++++++++++ verify/tests/data/trigger_next_event.json | 4 + .../tests/data/trigger_next_event_error.json | 4 + verify/tests/test_verify.py | 119 +++++++++++++++++- 15 files changed, 373 insertions(+), 31 deletions(-) create mode 100644 verify/tests/data/cancel_verification.json create mode 100644 verify/tests/data/cancel_verification_error.json create mode 100644 verify/tests/data/check_code_error.json create mode 100644 verify/tests/data/search_request.json create mode 100644 verify/tests/data/search_request_error.json create mode 100644 verify/tests/data/search_request_list.json create mode 100644 verify/tests/data/trigger_next_event.json create mode 100644 verify/tests/data/trigger_next_event_error.json diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 43e453f3..f757378a 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -96,7 +96,6 @@ def sign_params(self, params: dict) -> str: if not params.get('timestamp'): params['timestamp'] = int(time()) - print(params['timestamp']) for key in sorted(params): value = params[key] diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index f6006496..6d8dd55c 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -221,7 +221,7 @@ def _parse_response(self, response: Response) -> Union[dict, None]: f'Response received from {response.url} with status code: {response.status_code}; headers: {response.headers}' ) self._last_response = response - + print(response.content) content_type = response.headers['Content-Type'].split(';', 1)[0] if 200 <= response.status_code < 300: if response.status_code == 204: diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py index 26c63f16..c63982a0 100644 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -72,7 +72,6 @@ def test_ni2_fraud_score_only(): assert response.sim_swap is None clear_response = asdict(response, dict_factory=remove_none_values) - print(clear_response) assert 'fraud_score' in clear_response assert 'sim_swap' not in clear_response diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py index 56c16300..6aa16ef0 100644 --- a/verify/src/vonage_verify/responses.py +++ b/verify/src/vonage_verify/responses.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Literal, Optional from pydantic import BaseModel @@ -17,17 +17,40 @@ class CheckCodeResponse(BaseModel): estimated_price_messages_sent: Optional[str] = None -# class MessageResponse(BaseModel): -# to: str -# message_id: str = Field(..., validation_alias='message-id') -# status: str -# remaining_balance: str = Field(..., validation_alias='remaining-balance') -# message_price: str = Field(..., validation_alias='message-price') -# network: str -# client_ref: Optional[str] = Field(None, validation_alias='client-ref') -# account_ref: Optional[str] = Field(None, validation_alias='account-ref') +class Check(BaseModel): + date_received: Optional[str] = None + code: Optional[str] = None + status: Optional[str] = None + ip_address: Optional[str] = None + + +class Event(BaseModel): + type: Optional[str] = None + id: Optional[str] = None + + +class VerifyStatus(BaseModel): + 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): + status: str + command: str -# class SmsResponse(BaseModel): -# message_count: str = Field(..., validation_alias='message-count') -# messages: List[MessageResponse] +class NetworkUnblockStatus(BaseModel): + network: str + unblocked_until: str diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index 0ea10b2a..40da4443 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -1,9 +1,17 @@ -from pydantic import validate_call +import re +from typing import List, 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, StartVerificationResponse +from .responses import ( + CheckCodeResponse, + NetworkUnblockStatus, + StartVerificationResponse, + VerifyControlStatus, + VerifyStatus, +) class Verify: @@ -67,6 +75,85 @@ def check_code(self, request_id: str, code: str) -> CheckCodeResponse: 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) + def _make_verify_request( self, verify_request: BaseVerifyRequest ) -> StartVerificationResponse: @@ -84,6 +171,7 @@ def _make_verify_request( 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, @@ -92,14 +180,14 @@ def _make_verify_request( self._sent_data_type, ) self._check_for_error(response) - parsed_response = StartVerificationResponse(**response) - return parsed_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 an error and raises a VerifyError if an error is found. + 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. @@ -107,9 +195,6 @@ def _check_for_error(self, response: dict) -> None: Raises: VerifyError: If an error is found in the response. """ - print(self._http_client.last_request.body) if int(response['status']) != 0: - error_message = f'Error with Vonage status code {response["status"]}: {response["error_text"]}.' - if 'network' in response: - error_message += f' Network ID: {response["network"]}' + error_message = f'Error with the following details: {response}' raise VerifyError(error_message) diff --git a/verify/tests/data/cancel_verification.json b/verify/tests/data/cancel_verification.json new file mode 100644 index 00000000..8bfcf7bf --- /dev/null +++ b/verify/tests/data/cancel_verification.json @@ -0,0 +1,4 @@ +{ + "status": "0", + "command": "cancel" +} \ No newline at end of file diff --git a/verify/tests/data/cancel_verification_error.json b/verify/tests/data/cancel_verification_error.json new file mode 100644 index 00000000..e74e6622 --- /dev/null +++ b/verify/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/tests/data/check_code.json b/verify/tests/data/check_code.json index e69de29b..37267b48 100644 --- a/verify/tests/data/check_code.json +++ b/verify/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/tests/data/check_code_error.json b/verify/tests/data/check_code_error.json new file mode 100644 index 00000000..29ac3017 --- /dev/null +++ b/verify/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/tests/data/search_request.json b/verify/tests/data/search_request.json new file mode 100644 index 00000000..4330554c --- /dev/null +++ b/verify/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/tests/data/search_request_error.json b/verify/tests/data/search_request_error.json new file mode 100644 index 00000000..a0d020f6 --- /dev/null +++ b/verify/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/tests/data/search_request_list.json b/verify/tests/data/search_request_list.json new file mode 100644 index 00000000..939e9ed2 --- /dev/null +++ b/verify/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/tests/data/trigger_next_event.json b/verify/tests/data/trigger_next_event.json new file mode 100644 index 00000000..7939ad17 --- /dev/null +++ b/verify/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/tests/data/trigger_next_event_error.json b/verify/tests/data/trigger_next_event_error.json new file mode 100644 index 00000000..1a52f3a4 --- /dev/null +++ b/verify/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/tests/test_verify.py b/verify/tests/test_verify.py index 2bcecbc4..451a8feb 100644 --- a/verify/tests/test_verify.py +++ b/verify/tests/test_verify.py @@ -6,6 +6,7 @@ from vonage_verify.errors import VerifyError from vonage_verify.language_codes import LanguageCode, Psd2LanguageCode from vonage_verify.requests import Psd2Request, VerifyRequest +from vonage_verify.responses import VerifyControlStatus from vonage_verify.verify import Verify from testutils import build_response, get_mock_api_key_auth @@ -86,7 +87,7 @@ def test_verify_request_error(): verify.start_verification(request) assert e.match( - 'Error with Vonage status code 10: Concurrent verifications to the same number are not allowed' + "'error_text': 'Concurrent verifications to the same number are not allowed'" ) @@ -104,7 +105,7 @@ def test_verify_request_error_with_network(): with raises(VerifyError) as e: verify.start_verification(request) - assert e.match('Network ID: 244523') + assert e.match("'network': '244523'") @responses.activate @@ -113,10 +114,116 @@ def test_check_code(): path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code.json' ) response = verify.check_code( - request_id='abcdef0123456789abcdef0123456789', code='1234' + request_id='c5037cb8b47449158ed6611afde58990', code='1234' ) - assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.request_id == 'c5037cb8b47449158ed6611afde58990' assert response.status == '0' - assert response.event_id == 'abcdef0123456789abcdef0123456789' - assert response.price == '0.10000000' + assert response.event_id == '390f7296-aeff-45ba-8931-84a13f3f76d7' + assert response.price == '0.05000000' + assert response.currency == 'EUR' + assert response.estimated_price_messages_sent == '0.04675' + + +@responses.activate +def test_check_code_error(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code_error.json' + ) + + with raises(VerifyError) as e: + verify.check_code(request_id='c5037cb8b47449158ed6611afde58990', code='1234') + + assert e.match( + "'status': '16', 'error_text': 'The code provided does not match the expected value'" + ) + + +@responses.activate +def test_search(): + build_response( + path, 'GET', 'https://api.nexmo.com/verify/search/json', 'search_request.json' + ) + response = verify.search('c5037cb8b47449158ed6611afde58990') + + assert response.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' + assert response.account_id == 'abcdef01' + assert response.status == 'EXPIRED' + assert response.number == '1234567890' + assert response.price == '0' assert response.currency == 'EUR' + assert response.sender_id == 'Acme Inc.' + assert response.date_submitted == '2024-04-03 02:22:37' + assert response.date_finalized == '2024-04-03 02:27:38' + assert response.first_event_date == '2024-04-03 02:22:37' + assert response.last_event_date == '2024-04-03 02:24:38' + assert response.estimated_price_messages_sent == '0.09350' + assert response.checks[0].date_received == '2024-04-03 02:23:04' + assert response.checks[0].code == '1234' + assert response.checks[0].status == 'INVALID' + assert response.checks[0].ip_address == '' + assert response.events[0].type == 'sms' + assert response.events[0].id == '23f3a13d-6d03-4262-8f4d-67f12a56e1c8' + + +@responses.activate +def test_search_list_of_ids(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/verify/search/json', + 'search_request_list.json', + ) + response0, response1 = verify.search( + ['cc121958d8fb4368aa3bb762bb9a0f75', 'c5037cb8b47449158ed6611afde58990'] + ) + assert response0.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' + assert response1.request_id == 'c5037cb8b47449158ed6611afde58990' + assert response1.status == 'SUCCESS' + assert response1.checks[0].status == 'VALID' + + +@responses.activate +def test_search_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/verify/search/json', + 'search_request_error.json', + ) + + with raises(VerifyError) as e: + verify.search('c5037cb8b47449158ed6611afde58990') + + assert e.match("{'status': '101', 'error_text': 'No response found'}") + + +@responses.activate +def test_cancel_verification(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'cancel_verification.json', + ) + response = verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + + assert type(response) == VerifyControlStatus + assert response.status == '0' + assert response.command == 'cancel' + + +@responses.activate +def test_cancel_verification_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'cancel_verification_error.json', + ) + + with raises(VerifyError) as e: + verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + + assert e.match( + "The requestId 'cc121958d8fb4368aa3bb762bb9a0f75' does not exist or its no longer active." + ) From 0843a15b9578933e618c65fa11e59c0181c9119e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 4 Apr 2024 04:46:19 +0100 Subject: [PATCH 24/98] add verify controls --- verify/src/vonage_verify/responses.py | 2 +- verify/src/vonage_verify/verify.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py index 6aa16ef0..64383e53 100644 --- a/verify/src/vonage_verify/responses.py +++ b/verify/src/vonage_verify/responses.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional +from typing import List, Optional from pydantic import BaseModel diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index 40da4443..ad825bf6 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -1,13 +1,12 @@ -import re -from typing import List, Optional, Union -from pydantic import Field, validate_call +from typing import List, Union + +from pydantic import 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, From 2b68284b95eeb33dc5b0928d68f1dcd510bb6de9 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 4 Apr 2024 15:22:16 +0100 Subject: [PATCH 25/98] finish verify implementation and prepare for release --- http_client/CHANGES.md | 1 + http_client/README.md | 14 +++- http_client/pyproject.toml | 4 +- http_client/src/vonage_http_client/errors.py | 18 +++++ .../src/vonage_http_client/http_client.py | 12 ++-- http_client/tests/data/403.json | 6 ++ http_client/tests/test_http_client.py | 16 +++++ users/pyproject.toml | 8 +-- verify/README.md | 62 ++++++++++++---- verify/pyproject.toml | 4 +- verify/src/vonage_verify/__init__.py | 22 ++++-- verify/src/vonage_verify/verify.py | 27 ++++++- verify/tests/data/network_unblock.json | 4 ++ verify/tests/data/network_unblock_error.json | 6 ++ verify/tests/test_verify.py | 72 ++++++++++++++++++- vonage/CHANGES.md | 1 + vonage/pyproject.toml | 7 +- vonage/src/vonage/_version.py | 2 +- vonage_utils/CHANGES.md | 3 + vonage_utils/pyproject.toml | 2 +- vonage_utils/src/vonage_utils/__init__.py | 3 +- .../src/vonage_utils/types/__init__.py | 0 22 files changed, 255 insertions(+), 39 deletions(-) create mode 100644 http_client/tests/data/403.json create mode 100644 verify/tests/data/network_unblock.json create mode 100644 verify/tests/data/network_unblock_error.json create mode 100644 vonage_utils/src/vonage_utils/types/__init__.py diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 0e35f1be..2e3462b8 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,5 +1,6 @@ # 1.2.0 - Add `last_request` and `last_response` properties +- Add new `Forbidden` error # 1.1.1 - Add new Patch method diff --git a/http_client/README.md b/http_client/README.md index d5d39673..a422bf2e 100644 --- a/http_client/README.md +++ b/http_client/README.md @@ -42,9 +42,21 @@ response = client.get(host='api.nexmo.com', request_path='/v1/messages') 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: +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') diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index ffd4545f..d8f02731 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "vonage-http-client" -version = "1.1.1" +version = "1.2.0" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.0.0", + "vonage-utils>=1.0.1", "vonage-jwt>=1.1.0", "requests>=2.27.0", "pydantic>=2.6.1", diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index a44b00b4..3980a9a5 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -67,6 +67,24 @@ def __init__(self, response: Response, content_type: str): super().__init__(response, content_type) +class ForbiddenError(HttpRequestError): + """Exception indicating a forbidden request in a Vonage SDK request. + + This error is raised when the HTTP response status code is 403 (Forbidden). + + Args: + response (requests.Response): The HTTP response object. + content_type (str): The response content type. + + Attributes (inherited from HttpRequestError parent exception): + response (requests.Response): The HTTP response object. + message (str): The returned error message. + """ + + def __init__(self, response: Response, content_type: str): + super().__init__(response, content_type) + + class NotFoundError(HttpRequestError): """Exception indicating a resource was not found in a Vonage SDK request. diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 6d8dd55c..db1cccc0 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -11,6 +11,7 @@ from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, + ForbiddenError, HttpRequestError, InvalidHttpClientOptionsError, NotFoundError, @@ -109,7 +110,8 @@ 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. + 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 @@ -118,7 +120,8 @@ def last_response(self) -> Optional[Response]: """The last response received from the server. Returns: - Optional[Response]: The response object received from the server. + Optional[Response]: The response object received from the server, + or None if no response has been received. """ return self._last_response @@ -221,7 +224,6 @@ def _parse_response(self, response: Response) -> Union[dict, None]: f'Response received from {response.url} with status code: {response.status_code}; headers: {response.headers}' ) self._last_response = response - print(response.content) content_type = response.headers['Content-Type'].split(';', 1)[0] if 200 <= response.status_code < 300: if response.status_code == 204: @@ -234,8 +236,10 @@ def _parse_response(self, response: Response) -> Union[dict, None]: logger.warning( f'Http Response Error! Status code: {response.status_code}; content: {repr(response.text)}; from url: {response.url}' ) - if response.status_code == 401 or response.status_code == 403: + if response.status_code == 401: raise AuthenticationError(response, content_type) + if response.status_code == 403: + raise ForbiddenError(response, content_type) elif response.status_code == 404: raise NotFoundError(response, content_type) elif response.status_code == 429: 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/test_http_client.py b/http_client/tests/test_http_client.py index c13487b1..3bd4d7fa 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -8,6 +8,7 @@ from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, + ForbiddenError, HttpRequestError, InvalidHttpClientOptionsError, RateLimitedError, @@ -196,6 +197,21 @@ def test_authentication_error_no_content(): 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) diff --git a/users/pyproject.toml b/users/pyproject.toml index c42c2ba4..38337222 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-users' -version = '1.0.0' -description = 'Vonage SMS package' +version = '1.0.1' +description = 'Vonage Users package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.1.1", - "vonage-utils>=1.0.0", + "vonage-http-client>=1.2.0", + "vonage-utils>=1.0.1", "pydantic>=2.6.1", ] classifiers = [ diff --git a/verify/README.md b/verify/README.md index 6dccb42d..bd033887 100644 --- a/verify/README.md +++ b/verify/README.md @@ -1,13 +1,8 @@ # Vonage Verify Package -This package contains the code to use Vonage's Verify API in Python. There is a more current package to user Vonage's Verify v2 API which is recommended to use for most use cases. The v2 API lets you send messages via multiple channels, including Email, SMS, MMS, WhatsApp, Messenger and others. You can also make Silent Authentication requests with Verify v2 to give an end user a more seamless experience. - -This package includes methods for sending 2-factor authentication (2FA) messages and returns... - - -asdf -asdf +This package contains the code to use Vonage's 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 v2 API](https://developer.vonage.com/en/verify/overview) which is recommended for most use cases. The v2 API lets you send messages via multiple channels, including Email, SMS, MMS, WhatsApp, Messenger and others. You can also make Silent Authentication requests with Verify v2 to give an end user a more seamless experience. ## Usage @@ -15,13 +10,54 @@ It is recommended to use this as part of the main `vonage` package. The examples ### Make a Verify Request - +```python +response = vonage_client.verify.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.request_network_unblock('23410') +``` diff --git a/verify/pyproject.toml b/verify/pyproject.toml index 43d2ab4d..7994d8b8 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -6,8 +6,8 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.1.1", - "vonage-utils>=1.0.0", + "vonage-http-client>=1.2.0", + "vonage-utils>=1.0.1", "pydantic>=2.6.1", ] classifiers = [ diff --git a/verify/src/vonage_verify/__init__.py b/verify/src/vonage_verify/__init__.py index 442b7842..65a043bb 100644 --- a/verify/src/vonage_verify/__init__.py +++ b/verify/src/vonage_verify/__init__.py @@ -1,11 +1,25 @@ -# from .errors import PartialFailureError, SmsError +from .errors import VerifyError +from .language_codes import LanguageCode, Psd2LanguageCode from .requests import Psd2Request, VerifyRequest - -# from .responses import MessageResponse, SmsResponse +from .responses import ( + CheckCodeResponse, + NetworkUnblockStatus, + StartVerificationResponse, + VerifyControlStatus, + VerifyStatus, +) from .verify import Verify __all__ = [ 'Verify', - 'VerifyRequest', + 'VerifyError', + 'LanguageCode', + 'Psd2LanguageCode', 'Psd2Request', + 'VerifyRequest', + 'CheckCodeResponse', + 'NetworkUnblockStatus', + 'StartVerificationResponse', + 'VerifyControlStatus', + 'VerifyStatus', ] diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index ad825bf6..b26b4c66 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -1,12 +1,13 @@ -from typing import List, Union +from typing import List, Optional, Union -from pydantic import validate_call +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, @@ -153,6 +154,28 @@ def trigger_next_event(self, request_id: str) -> VerifyControlStatus: 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: diff --git a/verify/tests/data/network_unblock.json b/verify/tests/data/network_unblock.json new file mode 100644 index 00000000..6620d3f4 --- /dev/null +++ b/verify/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/tests/data/network_unblock_error.json b/verify/tests/data/network_unblock_error.json new file mode 100644 index 00000000..bf7cadba --- /dev/null +++ b/verify/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/tests/test_verify.py b/verify/tests/test_verify.py index 451a8feb..d8a5af19 100644 --- a/verify/tests/test_verify.py +++ b/verify/tests/test_verify.py @@ -2,11 +2,12 @@ import responses from pytest import raises +from vonage_http_client.errors import NotFoundError from vonage_http_client.http_client import HttpClient from vonage_verify.errors import VerifyError from vonage_verify.language_codes import LanguageCode, Psd2LanguageCode from vonage_verify.requests import Psd2Request, VerifyRequest -from vonage_verify.responses import VerifyControlStatus +from vonage_verify.responses import NetworkUnblockStatus, VerifyControlStatus from vonage_verify.verify import Verify from testutils import build_response, get_mock_api_key_auth @@ -227,3 +228,72 @@ def test_cancel_verification_error(): assert e.match( "The requestId 'cc121958d8fb4368aa3bb762bb9a0f75' does not exist or its no longer active." ) + + +@responses.activate +def test_trigger_next_event(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'trigger_next_event.json', + ) + response = verify.trigger_next_event('c5037cb8b47449158ed6611afde58990') + + assert type(response) == VerifyControlStatus + assert response.status == '0' + assert response.command == 'trigger_next_event' + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'trigger_next_event_error.json', + ) + + with raises(VerifyError) as e: + verify.trigger_next_event('2c021d25cf2e47a9b277a996f4325b81') + + assert e.match("'status': '19") + assert e.match('No more events are left to execute for the request') + + +@responses.activate +def test_request_network_unblock(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/network-unblock', + 'network_unblock.json', + 202, + ) + + response = verify.request_network_unblock('23410') + + assert verify._http_client.last_response.status_code == 202 + assert type(response) == NetworkUnblockStatus + assert response.network == '23410' + assert response.unblocked_until == '2024-04-22T08:34:58Z' + + +@responses.activate +def test_request_network_unblock_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/network-unblock', + 'network_unblock_error.json', + 404, + ) + + try: + verify.request_network_unblock('23410') + except NotFoundError as e: + assert ( + e.response.json()['detail'] + == 'The network you provided does not have an active block.' + ) + assert e.response.json()['title'] == 'Not Found' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 6bf57af6..ebeaef18 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,6 @@ # 3.99.0a4 - Add support for the [Vonage Verify API](https://developer.vonage.com/en/api/verify). +- Add `last_request` and `last_response` properties to the HTTP Client. # 3.99.0a3 - Add support for the [Vonage Users API](https://developer.vonage.com/en/api/application.v2#User). diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index f45f3e9c..053f8839 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,11 +6,12 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.0.0", - "vonage-http-client>=1.1.1", + "vonage-utils>=1.0.1", + "vonage-http-client>=1.2.0", "vonage-number-insight-v2>=0.1.0", "vonage-sms>=1.0.2", - "vonage-users>=1.0.0", + "vonage-users>=1.0.1", + "vonage-verify>=1.0.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index cbad4bb3..9233543a 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a3' +__version__ = '3.99.0a4' diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md index a376cb52..bc23e4c1 100644 --- a/vonage_utils/CHANGES.md +++ b/vonage_utils/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Add `PhoneNumber` type + # 1.0.0 - Initial upload \ No newline at end of file diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 9303d98e..75fd4474 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-utils' -version = '1.0.0' +version = '1.0.1' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/vonage_utils/src/vonage_utils/__init__.py b/vonage_utils/src/vonage_utils/__init__.py index 7e04e7f1..db619eb7 100644 --- a/vonage_utils/src/vonage_utils/__init__.py +++ b/vonage_utils/src/vonage_utils/__init__.py @@ -1,4 +1,5 @@ from .errors import VonageError +from .types.phone_number import PhoneNumber from .utils import format_phone_number, remove_none_values -__all__ = ['VonageError', 'format_phone_number', 'remove_none_values'] +__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', PhoneNumber] diff --git a/vonage_utils/src/vonage_utils/types/__init__.py b/vonage_utils/src/vonage_utils/types/__init__.py new file mode 100644 index 00000000..e69de29b From 20051fdb723179f3552be042cab1f3fefd36f913 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 6 Apr 2024 03:37:02 +0100 Subject: [PATCH 26/98] start adding verify v2 --- verify_v2/BUILD | 16 +++ verify_v2/CHANGES.md | 2 + verify_v2/README.md | 64 +++++++++++ verify_v2/pyproject.toml | 29 +++++ verify_v2/src/vonage_verify_v2/BUILD | 1 + verify_v2/src/vonage_verify_v2/__init__.py | 28 +++++ verify_v2/src/vonage_verify_v2/enums.py | 25 ++++ verify_v2/src/vonage_verify_v2/errors.py | 5 + verify_v2/src/vonage_verify_v2/requests.py | 81 +++++++++++++ verify_v2/src/vonage_verify_v2/responses.py | 8 ++ verify_v2/src/vonage_verify_v2/verify_v2.py | 121 ++++++++++++++++++++ 11 files changed, 380 insertions(+) create mode 100644 verify_v2/BUILD create mode 100644 verify_v2/CHANGES.md create mode 100644 verify_v2/README.md create mode 100644 verify_v2/pyproject.toml create mode 100644 verify_v2/src/vonage_verify_v2/BUILD create mode 100644 verify_v2/src/vonage_verify_v2/__init__.py create mode 100644 verify_v2/src/vonage_verify_v2/enums.py create mode 100644 verify_v2/src/vonage_verify_v2/errors.py create mode 100644 verify_v2/src/vonage_verify_v2/requests.py create mode 100644 verify_v2/src/vonage_verify_v2/responses.py create mode 100644 verify_v2/src/vonage_verify_v2/verify_v2.py diff --git a/verify_v2/BUILD b/verify_v2/BUILD new file mode 100644 index 00000000..a4665a48 --- /dev/null +++ b/verify_v2/BUILD @@ -0,0 +1,16 @@ +resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + +files(sources=['tests/data/*']) + +python_distribution( + name='vonage-verify-v2', + dependencies=[ + ':pyproject', + ':readme', + 'verify_v2/src/vonage_verify_v2', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md new file mode 100644 index 00000000..be516a55 --- /dev/null +++ b/verify_v2/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/verify_v2/README.md b/verify_v2/README.md new file mode 100644 index 00000000..e81d0584 --- /dev/null +++ b/verify_v2/README.md @@ -0,0 +1,64 @@ +# Vonage Verify V2 Package + +This package contains the code to use [Vonage's Verify v2 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 v2 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 +params = {'number': '1234567890', 'brand': 'Acme Inc.'} +request = VerifyRequest(**params) +response = vonage_client.verify.start_verification(request) +``` + +### Make a PSD2 (Payment Services Directive v2) Request + +```python +from vonage_verify import Psd2Request +params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} +request = VerifyRequest(**params) +response = vonage_client.verify.start_verification(request) +``` + +### Check a Verification Code + +```python +vonage_client.verify.check_code(request_id='my_request_id', code='1234') +``` + +### Search Verification Requests + +```python +# Search for single request +response = vonage_client.verify.search('my_request_id') + +# Search for multiple requests +response = vonage_client.verify.search(['my_request_id_1', 'my_request_id_2']) +``` + +### Cancel a Verification + +```python +response = vonage_client.verify.cancel_verification('my_request_id') +``` + +### Trigger the Next Workflow Event + +```python +response = vonage_client.verify.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.request_network_unblock('23410') +``` diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml new file mode 100644 index 00000000..87906f01 --- /dev/null +++ b/verify_v2/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-verify-v2' +version = '1.0.0' +description = 'Vonage verify v2 package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.2.0", + "vonage-utils>=1.0.1", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/verify_v2/src/vonage_verify_v2/BUILD b/verify_v2/src/vonage_verify_v2/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/verify_v2/src/vonage_verify_v2/__init__.py b/verify_v2/src/vonage_verify_v2/__init__.py new file mode 100644 index 00000000..3f60abce --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/__init__.py @@ -0,0 +1,28 @@ +from .errors import VerifyError +from .enums import VerifyChannel, VerifyLocale +from .requests import ( + VerifyRequest, + SilentAuthWorkflow, + SmsWorkflow, + WhatsappWorkflow, + VoiceWorkflow, + EmailWorkflow, +) +from .responses import ( + StartVerificationResponse, +) +from .verify_v2 import Verify + +__all__ = [ + 'Verify', + 'VerifyError', + 'VerifyChannel', + 'VerifyLocale', + 'VerifyRequest', + 'SilentAuthWorkflow', + 'SmsWorkflow', + 'WhatsappWorkflow', + 'VoiceWorkflow', + 'EmailWorkflow', + 'StartVerificationResponse', +] diff --git a/verify_v2/src/vonage_verify_v2/enums.py b/verify_v2/src/vonage_verify_v2/enums.py new file mode 100644 index 00000000..e7be7f1a --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/enums.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class VerifyChannel(Enum): + SILENT_AUTH = 'silent_auth' + SMS = 'sms' + WHATSAPP = 'whatsapp' + VOICE = 'voice' + EMAIL = 'email' + + +class VerifyLocale(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_v2/src/vonage_verify_v2/errors.py b/verify_v2/src/vonage_verify_v2/errors.py new file mode 100644 index 00000000..b2832d2f --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/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_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py new file mode 100644 index 00000000..14ff091a --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -0,0 +1,81 @@ +from typing import List, Optional, Union +from re import search + +from pydantic import ( + BaseModel, + Field, + field_validator, + model_validator, +) +from vonage_utils.types.phone_number import PhoneNumber + +from .enums import VerifyChannel, VerifyLocale +from .errors import VerifyError + + +class Workflow(BaseModel): + channel: VerifyChannel + to: PhoneNumber + + +class SilentAuthWorkflow(Workflow): + redirect_url: Optional[str] = None + sandbox: Optional[bool] = None + + +class SmsWorkflow(Workflow): + from_: Optional[Union[PhoneNumber, str]] = Field(None, serialization_alias='from') + entity_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') + content_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') + app_hash: Optional[str] = Field(None, min_length=11, max_length=11) + + @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]{1,15}$', v) + ): + raise VerifyError(f'You must specify a valid "from" value if included.') + + +class WhatsappWorkflow(Workflow): + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + + @field_validator('from_') + @classmethod + def check_valid_sender(cls, v): + if type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{1,15}$', v): + raise VerifyError(f'You must specify a valid "from" value.') + + +class VoiceWorkflow(Workflow): + @model_validator(mode='after') + def remove_from_field_from_voice(self): + self.from_ = None + return self + + +class EmailWorkflow(Workflow): + to: str + from_: Optional[str] = Field(None, serialization_alias='from') + + +class VerifyRequest(BaseModel): + brand: str = Field(..., min_length=1, max_length=16) + workflow: List[Workflow] + locale: Optional[VerifyLocale] = None + channel_timeout: Optional[int] = Field(None, ge=60, 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 remove_fields_if_only_silent_auth(self): + if len(self.workflow) == 1 and isinstance(self.workflow[0], SilentAuthWorkflow): + self.locale = None + self.client_ref = None + self.code_length = None + self.code = None + return self diff --git a/verify_v2/src/vonage_verify_v2/responses.py b/verify_v2/src/vonage_verify_v2/responses.py new file mode 100644 index 00000000..73eda125 --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/responses.py @@ -0,0 +1,8 @@ +from typing import Optional + +from pydantic import BaseModel + + +class StartVerificationResponse(BaseModel): + request_id: str + check_url: Optional[str] = None diff --git a/verify_v2/src/vonage_verify_v2/verify_v2.py b/verify_v2/src/vonage_verify_v2/verify_v2.py new file mode 100644 index 00000000..122dcdbd --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/verify_v2.py @@ -0,0 +1,121 @@ +from typing import List, Optional, Union + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import VerifyError +from .requests import ( + VerifyRequest, + SilentAuthWorkflow, + SmsWorkflow, + WhatsappWorkflow, + VoiceWorkflow, + EmailWorkflow, +) +from .responses import ( + CheckCodeResponse, + StartVerificationResponse, + VerifyControlStatus, + VerifyStatus, +) + + +class Verify: + """Calls Vonage's Verify V2 API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @validate_call + def start_verification( + self, verify_request: VerifyRequest + ) -> StartVerificationResponse: + """Start a verification process. + + Args: + verify_request (VerifyRequest): The verification request object. + + Returns: + StartVerificationResponse: The response object containing the `request_id`. + If requesting Silent Authentication, it will also contain a `check_url` field. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v2/verify', + verify_request.model_dump(by_alias=True, exclude_none=True), + ) + + return StartVerificationResponse(**response) + + #################################################################################################### + + #################################################################################################### + + #################################################################################################### + + #################################################################################################### + + @validate_call + def check_code(self, request_id: str, code: str) -> CheckCodeResponse: + """Check a verification code. + + Args: + request_id (str): The request ID. + code (str): The verification code. + + Returns: + CheckCodeResponse: The response object containing the verification result. + """ + response = self._http_client.post( + self._http_client.api_host, + '/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 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_workflow(self, request_id: str) -> VerifyControlStatus: + """Trigger the next workflow 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) From 2e3ff3ba124a1a10b7ea47de7fe6c57d037b9066 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 8 Apr 2024 03:09:09 +0100 Subject: [PATCH 27/98] verify logic, models and tests --- .../src/vonage_http_client/http_client.py | 2 +- verify/src/vonage_verify/language_codes.py | 2 +- verify_v2/src/vonage_verify_v2/__init__.py | 34 ++-- verify_v2/src/vonage_verify_v2/enums.py | 4 +- verify_v2/src/vonage_verify_v2/requests.py | 72 +++++--- verify_v2/src/vonage_verify_v2/responses.py | 5 + verify_v2/src/vonage_verify_v2/verify_v2.py | 68 +------ verify_v2/tests/BUILD | 1 + verify_v2/tests/data/check_code.json | 4 + verify_v2/tests/data/check_code_400.json | 6 + verify_v2/tests/data/check_code_410.json | 6 + verify_v2/tests/data/verify_request.json | 4 + .../tests/data/verify_request_error.json | 6 + verify_v2/tests/test_models.py | 145 +++++++++++++++ verify_v2/tests/test_verify_v2.py | 174 ++++++++++++++++++ vonage/src/vonage/__init__.py | 12 +- vonage/src/vonage/vonage.py | 2 + 17 files changed, 439 insertions(+), 108 deletions(-) create mode 100644 verify_v2/tests/BUILD create mode 100644 verify_v2/tests/data/check_code.json create mode 100644 verify_v2/tests/data/check_code_400.json create mode 100644 verify_v2/tests/data/check_code_410.json create mode 100644 verify_v2/tests/data/verify_request.json create mode 100644 verify_v2/tests/data/verify_request_error.json create mode 100644 verify_v2/tests/test_models.py create mode 100644 verify_v2/tests/test_verify_v2.py diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index db1cccc0..1f781d3c 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -224,7 +224,6 @@ def _parse_response(self, response: Response) -> Union[dict, None]: f'Response received from {response.url} with status code: {response.status_code}; headers: {response.headers}' ) self._last_response = response - content_type = response.headers['Content-Type'].split(';', 1)[0] if 200 <= response.status_code < 300: if response.status_code == 204: return None @@ -233,6 +232,7 @@ def _parse_response(self, response: Response) -> Union[dict, None]: except JSONDecodeError: return None if response.status_code >= 400: + content_type = response.headers['Content-Type'].split(';', 1)[0] logger.warning( f'Http Response Error! Status code: {response.status_code}; content: {repr(response.text)}; from url: {response.url}' ) diff --git a/verify/src/vonage_verify/language_codes.py b/verify/src/vonage_verify/language_codes.py index 1dcdcef5..cad9ec14 100644 --- a/verify/src/vonage_verify/language_codes.py +++ b/verify/src/vonage_verify/language_codes.py @@ -43,7 +43,7 @@ class LanguageCode(str, Enum): zh_tw = 'zh-tw' -class Psd2LanguageCode(Enum): +class Psd2LanguageCode(str, Enum): en_gb = 'en-gb' bg_bg = 'bg-bg' cs_cz = 'cs-cz' diff --git a/verify_v2/src/vonage_verify_v2/__init__.py b/verify_v2/src/vonage_verify_v2/__init__.py index 3f60abce..2bacce82 100644 --- a/verify_v2/src/vonage_verify_v2/__init__.py +++ b/verify_v2/src/vonage_verify_v2/__init__.py @@ -1,28 +1,26 @@ +from .enums import ChannelType, Locale from .errors import VerifyError -from .enums import VerifyChannel, VerifyLocale from .requests import ( + EmailChannel, + SilentAuthChannel, + SmsChannel, VerifyRequest, - SilentAuthWorkflow, - SmsWorkflow, - WhatsappWorkflow, - VoiceWorkflow, - EmailWorkflow, + VoiceChannel, + WhatsappChannel, ) -from .responses import ( - StartVerificationResponse, -) -from .verify_v2 import Verify +from .responses import StartVerificationResponse +from .verify_v2 import VerifyV2 __all__ = [ - 'Verify', + 'VerifyV2', 'VerifyError', - 'VerifyChannel', - 'VerifyLocale', + 'ChannelType', + 'Locale', 'VerifyRequest', - 'SilentAuthWorkflow', - 'SmsWorkflow', - 'WhatsappWorkflow', - 'VoiceWorkflow', - 'EmailWorkflow', + 'SilentAuthChannel', + 'SmsChannel', + 'WhatsappChannel', + 'VoiceChannel', + 'EmailChannel', 'StartVerificationResponse', ] diff --git a/verify_v2/src/vonage_verify_v2/enums.py b/verify_v2/src/vonage_verify_v2/enums.py index e7be7f1a..0871f945 100644 --- a/verify_v2/src/vonage_verify_v2/enums.py +++ b/verify_v2/src/vonage_verify_v2/enums.py @@ -1,7 +1,7 @@ from enum import Enum -class VerifyChannel(Enum): +class ChannelType(str, Enum): SILENT_AUTH = 'silent_auth' SMS = 'sms' WHATSAPP = 'whatsapp' @@ -9,7 +9,7 @@ class VerifyChannel(Enum): EMAIL = 'email' -class VerifyLocale(Enum): +class Locale(str, Enum): EN_US = 'en-us' EN_GB = 'en-gb' ES_ES = 'es-es' diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index 14ff091a..87f5003c 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -1,33 +1,29 @@ -from typing import List, Optional, Union from re import search +from typing import List, Optional, Union -from pydantic import ( - BaseModel, - Field, - field_validator, - model_validator, -) +from pydantic import BaseModel, Field, field_validator, model_validator from vonage_utils.types.phone_number import PhoneNumber -from .enums import VerifyChannel, VerifyLocale +from .enums import ChannelType, Locale from .errors import VerifyError -class Workflow(BaseModel): - channel: VerifyChannel +class Channel(BaseModel): to: PhoneNumber -class SilentAuthWorkflow(Workflow): +class SilentAuthChannel(Channel): redirect_url: Optional[str] = None sandbox: Optional[bool] = None + channel: ChannelType = ChannelType.SILENT_AUTH -class SmsWorkflow(Workflow): +class SmsChannel(Channel): from_: Optional[Union[PhoneNumber, str]] = Field(None, serialization_alias='from') entity_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') content_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') app_hash: Optional[str] = Field(None, min_length=11, max_length=11) + channel: ChannelType = ChannelType.SMS @field_validator('from_') @classmethod @@ -37,35 +33,52 @@ def check_valid_from_field(cls, v): and type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{1,15}$', v) ): - raise VerifyError(f'You must specify a valid "from" value if included.') + 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 1-15 alphanumeric characters. ' + f'You set "from_": "{v}".' + ) + return v -class WhatsappWorkflow(Workflow): +class WhatsappChannel(Channel): from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + channel: ChannelType = ChannelType.WHATSAPP @field_validator('from_') @classmethod def check_valid_sender(cls, v): if type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{1,15}$', v): - raise VerifyError(f'You must specify a valid "from" value.') + raise VerifyError( + f'You must specify a valid "from_" value. ' + 'It must be a valid phone number without the leading +, or a string of 1-15 alphanumeric characters. ' + f'You set "from_": "{v}".' + ) + return v -class VoiceWorkflow(Workflow): - @model_validator(mode='after') - def remove_from_field_from_voice(self): - self.from_ = None - return self +class VoiceChannel(Channel): + channel: ChannelType = ChannelType.VOICE -class EmailWorkflow(Workflow): +class EmailChannel(Channel): to: str from_: Optional[str] = Field(None, serialization_alias='from') + channel: ChannelType = ChannelType.EMAIL class VerifyRequest(BaseModel): brand: str = Field(..., min_length=1, max_length=16) - workflow: List[Workflow] - locale: Optional[VerifyLocale] = None + workflow: List[ + Union[ + SilentAuthChannel, + SmsChannel, + WhatsappChannel, + VoiceChannel, + EmailChannel, + ] + ] + locale: Optional[Locale] = None channel_timeout: Optional[int] = Field(None, ge=60, le=900) client_ref: Optional[str] = Field(None, min_length=1, max_length=16) code_length: Optional[int] = Field(None, ge=4, le=10) @@ -73,9 +86,18 @@ class VerifyRequest(BaseModel): @model_validator(mode='after') def remove_fields_if_only_silent_auth(self): - if len(self.workflow) == 1 and isinstance(self.workflow[0], SilentAuthWorkflow): + if len(self.workflow) == 1 and isinstance(self.workflow[0], SilentAuthChannel): self.locale = None - self.client_ref = None self.code_length = None self.code = None return self + + @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_v2/src/vonage_verify_v2/responses.py b/verify_v2/src/vonage_verify_v2/responses.py index 73eda125..09324d34 100644 --- a/verify_v2/src/vonage_verify_v2/responses.py +++ b/verify_v2/src/vonage_verify_v2/responses.py @@ -6,3 +6,8 @@ class StartVerificationResponse(BaseModel): request_id: str check_url: Optional[str] = None + + +class CheckCodeResponse(BaseModel): + request_id: str + status: str diff --git a/verify_v2/src/vonage_verify_v2/verify_v2.py b/verify_v2/src/vonage_verify_v2/verify_v2.py index 122dcdbd..2e7380d7 100644 --- a/verify_v2/src/vonage_verify_v2/verify_v2.py +++ b/verify_v2/src/vonage_verify_v2/verify_v2.py @@ -1,26 +1,11 @@ -from typing import List, Optional, Union - from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .errors import VerifyError -from .requests import ( - VerifyRequest, - SilentAuthWorkflow, - SmsWorkflow, - WhatsappWorkflow, - VoiceWorkflow, - EmailWorkflow, -) -from .responses import ( - CheckCodeResponse, - StartVerificationResponse, - VerifyControlStatus, - VerifyStatus, -) +from .requests import VerifyRequest +from .responses import CheckCodeResponse, StartVerificationResponse -class Verify: +class VerifyV2: """Calls Vonage's Verify V2 API.""" def __init__(self, http_client: HttpClient) -> None: @@ -47,14 +32,6 @@ def start_verification( return StartVerificationResponse(**response) - #################################################################################################### - - #################################################################################################### - - #################################################################################################### - - #################################################################################################### - @validate_call def check_code(self, request_id: str, code: str) -> CheckCodeResponse: """Check a verification code. @@ -67,55 +44,26 @@ def check_code(self, request_id: str, code: str) -> CheckCodeResponse: CheckCodeResponse: The response object containing the verification result. """ response = self._http_client.post( - self._http_client.api_host, - '/verify/check/json', - {'request_id': request_id, 'code': code}, - self._auth_type, - self._sent_data_type, + self._http_client.api_host, f'/v2/verify/{request_id}', {'code': code} ) - self._check_for_error(response) return CheckCodeResponse(**response) @validate_call - def cancel_verification(self, request_id: str) -> VerifyControlStatus: + def cancel_verification(self, request_id: str) -> None: """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) + self._http_client.delete(self._http_client.api_host, f'/v2/verify/{request_id}') @validate_call - def trigger_next_workflow(self, request_id: str) -> VerifyControlStatus: + def trigger_next_workflow(self, request_id: str) -> None: """Trigger the next workflow 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._http_client.api_host, f'/v2/verify/{request_id}' ) - self._check_for_error(response) - - return VerifyControlStatus(**response) diff --git a/verify_v2/tests/BUILD b/verify_v2/tests/BUILD new file mode 100644 index 00000000..7453da10 --- /dev/null +++ b/verify_v2/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['verify_v2', 'testutils']) diff --git a/verify_v2/tests/data/check_code.json b/verify_v2/tests/data/check_code.json new file mode 100644 index 00000000..2fbe4b8e --- /dev/null +++ b/verify_v2/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_v2/tests/data/check_code_400.json b/verify_v2/tests/data/check_code_400.json new file mode 100644 index 00000000..23690a83 --- /dev/null +++ b/verify_v2/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_v2/tests/data/check_code_410.json b/verify_v2/tests/data/check_code_410.json new file mode 100644 index 00000000..9d2534c7 --- /dev/null +++ b/verify_v2/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_v2/tests/data/verify_request.json b/verify_v2/tests/data/verify_request.json new file mode 100644 index 00000000..719396cc --- /dev/null +++ b/verify_v2/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_v2/tests/data/verify_request_error.json b/verify_v2/tests/data/verify_request_error.json new file mode 100644 index 00000000..f40b1b12 --- /dev/null +++ b/verify_v2/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_v2/tests/test_models.py b/verify_v2/tests/test_models.py new file mode 100644 index 00000000..f9877ef7 --- /dev/null +++ b/verify_v2/tests/test_models.py @@ -0,0 +1,145 @@ +from pytest import raises +from vonage_verify_v2.enums import ChannelType, Locale +from vonage_verify_v2.errors import VerifyError +from vonage_verify_v2.requests import * + + +def test_create_silent_auth_channel(): + params = { + 'channel': ChannelType.SILENT_AUTH, + 'to': '1234567890', + 'redirect_url': 'https://example.com', + 'sandbox': True, + } + channel = SilentAuthChannel(**params) + + assert channel.model_dump() == params + + +def test_create_sms_channel(): + params = { + 'channel': ChannelType.SMS, + 'to': '1234567890', + 'from_': 'Vonage', + 'entity_id': '12345678901234567890', + 'content_id': '12345678901234567890', + 'app_hash': '12345678901', + } + channel = SmsChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + SmsChannel(**params) + + +def test_create_whatsapp_channel(): + params = { + 'channel': ChannelType.WHATSAPP, + 'to': '1234567890', + 'from_': 'Vonage', + } + channel = WhatsappChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + WhatsappChannel(**params) + + +def test_create_voice_channel(): + params = { + 'channel': ChannelType.VOICE, + 'to': '1234567890', + } + channel = VoiceChannel(**params) + + assert channel.model_dump() == params + + +def test_create_email_channel(): + params = { + 'channel': ChannelType.EMAIL, + 'to': 'customer@example.com', + 'from_': 'vonage@vonage.com', + } + channel = EmailChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'vonage@vonage.com' + + +def test_create_verify_request(): + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + # Basic request + + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [sms_channel] + + # Multiple channel request + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == workflow + + # All fields + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel, sms_channel], + 'locale': Locale.EN_GB, + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [silent_auth_channel, sms_channel, sms_channel] + assert verify_request.locale == Locale.EN_GB + assert verify_request.channel_timeout == 60 + assert verify_request.client_ref == 'my-client-ref' + assert verify_request.code_length == 6 + assert verify_request.code == '123456' + + # Silent auth only + params['workflow'] = [silent_auth_channel] + verify_request = VerifyRequest(**params) + assert verify_request.locale == None + assert verify_request.code_length == None + assert verify_request.code == None + + +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_v2/tests/test_verify_v2.py b/verify_v2/tests/test_verify_v2.py new file mode 100644 index 00000000..3e1d57f8 --- /dev/null +++ b/verify_v2/tests/test_verify_v2.py @@ -0,0 +1,174 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_verify_v2.requests import * +from vonage_verify_v2.verify_v2 import VerifyV2 + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +verify = VerifyV2(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert ( + response.check_url + == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' + ) + assert verify._http_client.last_response.status_code == 202 + + +@responses.activate +def test_make_verify_request_full(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + 'locale': 'en-gb', + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + + +@responses.activate +def test_verify_request_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify', + 'verify_request_error.json', + 409, + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + request = VerifyRequest(**params) + + with raises(HttpRequestError) as e: + verify.start_verification(request) + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] + == 'Concurrent verifications to the same number are not allowed' + ) + + +@responses.activate +def test_check_code(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', + ) + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' + ) + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' + + +@responses.activate +def test_check_code_invalid_code_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_400.json', + 400, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 400 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_check_code_too_many_attempts(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_410.json', + 410, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 410 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_cancel_verification(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, + ) + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 + + +@responses.activate +def test_trigger_next_workflow(): + response = verify.trigger_next_event('c5037cb8b47449158ed6611afde58990') + + +# @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') diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index feab796b..62747843 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,6 +1,15 @@ from vonage_utils import VonageError -from .vonage import Auth, HttpClientOptions, NumberInsightV2, Sms, Users, Verify, Vonage +from .vonage import ( + Auth, + HttpClientOptions, + NumberInsightV2, + Sms, + Users, + Verify, + VerifyV2, + Vonage, +) __all__ = [ 'Vonage', @@ -10,5 +19,6 @@ 'Sms', 'Users', 'Verify', + 'VerifyV2', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index cd56f8d1..76eceeff 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -6,6 +6,7 @@ from vonage_sms.sms import Sms from vonage_users.users import Users from vonage_verify.verify import Verify +from vonage_verify_v2 import VerifyV2 from ._version import __version__ @@ -32,6 +33,7 @@ def __init__( self.sms = Sms(self._http_client) self.users = Users(self._http_client) self.verify = Verify(self._http_client) + self.verify_v2 = VerifyV2(self._http_client) @property def http_client(self): From 10ae9b96a24968a92c2f754242fabe928c62ad55 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 8 Apr 2024 16:29:34 +0100 Subject: [PATCH 28/98] finish verify v2 and prepare for release --- http_client/CHANGES.md | 3 + http_client/README.md | 13 +++++ http_client/pyproject.toml | 2 +- .../src/vonage_http_client/__init__.py | 28 +++++++++ verify/CHANGES.md | 3 + verify/pyproject.toml | 4 +- verify_v2/README.md | 58 ++++++++----------- verify_v2/pyproject.toml | 2 +- verify_v2/src/vonage_verify_v2/__init__.py | 3 +- verify_v2/src/vonage_verify_v2/requests.py | 16 ++--- verify_v2/src/vonage_verify_v2/verify_v2.py | 8 ++- .../data/trigger_next_workflow_error.json | 6 ++ verify_v2/tests/test_models.py | 7 --- verify_v2/tests/test_verify_v2.py | 38 +++++++----- vonage/CHANGES.md | 4 ++ vonage/pyproject.toml | 5 +- vonage/src/vonage/_version.py | 2 +- vonage/src/vonage/vonage.py | 11 ++-- 18 files changed, 129 insertions(+), 84 deletions(-) create mode 100644 verify_v2/tests/data/trigger_next_workflow_error.json diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 2e3462b8..528d4668 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 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 diff --git a/http_client/README.md b/http_client/README.md index a422bf2e..9ed14438 100644 --- a/http_client/README.md +++ b/http_client/README.md @@ -69,4 +69,17 @@ The `HttpClient` class automatically handles JWT and basic authentication based ```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 index d8f02731..683dc16c 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vonage-http-client" -version = "1.2.0" +version = "1.2.1" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/http_client/src/vonage_http_client/__init__.py b/http_client/src/vonage_http_client/__init__.py index e69de29b..88e8a9b7 100644 --- a/http_client/src/vonage_http_client/__init__.py +++ b/http_client/src/vonage_http_client/__init__.py @@ -0,0 +1,28 @@ +from .auth import Auth +from .errors import ( + AuthenticationError, + ForbiddenError, + HttpRequestError, + InvalidAuthError, + InvalidHttpClientOptionsError, + JWTGenerationError, + NotFoundError, + RateLimitedError, + ServerError, +) +from .http_client import HttpClient, HttpClientOptions + +__all__ = [ + 'Auth', + 'AuthenticationError', + 'ForbiddenError', + 'HttpRequestError', + 'InvalidAuthError', + 'InvalidHttpClientOptionsError', + 'JWTGenerationError', + 'NotFoundError', + 'RateLimitedError', + 'ServerError', + 'HttpClient', + 'HttpClientOptions', +] diff --git a/verify/CHANGES.md b/verify/CHANGES.md index be516a55..a28aa741 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Internal refactoring + # 1.0.0 - Initial upload diff --git a/verify/pyproject.toml b/verify/pyproject.toml index 7994d8b8..01ba042d 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -1,12 +1,12 @@ [project] name = 'vonage-verify' -version = '1.0.0' +version = '1.0.1' description = 'Vonage verify package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.0", + "vonage-http-client>=1.2.1", "vonage-utils>=1.0.1", "pydantic>=2.6.1", ] diff --git a/verify_v2/README.md b/verify_v2/README.md index e81d0584..79a4f7f4 100644 --- a/verify_v2/README.md +++ b/verify_v2/README.md @@ -6,59 +6,49 @@ This package contains the code to use [Vonage's Verify v2 API](https://developer 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 -params = {'number': '1234567890', 'brand': 'Acme Inc.'} -request = VerifyRequest(**params) -response = vonage_client.verify.start_verification(request) +from vonage_verify_v2 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_v2.start_verification(verify_request) ``` -### Make a PSD2 (Payment Services Directive v2) 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 -from vonage_verify import Psd2Request -params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} -request = VerifyRequest(**params) -response = vonage_client.verify.start_verification(request) +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_v2.start_verification(verify_request) ``` ### Check a Verification Code ```python -vonage_client.verify.check_code(request_id='my_request_id', code='1234') -``` - -### Search Verification Requests - -```python -# Search for single request -response = vonage_client.verify.search('my_request_id') - -# Search for multiple requests -response = vonage_client.verify.search(['my_request_id_1', 'my_request_id_2']) +vonage_client.verify_v2.check_code(request_id='my_request_id', code='1234') ``` ### Cancel a Verification ```python -response = vonage_client.verify.cancel_verification('my_request_id') +vonage_client.verify_v2.cancel_verification('my_request_id') ``` ### Trigger the Next Workflow Event ```python -response = vonage_client.verify.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.request_network_unblock('23410') -``` +vonage_client.verify_v2.trigger_next_workflow('my_request_id') +``` \ No newline at end of file diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml index 87906f01..1b4894dc 100644 --- a/verify_v2/pyproject.toml +++ b/verify_v2/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.0", + "vonage-http-client>=1.2.1", "vonage-utils>=1.0.1", "pydantic>=2.6.1", ] diff --git a/verify_v2/src/vonage_verify_v2/__init__.py b/verify_v2/src/vonage_verify_v2/__init__.py index 2bacce82..f0b3b432 100644 --- a/verify_v2/src/vonage_verify_v2/__init__.py +++ b/verify_v2/src/vonage_verify_v2/__init__.py @@ -8,13 +8,14 @@ VoiceChannel, WhatsappChannel, ) -from .responses import StartVerificationResponse +from .responses import CheckCodeResponse, StartVerificationResponse from .verify_v2 import VerifyV2 __all__ = [ 'VerifyV2', 'VerifyError', 'ChannelType', + 'CheckCodeResponse', 'Locale', 'VerifyRequest', 'SilentAuthChannel', diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index 87f5003c..2aa7d124 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -31,11 +31,11 @@ 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]{1,15}$', v) + 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 1-15 alphanumeric characters. ' + '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 @@ -48,10 +48,10 @@ class WhatsappChannel(Channel): @field_validator('from_') @classmethod def check_valid_sender(cls, v): - if type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{1,15}$', 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 1-15 alphanumeric characters. ' + '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 @@ -84,14 +84,6 @@ class VerifyRequest(BaseModel): 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 remove_fields_if_only_silent_auth(self): - if len(self.workflow) == 1 and isinstance(self.workflow[0], SilentAuthChannel): - self.locale = None - self.code_length = None - self.code = None - return self - @model_validator(mode='after') def check_silent_auth_first_if_present(self): if len(self.workflow) > 1: diff --git a/verify_v2/src/vonage_verify_v2/verify_v2.py b/verify_v2/src/vonage_verify_v2/verify_v2.py index 2e7380d7..472033a4 100644 --- a/verify_v2/src/vonage_verify_v2/verify_v2.py +++ b/verify_v2/src/vonage_verify_v2/verify_v2.py @@ -59,11 +59,13 @@ def cancel_verification(self, request_id: str) -> None: @validate_call def trigger_next_workflow(self, request_id: str) -> None: - """Trigger the next workflow event in the verification process. + """Trigger the next workflow event in the list of workflows passed in when making the + request. Args: request_id (str): The request ID. """ - response = self._http_client.post( - self._http_client.api_host, f'/v2/verify/{request_id}' + self._http_client.post( + self._http_client.api_host, + f'/v2/verify/{request_id}/next_workflow', ) diff --git a/verify_v2/tests/data/trigger_next_workflow_error.json b/verify_v2/tests/data/trigger_next_workflow_error.json new file mode 100644 index 00000000..befd87a7 --- /dev/null +++ b/verify_v2/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_v2/tests/test_models.py b/verify_v2/tests/test_models.py index f9877ef7..b1a3eb81 100644 --- a/verify_v2/tests/test_models.py +++ b/verify_v2/tests/test_models.py @@ -124,13 +124,6 @@ def test_create_verify_request(): assert verify_request.code_length == 6 assert verify_request.code == '123456' - # Silent auth only - params['workflow'] = [silent_auth_channel] - verify_request = VerifyRequest(**params) - assert verify_request.locale == None - assert verify_request.code_length == None - assert verify_request.code == None - def test_create_verify_request_error(): params = { diff --git a/verify_v2/tests/test_verify_v2.py b/verify_v2/tests/test_verify_v2.py index 3e1d57f8..57da7c89 100644 --- a/verify_v2/tests/test_verify_v2.py +++ b/verify_v2/tests/test_verify_v2.py @@ -67,7 +67,7 @@ def test_make_verify_request_full(): @responses.activate -def test_verify_request_error(): +def test_verify_request_concurrent_verifications_error(): build_response( path, 'POST', @@ -155,20 +155,30 @@ def test_cancel_verification(): @responses.activate def test_trigger_next_workflow(): - response = verify.trigger_next_event('c5037cb8b47449158ed6611afde58990') + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, + ) + assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 200 -# @responses.activate -# def test_trigger_next_event_error(): -# build_response( -# path, -# 'POST', -# 'https://api.nexmo.com/verify/control/json', -# 'trigger_next_event_error.json', -# ) +@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(VerifyError) as e: -# verify.trigger_next_event('2c021d25cf2e47a9b277a996f4325b81') + with raises(HttpRequestError) as e: + verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') -# assert e.match("'status': '19") -# assert e.match('No more events are left to execute for the request') + 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.' + ) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index ebeaef18..ddbf1d44 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,7 @@ +# 3.99.0a5 +- Add support for the [Vonage Verify V2 API](https://developer.vonage.com/en/verify/overview). +- Expose error classes at the top level of the `vonage-http-client` package. + # 3.99.0a4 - Add support for the [Vonage Verify API](https://developer.vonage.com/en/api/verify). - Add `last_request` and `last_response` properties to the HTTP Client. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 053f8839..86026806 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -7,11 +7,12 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.0.1", - "vonage-http-client>=1.2.0", + "vonage-http-client>=1.2.1", "vonage-number-insight-v2>=0.1.0", "vonage-sms>=1.0.2", "vonage-users>=1.0.1", - "vonage-verify>=1.0.0", + "vonage-verify>=1.0.1", + "vonage-verify-v2>=1.0.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 9233543a..29defcc4 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a4' +__version__ = '3.99.0a5' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 76eceeff..92b2d9a5 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -1,11 +1,10 @@ from typing import Optional -from vonage_http_client.auth import Auth -from vonage_http_client.http_client import HttpClient, HttpClientOptions -from vonage_number_insight_v2.number_insight_v2 import NumberInsightV2 -from vonage_sms.sms import Sms -from vonage_users.users import Users -from vonage_verify.verify import Verify +from vonage_http_client import Auth, HttpClient, HttpClientOptions +from vonage_number_insight_v2 import NumberInsightV2 +from vonage_sms import Sms +from vonage_users import Users +from vonage_verify import Verify from vonage_verify_v2 import VerifyV2 from ._version import __version__ From f22d90ba98bcc33360af7720baf8c1d76609de37 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 9 Apr 2024 05:23:21 +0100 Subject: [PATCH 29/98] create messages package --- messages/BUILD | 16 +++++ messages/CHANGES.md | 2 + messages/README.md | 10 +++ messages/pyproject.toml | 29 ++++++++ messages/src/vonage_messages/BUILD | 1 + messages/src/vonage_messages/__init__.py | 27 ++++++++ messages/src/vonage_messages/enums.py | 32 +++++++++ messages/src/vonage_messages/errors.py | 5 ++ messages/src/vonage_messages/messages.py | 36 ++++++++++ .../src/vonage_messages/models/message.py | 13 ++++ messages/src/vonage_messages/models/sms.py | 22 +++++++ messages/src/vonage_messages/responses.py | 7 ++ messages/tests/BUILD | 1 + messages/tests/test_models.py | 14 ++++ messages/tests/test_verify_v2.py | 66 +++++++++++++++++++ vonage/pyproject.toml | 1 + 16 files changed, 282 insertions(+) create mode 100644 messages/BUILD create mode 100644 messages/CHANGES.md create mode 100644 messages/README.md create mode 100644 messages/pyproject.toml create mode 100644 messages/src/vonage_messages/BUILD create mode 100644 messages/src/vonage_messages/__init__.py create mode 100644 messages/src/vonage_messages/enums.py create mode 100644 messages/src/vonage_messages/errors.py create mode 100644 messages/src/vonage_messages/messages.py create mode 100644 messages/src/vonage_messages/models/message.py create mode 100644 messages/src/vonage_messages/models/sms.py create mode 100644 messages/src/vonage_messages/responses.py create mode 100644 messages/tests/BUILD create mode 100644 messages/tests/test_models.py create mode 100644 messages/tests/test_verify_v2.py 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..be516a55 --- /dev/null +++ b/messages/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/messages/README.md b/messages/README.md new file mode 100644 index 00000000..5c3d16ab --- /dev/null +++ b/messages/README.md @@ -0,0 +1,10 @@ +# 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`. + +### Send a message + diff --git a/messages/pyproject.toml b/messages/pyproject.toml new file mode 100644 index 00000000..be11ac64 --- /dev/null +++ b/messages/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-messages' +version = '1.0.0' +description = 'Vonage messages package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.2.1", + "vonage-utils>=1.0.1", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..06fbfd9e --- /dev/null +++ b/messages/src/vonage_messages/__init__.py @@ -0,0 +1,27 @@ +# from .enums import ChannelType, Locale +# from .errors import VerifyError +# from .requests import ( +# EmailChannel, +# SilentAuthChannel, +# SmsChannel, +# VerifyRequest, +# VoiceChannel, +# WhatsappChannel, +# ) +# from .responses import CheckCodeResponse, StartVerificationResponse +# from .verify_v2 import VerifyV2 + +# __all__ = [ +# 'VerifyV2', +# 'VerifyError', +# 'ChannelType', +# 'CheckCodeResponse', +# 'Locale', +# 'VerifyRequest', +# 'SilentAuthChannel', +# 'SmsChannel', +# 'WhatsappChannel', +# 'VoiceChannel', +# 'EmailChannel', +# 'StartVerificationResponse', +# ] diff --git a/messages/src/vonage_messages/enums.py b/messages/src/vonage_messages/enums.py new file mode 100644 index 00000000..d43cbfc3 --- /dev/null +++ b/messages/src/vonage_messages/enums.py @@ -0,0 +1,32 @@ +from enum import Enum + + +class MessageType(str, Enum): + TEXT = 'text' + IMAGE = 'image' + AUDIO = 'audio' + VIDEO = 'video' + FILE = 'file' + TEMPLATE = 'template' + STICKER = 'sticker' + CUSTOM = 'custom' + VCARD = 'vcard' + + +class ChannelType(str, Enum): + SMS = 'sms' + MMS = 'mms' + WHATSAPP = 'whatsapp' + MESSENGER = 'messenger' + VIBER = 'viber_service' + + +class WebhookVersion(str, Enum): + V0_1 = 'v0.1' + V1 = 'v1' + + +class EncodingType(str, Enum): + TEXT = 'text' + UNICODE = 'unicode' + AUTO = 'auto' diff --git a/messages/src/vonage_messages/errors.py b/messages/src/vonage_messages/errors.py new file mode 100644 index 00000000..e3506baa --- /dev/null +++ b/messages/src/vonage_messages/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class MessagesError(VonageError): + """Indicates an error when using the Vonage Verify API.""" diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py new file mode 100644 index 00000000..34b0d314 --- /dev/null +++ b/messages/src/vonage_messages/messages.py @@ -0,0 +1,36 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .models.sms import BaseMessage +from .responses import MessageUuid + + +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 + + @validate_call + def send(self, message: BaseMessage) -> MessageUuid: + """Send a message using Vonage's Messages API. + + Args: + message (Message): The message to be sent. + + Returns: + MessageUuid: The unique identifier of the sent message. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v1/messages', + message.model_dump(by_alias=True, exclude_none=True), + ) + + return MessageUuid(**response) diff --git a/messages/src/vonage_messages/models/message.py b/messages/src/vonage_messages/models/message.py new file mode 100644 index 00000000..8c33e7f9 --- /dev/null +++ b/messages/src/vonage_messages/models/message.py @@ -0,0 +1,13 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_utils.types.phone_number import PhoneNumber + +from ..enums import WebhookVersion + + +class BaseMessage(BaseModel): + 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/sms.py b/messages/src/vonage_messages/models/sms.py new file mode 100644 index 00000000..b747b7ee --- /dev/null +++ b/messages/src/vonage_messages/models/sms.py @@ -0,0 +1,22 @@ +from typing import Optional, Union + +from pydantic import BaseModel, Field +from vonage_utils.types.phone_number import PhoneNumber + +from ..enums import ChannelType, EncodingType, MessageType +from .message import BaseMessage + + +class SmsOptions(BaseModel): + encoding_type: Optional[EncodingType] = None + content_id: Optional[str] = None + entity_id: Optional[str] = None + + +class Sms(BaseMessage): + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + text: str = Field(..., max_length=1000) + ttl: Optional[int] = None + sms: Optional[SmsOptions] = None + channel: ChannelType = ChannelType.SMS + message_type: MessageType = MessageType.TEXT diff --git a/messages/src/vonage_messages/responses.py b/messages/src/vonage_messages/responses.py new file mode 100644 index 00000000..06a3eace --- /dev/null +++ b/messages/src/vonage_messages/responses.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class MessageUuid(BaseModel): + """Response from Vonage's Messages API.""" + + message_uuid: str diff --git a/messages/tests/BUILD b/messages/tests/BUILD new file mode 100644 index 00000000..72fce921 --- /dev/null +++ b/messages/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['messages', 'testutils']) diff --git a/messages/tests/test_models.py b/messages/tests/test_models.py new file mode 100644 index 00000000..2ab6aee0 --- /dev/null +++ b/messages/tests/test_models.py @@ -0,0 +1,14 @@ +from vonage_verify_v2.enums import ChannelType +from vonage_verify_v2.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 diff --git a/messages/tests/test_verify_v2.py b/messages/tests/test_verify_v2.py new file mode 100644 index 00000000..c6e72a98 --- /dev/null +++ b/messages/tests/test_verify_v2.py @@ -0,0 +1,66 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_verify_v2.requests import * +from vonage_verify_v2.verify_v2 import VerifyV2 + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +verify = VerifyV2(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert ( + response.check_url + == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' + ) + assert verify._http_client.last_response.status_code == 202 + + +@responses.activate +def test_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' + ) diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 86026806..f024e28a 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "vonage-users>=1.0.1", "vonage-verify>=1.0.1", "vonage-verify-v2>=1.0.0", + "vonage-messages>=1.0.0", ] classifiers = [ "Programming Language :: Python", From 989be78e649323c47f05415fbaee43e1d1a7ad46 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 9 Apr 2024 16:16:56 +0100 Subject: [PATCH 30/98] create message models --- messages/src/vonage_messages/models/BUILD | 1 + .../src/vonage_messages/models/__init__.py | 92 +++++++++++++++++ .../src/vonage_messages/models/messenger.py | 53 ++++++++++ messages/src/vonage_messages/models/mms.py | 39 ++++++++ messages/src/vonage_messages/models/viber.py | 96 ++++++++++++++++++ .../src/vonage_messages/models/whatsapp.py | 98 +++++++++++++++++++ 6 files changed, 379 insertions(+) create mode 100644 messages/src/vonage_messages/models/BUILD create mode 100644 messages/src/vonage_messages/models/__init__.py create mode 100644 messages/src/vonage_messages/models/messenger.py create mode 100644 messages/src/vonage_messages/models/mms.py create mode 100644 messages/src/vonage_messages/models/viber.py create mode 100644 messages/src/vonage_messages/models/whatsapp.py diff --git a/messages/src/vonage_messages/models/BUILD b/messages/src/vonage_messages/models/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/messages/src/vonage_messages/models/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py new file mode 100644 index 00000000..b9f1eb21 --- /dev/null +++ b/messages/src/vonage_messages/models/__init__.py @@ -0,0 +1,92 @@ +from .message import BaseMessage +from .messenger import ( + MessengerAudio, + MessengerFile, + MessengerImage, + MessengerOptions, + MessengerResource, + MessengerText, + MessengerVideo, +) +from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from .sms import Sms, SmsOptions +from .viber import ( + ViberAction, + ViberFile, + ViberFileOptions, + ViberFileResource, + ViberImage, + ViberImageOptions, + ViberImageResource, + ViberText, + ViberTextOptions, + ViberVideo, + ViberVideoOptions, + ViberVideoResource, +) +from .whatsapp import ( + WhatsappAudio, + WhatsappAudioResource, + WhatsappContext, + WhatsappCustom, + WhatsappFile, + WhatsappFileResource, + WhatsappImage, + WhatsappImageResource, + WhatsappSticker, + WhatsappStickerId, + WhatsappStickerUrl, + WhatsappTemplate, + WhatsappTemplateResource, + WhatsappTemplateSettings, + WhatsappText, + WhatsappVideo, + WhatsappVideoResource, +) + +__all__ = [ + 'BaseMessage', + 'MessengerAudio', + 'MessengerFile', + 'MessengerImage', + 'MessengerOptions', + 'MessengerResource', + 'MessengerText', + 'MessengerVideo', + 'MmsAudio', + 'MmsImage', + 'MmsResource', + 'MmsVcard', + 'MmsVideo', + 'Sms', + 'SmsOptions', + 'ViberAction', + 'ViberFile', + 'ViberFileOptions', + 'ViberFileResource', + 'ViberImage', + 'ViberImageOptions', + 'ViberImageResource', + 'ViberText', + 'ViberTextOptions', + 'ViberVideo', + 'ViberVideoOptions', + 'ViberVideoResource', + '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/messenger.py b/messages/src/vonage_messages/models/messenger.py new file mode 100644 index 00000000..59d9e52b --- /dev/null +++ b/messages/src/vonage_messages/models/messenger.py @@ -0,0 +1,53 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator + +from ..enums import ChannelType, MessageType +from .message import BaseMessage + + +class MessengerResource(BaseModel): + url: str + + +class MessengerOptions(BaseModel): + 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): + 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): + text: str = Field(..., max_length=640) + type: MessageType = MessageType.TEXT + + +class MessengerImage(BaseMessenger): + image: MessengerResource + type: MessageType = MessageType.IMAGE + + +class MessengerAudio(BaseMessenger): + audio: MessengerResource + type: MessageType = MessageType.AUDIO + + +class MessengerVideo(BaseMessenger): + video: MessengerResource + type: MessageType = MessageType.VIDEO + + +class MessengerFile(BaseMessenger): + file: MessengerResource + 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..0f5709a7 --- /dev/null +++ b/messages/src/vonage_messages/models/mms.py @@ -0,0 +1,39 @@ +from typing import Optional, Union + +from pydantic import BaseModel, Field +from vonage_utils.types.phone_number import PhoneNumber + +from ..enums import ChannelType, MessageType +from .message import BaseMessage + + +class MmsResource(BaseModel): + url: str + caption: Optional[str] = Field(None, min_length=1, max_length=2000) + + +class BaseMms(BaseMessage): + to: PhoneNumber + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + ttl: Optional[int] = Field(None, ge=300, le=259200) + channel: ChannelType = ChannelType.MMS + + +class MmsImage(BaseMms): + image: MmsResource + message_type: MessageType = MessageType.IMAGE + + +class MmsVcard(BaseMms): + vcard: MmsResource + message_type: MessageType = MessageType.VCARD + + +class MmsAudio(BaseMms): + audio: MmsResource + message_type: MessageType = MessageType.AUDIO + + +class MmsVideo(BaseMms): + video: MmsResource + message_type: MessageType = MessageType.VIDEO diff --git a/messages/src/vonage_messages/models/viber.py b/messages/src/vonage_messages/models/viber.py new file mode 100644 index 00000000..85199bed --- /dev/null +++ b/messages/src/vonage_messages/models/viber.py @@ -0,0 +1,96 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field, field_validator + +from ..enums import ChannelType, MessageType +from .message import BaseMessage + + +class ViberAction(BaseModel): + url: str + text: str = Field(..., max_length=30) + + +class ViberOptions(BaseModel): + category: Literal['transaction', 'promotion'] = None + ttl: Optional[int] = Field(None, ge=30, le=259200) + type: Optional[Literal['string', 'template']] = None + + +class BaseViber(BaseMessage): + from_: str = Field(..., min_length=1, max_length=50, serialization_alias='from') + viber_service: Optional[ViberOptions] = None + channel: ChannelType = ChannelType.VIBER + + +class ViberTextOptions(ViberOptions): + action: Optional[ViberAction] = None + + +class ViberText(BaseViber): + text: str = Field(..., max_length=1000) + viber_service: Optional[ViberTextOptions] = None + message_type: MessageType = MessageType.TEXT + + +class ViberImageResource(BaseModel): + url: str + caption: Optional[str] = None + + +class ViberImageOptions(ViberOptions): + action: Optional[ViberAction] = None + + +class ViberImage(BaseViber): + image: ViberImageResource + viber_service: Optional[ViberImageOptions] = None + message_type: MessageType = MessageType.IMAGE + + +class ViberVideoResource(BaseModel): + url: str + thumb_url: str = Field(..., max_length=1000) + caption: Optional[str] = Field(None, max_length=1000) + + +class ViberVideoOptions(ViberOptions): + 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): + video: ViberVideoResource + viber_service: Optional[ViberVideoOptions] = None + message_type: MessageType = MessageType.VIDEO + + +class ViberFileResource(BaseModel): + url: str + name: Optional[str] = Field(None, max_length=25) + + +class ViberFileOptions(ViberOptions): + pass + + +class ViberFile(BaseViber): + 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..4e9cc153 --- /dev/null +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -0,0 +1,98 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from vonage_utils.types.phone_number import PhoneNumber + +from ..enums import ChannelType, MessageType +from .message import BaseMessage + + +class WhatsappContext(BaseModel): + message_uuid: str + + +class BaseWhatsapp(BaseMessage): + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + context: WhatsappContext + channel: ChannelType = ChannelType.WHATSAPP + + +class WhatsappText(BaseWhatsapp): + text: str = Field(..., max_length=4096) + type: MessageType = MessageType.TEXT + + +class WhatsappImageResource(BaseModel): + url: str + caption: Optional[str] = Field(None, min_length=1, max_length=3000) + + +class WhatsappImage(BaseWhatsapp): + image: WhatsappImageResource + type: MessageType = MessageType.IMAGE + + +class WhatsappAudioResource(BaseModel): + url: str = Field(..., min_length=10, max_length=2000) + + +class WhatsappAudio(BaseWhatsapp): + audio: WhatsappAudioResource + type: MessageType = MessageType.AUDIO + + +class WhatsappVideoResource(BaseModel): + url: str + caption: Optional[str] = None + + +class WhatsappVideo(BaseWhatsapp): + video: WhatsappVideoResource + type: MessageType = MessageType.VIDEO + + +class WhatsappFileResource(BaseModel): + url: str + caption: Optional[str] = None + name: Optional[str] = None + + +class WhatsappFile(BaseWhatsapp): + file: WhatsappFileResource + type: MessageType = MessageType.FILE + + +class WhatsappTemplateResource(BaseModel): + name: str + parameters: Optional[list] = None + + model_config = ConfigDict(extra='allow') + + +class WhatsappTemplateSettings(BaseModel): + locale: str = 'en_US' + policy: Optional[Literal['deterministic']] = None + + +class WhatsappTemplate(BaseWhatsapp): + template: WhatsappTemplateResource + whatsapp: WhatsappTemplateSettings + type: MessageType = MessageType.TEMPLATE + + +class WhatsappStickerUrl(BaseModel): + url: str + + +class WhatsappStickerId(BaseModel): + id: str + + +class WhatsappSticker(BaseWhatsapp): + sticker: Union[WhatsappStickerUrl, WhatsappStickerId] + type: MessageType = MessageType.STICKER + + +class WhatsappCustom(BaseWhatsapp): + custom: Optional[dict] = None + type: MessageType = MessageType.CUSTOM From 847df229716ae4044eddc27ed0997fa5c9ba6de1 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 10 Apr 2024 02:49:46 +0100 Subject: [PATCH 31/98] test message models --- messages/src/vonage_messages/__init__.py | 37 +- messages/src/vonage_messages/errors.py | 5 - messages/src/vonage_messages/messages.py | 4 +- messages/src/vonage_messages/models/BUILD | 2 +- .../src/vonage_messages/models/__init__.py | 2 +- .../models/{message.py => base_message.py} | 0 .../src/vonage_messages/models/messenger.py | 12 +- messages/src/vonage_messages/models/mms.py | 2 +- messages/src/vonage_messages/models/sms.py | 2 +- messages/src/vonage_messages/models/viber.py | 4 +- .../src/vonage_messages/models/whatsapp.py | 28 +- messages/tests/BUILD | 4 + .../{test_verify_v2.py => _test_verify_v2.py} | 0 messages/tests/test_messenger_models.py | 224 ++++++++++ messages/tests/test_mms_models.py | 210 ++++++++++ messages/tests/test_models.py | 14 - messages/tests/test_sms_models.py | 54 +++ messages/tests/test_viber_models.py | 243 +++++++++++ messages/tests/test_whatsapp_models.py | 384 ++++++++++++++++++ pants.toml | 3 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + 22 files changed, 1165 insertions(+), 73 deletions(-) delete mode 100644 messages/src/vonage_messages/errors.py rename messages/src/vonage_messages/models/{message.py => base_message.py} (100%) rename messages/tests/{test_verify_v2.py => _test_verify_v2.py} (100%) create mode 100644 messages/tests/test_messenger_models.py create mode 100644 messages/tests/test_mms_models.py delete mode 100644 messages/tests/test_models.py create mode 100644 messages/tests/test_sms_models.py create mode 100644 messages/tests/test_viber_models.py create mode 100644 messages/tests/test_whatsapp_models.py diff --git a/messages/src/vonage_messages/__init__.py b/messages/src/vonage_messages/__init__.py index 06fbfd9e..e1aa8216 100644 --- a/messages/src/vonage_messages/__init__.py +++ b/messages/src/vonage_messages/__init__.py @@ -1,27 +1,12 @@ -# from .enums import ChannelType, Locale -# from .errors import VerifyError -# from .requests import ( -# EmailChannel, -# SilentAuthChannel, -# SmsChannel, -# VerifyRequest, -# VoiceChannel, -# WhatsappChannel, -# ) -# from .responses import CheckCodeResponse, StartVerificationResponse -# from .verify_v2 import VerifyV2 +from . import models +from .enums import ChannelType, EncodingType, MessageType, WebhookVersion +from .messages import Messages -# __all__ = [ -# 'VerifyV2', -# 'VerifyError', -# 'ChannelType', -# 'CheckCodeResponse', -# 'Locale', -# 'VerifyRequest', -# 'SilentAuthChannel', -# 'SmsChannel', -# 'WhatsappChannel', -# 'VoiceChannel', -# 'EmailChannel', -# 'StartVerificationResponse', -# ] +__all__ = [ + 'models', + 'Messages', + 'ChannelType', + 'MessageType', + 'WebhookVersion', + 'EncodingType', +] diff --git a/messages/src/vonage_messages/errors.py b/messages/src/vonage_messages/errors.py deleted file mode 100644 index e3506baa..00000000 --- a/messages/src/vonage_messages/errors.py +++ /dev/null @@ -1,5 +0,0 @@ -from vonage_utils.errors import VonageError - - -class MessagesError(VonageError): - """Indicates an error when using the Vonage Verify API.""" diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 34b0d314..98ce25c0 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -1,7 +1,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .models.sms import BaseMessage +from .models import BaseMessage from .responses import MessageUuid @@ -22,7 +22,7 @@ def send(self, message: BaseMessage) -> MessageUuid: """Send a message using Vonage's Messages API. Args: - message (Message): The message to be sent. + message (BaseMessage): The message to be sent. Returns: MessageUuid: The unique identifier of the sent message. diff --git a/messages/src/vonage_messages/models/BUILD b/messages/src/vonage_messages/models/BUILD index db46e8d6..62f5decc 100644 --- a/messages/src/vonage_messages/models/BUILD +++ b/messages/src/vonage_messages/models/BUILD @@ -1 +1 @@ -python_sources() +python_sources(name='models') diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index b9f1eb21..75a192be 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -1,4 +1,4 @@ -from .message import BaseMessage +from .base_message import BaseMessage from .messenger import ( MessengerAudio, MessengerFile, diff --git a/messages/src/vonage_messages/models/message.py b/messages/src/vonage_messages/models/base_message.py similarity index 100% rename from messages/src/vonage_messages/models/message.py rename to messages/src/vonage_messages/models/base_message.py diff --git a/messages/src/vonage_messages/models/messenger.py b/messages/src/vonage_messages/models/messenger.py index 59d9e52b..465d1d3d 100644 --- a/messages/src/vonage_messages/models/messenger.py +++ b/messages/src/vonage_messages/models/messenger.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, model_validator from ..enums import ChannelType, MessageType -from .message import BaseMessage +from .base_message import BaseMessage class MessengerResource(BaseModel): @@ -30,24 +30,24 @@ class BaseMessenger(BaseMessage): class MessengerText(BaseMessenger): text: str = Field(..., max_length=640) - type: MessageType = MessageType.TEXT + message_type: MessageType = MessageType.TEXT class MessengerImage(BaseMessenger): image: MessengerResource - type: MessageType = MessageType.IMAGE + message_type: MessageType = MessageType.IMAGE class MessengerAudio(BaseMessenger): audio: MessengerResource - type: MessageType = MessageType.AUDIO + message_type: MessageType = MessageType.AUDIO class MessengerVideo(BaseMessenger): video: MessengerResource - type: MessageType = MessageType.VIDEO + message_type: MessageType = MessageType.VIDEO class MessengerFile(BaseMessenger): file: MessengerResource - type: MessageType = MessageType.FILE + message_type: MessageType = MessageType.FILE diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index 0f5709a7..b72c5ac8 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -4,7 +4,7 @@ from vonage_utils.types.phone_number import PhoneNumber from ..enums import ChannelType, MessageType -from .message import BaseMessage +from .base_message import BaseMessage class MmsResource(BaseModel): diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index b747b7ee..01bc453a 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -4,7 +4,7 @@ from vonage_utils.types.phone_number import PhoneNumber from ..enums import ChannelType, EncodingType, MessageType -from .message import BaseMessage +from .base_message import BaseMessage class SmsOptions(BaseModel): diff --git a/messages/src/vonage_messages/models/viber.py b/messages/src/vonage_messages/models/viber.py index 85199bed..49239bc5 100644 --- a/messages/src/vonage_messages/models/viber.py +++ b/messages/src/vonage_messages/models/viber.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, field_validator from ..enums import ChannelType, MessageType -from .message import BaseMessage +from .base_message import BaseMessage class ViberAction(BaseModel): @@ -77,7 +77,7 @@ def validate_file_size(cls, value): class ViberVideo(BaseViber): video: ViberVideoResource - viber_service: Optional[ViberVideoOptions] = None + viber_service: ViberVideoOptions message_type: MessageType = MessageType.VIDEO diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index 4e9cc153..b94dd75d 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -1,10 +1,10 @@ -from typing import Literal, Optional, Union +from typing import List, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field from vonage_utils.types.phone_number import PhoneNumber from ..enums import ChannelType, MessageType -from .message import BaseMessage +from .base_message import BaseMessage class WhatsappContext(BaseModel): @@ -13,13 +13,13 @@ class WhatsappContext(BaseModel): class BaseWhatsapp(BaseMessage): from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') - context: WhatsappContext + context: Optional[WhatsappContext] = None channel: ChannelType = ChannelType.WHATSAPP class WhatsappText(BaseWhatsapp): text: str = Field(..., max_length=4096) - type: MessageType = MessageType.TEXT + message_type: MessageType = MessageType.TEXT class WhatsappImageResource(BaseModel): @@ -29,7 +29,7 @@ class WhatsappImageResource(BaseModel): class WhatsappImage(BaseWhatsapp): image: WhatsappImageResource - type: MessageType = MessageType.IMAGE + message_type: MessageType = MessageType.IMAGE class WhatsappAudioResource(BaseModel): @@ -38,7 +38,7 @@ class WhatsappAudioResource(BaseModel): class WhatsappAudio(BaseWhatsapp): audio: WhatsappAudioResource - type: MessageType = MessageType.AUDIO + message_type: MessageType = MessageType.AUDIO class WhatsappVideoResource(BaseModel): @@ -48,7 +48,7 @@ class WhatsappVideoResource(BaseModel): class WhatsappVideo(BaseWhatsapp): video: WhatsappVideoResource - type: MessageType = MessageType.VIDEO + message_type: MessageType = MessageType.VIDEO class WhatsappFileResource(BaseModel): @@ -59,25 +59,25 @@ class WhatsappFileResource(BaseModel): class WhatsappFile(BaseWhatsapp): file: WhatsappFileResource - type: MessageType = MessageType.FILE + message_type: MessageType = MessageType.FILE class WhatsappTemplateResource(BaseModel): name: str - parameters: Optional[list] = None + parameters: Optional[List[str]] = None model_config = ConfigDict(extra='allow') class WhatsappTemplateSettings(BaseModel): - locale: str = 'en_US' + locale: Optional[str] = 'en_US' policy: Optional[Literal['deterministic']] = None class WhatsappTemplate(BaseWhatsapp): template: WhatsappTemplateResource - whatsapp: WhatsappTemplateSettings - type: MessageType = MessageType.TEMPLATE + whatsapp: WhatsappTemplateSettings = WhatsappTemplateSettings() + message_type: MessageType = MessageType.TEMPLATE class WhatsappStickerUrl(BaseModel): @@ -90,9 +90,9 @@ class WhatsappStickerId(BaseModel): class WhatsappSticker(BaseWhatsapp): sticker: Union[WhatsappStickerUrl, WhatsappStickerId] - type: MessageType = MessageType.STICKER + message_type: MessageType = MessageType.STICKER class WhatsappCustom(BaseWhatsapp): custom: Optional[dict] = None - type: MessageType = MessageType.CUSTOM + message_type: MessageType = MessageType.CUSTOM diff --git a/messages/tests/BUILD b/messages/tests/BUILD index 72fce921..ea1c26ad 100644 --- a/messages/tests/BUILD +++ b/messages/tests/BUILD @@ -1 +1,5 @@ python_tests(dependencies=['messages', 'testutils']) + +python_sources( + name="tests0", +) diff --git a/messages/tests/test_verify_v2.py b/messages/tests/_test_verify_v2.py similarity index 100% rename from messages/tests/test_verify_v2.py rename to messages/tests/_test_verify_v2.py diff --git a/messages/tests/test_messenger_models.py b/messages/tests/test_messenger_models.py new file mode 100644 index 00000000..e3dc141d --- /dev/null +++ b/messages/tests/test_messenger_models.py @@ -0,0 +1,224 @@ +from pytest import raises +from vonage_messages.enums import WebhookVersion +from vonage_messages.models import ( + MessengerAudio, + MessengerFile, + MessengerImage, + MessengerOptions, + MessengerResource, + MessengerText, + MessengerVideo, +) + + +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..4bf75979 --- /dev/null +++ b/messages/tests/test_mms_models.py @@ -0,0 +1,210 @@ +from vonage_messages.enums import WebhookVersion +from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo + + +def test_create_mms_image(): + mms_model = MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource( + url='https://example.com/image.jpg', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': { + 'url': 'https://example.com/image.jpg', + }, + 'channel': 'mms', + 'message_type': 'image', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_image_all_fields(): + mms_model = MmsImage( + to='1234567890', + from_='1234567890', + image=MmsResource( + url='https://example.com/image.jpg', + caption='Image caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': { + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'image', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_vcard(): + mms_model = MmsVcard( + to='1234567890', + from_='1234567890', + vcard=MmsResource( + url='https://example.com/vcard.vcf', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'vcard': { + 'url': 'https://example.com/vcard.vcf', + }, + 'channel': 'mms', + 'message_type': 'vcard', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_vcard_all_fields(): + mms_model = MmsVcard( + to='1234567890', + from_='1234567890', + vcard=MmsResource( + url='https://example.com/vcard.vcf', + caption='Vcard caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'vcard': { + 'url': 'https://example.com/vcard.vcf', + 'caption': 'Vcard caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'vcard', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_audio(): + mms_model = MmsAudio( + to='1234567890', + from_='1234567890', + audio=MmsResource( + url='https://example.com/audio.mp3', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': { + 'url': 'https://example.com/audio.mp3', + }, + 'channel': 'mms', + 'message_type': 'audio', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_audio_all_fields(): + mms_model = MmsAudio( + to='1234567890', + from_='1234567890', + audio=MmsResource( + url='https://example.com/audio.mp3', + caption='Audio caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': { + 'url': 'https://example.com/audio.mp3', + 'caption': 'Audio caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'audio', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict + + +def test_create_mms_video(): + mms_model = MmsVideo( + to='1234567890', + from_='1234567890', + video=MmsResource( + url='https://example.com/video.mp4', + ), + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + }, + 'channel': 'mms', + 'message_type': 'video', + } + + assert mms_model.model_dump(by_alias=True, exclude_none=True) == mms_dict + + +def test_create_mms_video_all_fields(): + mms_model = MmsVideo( + to='1234567890', + from_='1234567890', + video=MmsResource( + url='https://example.com/video.mp4', + caption='Video caption', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + mms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'mms', + 'message_type': 'video', + } + + assert mms_model.model_dump(by_alias=True) == mms_dict diff --git a/messages/tests/test_models.py b/messages/tests/test_models.py deleted file mode 100644 index 2ab6aee0..00000000 --- a/messages/tests/test_models.py +++ /dev/null @@ -1,14 +0,0 @@ -from vonage_verify_v2.enums import ChannelType -from vonage_verify_v2.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 diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py new file mode 100644 index 00000000..ce5012fe --- /dev/null +++ b/messages/tests/test_sms_models.py @@ -0,0 +1,54 @@ +from vonage_messages.enums import EncodingType, WebhookVersion +from vonage_messages.models import Sms, SmsOptions + + +def test_create_sms(): + sms_model = Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ) + sms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'channel': 'sms', + 'message_type': 'text', + } + + assert sms_model.model_dump(by_alias=True, exclude_none=True) == sms_dict + + +def test_create_sms_all_fields(): + sms_model = Sms( + to='1234567890', + from_='1234567890', + text='Hello, World!', + sms=SmsOptions( + encoding_type=EncodingType.TEXT, + content_id='content-id', + entity_id='entity-id', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ttl=600, + ) + sms_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'sms': { + 'encoding_type': 'text', + 'content_id': 'content-id', + 'entity_id': 'entity-id', + }, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'ttl': 600, + 'channel': 'sms', + 'message_type': 'text', + } + + assert sms_model.model_dump(by_alias=True) == sms_dict diff --git a/messages/tests/test_viber_models.py b/messages/tests/test_viber_models.py new file mode 100644 index 00000000..f2fa7476 --- /dev/null +++ b/messages/tests/test_viber_models.py @@ -0,0 +1,243 @@ +from pytest import raises +from vonage_messages.enums import WebhookVersion +from vonage_messages.models import ( + ViberAction, + ViberFile, + ViberFileOptions, + ViberFileResource, + ViberImage, + ViberImageOptions, + ViberImageResource, + ViberText, + ViberTextOptions, + ViberVideo, + ViberVideoOptions, + ViberVideoResource, +) + + +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..6a47c5c5 --- /dev/null +++ b/messages/tests/test_whatsapp_models.py @@ -0,0 +1,384 @@ +from copy import deepcopy + +from vonage_messages.enums import WebhookVersion +from vonage_messages.models import ( + WhatsappAudio, + WhatsappAudioResource, + WhatsappContext, + WhatsappCustom, + WhatsappFile, + WhatsappFileResource, + WhatsappImage, + WhatsappImageResource, + WhatsappSticker, + WhatsappStickerId, + WhatsappStickerUrl, + WhatsappTemplate, + WhatsappTemplateResource, + WhatsappTemplateSettings, + WhatsappText, + WhatsappVideo, + WhatsappVideoResource, +) + + +def test_whatsapp_text(): + whatsapp_model = WhatsappText( + to='1234567890', + from_='1234567890', + text='Hello, World!', + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'channel': 'whatsapp', + 'message_type': 'text', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_text_all_fields(): + whatsapp_model = WhatsappText( + to='1234567890', + from_='1234567890', + text='Hello, World!', + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'text': 'Hello, World!', + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'text', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + whatsapp_pre_dict = deepcopy(whatsapp_dict) + whatsapp_pre_dict['from_'] = '1234567890' + whatsapp_model_from_dict = WhatsappText(**whatsapp_pre_dict) + assert whatsapp_model_from_dict.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_image(): + whatsapp_model = WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource(url='https://example.com/image.jpg'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': {'url': 'https://example.com/image.jpg'}, + 'channel': 'whatsapp', + 'message_type': 'image', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_image_all_fields(): + whatsapp_model = WhatsappImage( + to='1234567890', + from_='1234567890', + image=WhatsappImageResource( + url='https://example.com/image.jpg', + caption='Image caption', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'image': { + 'url': 'https://example.com/image.jpg', + 'caption': 'Image caption', + }, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'image', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_audio(): + whatsapp_model = WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource(url='https://example.com/audio.mp3'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': {'url': 'https://example.com/audio.mp3'}, + 'channel': 'whatsapp', + 'message_type': 'audio', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_audio_all_fields(): + whatsapp_model = WhatsappAudio( + to='1234567890', + from_='1234567890', + audio=WhatsappAudioResource( + url='https://example.com/audio.mp3', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'audio': {'url': 'https://example.com/audio.mp3'}, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'audio', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_video(): + whatsapp_model = WhatsappVideo( + to='1234567890', + from_='1234567890', + video=WhatsappVideoResource(url='https://example.com/video.mp4'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': {'url': 'https://example.com/video.mp4'}, + 'channel': 'whatsapp', + 'message_type': 'video', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_video_all_fields(): + whatsapp_model = WhatsappVideo( + to='1234567890', + from_='1234567890', + video=WhatsappVideoResource( + url='https://example.com/video.mp4', + caption='Video caption', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'video': { + 'url': 'https://example.com/video.mp4', + 'caption': 'Video caption', + }, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'video', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_file(): + whatsapp_model = WhatsappFile( + to='1234567890', + from_='1234567890', + file=WhatsappFileResource(url='https://example.com/file.pdf'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': {'url': 'https://example.com/file.pdf'}, + 'channel': 'whatsapp', + 'message_type': 'file', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_file_all_fields(): + whatsapp_model = WhatsappFile( + to='1234567890', + from_='1234567890', + file=WhatsappFileResource( + url='https://example.com/file.pdf', + caption='File caption', + name='file.pdf', + ), + context=WhatsappContext( + message_uuid='uuid', + ), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'file': { + 'url': 'https://example.com/file.pdf', + 'caption': 'File caption', + 'name': 'file.pdf', + }, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'file', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_template(): + whatsapp_model = WhatsappTemplate( + to='1234567890', + from_='1234567890', + template=WhatsappTemplateResource(name='template'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'template': {'name': 'template'}, + 'whatsapp': {'locale': 'en_US'}, + 'channel': 'whatsapp', + 'message_type': 'template', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_template_all_fields(): + whatsapp_model = WhatsappTemplate( + to='1234567890', + from_='1234567890', + template=WhatsappTemplateResource( + name='template', parameters=['param1', 'param2'] + ), + whatsapp=WhatsappTemplateSettings(locale='es_ES', policy='deterministic'), + context=WhatsappContext(message_uuid='uuid'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'template': {'name': 'template', 'parameters': ['param1', 'param2']}, + 'whatsapp': {'locale': 'es_ES', 'policy': 'deterministic'}, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'template', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict + + +def test_whatsapp_sticker_url(): + whatsapp_model = WhatsappSticker( + to='1234567890', + from_='1234567890', + sticker=WhatsappStickerUrl(url='https://example.com/sticker.webp'), + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'sticker': {'url': 'https://example.com/sticker.webp'}, + 'channel': 'whatsapp', + 'message_type': 'sticker', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_sticker_id(): + whatsapp_model = WhatsappSticker( + to='1234567890', from_='1234567890', sticker=WhatsappStickerId(id='sticker-id') + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'sticker': {'id': 'sticker-id'}, + 'channel': 'whatsapp', + 'message_type': 'sticker', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_custom(): + whatsapp_model = WhatsappCustom(to='1234567890', from_='1234567890') + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'channel': 'whatsapp', + 'message_type': 'custom', + } + + assert whatsapp_model.model_dump(by_alias=True, exclude_none=True) == whatsapp_dict + + +def test_whatsapp_custom_all_fields(): + whatsapp_model = WhatsappCustom( + to='1234567890', + from_='1234567890', + custom={'key': 'value'}, + context=WhatsappContext(message_uuid='uuid'), + client_ref='client-ref', + webhook_url='https://example.com', + webhook_version=WebhookVersion.V1, + ) + whatsapp_dict = { + 'to': '1234567890', + 'from': '1234567890', + 'custom': {'key': 'value'}, + 'context': {'message_uuid': 'uuid'}, + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'webhook_version': 'v1', + 'channel': 'whatsapp', + 'message_type': 'custom', + } + + assert whatsapp_model.model_dump(by_alias=True) == whatsapp_dict diff --git a/pants.toml b/pants.toml index a9c671a5..15e17b66 100644 --- a/pants.toml +++ b/pants.toml @@ -32,12 +32,15 @@ report = ['html', 'console'] filter = [ 'vonage/src', 'http_client/src', + 'messages/src', 'number_insight_v2/src', 'sms/src', 'users/src', 'utils/src', 'testutils', 'verify/src', + 'verify_v2/src', + 'vonage_utils/src', ] [black] diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 62747843..4005985f 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -3,6 +3,7 @@ from .vonage import ( Auth, HttpClientOptions, + Messages, NumberInsightV2, Sms, Users, @@ -15,6 +16,7 @@ 'Vonage', 'Auth', 'HttpClientOptions', + 'Messages', 'NumberInsightV2', 'Sms', 'Users', diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 92b2d9a5..e8d18a7f 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -1,6 +1,7 @@ from typing import Optional from vonage_http_client import Auth, HttpClient, HttpClientOptions +from vonage_messages import Messages from vonage_number_insight_v2 import NumberInsightV2 from vonage_sms import Sms from vonage_users import Users @@ -28,6 +29,7 @@ def __init__( ): self._http_client = HttpClient(auth, http_client_options, __version__) + self.messages = Messages(self._http_client) self.number_insight_v2 = NumberInsightV2(self._http_client) self.sms = Sms(self._http_client) self.users = Users(self._http_client) From c45389b0c7d53ef22845a8518bdd066832f397ff Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 10 Apr 2024 21:26:52 +0100 Subject: [PATCH 32/98] prepare for messages release --- .../src/vonage_http_client/http_client.py | 2 - messages/README.md | 67 ++++++++++++++++ messages/src/vonage_messages/__init__.py | 11 +-- messages/src/vonage_messages/messages.py | 15 ++-- .../src/vonage_messages/models/__init__.py | 5 ++ .../vonage_messages/models/base_message.py | 2 +- .../src/vonage_messages/{ => models}/enums.py | 0 .../src/vonage_messages/models/messenger.py | 2 +- messages/src/vonage_messages/models/mms.py | 2 +- messages/src/vonage_messages/models/sms.py | 2 +- messages/src/vonage_messages/models/viber.py | 2 +- .../src/vonage_messages/models/whatsapp.py | 2 +- messages/src/vonage_messages/responses.py | 8 +- messages/tests/_test_verify_v2.py | 66 ---------------- messages/tests/data/invalid_error.json | 12 +++ messages/tests/data/low_balance_error.json | 6 ++ messages/tests/data/send_message.json | 3 + messages/tests/test_messages.py | 77 +++++++++++++++++++ messages/tests/test_messenger_models.py | 2 +- messages/tests/test_mms_models.py | 2 +- messages/tests/test_sms_models.py | 2 +- messages/tests/test_viber_models.py | 2 +- messages/tests/test_whatsapp_models.py | 2 +- users/src/vonage_users/common.py | 9 +-- vonage/CHANGES.md | 3 + vonage/pyproject.toml | 2 +- vonage/src/vonage/_version.py | 2 +- 27 files changed, 206 insertions(+), 104 deletions(-) rename messages/src/vonage_messages/{ => models}/enums.py (100%) delete mode 100644 messages/tests/_test_verify_v2.py create mode 100644 messages/tests/data/invalid_error.json create mode 100644 messages/tests/data/low_balance_error.json create mode 100644 messages/tests/data/send_message.json create mode 100644 messages/tests/test_messages.py diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 1f781d3c..c8df1430 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -225,8 +225,6 @@ def _parse_response(self, response: Response) -> Union[dict, None]: ) self._last_response = response if 200 <= response.status_code < 300: - if response.status_code == 204: - return None try: return response.json() except JSONDecodeError: diff --git a/messages/README.md b/messages/README.md index 5c3d16ab..e25c0985 100644 --- a/messages/README.md +++ b/messages/README.md @@ -6,5 +6,72 @@ This package contains the code to use [Vonage's Messages API](https://developer. It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. +### How to Construct a Message + +In order to send a message, you must construct a message object of the correct type. These are all found under `vonage_messages.models`. + +```python +from vonage_messages.models import Sms + +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) +``` + +This message can now be sent with + +```python +vonage_client.messages.send(message) +``` + +All possible message types from every message channel have their own message model. They are named following this rule: {Channel}{MessageType}, e.g. `Sms`, `MmsImage`, `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.models import Sms + +vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) + +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) + +vonage_client.messages.send(message) +``` + +## 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 +WhatsappText, WhatsappImage, WhatsappAudio, WhatsappVideo, WhatsappFile, WhatsappTemplate, WhatsappSticker, WhatsappCustom +MessengerText, MessengerImage, MessengerAudio, MessengerVideo, MessengerFile +ViberText, ViberImage, ViberVideo, ViberFile +``` \ No newline at end of file diff --git a/messages/src/vonage_messages/__init__.py b/messages/src/vonage_messages/__init__.py index e1aa8216..11000717 100644 --- a/messages/src/vonage_messages/__init__.py +++ b/messages/src/vonage_messages/__init__.py @@ -1,12 +1,5 @@ from . import models -from .enums import ChannelType, EncodingType, MessageType, WebhookVersion from .messages import Messages +from .responses import SendMessageResponse -__all__ = [ - 'models', - 'Messages', - 'ChannelType', - 'MessageType', - 'WebhookVersion', - 'EncodingType', -] +__all__ = ['models', 'Messages', 'SendMessageResponse'] diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 98ce25c0..e1d32d35 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -2,7 +2,7 @@ from vonage_http_client.http_client import HttpClient from .models import BaseMessage -from .responses import MessageUuid +from .responses import SendMessageResponse class Messages: @@ -18,19 +18,20 @@ def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client @validate_call - def send(self, message: BaseMessage) -> MessageUuid: + def send(self, message: BaseMessage) -> SendMessageResponse: """Send a message using Vonage's Messages API. Args: - message (BaseMessage): The message to be sent. + message (BaseMessage): The message to be sent as a Pydantic model. + Use the provided models (in `vonage_messages.models`) to create messages and pass them in to this method. Returns: - MessageUuid: The unique identifier of the sent message. + SendMessageResponse: Response model containing the unique identifier of the sent message. + Access the identifier with the `message_uuid` attribute. """ response = self._http_client.post( self._http_client.api_host, '/v1/messages', - message.model_dump(by_alias=True, exclude_none=True), + message.model_dump(by_alias=True, exclude_none=True) or message, ) - - return MessageUuid(**response) + return SendMessageResponse(**response) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 75a192be..92b5b84c 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -1,4 +1,5 @@ from .base_message import BaseMessage +from .enums import ChannelType, EncodingType, MessageType, WebhookVersion from .messenger import ( MessengerAudio, MessengerFile, @@ -46,6 +47,9 @@ __all__ = [ 'BaseMessage', + 'ChannelType', + 'EncodingType', + 'MessageType', 'MessengerAudio', 'MessengerFile', 'MessengerImage', @@ -72,6 +76,7 @@ 'ViberVideo', 'ViberVideoOptions', 'ViberVideoResource', + 'WebhookVersion', 'WhatsappAudio', 'WhatsappAudioResource', 'WhatsappContext', diff --git a/messages/src/vonage_messages/models/base_message.py b/messages/src/vonage_messages/models/base_message.py index 8c33e7f9..ba59d0a0 100644 --- a/messages/src/vonage_messages/models/base_message.py +++ b/messages/src/vonage_messages/models/base_message.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field from vonage_utils.types.phone_number import PhoneNumber -from ..enums import WebhookVersion +from .enums import WebhookVersion class BaseMessage(BaseModel): diff --git a/messages/src/vonage_messages/enums.py b/messages/src/vonage_messages/models/enums.py similarity index 100% rename from messages/src/vonage_messages/enums.py rename to messages/src/vonage_messages/models/enums.py diff --git a/messages/src/vonage_messages/models/messenger.py b/messages/src/vonage_messages/models/messenger.py index 465d1d3d..abb5bc9d 100644 --- a/messages/src/vonage_messages/models/messenger.py +++ b/messages/src/vonage_messages/models/messenger.py @@ -2,8 +2,8 @@ from pydantic import BaseModel, Field, model_validator -from ..enums import ChannelType, MessageType from .base_message import BaseMessage +from .enums import ChannelType, MessageType class MessengerResource(BaseModel): diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index b72c5ac8..dc252c42 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, Field from vonage_utils.types.phone_number import PhoneNumber -from ..enums import ChannelType, MessageType from .base_message import BaseMessage +from .enums import ChannelType, MessageType class MmsResource(BaseModel): diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index 01bc453a..56e89901 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, Field from vonage_utils.types.phone_number import PhoneNumber -from ..enums import ChannelType, EncodingType, MessageType from .base_message import BaseMessage +from .enums import ChannelType, EncodingType, MessageType class SmsOptions(BaseModel): diff --git a/messages/src/vonage_messages/models/viber.py b/messages/src/vonage_messages/models/viber.py index 49239bc5..b135f614 100644 --- a/messages/src/vonage_messages/models/viber.py +++ b/messages/src/vonage_messages/models/viber.py @@ -2,8 +2,8 @@ from pydantic import BaseModel, Field, field_validator -from ..enums import ChannelType, MessageType from .base_message import BaseMessage +from .enums import ChannelType, MessageType class ViberAction(BaseModel): diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index b94dd75d..01c12fe5 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, ConfigDict, Field from vonage_utils.types.phone_number import PhoneNumber -from ..enums import ChannelType, MessageType from .base_message import BaseMessage +from .enums import ChannelType, MessageType class WhatsappContext(BaseModel): diff --git a/messages/src/vonage_messages/responses.py b/messages/src/vonage_messages/responses.py index 06a3eace..59a84b50 100644 --- a/messages/src/vonage_messages/responses.py +++ b/messages/src/vonage_messages/responses.py @@ -1,7 +1,11 @@ from pydantic import BaseModel -class MessageUuid(BaseModel): - """Response from Vonage's Messages API.""" +class SendMessageResponse(BaseModel): + """Response from Vonage's Messages API. + + Attributes: + message_uuid (str): The UUID of the sent message. + """ message_uuid: str diff --git a/messages/tests/_test_verify_v2.py b/messages/tests/_test_verify_v2.py deleted file mode 100644 index c6e72a98..00000000 --- a/messages/tests/_test_verify_v2.py +++ /dev/null @@ -1,66 +0,0 @@ -from os.path import abspath - -import responses -from pytest import raises -from vonage_http_client.errors import HttpRequestError -from vonage_http_client.http_client import HttpClient -from vonage_verify_v2.requests import * -from vonage_verify_v2.verify_v2 import VerifyV2 - -from testutils import build_response, get_mock_jwt_auth - -path = abspath(__file__) - - -verify = VerifyV2(HttpClient(get_mock_jwt_auth())) - - -@responses.activate -def test_make_verify_request(): - build_response( - path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 - ) - silent_auth_channel = SilentAuthChannel( - channel=ChannelType.SILENT_AUTH, to='1234567890' - ) - sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') - params = { - 'brand': 'Vonage', - 'workflow': [silent_auth_channel, sms_channel], - } - request = VerifyRequest(**params) - - response = verify.start_verification(request) - assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' - assert ( - response.check_url - == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' - ) - assert verify._http_client.last_response.status_code == 202 - - -@responses.activate -def test_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' - ) 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/send_message.json b/messages/tests/data/send_message.json new file mode 100644 index 00000000..b584d2db --- /dev/null +++ b/messages/tests/data/send_message.json @@ -0,0 +1,3 @@ +{ + "message_uuid": "d8f86df1-dec6-442f-870a-2241be27d721" +} \ No newline at end of file diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py new file mode 100644 index 00000000..a57ca024 --- /dev/null +++ b/messages/tests/test_messages.py @@ -0,0 +1,77 @@ +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_messages.messages import Messages +from vonage_messages.models import Sms +from vonage_messages.models.messenger import ( + MessengerImage, + MessengerOptions, + MessengerResource, +) +from vonage_messages.responses import SendMessageResponse + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +messages = Messages(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_send_message(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/messages', 'send_message.json', 202 + ) + sms = Sms( + from_='Vonage APIs', + to='1234567890', + text='Hello, World!', + ) + response = messages.send(sms) + assert type(response) == SendMessageResponse + assert response.message_uuid == 'd8f86df1-dec6-442f-870a-2241be27d721' + + +@responses.activate +def test_send_message_low_balance_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v1/messages', + 'low_balance_error.json', + 402, + ) + + with raises(HttpRequestError) as e: + messages.send(Sms(from_='Vonage APIs', to='1234567890', text='Hello, World!')) + + assert e.value.response.status_code == 402 + assert e.value.response.json()['title'] == 'Low balance' + + +@responses.activate +def test_send_message_invalid_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v1/messages', + 'invalid_error.json', + 422, + ) + + messenger = MessengerImage( + to='1234567890', + from_='1234567890', + image=MessengerResource(url='https://example.com/image.jpg'), + messenger=MessengerOptions(category='message_tag', tag='invalid_tag'), + ) + + with raises(HttpRequestError) as e: + messages.send(messenger) + + assert e.value.response.status_code == 422 + assert e.value.response.json()['title'] == 'Invalid params' diff --git a/messages/tests/test_messenger_models.py b/messages/tests/test_messenger_models.py index e3dc141d..f34ef099 100644 --- a/messages/tests/test_messenger_models.py +++ b/messages/tests/test_messenger_models.py @@ -1,5 +1,4 @@ from pytest import raises -from vonage_messages.enums import WebhookVersion from vonage_messages.models import ( MessengerAudio, MessengerFile, @@ -9,6 +8,7 @@ MessengerText, MessengerVideo, ) +from vonage_messages.models.enums import WebhookVersion def test_messenger_options_validator(): diff --git a/messages/tests/test_mms_models.py b/messages/tests/test_mms_models.py index 4bf75979..c74d6994 100644 --- a/messages/tests/test_mms_models.py +++ b/messages/tests/test_mms_models.py @@ -1,5 +1,5 @@ -from vonage_messages.enums import WebhookVersion from vonage_messages.models import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from vonage_messages.models.enums import WebhookVersion def test_create_mms_image(): diff --git a/messages/tests/test_sms_models.py b/messages/tests/test_sms_models.py index ce5012fe..49b19771 100644 --- a/messages/tests/test_sms_models.py +++ b/messages/tests/test_sms_models.py @@ -1,5 +1,5 @@ -from vonage_messages.enums import EncodingType, WebhookVersion from vonage_messages.models import Sms, SmsOptions +from vonage_messages.models.enums import EncodingType, WebhookVersion def test_create_sms(): diff --git a/messages/tests/test_viber_models.py b/messages/tests/test_viber_models.py index f2fa7476..21373261 100644 --- a/messages/tests/test_viber_models.py +++ b/messages/tests/test_viber_models.py @@ -1,5 +1,4 @@ from pytest import raises -from vonage_messages.enums import WebhookVersion from vonage_messages.models import ( ViberAction, ViberFile, @@ -14,6 +13,7 @@ ViberVideoOptions, ViberVideoResource, ) +from vonage_messages.models.enums import WebhookVersion def test_viber_video_options_validator(): diff --git a/messages/tests/test_whatsapp_models.py b/messages/tests/test_whatsapp_models.py index 6a47c5c5..6967d9dc 100644 --- a/messages/tests/test_whatsapp_models.py +++ b/messages/tests/test_whatsapp_models.py @@ -1,6 +1,5 @@ from copy import deepcopy -from vonage_messages.enums import WebhookVersion from vonage_messages.models import ( WhatsappAudio, WhatsappAudioResource, @@ -20,6 +19,7 @@ WhatsappVideo, WhatsappVideoResource, ) +from vonage_messages.models.enums import WebhookVersion def test_whatsapp_text(): diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 763a7b9b..a7b8dfd7 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -81,8 +81,7 @@ class User(BaseModel): id: Optional[str] = None @model_validator(mode='after') - @classmethod - def get_link(cls, data): - if data.links is not None: - data.link = data.links.self.href - return data + def get_link(self): + if self.links is not None: + self.link = self.links.self.href + return self diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index ddbf1d44..eef1c130 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.0a6 +- Add support for the [Vonage Messages API](https://developer.vonage.com/en/messages/overview). + # 3.99.0a5 - Add support for the [Vonage Verify V2 API](https://developer.vonage.com/en/verify/overview). - Expose error classes at the top level of the `vonage-http-client` package. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index f024e28a..06f1059b 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -8,12 +8,12 @@ requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.0.1", "vonage-http-client>=1.2.1", + "vonage-messages>=1.0.0", "vonage-number-insight-v2>=0.1.0", "vonage-sms>=1.0.2", "vonage-users>=1.0.1", "vonage-verify>=1.0.1", "vonage-verify-v2>=1.0.0", - "vonage-messages>=1.0.0", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 29defcc4..e04178b8 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a5' +__version__ = '3.99.0a6' From 5e7d13ec610bc972149b8e16bb9007f0fe75c588 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 11 Apr 2024 19:38:57 +0100 Subject: [PATCH 33/98] start adding voice api and ncco builder --- .../vonage_messages/models/base_message.py | 2 +- messages/src/vonage_messages/models/mms.py | 2 +- messages/src/vonage_messages/models/sms.py | 2 +- .../src/vonage_messages/models/whatsapp.py | 2 +- users/src/vonage_users/common.py | 2 +- verify/src/vonage_verify/requests.py | 2 +- verify_v2/src/vonage_verify_v2/requests.py | 2 +- voice/BUILD | 16 ++ voice/CHANGES.md | 5 + voice/README.md | 9 + voice/pyproject.toml | 29 +++ .../types => voice/src/vonage_voice}/BUILD | 0 voice/src/vonage_voice/__init__.py | 3 + voice/src/vonage_voice/errors.py | 9 + voice/src/vonage_voice/models/common.py | 9 + .../vonage_voice/models/connect_endpoints.py | 48 ++++ voice/src/vonage_voice/models/enums.py | 26 ++ voice/src/vonage_voice/models/input_types.py | 26 ++ voice/src/vonage_voice/models/ncco.py | 224 ++++++++++++++++++ voice/src/vonage_voice/models/requests.py | 79 ++++++ voice/src/vonage_voice/models/responses.py | 13 + voice/src/vonage_voice/voice.py | 30 +++ voice/tests/BUILD | 1 + voice/tests/_test_models.py | 138 +++++++++++ voice/tests/_test_verify_v2.py | 184 ++++++++++++++ voice/tests/data/check_code.json | 4 + voice/tests/data/check_code_400.json | 6 + voice/tests/data/check_code_410.json | 6 + .../data/trigger_next_workflow_error.json | 6 + voice/tests/data/verify_request.json | 4 + voice/tests/data/verify_request_error.json | 6 + vonage/CHANGES.md | 3 + vonage/pyproject.toml | 1 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + vonage_utils/src/vonage_utils/__init__.py | 5 +- .../{types/phone_number.py => types.py} | 2 + .../src/vonage_utils/types/__init__.py | 0 38 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 voice/BUILD create mode 100644 voice/CHANGES.md create mode 100644 voice/README.md create mode 100644 voice/pyproject.toml rename {vonage_utils/src/vonage_utils/types => voice/src/vonage_voice}/BUILD (100%) create mode 100644 voice/src/vonage_voice/__init__.py create mode 100644 voice/src/vonage_voice/errors.py create mode 100644 voice/src/vonage_voice/models/common.py create mode 100644 voice/src/vonage_voice/models/connect_endpoints.py create mode 100644 voice/src/vonage_voice/models/enums.py create mode 100644 voice/src/vonage_voice/models/input_types.py create mode 100644 voice/src/vonage_voice/models/ncco.py create mode 100644 voice/src/vonage_voice/models/requests.py create mode 100644 voice/src/vonage_voice/models/responses.py create mode 100644 voice/src/vonage_voice/voice.py create mode 100644 voice/tests/BUILD create mode 100644 voice/tests/_test_models.py create mode 100644 voice/tests/_test_verify_v2.py create mode 100644 voice/tests/data/check_code.json create mode 100644 voice/tests/data/check_code_400.json create mode 100644 voice/tests/data/check_code_410.json create mode 100644 voice/tests/data/trigger_next_workflow_error.json create mode 100644 voice/tests/data/verify_request.json create mode 100644 voice/tests/data/verify_request_error.json rename vonage_utils/src/vonage_utils/{types/phone_number.py => types.py} (50%) delete mode 100644 vonage_utils/src/vonage_utils/types/__init__.py diff --git a/messages/src/vonage_messages/models/base_message.py b/messages/src/vonage_messages/models/base_message.py index ba59d0a0..aa74e733 100644 --- a/messages/src/vonage_messages/models/base_message.py +++ b/messages/src/vonage_messages/models/base_message.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import BaseModel, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .enums import WebhookVersion diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index dc252c42..5dcd1e8a 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -1,7 +1,7 @@ from typing import Optional, Union from pydantic import BaseModel, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .base_message import BaseMessage from .enums import ChannelType, MessageType diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index 56e89901..cf6e5dbd 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -1,7 +1,7 @@ from typing import Optional, Union from pydantic import BaseModel, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .base_message import BaseMessage from .enums import ChannelType, EncodingType, MessageType diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index 01c12fe5..c5ee18cc 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -1,7 +1,7 @@ from typing import List, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .base_message import BaseMessage from .enums import ChannelType, MessageType diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index a7b8dfd7..ab1118ca 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,7 +1,7 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber class Link(BaseModel): diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py index 834bdee5..242e7bb2 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -2,7 +2,7 @@ from typing import Literal, Optional from pydantic import BaseModel, Field, model_validator -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .language_codes import LanguageCode, Psd2LanguageCode diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index 2aa7d124..e4869d6e 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -2,7 +2,7 @@ from typing import List, Optional, Union from pydantic import BaseModel, Field, field_validator, model_validator -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .enums import ChannelType, Locale from .errors import VerifyError 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..5d3cfbb8 --- /dev/null +++ b/voice/CHANGES.md @@ -0,0 +1,5 @@ +# 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..54828483 --- /dev/null +++ b/voice/README.md @@ -0,0 +1,9 @@ +# 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. + +## 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`. + +### Create a Call diff --git a/voice/pyproject.toml b/voice/pyproject.toml new file mode 100644 index 00000000..1de9acac --- /dev/null +++ b/voice/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-voice' +version = '1.0.0' +description = 'Vonage voice package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.2.1", + "vonage-utils>=1.0.1", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/vonage_utils/src/vonage_utils/types/BUILD b/voice/src/vonage_voice/BUILD similarity index 100% rename from vonage_utils/src/vonage_utils/types/BUILD rename to voice/src/vonage_voice/BUILD diff --git a/voice/src/vonage_voice/__init__.py b/voice/src/vonage_voice/__init__.py new file mode 100644 index 00000000..2df5dea0 --- /dev/null +++ b/voice/src/vonage_voice/__init__.py @@ -0,0 +1,3 @@ +from .voice import Voice + +__all__ = ['Voice'] 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/common.py b/voice/src/vonage_voice/models/common.py new file mode 100644 index 00000000..0939b6e0 --- /dev/null +++ b/voice/src/vonage_voice/models/common.py @@ -0,0 +1,9 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class AdvancedMachineDetection(BaseModel): + behavior: Optional[Literal['continue', 'hangup']] = None + mode: Optional[Literal['default', 'detect', 'detect_beep']] = 'detect' + 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..6618f014 --- /dev/null +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel, AnyUrl, Field +from typing import Optional +from typing_extensions import Literal + +from .enums import ConnectEndpointType + +from vonage_utils.types import Dtmf, PhoneNumber, SipUri + + +class BaseEndpoint(BaseModel): + """Base Endpoint model for use with the NCCO Connect action.""" + + +class OnAnswer(BaseModel): + url: AnyUrl + ringbackTone: Optional[AnyUrl] = None + + +class PhoneEndpoint(BaseEndpoint): + number: PhoneNumber + dtmfAnswer: Optional[Dtmf] = None + onAnswer: Optional[OnAnswer] = None + type: ConnectEndpointType = ConnectEndpointType.PHONE + + +class AppEndpoint(BaseEndpoint): + user: str + type: ConnectEndpointType = ConnectEndpointType.APP + + +class WebsocketEndpoint(BaseEndpoint): + uri: AnyUrl + contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] = Field( + 'audio/l16;rate=16000', serialization_alias='content-type' + ) + headers: Optional[dict] = {} + type: ConnectEndpointType = ConnectEndpointType.WEBSOCKET + + +class SipEndpoint(BaseEndpoint): + uri: SipUri + headers: Optional[dict] = {} + type: ConnectEndpointType = ConnectEndpointType.SIP + + +class VbcEndpoint(BaseEndpoint): + 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..5ace21d3 --- /dev/null +++ b/voice/src/vonage_voice/models/enums.py @@ -0,0 +1,26 @@ +from enum import Enum + + +class Channel(Enum, str): + PHONE = 'phone' + SIP = 'sip' + WEBSOCKET = 'websocket' + VBC = 'vbc' + + +class NccoActionType(Enum, str): + RECORD = 'record' + CONVERSATION = 'conversation' + CONNECT = 'connect' + TALK = 'talk' + STREAM = 'stream' + INPUT = 'input' + NOTIFY = 'notify' + + +class ConnectEndpointType(Enum, str): + PHONE = 'phone' + APP = 'app' + WEBSOCKET = 'websocket' + SIP = 'sip' + VBC = 'vbc' 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..761ba31d --- /dev/null +++ b/voice/src/vonage_voice/models/input_types.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, confloat, conint +from typing import Optional, List + + +class InputTypes: + class Dtmf(BaseModel): + timeOut: Optional[conint(ge=0, le=10)] + maxDigits: Optional[conint(ge=1, le=20)] + submitOnHash: Optional[bool] + + class Speech(BaseModel): + uuid: Optional[str] + endOnSilence: Optional[confloat(ge=0.4, le=10.0)] + language: Optional[str] + context: Optional[List[str]] + startTimeout: Optional[conint(ge=1, le=60)] + maxDuration: Optional[conint(ge=1, le=60)] + saveAudio: Optional[bool] + + @classmethod + def create_dtmf_model(cls, dict) -> Dtmf: + return cls.Dtmf.parse_obj(dict) + + @classmethod + def create_speech_model(cls, dict) -> Speech: + return cls.Speech.parse_obj(dict) diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py new file mode 100644 index 00000000..c1d4d49b --- /dev/null +++ b/voice/src/vonage_voice/models/ncco.py @@ -0,0 +1,224 @@ +from pydantic import ( + AnyUrl, + BaseModel, + Field, + model_validator, + validator, + constr, + confloat, + conint, +) +from typing import Optional, Union, List +from typing_extensions import Literal + +from vonage_voice.errors import NccoActionError, VoiceError +from vonage_voice.models.common import AdvancedMachineDetection + +from .connect_endpoints import BaseEndpoint +from .enums import NccoActionType +from .input_types import InputTypes +from vonage_utils.types import PhoneNumber + + +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.""" + + 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[AnyUrl]] = 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.""" + + name: str + musicOnHoldUrl: Optional[List[AnyUrl]] = None + startOnEnter: Optional[bool] = True + endOnExit: Optional[bool] = False + record: Optional[bool] = None + canSpeak: Optional[List[str]] = None + canHear: Optional[List[str]] = None + mute: Optional[bool] = None + action: NccoActionType = NccoActionType.CONVERSATION + + @model_validator(mode='after') + def can_mute(self): + if self.canSpeak and self.mute: + raise NccoActionError( + 'Cannot use mute option if canSpeak option is specified.' + ) + return self + + +class Connect(NccoAction): + """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" + + endpoint: List[BaseEndpoint] + from_: Optional[PhoneNumber] = Field(None, serialization_alias='from') + randomFromNumber: Optional[bool] = False + 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[AnyUrl]] = None + eventMethod: Optional[str] = 'POST' + ringbackTone: Optional[AnyUrl] = 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 VoiceError('Either `from_` or `random_from_number` must be set') + if self.randomFromNumber == True and self.from_ is not None: + raise VoiceError('`from_` and `random_from_number` cannot be used together') + return self + + +class Talk(NccoAction): + """The talk action sends synthesized speech to a Conversation.""" + + text: constr(max_length=1500) + bargeIn: Optional[bool] + loop: Optional[conint(ge=0)] + level: Optional[confloat(ge=-1, le=1)] + language: Optional[str] + style: Optional[int] + premium: Optional[bool] + action: NccoActionType = NccoActionType.TALK + + +class Stream(NccoAction): + """The stream action allows you to send an audio stream to a Conversation.""" + + streamUrl: Union[List[str], str] + level: Optional[confloat(ge=-1, le=1)] + bargeIn: Optional[bool] + loop: Optional[conint(ge=0)] + action: NccoActionType = NccoActionType.STREAM + + @validator('streamUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + +class Input(NccoAction): + """Collect digits or speech input by the person you are are calling.""" + + type: Union[ + Literal['dtmf', 'speech'], + List[Literal['dtmf']], + List[Literal['speech']], + List[Literal['dtmf', 'speech']], + ] + dtmf: Optional[Union[InputTypes.Dtmf, dict]] + speech: Optional[Union[InputTypes.Speech, dict]] + eventUrl: Optional[Union[List[str], str]] + eventMethod: Optional[constr(to_upper=True)] + action: NccoActionType = NccoActionType.INPUT + + @validator('type', 'eventUrl') + def ensure_value_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('dtmf') + def ensure_input_object_is_dtmf_model(cls, v): + if type(v) is dict: + return InputTypes.create_dtmf_model(v) + else: + return v + + @validator('speech') + def ensure_input_object_is_speech_model(cls, v): + if type(v) is dict: + return InputTypes.create_speech_model(v) + else: + return v + + +class Notify(NccoAction): + """Use the notify action to send a custom payload to your event URL.""" + + payload: dict + eventUrl: Union[List[str], str] + eventMethod: Optional[constr(to_upper=True)] + action: NccoActionType = NccoActionType.NOTIFY + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + +@deprecated(version='3.2.3', reason='The Pay NCCO action has been deprecated.') +class Pay(NccoAction): + """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" + + action = Field('pay', const=True) + amount: confloat(ge=0) + currency: Optional[constr(to_lower=True)] + eventUrl: Optional[Union[List[str], str]] + prompts: Optional[Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict]] + voice: Optional[Union[PayPrompts.VoicePrompt, dict]] + + @validator('amount') + def round_amount(cls, v): + return round(v, 2) + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('prompts') + def ensure_text_model(cls, v): + if type(v) is dict: + return PayPrompts.create_text_model(v) + else: + return v + + @validator('voice') + def ensure_voice_model(cls, v): + if type(v) is dict: + return PayPrompts.create_voice_model(v) + else: + return v + + +@staticmethod +def build_ncco(*args: NccoAction, actions: List[NccoAction] = None) -> str: + ncco = [] + if actions is not None: + for action in actions: + ncco.append(action.dict(exclude_none=True)) + for action in args: + ncco.append(action.dict(exclude_none=True)) + return ncco + + +@staticmethod +def _ensure_object_in_list(obj): + if type(obj) != list: + return [obj] + else: + return obj diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py new file mode 100644 index 00000000..4f5a08dc --- /dev/null +++ b/voice/src/vonage_voice/models/requests.py @@ -0,0 +1,79 @@ +from typing import List, Literal, Optional, Union +from pydantic import BaseModel, Field, AnyUrl, field_validator, model_validator + +from ..errors import VoiceError +from .ncco import NccoAction +from .common import AdvancedMachineDetection +from .enums import Channel +from vonage_utils.types import PhoneNumber, Dtmf, SipUri + + +class Phone(BaseModel): + """If using this model for a `from_` field, the `dtmf_answer` field is not allowed.""" + + number: PhoneNumber + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + type: Channel = Channel.PHONE + + +class Sip(BaseModel): + uri: SipUri + type: Channel = Channel.SIP + + +class Websocket(BaseModel): + uri: str = Field(..., min_length=1, max_length=50) + content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( + 'audio/l16;rate=16000', serialization_alias='content-type' + ) + type: Channel = Channel.WEBSOCKET + headers: Optional[dict] = None + + +class Vbc(BaseModel): + extension: str + type: Channel = Channel.VBC + + +class Call(BaseModel): + ncco: List[NccoAction] = None + answer_url: List[AnyUrl] = None + answer_method: Optional[Literal['POST', 'GET']] = 'POST' + to: List[Union[Phone, Sip, Websocket, Vbc]] + from_: Optional[Phone] = Field(None, serialization_alias='from') + random_from_number: Optional[bool] = None + event_url: Optional[List[AnyUrl]] = 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(7200, ge=1, le=7200) + ringing_timer: Optional[int] = Field(60, ge=1, le=120) + + @field_validator('from_') + @classmethod + def validate_from(cls, v: Phone): + if v.dtmf_answer is not None: + v.dtmf_answer = None + return v + + @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') + if ( + self.ncco is not None + and self.answer_url is None + and self.answer_method is not None + ): + self.answer_method = None + 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 diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py new file mode 100644 index 00000000..305c2d9a --- /dev/null +++ b/voice/src/vonage_voice/models/responses.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class CreateCallResponse(BaseModel): + uuid: str + status: str + direction: str + conversation_uuid: str + + +class CallStatus(BaseModel): + message: str + uuid: str diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py new file mode 100644 index 00000000..c2d9f5d8 --- /dev/null +++ b/voice/src/vonage_voice/voice.py @@ -0,0 +1,30 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .models.requests import Call +from .models.responses import CreateCallResponse + + +class Voice: + """Calls Vonage's Voice API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @validate_call + def create_call(self, params: Call) -> CreateCallResponse: + """Creates a new call using the Vonage Voice API. + + Args: + params (Call): 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) 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/_test_models.py b/voice/tests/_test_models.py new file mode 100644 index 00000000..b1a3eb81 --- /dev/null +++ b/voice/tests/_test_models.py @@ -0,0 +1,138 @@ +from pytest import raises +from vonage_verify_v2.enums import ChannelType, Locale +from vonage_verify_v2.errors import VerifyError +from vonage_verify_v2.requests import * + + +def test_create_silent_auth_channel(): + params = { + 'channel': ChannelType.SILENT_AUTH, + 'to': '1234567890', + 'redirect_url': 'https://example.com', + 'sandbox': True, + } + channel = SilentAuthChannel(**params) + + assert channel.model_dump() == params + + +def test_create_sms_channel(): + params = { + 'channel': ChannelType.SMS, + 'to': '1234567890', + 'from_': 'Vonage', + 'entity_id': '12345678901234567890', + 'content_id': '12345678901234567890', + 'app_hash': '12345678901', + } + channel = SmsChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + SmsChannel(**params) + + +def test_create_whatsapp_channel(): + params = { + 'channel': ChannelType.WHATSAPP, + 'to': '1234567890', + 'from_': 'Vonage', + } + channel = WhatsappChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + WhatsappChannel(**params) + + +def test_create_voice_channel(): + params = { + 'channel': ChannelType.VOICE, + 'to': '1234567890', + } + channel = VoiceChannel(**params) + + assert channel.model_dump() == params + + +def test_create_email_channel(): + params = { + 'channel': ChannelType.EMAIL, + 'to': 'customer@example.com', + 'from_': 'vonage@vonage.com', + } + channel = EmailChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'vonage@vonage.com' + + +def test_create_verify_request(): + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + # Basic request + + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [sms_channel] + + # Multiple channel request + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == workflow + + # All fields + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel, sms_channel], + 'locale': Locale.EN_GB, + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [silent_auth_channel, sms_channel, sms_channel] + assert verify_request.locale == Locale.EN_GB + assert verify_request.channel_timeout == 60 + assert verify_request.client_ref == 'my-client-ref' + assert verify_request.code_length == 6 + assert verify_request.code == '123456' + + +def test_create_verify_request_error(): + params = { + 'brand': 'Vonage', + 'workflow': [ + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + ], + } + with raises(VerifyError) as e: + VerifyRequest(**params) + assert e.match('must be the first channel') diff --git a/voice/tests/_test_verify_v2.py b/voice/tests/_test_verify_v2.py new file mode 100644 index 00000000..57da7c89 --- /dev/null +++ b/voice/tests/_test_verify_v2.py @@ -0,0 +1,184 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_verify_v2.requests import * +from vonage_verify_v2.verify_v2 import VerifyV2 + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +verify = VerifyV2(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert ( + response.check_url + == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' + ) + assert verify._http_client.last_response.status_code == 202 + + +@responses.activate +def test_make_verify_request_full(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + 'locale': 'en-gb', + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + + +@responses.activate +def test_verify_request_concurrent_verifications_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify', + 'verify_request_error.json', + 409, + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + request = VerifyRequest(**params) + + with raises(HttpRequestError) as e: + verify.start_verification(request) + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] + == 'Concurrent verifications to the same number are not allowed' + ) + + +@responses.activate +def test_check_code(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', + ) + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' + ) + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' + + +@responses.activate +def test_check_code_invalid_code_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_400.json', + 400, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 400 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_check_code_too_many_attempts(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_410.json', + 410, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 410 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_cancel_verification(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, + ) + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 + + +@responses.activate +def test_trigger_next_workflow(): + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, + ) + assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 200 + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + 'trigger_next_workflow_error.json', + status_code=409, + ) + + with raises(HttpRequestError) as e: + verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] == 'There are no more events left to trigger.' + ) diff --git a/voice/tests/data/check_code.json b/voice/tests/data/check_code.json new file mode 100644 index 00000000..2fbe4b8e --- /dev/null +++ b/voice/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/voice/tests/data/check_code_400.json b/voice/tests/data/check_code_400.json new file mode 100644 index 00000000..23690a83 --- /dev/null +++ b/voice/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/voice/tests/data/check_code_410.json b/voice/tests/data/check_code_410.json new file mode 100644 index 00000000..9d2534c7 --- /dev/null +++ b/voice/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/voice/tests/data/trigger_next_workflow_error.json b/voice/tests/data/trigger_next_workflow_error.json new file mode 100644 index 00000000..befd87a7 --- /dev/null +++ b/voice/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/voice/tests/data/verify_request.json b/voice/tests/data/verify_request.json new file mode 100644 index 00000000..719396cc --- /dev/null +++ b/voice/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/voice/tests/data/verify_request_error.json b/voice/tests/data/verify_request_error.json new file mode 100644 index 00000000..f40b1b12 --- /dev/null +++ b/voice/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/vonage/CHANGES.md b/vonage/CHANGES.md index eef1c130..40b182e4 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.0a7 +- Add support for the [Vonage Voice API](https://developer.vonage.com/en/voice/voice-api/overview). + # 3.99.0a6 - Add support for the [Vonage Messages API](https://developer.vonage.com/en/messages/overview). diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 06f1059b..20b8ba97 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "vonage-users>=1.0.1", "vonage-verify>=1.0.1", "vonage-verify-v2>=1.0.0", + "vonage-voice>=1.0.1", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 4005985f..fcd6bcaa 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -9,6 +9,7 @@ Users, Verify, VerifyV2, + Voice, Vonage, ) @@ -22,5 +23,6 @@ 'Users', 'Verify', 'VerifyV2', + 'Voice', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index e8d18a7f..9f599778 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -7,6 +7,7 @@ from vonage_users import Users from vonage_verify import Verify from vonage_verify_v2 import VerifyV2 +from vonage_voice import Voice from ._version import __version__ @@ -35,6 +36,7 @@ def __init__( self.users = Users(self._http_client) self.verify = Verify(self._http_client) self.verify_v2 = VerifyV2(self._http_client) + self.voice = Voice(self._http_client) @property def http_client(self): diff --git a/vonage_utils/src/vonage_utils/__init__.py b/vonage_utils/src/vonage_utils/__init__.py index db619eb7..a0103b6a 100644 --- a/vonage_utils/src/vonage_utils/__init__.py +++ b/vonage_utils/src/vonage_utils/__init__.py @@ -1,5 +1,6 @@ +import types + from .errors import VonageError -from .types.phone_number import PhoneNumber from .utils import format_phone_number, remove_none_values -__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', PhoneNumber] +__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', 'types'] diff --git a/vonage_utils/src/vonage_utils/types/phone_number.py b/vonage_utils/src/vonage_utils/types.py similarity index 50% rename from vonage_utils/src/vonage_utils/types/phone_number.py rename to vonage_utils/src/vonage_utils/types.py index 88e4da45..63bd319c 100644 --- a/vonage_utils/src/vonage_utils/types/phone_number.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -2,3 +2,5 @@ from typing_extensions import Annotated PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] +Dtmf = Annotated[str, Field(pattern=r'^[0-9#*p]+$')] +SipUri = Annotated[str, Field(pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)')] diff --git a/vonage_utils/src/vonage_utils/types/__init__.py b/vonage_utils/src/vonage_utils/types/__init__.py deleted file mode 100644 index e69de29b..00000000 From ff25ff3031ad254e489263002a1dcfb432895550 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 17 Apr 2024 03:02:24 +0100 Subject: [PATCH 34/98] finish adding NCCO actions and add tests --- messages/tests/BUILD | 4 - voice/README.md | 15 + voice/src/vonage_voice/models/BUILD | 1 + voice/src/vonage_voice/models/__init__.py | 45 +++ voice/src/vonage_voice/models/common.py | 2 +- .../vonage_voice/models/connect_endpoints.py | 32 +- voice/src/vonage_voice/models/enums.py | 6 +- voice/src/vonage_voice/models/input_types.py | 36 +- voice/src/vonage_voice/models/ncco.py | 207 ++++------ voice/src/vonage_voice/models/requests.py | 33 +- voice/tests/BUILD | 4 + voice/tests/test_ncco_actions.py | 381 ++++++++++++++++++ 12 files changed, 564 insertions(+), 202 deletions(-) create mode 100644 voice/src/vonage_voice/models/BUILD create mode 100644 voice/src/vonage_voice/models/__init__.py create mode 100644 voice/tests/test_ncco_actions.py diff --git a/messages/tests/BUILD b/messages/tests/BUILD index ea1c26ad..72fce921 100644 --- a/messages/tests/BUILD +++ b/messages/tests/BUILD @@ -1,5 +1 @@ python_tests(dependencies=['messages', 'testutils']) - -python_sources( - name="tests0", -) diff --git a/voice/README.md b/voice/README.md index 54828483..cf2e611b 100644 --- a/voice/README.md +++ b/voice/README.md @@ -7,3 +7,18 @@ This package contains the code to use [Vonage's Voice API](https://developer.von It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. ### Create a Call + + +### Note on URLs + +The Voice API requires most URLs to be passed in a list with only one element. When creating models, simply pass the url and the model will marshal it into the correct structure for you. + +e.g. + +```python +# Don't do this + + +# Do this + +``` \ No newline at end of file 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..119685cf --- /dev/null +++ b/voice/src/vonage_voice/models/__init__.py @@ -0,0 +1,45 @@ +from .common import AdvancedMachineDetection +from .connect_endpoints import ( + AppEndpoint, + OnAnswer, + PhoneEndpoint, + SipEndpoint, + VbcEndpoint, + WebsocketEndpoint, +) +from .enums import Channel, ConnectEndpointType, NccoActionType +from .input_types import Dtmf, Speech +from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk +from .requests import Call, NccoAction, Phone, Sip, ToPhone, Vbc, Websocket +from .responses import CallStatus, CreateCallResponse + +__all__ = [ + 'AdvancedMachineDetection', + 'Call', + 'ToPhone', + 'Sip', + 'Websocket', + 'Vbc', + 'Phone', + 'NccoAction', + 'Channel', + 'NccoActionType', + 'ConnectEndpointType', + 'OnAnswer', + 'PhoneEndpoint', + 'AppEndpoint', + 'WebsocketEndpoint', + 'SipEndpoint', + 'VbcEndpoint', + 'Dtmf', + 'Speech', + 'CreateCallResponse', + 'CallStatus', + 'Record', + 'Conversation', + 'Connect', + 'Talk', + 'Stream', + 'Input', + 'Notify', +] diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 0939b6e0..737ab4d7 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -5,5 +5,5 @@ class AdvancedMachineDetection(BaseModel): behavior: Optional[Literal['continue', 'hangup']] = None - mode: Optional[Literal['default', 'detect', 'detect_beep']] = 'detect' + mode: Optional[Literal['default', 'detect', 'detect_beep']] = None beep_timeout: Optional[int] = Field(None, ge=45, le=120) diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index 6618f014..711cd91a 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -1,48 +1,44 @@ -from pydantic import BaseModel, AnyUrl, Field from typing import Optional -from typing_extensions import Literal - -from .enums import ConnectEndpointType +from pydantic import BaseModel, Field +from typing_extensions import Literal from vonage_utils.types import Dtmf, PhoneNumber, SipUri - -class BaseEndpoint(BaseModel): - """Base Endpoint model for use with the NCCO Connect action.""" +from .enums import ConnectEndpointType class OnAnswer(BaseModel): - url: AnyUrl - ringbackTone: Optional[AnyUrl] = None + url: str + ringbackTone: Optional[str] = None -class PhoneEndpoint(BaseEndpoint): +class PhoneEndpoint(BaseModel): number: PhoneNumber dtmfAnswer: Optional[Dtmf] = None onAnswer: Optional[OnAnswer] = None type: ConnectEndpointType = ConnectEndpointType.PHONE -class AppEndpoint(BaseEndpoint): +class AppEndpoint(BaseModel): user: str type: ConnectEndpointType = ConnectEndpointType.APP -class WebsocketEndpoint(BaseEndpoint): - uri: AnyUrl +class WebsocketEndpoint(BaseModel): + uri: str contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] = Field( - 'audio/l16;rate=16000', serialization_alias='content-type' + None, serialization_alias='content-type' ) - headers: Optional[dict] = {} + headers: Optional[dict] = None type: ConnectEndpointType = ConnectEndpointType.WEBSOCKET -class SipEndpoint(BaseEndpoint): +class SipEndpoint(BaseModel): uri: SipUri - headers: Optional[dict] = {} + headers: Optional[dict] = None type: ConnectEndpointType = ConnectEndpointType.SIP -class VbcEndpoint(BaseEndpoint): +class VbcEndpoint(BaseModel): extension: str type: ConnectEndpointType = ConnectEndpointType.VBC diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py index 5ace21d3..9a3f4ee6 100644 --- a/voice/src/vonage_voice/models/enums.py +++ b/voice/src/vonage_voice/models/enums.py @@ -1,14 +1,14 @@ from enum import Enum -class Channel(Enum, str): +class Channel(str, Enum): PHONE = 'phone' SIP = 'sip' WEBSOCKET = 'websocket' VBC = 'vbc' -class NccoActionType(Enum, str): +class NccoActionType(str, Enum): RECORD = 'record' CONVERSATION = 'conversation' CONNECT = 'connect' @@ -18,7 +18,7 @@ class NccoActionType(Enum, str): NOTIFY = 'notify' -class ConnectEndpointType(Enum, str): +class ConnectEndpointType(str, Enum): PHONE = 'phone' APP = 'app' WEBSOCKET = 'websocket' diff --git a/voice/src/vonage_voice/models/input_types.py b/voice/src/vonage_voice/models/input_types.py index 761ba31d..71c912e0 100644 --- a/voice/src/vonage_voice/models/input_types.py +++ b/voice/src/vonage_voice/models/input_types.py @@ -1,26 +1,20 @@ -from pydantic import BaseModel, confloat, conint -from typing import Optional, List +from typing import List, Optional +from pydantic import BaseModel, Field -class InputTypes: - class Dtmf(BaseModel): - timeOut: Optional[conint(ge=0, le=10)] - maxDigits: Optional[conint(ge=1, le=20)] - submitOnHash: Optional[bool] - class Speech(BaseModel): - uuid: Optional[str] - endOnSilence: Optional[confloat(ge=0.4, le=10.0)] - language: Optional[str] - context: Optional[List[str]] - startTimeout: Optional[conint(ge=1, le=60)] - maxDuration: Optional[conint(ge=1, le=60)] - saveAudio: Optional[bool] +class Dtmf(BaseModel): + timeOut: Optional[int] = Field(None, ge=0, le=10) + maxDigits: Optional[int] = Field(None, ge=1, le=20) + submitOnHash: Optional[bool] = None - @classmethod - def create_dtmf_model(cls, dict) -> Dtmf: - return cls.Dtmf.parse_obj(dict) - @classmethod - def create_speech_model(cls, dict) -> Speech: - return cls.Speech.parse_obj(dict) +class Speech(BaseModel): + 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 index c1d4d49b..60c6c0c7 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -1,33 +1,31 @@ -from pydantic import ( - AnyUrl, - BaseModel, - Field, - model_validator, - validator, - constr, - confloat, - conint, -) -from typing import Optional, Union, List -from typing_extensions import Literal +from typing import List, Optional, Union -from vonage_voice.errors import NccoActionError, VoiceError +from pydantic import BaseModel, Field, model_validator +from typing_extensions import Literal +from vonage_utils.types import PhoneNumber +from vonage_voice.errors import NccoActionError from vonage_voice.models.common import AdvancedMachineDetection -from .connect_endpoints import BaseEndpoint +from .connect_endpoints import ( + AppEndpoint, + PhoneEndpoint, + SipEndpoint, + VbcEndpoint, + WebsocketEndpoint, +) from .enums import NccoActionType -from .input_types import InputTypes -from vonage_utils.types import PhoneNumber +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.""" + 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.""" + """Use the Record action to record a call or part of a call.""" format: Optional[Literal['mp3', 'wav', 'ogg']] = None split: Optional[Literal['conversation']] = None @@ -36,7 +34,7 @@ class Record(NccoAction): 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[AnyUrl]] = None + eventUrl: Optional[List[str]] = None eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.RECORD @@ -48,15 +46,16 @@ def enable_split(self): class Conversation(NccoAction): - """You can use the conversation action to create standard or moderated conferences, - while preserving the communication context. + """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.""" + Using a conversation with the same name reuses the same persisted conversation. + """ name: str - musicOnHoldUrl: Optional[List[AnyUrl]] = None - startOnEnter: Optional[bool] = True - endOnExit: Optional[bool] = False + 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 @@ -73,152 +72,88 @@ def can_mute(self): class Connect(NccoAction): - """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" + """You can use the Connect action to connect a call to endpoints such as phone numbers or a VBC + extension.""" - endpoint: List[BaseEndpoint] + endpoint: List[ + Union[PhoneEndpoint, AppEndpoint, WebsocketEndpoint, SipEndpoint, VbcEndpoint] + ] from_: Optional[PhoneNumber] = Field(None, serialization_alias='from') - randomFromNumber: Optional[bool] = False + 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[AnyUrl]] = None - eventMethod: Optional[str] = 'POST' - ringbackTone: Optional[AnyUrl] = 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 VoiceError('Either `from_` or `random_from_number` must be set') + raise NccoActionError('Either `from_` or `random_from_number` must be set.') if self.randomFromNumber == True and self.from_ is not None: - raise VoiceError('`from_` and `random_from_number` cannot be used together') + 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.""" - - text: constr(max_length=1500) - bargeIn: Optional[bool] - loop: Optional[conint(ge=0)] - level: Optional[confloat(ge=-1, le=1)] - language: Optional[str] - style: Optional[int] - premium: Optional[bool] + """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 + """ + + 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 Conversation.""" - streamUrl: Union[List[str], str] - level: Optional[confloat(ge=-1, le=1)] - bargeIn: Optional[bool] - loop: Optional[conint(ge=0)] + 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 - @validator('streamUrl') - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - class Input(NccoAction): """Collect digits or speech input by the person you are are calling.""" - type: Union[ - Literal['dtmf', 'speech'], - List[Literal['dtmf']], - List[Literal['speech']], - List[Literal['dtmf', 'speech']], - ] - dtmf: Optional[Union[InputTypes.Dtmf, dict]] - speech: Optional[Union[InputTypes.Speech, dict]] - eventUrl: Optional[Union[List[str], str]] - eventMethod: Optional[constr(to_upper=True)] + 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 - @validator('type', 'eventUrl') - def ensure_value_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @validator('dtmf') - def ensure_input_object_is_dtmf_model(cls, v): - if type(v) is dict: - return InputTypes.create_dtmf_model(v) - else: - return v - - @validator('speech') - def ensure_input_object_is_speech_model(cls, v): - if type(v) is dict: - return InputTypes.create_speech_model(v) - else: - return v + @model_validator(mode='after') + def validate_objects_provided(self): + if 'dtmf' in self.type and self.dtmf is None: + raise NccoActionError( + '`dtmf` object must be provided if `dtmf` is in the `type` array.' + ) + if 'speech' in self.type and self.speech is None: + raise NccoActionError( + '`speech` object must be provided if `speech` is in the `type` array.' + ) class Notify(NccoAction): """Use the notify action to send a custom payload to your event URL.""" payload: dict - eventUrl: Union[List[str], str] - eventMethod: Optional[constr(to_upper=True)] + eventUrl: List[str] + eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.NOTIFY - - @validator('eventUrl') - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - -@deprecated(version='3.2.3', reason='The Pay NCCO action has been deprecated.') -class Pay(NccoAction): - """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" - - action = Field('pay', const=True) - amount: confloat(ge=0) - currency: Optional[constr(to_lower=True)] - eventUrl: Optional[Union[List[str], str]] - prompts: Optional[Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict]] - voice: Optional[Union[PayPrompts.VoicePrompt, dict]] - - @validator('amount') - def round_amount(cls, v): - return round(v, 2) - - @validator('eventUrl') - def ensure_url_in_list(cls, v): - return Ncco._ensure_object_in_list(v) - - @validator('prompts') - def ensure_text_model(cls, v): - if type(v) is dict: - return PayPrompts.create_text_model(v) - else: - return v - - @validator('voice') - def ensure_voice_model(cls, v): - if type(v) is dict: - return PayPrompts.create_voice_model(v) - else: - return v - - -@staticmethod -def build_ncco(*args: NccoAction, actions: List[NccoAction] = None) -> str: - ncco = [] - if actions is not None: - for action in actions: - ncco.append(action.dict(exclude_none=True)) - for action in args: - ncco.append(action.dict(exclude_none=True)) - return ncco - - -@staticmethod -def _ensure_object_in_list(obj): - if type(obj) != list: - return [obj] - else: - return obj diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index 4f5a08dc..d652ca1c 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -1,21 +1,23 @@ from typing import List, Literal, Optional, Union -from pydantic import BaseModel, Field, AnyUrl, field_validator, model_validator + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types import Dtmf, PhoneNumber, SipUri from ..errors import VoiceError -from .ncco import NccoAction from .common import AdvancedMachineDetection from .enums import Channel -from vonage_utils.types import PhoneNumber, Dtmf, SipUri +from .ncco import NccoAction class Phone(BaseModel): - """If using this model for a `from_` field, the `dtmf_answer` field is not allowed.""" - number: PhoneNumber - dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') type: Channel = Channel.PHONE +class ToPhone(Phone): + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + + class Sip(BaseModel): uri: SipUri type: Channel = Channel.SIP @@ -37,24 +39,17 @@ class Vbc(BaseModel): class Call(BaseModel): ncco: List[NccoAction] = None - answer_url: List[AnyUrl] = None - answer_method: Optional[Literal['POST', 'GET']] = 'POST' - to: List[Union[Phone, Sip, Websocket, Vbc]] + answer_url: List[str] = None + answer_method: Optional[Literal['POST', 'GET']] = None + to: List[Union[ToPhone, Sip, Websocket, Vbc]] from_: Optional[Phone] = Field(None, serialization_alias='from') random_from_number: Optional[bool] = None - event_url: Optional[List[AnyUrl]] = 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(7200, ge=1, le=7200) - ringing_timer: Optional[int] = Field(60, ge=1, le=120) - - @field_validator('from_') - @classmethod - def validate_from(cls, v: Phone): - if v.dtmf_answer is not None: - v.dtmf_answer = None - return v + length_timer: Optional[int] = Field(None, ge=1, le=7200) + ringing_timer: Optional[int] = Field(None, ge=1, le=120) @model_validator(mode='after') def validate_ncco_and_answer_url(self): diff --git a/voice/tests/BUILD b/voice/tests/BUILD index 44127fb3..fa7368b6 100644 --- a/voice/tests/BUILD +++ b/voice/tests/BUILD @@ -1 +1,5 @@ python_tests(dependencies=['voice', 'testutils']) + +python_sources( + name="tests0", +) diff --git a/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py new file mode 100644 index 00000000..70737ae5 --- /dev/null +++ b/voice/tests/test_ncco_actions.py @@ -0,0 +1,381 @@ +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_full(): + 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_full(): + conversation = ncco.Conversation( + name='my_conversation', + musicOnHoldUrl=['http://example.com/music.mp3'], + startOnEnter=True, + endOnExit=True, + record=True, + canSpeak=['asdf', 'qwer'], + canHear=['asdf'], + mute=False, + ) + conversation_dict = { + 'name': 'my_conversation', + 'musicOnHoldUrl': ['http://example.com/music.mp3'], + 'startOnEnter': True, + 'endOnExit': True, + 'record': True, + 'canSpeak': ['asdf', 'qwer'], + 'canHear': ['asdf'], + 'mute': False, + 'action': 'conversation', + } + assert conversation.model_dump(by_alias=True, exclude_none=True) == conversation_dict + + +def test_conversation_mute(): + with raises(NccoActionError) as e: + ncco.Conversation(name='my_conversation', canSpeak=['asdf'], mute=True) + assert e.match('Cannot use mute option if canSpeak option is specified.') + + +def test_create_connect_endpoints(): + assert connect_endpoints.PhoneEndpoint( + number='447000000000', + dtmfAnswer='1234', + onAnswer={'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + ).model_dump() == { + 'number': '447000000000', + 'dtmfAnswer': '1234', + 'onAnswer': {'url': 'https://example.com', 'ringbackTone': 'http://example.com'}, + 'type': 'phone', + } + + assert connect_endpoints.AppEndpoint(user='my_user').model_dump() == { + 'user': 'my_user', + 'type': 'app', + } + + assert connect_endpoints.WebsocketEndpoint( + uri='wss://example.com', + contentType='audio/l16;rate=8000', + headers={'asdf': 'qwer'}, + ).model_dump(by_alias=True) == { + 'uri': 'wss://example.com', + 'content-type': 'audio/l16;rate=8000', + 'headers': {'asdf': 'qwer'}, + 'type': 'websocket', + } + + assert connect_endpoints.SipEndpoint( + uri='sip:example@sip.example.com', headers={'qwer': 'asdf'} + ).model_dump() == { + 'uri': 'sip:example@sip.example.com', + 'headers': {'qwer': 'asdf'}, + 'type': 'sip', + } + + assert connect_endpoints.VbcEndpoint(extension='1234').model_dump() == { + 'extension': '1234', + 'type': 'vbc', + } + + +def test_connect_basic(): + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + connect = ncco.Connect(endpoint=[endpoint], from_='1234567890') + assert connect.model_dump(by_alias=True, exclude_none=True) == { + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'from': '1234567890', + 'action': 'connect', + } + + +def test_connect_advanced_machine_detection(): + amd = AdvancedMachineDetection(behavior='continue', mode='detect', beep_timeout=60) + + assert amd.model_dump() == { + 'behavior': 'continue', + 'mode': 'detect', + 'beep_timeout': 60, + } + + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + assert ncco.Connect( + endpoint=[endpoint], + from_='1234567890', + advancedMachineDetection=amd, + ).model_dump(by_alias=True, exclude_none=True) == { + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'from': '1234567890', + 'advancedMachineDetection': { + 'behavior': 'continue', + 'mode': 'detect', + 'beep_timeout': 60, + }, + 'action': 'connect', + } + + +def test_connect_options(): + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + connect = ncco.Connect( + endpoint=[endpoint], + randomFromNumber=True, + eventType='synchronous', + timeout=15, + limit=1000, + machineDetection='hangup', + eventUrl=['http://example.com'], + eventMethod='PUT', + ringbackTone=['http://example.com'], + ) + assert connect.model_dump(by_alias=True, exclude_none=True) == { + 'endpoint': [{'type': 'phone', 'number': '447000000000'}], + 'randomFromNumber': True, + 'eventType': 'synchronous', + 'timeout': 15, + 'limit': 1000, + 'machineDetection': 'hangup', + 'eventUrl': ['http://example.com'], + 'eventMethod': 'PUT', + 'ringbackTone': ['http://example.com'], + 'action': 'connect', + } + + +def test_connect_random_from_number_error(): + endpoint = connect_endpoints.PhoneEndpoint(number='447000000000') + with raises(NccoActionError) as e: + ncco.Connect(endpoint=[endpoint]) + + assert e.match('Either `from_` or `random_from_number` must be set.') + + with raises(NccoActionError) as e: + ncco.Connect(endpoint=[endpoint], from_='1234567890', randomFromNumber=True) + assert e.match('`from_` and `random_from_number` cannot be used together.') + + +def test_talk_basic(): + talk = ncco.Talk(text='hello') + assert talk.model_dump(by_alias=True, exclude_none=True) == { + 'text': 'hello', + 'action': 'talk', + } + + +def test_talk_optional_params(): + 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 type(stream) == Ncco.Stream +# assert json.dumps(_action_as_dict(stream)) == nas.stream_basic + + +# def test_stream_full(): +# stream = Ncco.Stream( +# streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 +# ) +# assert json.dumps(_action_as_dict(stream)) == nas.stream_full + + +# def test_input_basic(): +# input = Ncco.Input(type='dtmf') +# assert type(input) == Ncco.Input +# assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf + + +# def test_input_basic_list(): +# input = Ncco.Input(type=['dtmf', 'speech']) +# assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf_speech + + +# def test_input_dtmf_and_speech_options(): +# dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True) +# speech = InputTypes.Speech( +# uuid='my-uuid', +# endOnSilence=2.5, +# language='en-GB', +# context=['sales', 'billing'], +# startTimeout=20, +# maxDuration=30, +# saveAudio=True, +# ) +# input = Ncco.Input( +# type=['dtmf', 'speech'], +# dtmf=dtmf, +# speech=speech, +# eventUrl='http://example.com/speech', +# eventMethod='put', +# ) +# assert json.dumps(_action_as_dict(input)) == nas.input_dtmf_and_speech_full + + +# def test_input_validation_error(): +# with pytest.raises(ValidationError): +# Ncco.Input(type='invalid_type') + + +# def test_notify_basic(): +# notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com']) +# assert type(notify) == Ncco.Notify +# assert json.dumps(_action_as_dict(notify)) == nas.notify_basic + + +# def test_notify_basic_str_in_event_url(): +# notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl='http://example.com') +# assert type(notify) == Ncco.Notify +# assert json.dumps(_action_as_dict(notify)) == nas.notify_basic + + +# def test_notify_full(): +# notify = Ncco.Notify( +# payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST' +# ) +# assert type(notify) == Ncco.Notify +# assert json.dumps(_action_as_dict(notify)) == nas.notify_full + + +# def test_notify_validation_error(): +# with pytest.raises(ValidationError): +# Ncco.Notify(payload={'message', 'hello'}, eventUrl=['http://example.com']) + + +# def test_pay_voice_basic(): +# pay = Ncco.Pay(amount='10.00') +# assert type(pay) == Ncco.Pay +# assert json.dumps(_action_as_dict(pay)) == nas.pay_basic + + +# def test_pay_voice_full(): +# voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) +# pay = Ncco.Pay( +# amount=99.99, +# currency='gbp', +# eventUrl='https://example.com/payment', +# voice=voice_settings, +# ) +# assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full + + +# def test_pay_text(): +# text_prompts = PayPrompts.TextPrompt( +# type='CardNumber', +# text='Enter your card number.', +# errors={ +# 'InvalidCardType': { +# 'text': 'The card you are trying to use is not valid for this purchase.' +# } +# }, +# ) +# pay = Ncco.Pay( +# amount=12.345, +# currency='gbp', +# eventUrl='https://example.com/payment', +# prompts=text_prompts, +# ) +# assert json.dumps(_action_as_dict(pay)) == nas.pay_text + + +# def test_pay_text_multiple_prompts(): +# card_prompt = PayPrompts.TextPrompt( +# type='CardNumber', +# text='Enter your card number.', +# errors={ +# 'InvalidCardType': { +# 'text': 'The card you are trying to use is not valid for this purchase.' +# } +# }, +# ) +# expiration_date_prompt = PayPrompts.TextPrompt( +# type='ExpirationDate', +# text='Enter your card expiration date.', +# errors={ +# 'InvalidExpirationDate': { +# 'text': 'You have entered an invalid expiration date.' +# }, +# 'Timeout': {'text': 'Please enter your card\'s expiration date.'}, +# }, +# ) +# security_code_prompt = PayPrompts.TextPrompt( +# type='SecurityCode', +# text='Enter your 3-digit security code.', +# errors={ +# 'InvalidSecurityCode': {'text': 'You have entered an invalid security code.'}, +# 'Timeout': {'text': 'Please enter your card\'s security code.'}, +# }, +# ) + +# text_prompts = [card_prompt, expiration_date_prompt, security_code_prompt] +# pay = Ncco.Pay(amount=12, prompts=text_prompts) +# assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts + + +# def test_pay_validation_error(): +# with pytest.raises(ValidationError): +# Ncco.Pay(amount='not-valid') From 5819ed3fe899b1897224aee2a002574bc4e9f73f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 18 Apr 2024 03:25:09 +0100 Subject: [PATCH 35/98] finish ncco testing, start with voice endpoints --- pants.toml | 1 + users/src/vonage_users/responses.py | 8 +- voice/src/vonage_voice/models/__init__.py | 4 +- voice/src/vonage_voice/models/enums.py | 14 + voice/src/vonage_voice/models/ncco.py | 13 +- voice/src/vonage_voice/models/requests.py | 25 +- voice/src/vonage_voice/models/responses.py | 54 +++- voice/src/vonage_voice/voice.py | 42 ++- voice/tests/_test_models.py | 138 ---------- voice/tests/_test_verify_v2.py | 184 -------------- voice/tests/data/check_code.json | 4 - voice/tests/data/check_code_400.json | 6 - voice/tests/data/check_code_410.json | 6 - voice/tests/data/create_call.json | 6 + .../data/trigger_next_workflow_error.json | 6 - voice/tests/data/verify_request.json | 4 - voice/tests/data/verify_request_error.json | 6 - voice/tests/test_ncco_actions.py | 239 +++++++----------- voice/tests/test_voice.py | 154 +++++++++++ vonage_utils/src/vonage_utils/types.py | 2 +- 20 files changed, 380 insertions(+), 536 deletions(-) delete mode 100644 voice/tests/_test_models.py delete mode 100644 voice/tests/_test_verify_v2.py delete mode 100644 voice/tests/data/check_code.json delete mode 100644 voice/tests/data/check_code_400.json delete mode 100644 voice/tests/data/check_code_410.json create mode 100644 voice/tests/data/create_call.json delete mode 100644 voice/tests/data/trigger_next_workflow_error.json delete mode 100644 voice/tests/data/verify_request.json delete mode 100644 voice/tests/data/verify_request_error.json create mode 100644 voice/tests/test_voice.py diff --git a/pants.toml b/pants.toml index 15e17b66..e6254cd8 100644 --- a/pants.toml +++ b/pants.toml @@ -40,6 +40,7 @@ filter = [ 'testutils', 'verify/src', 'verify_v2/src', + 'voice/src', 'vonage_utils/src', ] diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index ba16b18f..22e36c9b 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -20,10 +20,10 @@ class UserSummary(BaseModel): @model_validator(mode='after') @classmethod - def get_link(cls, data): - if data.links is not None: - data.link = data.links.self.href - return data + def get_link(self): + if self.links is not None: + self.link = self.links.self.href + return self class Embedded(BaseModel): diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index 119685cf..5b138b6c 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -10,12 +10,12 @@ from .enums import Channel, ConnectEndpointType, NccoActionType from .input_types import Dtmf, Speech from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk -from .requests import Call, NccoAction, Phone, Sip, ToPhone, Vbc, Websocket +from .requests import CreateCallRequest, Phone, Sip, ToPhone, Vbc, Websocket from .responses import CallStatus, CreateCallResponse __all__ = [ 'AdvancedMachineDetection', - 'Call', + 'CreateCallRequest', 'ToPhone', 'Sip', 'Websocket', diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py index 9a3f4ee6..bf565153 100644 --- a/voice/src/vonage_voice/models/enums.py +++ b/voice/src/vonage_voice/models/enums.py @@ -24,3 +24,17 @@ class ConnectEndpointType(str, Enum): WEBSOCKET = 'websocket' SIP = 'sip' VBC = 'vbc' + + +class CallStatus(str, Enum): + STARTED = 'started' + RINGING = 'ringing' + ANSWERED = 'answered' + MACHINE = 'machine' + COMPLETED = 'completed' + BUSY = 'busy' + CANCELLED = 'cancelled' + FAILED = 'failed' + REJECTED = 'rejected' + TIMEOUT = 'timeout' + UNANSWERED = 'unanswered' diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index 60c6c0c7..80c28f75 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Annotated, List, Optional, Union from pydantic import BaseModel, Field, model_validator from typing_extensions import Literal @@ -138,17 +138,6 @@ class Input(NccoAction): eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.INPUT - @model_validator(mode='after') - def validate_objects_provided(self): - if 'dtmf' in self.type and self.dtmf is None: - raise NccoActionError( - '`dtmf` object must be provided if `dtmf` is in the `type` array.' - ) - if 'speech' in self.type and self.speech is None: - raise NccoActionError( - '`speech` object must be provided if `speech` is in the `type` array.' - ) - class Notify(NccoAction): """Use the notify action to send a custom payload to your event URL.""" diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index d652ca1c..3fbeb504 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -5,8 +5,8 @@ from ..errors import VoiceError from .common import AdvancedMachineDetection -from .enums import Channel -from .ncco import NccoAction +from .enums import CallStatus, Channel +from .ncco import Record, Conversation, Connect, Input, Talk, Stream, Notify class Phone(BaseModel): @@ -37,11 +37,12 @@ class Vbc(BaseModel): type: Channel = Channel.VBC -class Call(BaseModel): - ncco: List[NccoAction] = None +class CreateCallRequest(BaseModel): + ncco: List[Union[Record, Conversation, Connect, Input, Talk, Stream, Notify]] = None answer_url: List[str] = None answer_method: Optional[Literal['POST', 'GET']] = None to: List[Union[ToPhone, Sip, Websocket, Vbc]] + from_: Optional[Phone] = Field(None, serialization_alias='from') random_from_number: Optional[bool] = None event_url: Optional[List[str]] = None @@ -57,12 +58,6 @@ def validate_ncco_and_answer_url(self): 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') - if ( - self.ncco is not None - and self.answer_url is None - and self.answer_method is not None - ): - self.answer_method = None return self @model_validator(mode='after') @@ -72,3 +67,13 @@ def validate_from_and_random_from_number(self): 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): + status: Optional[CallStatus] = None + date_start: Optional[str] = None + date_end: Optional[str] = None + page_size: Optional[int] = Field(None, ge=1, le=100) + record_index: Optional[int] = None + order: Optional[Literal['asc', 'desc']] = None + conversation_uuid: Optional[str] = None diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py index 305c2d9a..f81144f6 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel +from typing import List, Optional +from pydantic import BaseModel, Field, model_validator class CreateCallResponse(BaseModel): @@ -11,3 +12,54 @@ class CreateCallResponse(BaseModel): class CallStatus(BaseModel): message: str uuid: str + + +class Link(BaseModel): + href: str + + +class Links(BaseModel): + self: Link + first: Optional[Link] = None + next: Optional[Link] = None + prev: Optional[Link] = None + + +class Endpoint(BaseModel): + type: str + number: str + + +class CallInfo(BaseModel): + uuid: str + conversation_uuid: str + to: Endpoint + from_: Endpoint = Field(..., 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: Links = Field(..., validation_alias='_links', exclude=True) + link: Optional[str] = None + + @model_validator(mode='after') + @classmethod + def get_link(self): + self.link = self.links.self.href + return self + + +class Embedded(BaseModel): + calls: List[CallInfo] + + +class CallList(BaseModel): + count: int + page_size: int + record_index: int + embedded: Embedded = Field(..., validation_alias='_embedded') + links: Links = Field(..., validation_alias='_links') diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index c2d9f5d8..2bdd0287 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -1,8 +1,8 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .models.requests import Call -from .models.responses import CreateCallResponse +from .models.requests import CreateCallRequest, ListCallsFilter +from .models.responses import CallInfo, CallList, CreateCallResponse class Voice: @@ -12,11 +12,11 @@ def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client @validate_call - def create_call(self, params: Call) -> CreateCallResponse: + def create_call(self, params: CreateCallRequest) -> CreateCallResponse: """Creates a new call using the Vonage Voice API. Args: - params (Call): The parameters for the call. + params (CreateCallRequest): The parameters for the call. Returns: CreateCallResponse: The response object containing information about the created call. @@ -28,3 +28,37 @@ def create_call(self, params: Call) -> CreateCallResponse: ) return CreateCallResponse(**response) + + @validate_call + def list_calls(self, params: ListCallsFilter = None) -> CallList: + """Lists calls made with the Vonage Voice API. + + Args: + params (ListCallsFilter): The parameters to filter the list of calls. + + Returns: + CallList: The response object containing information about the calls. + """ + response = self._http_client.get( + self._http_client.api_host, + '/v1/calls', + params.model_dump(by_alias=True, exclude_none=True) if params else None, + ) + + return CallList(**response) + + @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) diff --git a/voice/tests/_test_models.py b/voice/tests/_test_models.py deleted file mode 100644 index b1a3eb81..00000000 --- a/voice/tests/_test_models.py +++ /dev/null @@ -1,138 +0,0 @@ -from pytest import raises -from vonage_verify_v2.enums import ChannelType, Locale -from vonage_verify_v2.errors import VerifyError -from vonage_verify_v2.requests import * - - -def test_create_silent_auth_channel(): - params = { - 'channel': ChannelType.SILENT_AUTH, - 'to': '1234567890', - 'redirect_url': 'https://example.com', - 'sandbox': True, - } - channel = SilentAuthChannel(**params) - - assert channel.model_dump() == params - - -def test_create_sms_channel(): - params = { - 'channel': ChannelType.SMS, - 'to': '1234567890', - 'from_': 'Vonage', - 'entity_id': '12345678901234567890', - 'content_id': '12345678901234567890', - 'app_hash': '12345678901', - } - channel = SmsChannel(**params) - - assert channel.model_dump() == params - assert channel.model_dump(by_alias=True)['from'] == 'Vonage' - - params['from_'] = 'this.is!invalid' - with raises(VerifyError): - SmsChannel(**params) - - -def test_create_whatsapp_channel(): - params = { - 'channel': ChannelType.WHATSAPP, - 'to': '1234567890', - 'from_': 'Vonage', - } - channel = WhatsappChannel(**params) - - assert channel.model_dump() == params - assert channel.model_dump(by_alias=True)['from'] == 'Vonage' - - params['from_'] = 'this.is!invalid' - with raises(VerifyError): - WhatsappChannel(**params) - - -def test_create_voice_channel(): - params = { - 'channel': ChannelType.VOICE, - 'to': '1234567890', - } - channel = VoiceChannel(**params) - - assert channel.model_dump() == params - - -def test_create_email_channel(): - params = { - 'channel': ChannelType.EMAIL, - 'to': 'customer@example.com', - 'from_': 'vonage@vonage.com', - } - channel = EmailChannel(**params) - - assert channel.model_dump() == params - assert channel.model_dump(by_alias=True)['from'] == 'vonage@vonage.com' - - -def test_create_verify_request(): - silent_auth_channel = SilentAuthChannel( - channel=ChannelType.SILENT_AUTH, to='1234567890' - ) - - sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') - params = { - 'brand': 'Vonage', - 'workflow': [sms_channel], - } - # Basic request - - verify_request = VerifyRequest(**params) - assert verify_request.brand == 'Vonage' - assert verify_request.workflow == [sms_channel] - - # Multiple channel request - workflow = [ - SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), - SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), - WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), - VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), - EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), - ] - params = { - 'brand': 'Vonage', - 'workflow': workflow, - } - verify_request = VerifyRequest(**params) - assert verify_request.brand == 'Vonage' - assert verify_request.workflow == workflow - - # All fields - params = { - 'brand': 'Vonage', - 'workflow': [silent_auth_channel, sms_channel, sms_channel], - 'locale': Locale.EN_GB, - 'channel_timeout': 60, - 'client_ref': 'my-client-ref', - 'code_length': 6, - 'code': '123456', - } - verify_request = VerifyRequest(**params) - assert verify_request.brand == 'Vonage' - assert verify_request.workflow == [silent_auth_channel, sms_channel, sms_channel] - assert verify_request.locale == Locale.EN_GB - assert verify_request.channel_timeout == 60 - assert verify_request.client_ref == 'my-client-ref' - assert verify_request.code_length == 6 - assert verify_request.code == '123456' - - -def test_create_verify_request_error(): - params = { - 'brand': 'Vonage', - 'workflow': [ - SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), - SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), - ], - } - with raises(VerifyError) as e: - VerifyRequest(**params) - assert e.match('must be the first channel') diff --git a/voice/tests/_test_verify_v2.py b/voice/tests/_test_verify_v2.py deleted file mode 100644 index 57da7c89..00000000 --- a/voice/tests/_test_verify_v2.py +++ /dev/null @@ -1,184 +0,0 @@ -from os.path import abspath - -import responses -from pytest import raises -from vonage_http_client.errors import HttpRequestError -from vonage_http_client.http_client import HttpClient -from vonage_verify_v2.requests import * -from vonage_verify_v2.verify_v2 import VerifyV2 - -from testutils import build_response, get_mock_jwt_auth - -path = abspath(__file__) - - -verify = VerifyV2(HttpClient(get_mock_jwt_auth())) - - -@responses.activate -def test_make_verify_request(): - build_response( - path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 - ) - silent_auth_channel = SilentAuthChannel( - channel=ChannelType.SILENT_AUTH, to='1234567890' - ) - sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') - params = { - 'brand': 'Vonage', - 'workflow': [silent_auth_channel, sms_channel], - } - request = VerifyRequest(**params) - - response = verify.start_verification(request) - assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' - assert ( - response.check_url - == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' - ) - assert verify._http_client.last_response.status_code == 202 - - -@responses.activate -def test_make_verify_request_full(): - build_response( - path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 - ) - workflow = [ - SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), - SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), - WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), - VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), - EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), - ] - params = { - 'brand': 'Vonage', - 'workflow': workflow, - 'locale': 'en-gb', - 'channel_timeout': 60, - 'client_ref': 'my-client-ref', - 'code_length': 6, - 'code': '123456', - } - request = VerifyRequest(**params) - - response = verify.start_verification(request) - assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' - - -@responses.activate -def test_verify_request_concurrent_verifications_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify', - 'verify_request_error.json', - 409, - ) - sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') - params = { - 'brand': 'Vonage', - 'workflow': [sms_channel], - } - request = VerifyRequest(**params) - - with raises(HttpRequestError) as e: - verify.start_verification(request) - - assert e.value.response.status_code == 409 - assert e.value.response.json()['title'] == 'Conflict' - assert ( - e.value.response.json()['detail'] - == 'Concurrent verifications to the same number are not allowed' - ) - - -@responses.activate -def test_check_code(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - 'check_code.json', - ) - response = verify.check_code( - request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' - ) - assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' - assert response.status == 'completed' - - -@responses.activate -def test_check_code_invalid_code_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - 'check_code_400.json', - 400, - ) - - with raises(HttpRequestError) as e: - verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') - - assert e.value.response.status_code == 400 - assert e.value.response.json()['title'] == 'Invalid Code' - - -@responses.activate -def test_check_code_too_many_attempts(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - 'check_code_410.json', - 410, - ) - - with raises(HttpRequestError) as e: - verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') - - assert e.value.response.status_code == 410 - assert e.value.response.json()['title'] == 'Invalid Code' - - -@responses.activate -def test_cancel_verification(): - responses.add( - responses.DELETE, - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - status=204, - ) - assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None - assert verify._http_client.last_response.status_code == 204 - - -@responses.activate -def test_trigger_next_workflow(): - responses.add( - responses.POST, - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', - status=200, - ) - assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None - assert verify._http_client.last_response.status_code == 200 - - -@responses.activate -def test_trigger_next_event_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', - 'trigger_next_workflow_error.json', - status_code=409, - ) - - with raises(HttpRequestError) as e: - verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') - - assert e.value.response.status_code == 409 - assert e.value.response.json()['title'] == 'Conflict' - assert ( - e.value.response.json()['detail'] == 'There are no more events left to trigger.' - ) diff --git a/voice/tests/data/check_code.json b/voice/tests/data/check_code.json deleted file mode 100644 index 2fbe4b8e..00000000 --- a/voice/tests/data/check_code.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "request_id": "36e7060d-2b23-4257-bad0-773ab47f85ef", - "status": "completed" -} \ No newline at end of file diff --git a/voice/tests/data/check_code_400.json b/voice/tests/data/check_code_400.json deleted file mode 100644 index 23690a83..00000000 --- a/voice/tests/data/check_code_400.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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/voice/tests/data/check_code_410.json b/voice/tests/data/check_code_410.json deleted file mode 100644 index 9d2534c7..00000000 --- a/voice/tests/data/check_code_410.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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/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/trigger_next_workflow_error.json b/voice/tests/data/trigger_next_workflow_error.json deleted file mode 100644 index befd87a7..00000000 --- a/voice/tests/data/trigger_next_workflow_error.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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/voice/tests/data/verify_request.json b/voice/tests/data/verify_request.json deleted file mode 100644 index 719396cc..00000000 --- a/voice/tests/data/verify_request.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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/voice/tests/data/verify_request_error.json b/voice/tests/data/verify_request_error.json deleted file mode 100644 index f40b1b12..00000000 --- a/voice/tests/data/verify_request_error.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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/voice/tests/test_ncco_actions.py b/voice/tests/test_ncco_actions.py index 70737ae5..4b9e9069 100644 --- a/voice/tests/test_ncco_actions.py +++ b/voice/tests/test_ncco_actions.py @@ -9,7 +9,7 @@ def test_record_basic(): assert record.model_dump(by_alias=True, exclude_none=True) == {'action': 'record'} -def test_record_full(): +def test_record_options(): record = ncco.Record( format='wav', split='conversation', @@ -53,7 +53,7 @@ def test_conversation_basic(): } -def test_conversation_full(): +def test_conversation_options(): conversation = ncco.Conversation( name='my_conversation', musicOnHoldUrl=['http://example.com/music.mp3'], @@ -209,7 +209,7 @@ def test_talk_basic(): } -def test_talk_optional_params(): +def test_talk_options(): talk = ncco.Talk( text='hello', bargeIn=True, @@ -231,151 +231,94 @@ def test_talk_optional_params(): } -# def test_stream_basic(): -# stream = Ncco.Stream(streamUrl='https://example.com/stream/music.mp3') -# assert type(stream) == Ncco.Stream -# assert json.dumps(_action_as_dict(stream)) == nas.stream_basic +def test_stream_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_stream_full(): -# stream = Ncco.Stream( -# streamUrl='https://example.com/stream/music.mp3', level=0.1, bargeIn=True, loop=10 -# ) -# assert json.dumps(_action_as_dict(stream)) == nas.stream_full - +def test_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_input_basic(): -# input = Ncco.Input(type='dtmf') -# assert type(input) == Ncco.Input -# assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf - - -# def test_input_basic_list(): -# input = Ncco.Input(type=['dtmf', 'speech']) -# assert json.dumps(_action_as_dict(input)) == nas.input_basic_dtmf_speech - - -# def test_input_dtmf_and_speech_options(): -# dtmf = InputTypes.Dtmf(timeOut=5, maxDigits=12, submitOnHash=True) -# speech = InputTypes.Speech( -# uuid='my-uuid', -# endOnSilence=2.5, -# language='en-GB', -# context=['sales', 'billing'], -# startTimeout=20, -# maxDuration=30, -# saveAudio=True, -# ) -# input = Ncco.Input( -# type=['dtmf', 'speech'], -# dtmf=dtmf, -# speech=speech, -# eventUrl='http://example.com/speech', -# eventMethod='put', -# ) -# assert json.dumps(_action_as_dict(input)) == nas.input_dtmf_and_speech_full - - -# def test_input_validation_error(): -# with pytest.raises(ValidationError): -# Ncco.Input(type='invalid_type') - - -# def test_notify_basic(): -# notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl=['http://example.com']) -# assert type(notify) == Ncco.Notify -# assert json.dumps(_action_as_dict(notify)) == nas.notify_basic - - -# def test_notify_basic_str_in_event_url(): -# notify = Ncco.Notify(payload={'message': 'hello'}, eventUrl='http://example.com') -# assert type(notify) == Ncco.Notify -# assert json.dumps(_action_as_dict(notify)) == nas.notify_basic - - -# def test_notify_full(): -# notify = Ncco.Notify( -# payload={'message': 'hello'}, eventUrl=['http://example.com'], eventMethod='POST' -# ) -# assert type(notify) == Ncco.Notify -# assert json.dumps(_action_as_dict(notify)) == nas.notify_full - - -# def test_notify_validation_error(): -# with pytest.raises(ValidationError): -# Ncco.Notify(payload={'message', 'hello'}, eventUrl=['http://example.com']) - - -# def test_pay_voice_basic(): -# pay = Ncco.Pay(amount='10.00') -# assert type(pay) == Ncco.Pay -# assert json.dumps(_action_as_dict(pay)) == nas.pay_basic - - -# def test_pay_voice_full(): -# voice_settings = PayPrompts.VoicePrompt(language='en-GB', style=1) -# pay = Ncco.Pay( -# amount=99.99, -# currency='gbp', -# eventUrl='https://example.com/payment', -# voice=voice_settings, -# ) -# assert json.dumps(_action_as_dict(pay)) == nas.pay_voice_full - - -# def test_pay_text(): -# text_prompts = PayPrompts.TextPrompt( -# type='CardNumber', -# text='Enter your card number.', -# errors={ -# 'InvalidCardType': { -# 'text': 'The card you are trying to use is not valid for this purchase.' -# } -# }, -# ) -# pay = Ncco.Pay( -# amount=12.345, -# currency='gbp', -# eventUrl='https://example.com/payment', -# prompts=text_prompts, -# ) -# assert json.dumps(_action_as_dict(pay)) == nas.pay_text - - -# def test_pay_text_multiple_prompts(): -# card_prompt = PayPrompts.TextPrompt( -# type='CardNumber', -# text='Enter your card number.', -# errors={ -# 'InvalidCardType': { -# 'text': 'The card you are trying to use is not valid for this purchase.' -# } -# }, -# ) -# expiration_date_prompt = PayPrompts.TextPrompt( -# type='ExpirationDate', -# text='Enter your card expiration date.', -# errors={ -# 'InvalidExpirationDate': { -# 'text': 'You have entered an invalid expiration date.' -# }, -# 'Timeout': {'text': 'Please enter your card\'s expiration date.'}, -# }, -# ) -# security_code_prompt = PayPrompts.TextPrompt( -# type='SecurityCode', -# text='Enter your 3-digit security code.', -# errors={ -# 'InvalidSecurityCode': {'text': 'You have entered an invalid security code.'}, -# 'Timeout': {'text': 'Please enter your card\'s security code.'}, -# }, -# ) - -# text_prompts = [card_prompt, expiration_date_prompt, security_code_prompt] -# pay = Ncco.Pay(amount=12, prompts=text_prompts) -# assert json.dumps(_action_as_dict(pay)) == nas.pay_text_multiple_prompts - - -# def test_pay_validation_error(): -# with pytest.raises(ValidationError): -# Ncco.Pay(amount='not-valid') +def test_notify_options(): + notify = ncco.Notify( + payload={'message': 'hello'}, + eventUrl=['http://example.com'], + eventMethod='POST', + ) + assert notify.model_dump(by_alias=True, exclude_none=True) == { + 'payload': {'message': 'hello'}, + 'eventUrl': ['http://example.com'], + 'eventMethod': 'POST', + 'action': 'notify', + } diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py new file mode 100644 index 00000000..4fa71058 --- /dev/null +++ b/voice/tests/test_voice.py @@ -0,0 +1,154 @@ +from os.path import abspath + +import responses +from pytest import raises + +from vonage_http_client.http_client import HttpClient +from vonage_voice.errors import VoiceError +from vonage_voice.models.ncco import Talk +from vonage_voice.voice import Voice +from vonage_voice.models.requests import CreateCallRequest +from vonage_voice.models.responses import CreateCallResponse + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +voice = Voice(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_create_call_basic_ncco(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], + random_from_number=True, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_ncco_options(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], + from_={'number': '1234567890', 'type': 'phone'}, + event_url=['https://example.com/event'], + event_method='POST', + machine_detection='hangup', + length_timer=60, + ringing_timer=30, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_basic_answer_url(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + call = CreateCallRequest( + to=[ + { + 'type': 'websocket', + 'uri': 'wss://example.com/websocket', + 'content_type': 'audio/l16;rate=8000', + 'headers': {'key': 'value'}, + } + ], + answer_url=['https://example.com/answer'], + random_from_number=True, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_answer_url_options(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + call = CreateCallRequest( + to=[{'type': 'vbc', 'extension': '1234'}], + answer_url=['https://example.com/answer'], + answer_method='GET', + random_from_number=True, + event_url=['https://example.com/event'], + event_method='POST', + advanced_machine_detection={ + 'behavior': 'hangup', + 'mode': 'detect_beep', + 'beep_timeout': 50, + }, + length_timer=60, + ringing_timer=30, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +def test_create_call_ncco_and_answer_url_error(): + with raises(VoiceError) as e: + CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + random_from_number=True, + ) + assert e.match('Either `ncco` or `answer_url` must be set') + + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + answer_url=['https://example.com/answer'], + to=[{'type': 'phone', 'number': '1234567890'}], + random_from_number=True, + ) + assert e.match('`ncco` and `answer_url` cannot be used together') + + +def test_create_call_from_and_random_from_number_error(): + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + to=[{'type': 'phone', 'number': '1234567890'}], + ) + assert e.match('Either `from_` or `random_from_number` must be set') + + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + to=[{'type': 'phone', 'number': '1234567890'}], + from_={'number': '9876543210', 'type': 'phone'}, + random_from_number=True, + ) + assert e.match('`from_` and `random_from_number` cannot be used together') diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py index 63bd319c..348f9ebf 100644 --- a/vonage_utils/src/vonage_utils/types.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -1,5 +1,5 @@ from pydantic import Field -from typing_extensions import Annotated +from typing import Annotated PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] Dtmf = Annotated[str, Field(pattern=r'^[0-9#*p]+$')] From 016e34c256249f2269a1bfaa35057767803d0d33 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 19 Apr 2024 03:43:42 +0100 Subject: [PATCH 36/98] get list_calls working --- users/src/vonage_users/__init__.py | 4 +- users/src/vonage_users/common.py | 5 +- users/src/vonage_users/requests.py | 2 +- users/src/vonage_users/responses.py | 1 - users/src/vonage_users/users.py | 18 ++-- users/tests/test_users.py | 6 +- voice/src/vonage_voice/models/common.py | 30 +++++++ voice/src/vonage_voice/models/ncco.py | 2 +- voice/src/vonage_voice/models/requests.py | 37 +------- voice/src/vonage_voice/models/responses.py | 23 ++--- voice/src/vonage_voice/voice.py | 20 +++-- voice/tests/data/list_calls.json | 95 +++++++++++++++++++++ voice/tests/data/list_calls_pagination.json | 51 +++++++++++ voice/tests/test_voice.py | 41 ++++++++- vonage_utils/src/vonage_utils/__init__.py | 5 +- vonage_utils/src/vonage_utils/models.py | 5 ++ vonage_utils/src/vonage_utils/types.py | 3 +- 17 files changed, 270 insertions(+), 78 deletions(-) create mode 100644 voice/tests/data/list_calls.json create mode 100644 voice/tests/data/list_calls_pagination.json create mode 100644 vonage_utils/src/vonage_utils/models.py diff --git a/users/src/vonage_users/__init__.py b/users/src/vonage_users/__init__.py index bb33e3c5..159bf010 100644 --- a/users/src/vonage_users/__init__.py +++ b/users/src/vonage_users/__init__.py @@ -1,11 +1,11 @@ from .common import User -from .requests import ListUsersRequest +from .requests import ListUsersFilter from .responses import UserSummary from .users import Users __all__ = [ 'Users', 'User', - 'ListUsersRequest', + 'ListUsersFilter', 'UserSummary', ] diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index ab1118ca..6e2bc10e 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,13 +1,10 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import Link from vonage_utils.types import PhoneNumber -class Link(BaseModel): - href: str - - class ResourceLink(BaseModel): self: Link diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py index d6943487..5a38507b 100644 --- a/users/src/vonage_users/requests.py +++ b/users/src/vonage_users/requests.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field -class ListUsersRequest(BaseModel): +class ListUsersFilter(BaseModel): """Request object for listing users.""" page_size: Optional[int] = Field(100, ge=1, le=100) diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 22e36c9b..387f3335 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -19,7 +19,6 @@ class UserSummary(BaseModel): link: Optional[str] = None @model_validator(mode='after') - @classmethod def get_link(self): if self.links is not None: self.link = self.links.self.href diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index 5b87db64..110dcbb9 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -5,7 +5,7 @@ from vonage_http_client.http_client import HttpClient from .common import User -from .requests import ListUsersRequest +from .requests import ListUsersFilter from .responses import ListUsersResponse, UserSummary @@ -22,23 +22,27 @@ def __init__(self, http_client: HttpClient) -> None: @validate_call def list_users( - self, params: ListUsersRequest = ListUsersRequest() - ) -> Tuple[List[UserSummary], str]: + 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. + If you want to see more information about a specific user, you can use the + `Users.get_user` method. Args: - params (ListUsersRequest, optional): An instance of the ListUsersRequest class that allows you to specify additional parameters for the user listing. + params (ListUsersFilter, optional): An instance of the `ListUsersFilter` + class that allows you to specify additional parameters for the user listing. Returns: - Tuple[List[UserSummary], str]: A tuple containing a list of UserSummary objects representing the users and a string representing the next cursor for pagination. + 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', - params.model_dump(exclude_none=True), + filter.model_dump(exclude_none=True), self._auth_type, ) diff --git a/users/tests/test_users.py b/users/tests/test_users.py index 1138191b..5aec3e71 100644 --- a/users/tests/test_users.py +++ b/users/tests/test_users.py @@ -5,7 +5,7 @@ from vonage_http_client.http_client import HttpClient from vonage_users import Users from vonage_users.common import * -from vonage_users.requests import ListUsersRequest +from vonage_users.requests import ListUsersFilter from testutils import build_response, get_mock_jwt_auth @@ -21,7 +21,7 @@ def test_create_list_users_request(): 'cursor': '7EjDNQrAcipmOnc0HCzpQRkhBULzY44ljGUX4lXKyUIVfiZay5pv9wg=', 'name': 'my_user', } - list_users_request = ListUsersRequest(**params) + list_users_request = ListUsersFilter(**params) assert list_users_request.model_dump() == params @@ -52,7 +52,7 @@ def test_list_users_options(): path, 'GET', 'https://api.nexmo.com/v1/users', 'list_users_options.json' ) - params = ListUsersRequest( + params = ListUsersFilter( page_size=2, order='asc', cursor='zAmuSchIBsUF1QaaohGdaf32NgHOkP130XeQrZkoOPEuGPnIxFb0Xj3iqCfOzxSSq9Es/S/2h+HYumKt3HS0V9ewjis+j74oMcsvYBLN1PwFEupI6ENEWHYC7lk=', diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index 737ab4d7..c5119e42 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -1,6 +1,36 @@ from typing import Literal, Optional from pydantic import BaseModel, Field +from vonage_utils.types import Dtmf, PhoneNumber, SipUri +from vonage_voice.models.enums import Channel + + +class Phone(BaseModel): + number: PhoneNumber + type: Channel = Channel.PHONE + + +class ToPhone(Phone): + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + + +class Sip(BaseModel): + uri: SipUri + type: Channel = Channel.SIP + + +class Websocket(BaseModel): + uri: str = Field(..., min_length=1, max_length=50) + content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( + 'audio/l16;rate=16000', serialization_alias='content-type' + ) + type: Channel = Channel.WEBSOCKET + headers: Optional[dict] = None + + +class Vbc(BaseModel): + extension: str + type: Channel = Channel.VBC class AdvancedMachineDetection(BaseModel): diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index 80c28f75..fb2607f3 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Optional, Union +from typing import List, Optional, Union from pydantic import BaseModel, Field, model_validator from typing_extensions import Literal diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index 3fbeb504..b325eec8 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -1,40 +1,11 @@ from typing import List, Literal, Optional, Union from pydantic import BaseModel, Field, model_validator -from vonage_utils.types import Dtmf, PhoneNumber, SipUri from ..errors import VoiceError -from .common import AdvancedMachineDetection -from .enums import CallStatus, Channel -from .ncco import Record, Conversation, Connect, Input, Talk, Stream, Notify - - -class Phone(BaseModel): - number: PhoneNumber - type: Channel = Channel.PHONE - - -class ToPhone(Phone): - dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') - - -class Sip(BaseModel): - uri: SipUri - type: Channel = Channel.SIP - - -class Websocket(BaseModel): - uri: str = Field(..., min_length=1, max_length=50) - content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( - 'audio/l16;rate=16000', serialization_alias='content-type' - ) - type: Channel = Channel.WEBSOCKET - headers: Optional[dict] = None - - -class Vbc(BaseModel): - extension: str - type: Channel = Channel.VBC +from .common import AdvancedMachineDetection, Phone, Sip, ToPhone, Vbc, Websocket +from .enums import CallStatus +from .ncco import Connect, Conversation, Input, Notify, Record, Stream, Talk class CreateCallRequest(BaseModel): @@ -73,7 +44,7 @@ class ListCallsFilter(BaseModel): status: Optional[CallStatus] = None date_start: Optional[str] = None date_end: Optional[str] = None - page_size: Optional[int] = Field(None, ge=1, le=100) + 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 diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py index f81144f6..1fe82a6a 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -1,5 +1,9 @@ -from typing import List, Optional +from typing import List, Optional, Union + from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import Link + +from .common import Phone, Sip, ToPhone, Vbc, Websocket class CreateCallResponse(BaseModel): @@ -14,27 +18,19 @@ class CallStatus(BaseModel): uuid: str -class Link(BaseModel): - href: str - - class Links(BaseModel): self: Link first: Optional[Link] = None - next: Optional[Link] = None + last: Optional[Link] = None prev: Optional[Link] = None - - -class Endpoint(BaseModel): - type: str - number: str + next: Optional[Link] = None class CallInfo(BaseModel): uuid: str conversation_uuid: str - to: Endpoint - from_: Endpoint = Field(..., alias='from') + to: ToPhone + from_: Union[Phone, Sip, Websocket, Vbc] = Field(..., validation_alias='from') status: str direction: str rate: Optional[str] = None @@ -47,7 +43,6 @@ class CallInfo(BaseModel): link: Optional[str] = None @model_validator(mode='after') - @classmethod def get_link(self): self.link = self.links.self.href return self diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index 2bdd0287..6964b630 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -1,3 +1,5 @@ +from typing import List, Optional, Tuple + from pydantic import validate_call from vonage_http_client.http_client import HttpClient @@ -30,22 +32,30 @@ def create_call(self, params: CreateCallRequest) -> CreateCallResponse: return CreateCallResponse(**response) @validate_call - def list_calls(self, params: ListCallsFilter = None) -> CallList: + def list_calls( + self, filter: ListCallsFilter = ListCallsFilter() + ) -> Tuple[List[CallInfo], Optional[int]]: """Lists calls made with the Vonage Voice API. Args: - params (ListCallsFilter): The parameters to filter the list of calls. + filter (ListCallsFilter): The parameters to filter the list of calls. Returns: - CallList: The response object containing information about the calls. + 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', - params.model_dump(by_alias=True, exclude_none=True) if params else None, + filter.model_dump(by_alias=True, exclude_none=True), ) - return CallList(**response) + 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: 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_pagination.json b/voice/tests/data/list_calls_pagination.json new file mode 100644 index 00000000..23f1ab90 --- /dev/null +++ b/voice/tests/data/list_calls_pagination.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/test_voice.py b/voice/tests/test_voice.py index 4fa71058..6315ebcc 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -2,13 +2,12 @@ import responses from pytest import raises - from vonage_http_client.http_client import HttpClient from vonage_voice.errors import VoiceError from vonage_voice.models.ncco import Talk -from vonage_voice.voice import Voice -from vonage_voice.models.requests import CreateCallRequest +from vonage_voice.models.requests import CreateCallRequest, ListCallsFilter from vonage_voice.models.responses import CreateCallResponse +from vonage_voice.voice import Voice from testutils import build_response, get_mock_jwt_auth @@ -152,3 +151,39 @@ def test_create_call_from_and_random_from_number_error(): 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) + response, _ = voice.list_calls() + assert len(response) == 3 + assert response[0].to.number == '1234567890' + assert response[0].from_.number == '9876543210' + assert response[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' + from pprint import pprint + + pprint(response) + assert 0 + + +def test_list_calls_filter(): + 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 diff --git a/vonage_utils/src/vonage_utils/__init__.py b/vonage_utils/src/vonage_utils/__init__.py index a0103b6a..8c229ce2 100644 --- a/vonage_utils/src/vonage_utils/__init__.py +++ b/vonage_utils/src/vonage_utils/__init__.py @@ -1,6 +1,5 @@ -import types - +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', 'types'] +__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', 'models', 'types'] diff --git a/vonage_utils/src/vonage_utils/models.py b/vonage_utils/src/vonage_utils/models.py new file mode 100644 index 00000000..8e3dd369 --- /dev/null +++ b/vonage_utils/src/vonage_utils/models.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class Link(BaseModel): + href: str diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py index 348f9ebf..b3d37bfc 100644 --- a/vonage_utils/src/vonage_utils/types.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -1,6 +1,7 @@ -from pydantic import Field from typing import Annotated +from pydantic import Field + PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] Dtmf = Annotated[str, Field(pattern=r'^[0-9#*p]+$')] SipUri = Annotated[str, Field(pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)')] From c8e2f69bcb6be3c6ab52e7430f7ef84d608e3114 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 22 Apr 2024 15:57:35 +0100 Subject: [PATCH 37/98] add more voice methods --- .../src/vonage_http_client/http_client.py | 14 +- testutils/testutils.py | 2 +- voice/src/vonage_voice/models/common.py | 6 +- voice/src/vonage_voice/models/enums.py | 2 +- voice/src/vonage_voice/models/requests.py | 12 +- voice/src/vonage_voice/models/responses.py | 6 +- voice/src/vonage_voice/voice.py | 87 +++++++++- voice/tests/data/get_call.json | 25 +++ ...pagination.json => list_calls_filter.json} | 0 voice/tests/test_voice.py | 149 ++++++++++++++++-- 10 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 voice/tests/data/get_call.json rename voice/tests/data/{list_calls_pagination.json => list_calls_filter.json} (100%) diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index c8df1430..89bfd7d0 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -161,6 +161,18 @@ def patch( '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, @@ -176,7 +188,7 @@ def delete( @validate_call def make_request( self, - request_type: Literal['GET', 'POST', 'PATCH', 'DELETE'], + request_type: Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], host: str, request_path: str = '', params: Optional[dict] = None, diff --git a/testutils/testutils.py b/testutils/testutils.py index 8bbfec60..8a7f46c9 100644 --- a/testutils/testutils.py +++ b/testutils/testutils.py @@ -17,7 +17,7 @@ def _filter_none_values(data: dict) -> dict: @validate_call def build_response( file_path: str, - method: Literal['GET', 'POST', 'PATCH', 'DELETE'], + method: Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], url: str, mock_path: str = None, status_code: int = 200, diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index c5119e42..c0e92978 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -1,7 +1,7 @@ from typing import Literal, Optional from pydantic import BaseModel, Field -from vonage_utils.types import Dtmf, PhoneNumber, SipUri +from vonage_utils.types import PhoneNumber, SipUri from vonage_voice.models.enums import Channel @@ -10,10 +10,6 @@ class Phone(BaseModel): type: Channel = Channel.PHONE -class ToPhone(Phone): - dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') - - class Sip(BaseModel): uri: SipUri type: Channel = Channel.SIP diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py index bf565153..a6183664 100644 --- a/voice/src/vonage_voice/models/enums.py +++ b/voice/src/vonage_voice/models/enums.py @@ -26,7 +26,7 @@ class ConnectEndpointType(str, Enum): VBC = 'vbc' -class CallStatus(str, Enum): +class CallState(str, Enum): STARTED = 'started' RINGING = 'ringing' ANSWERED = 'answered' diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index b325eec8..09a4c0d1 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -2,12 +2,18 @@ from pydantic import BaseModel, Field, model_validator +from vonage_utils.types import Dtmf + from ..errors import VoiceError -from .common import AdvancedMachineDetection, Phone, Sip, ToPhone, Vbc, Websocket -from .enums import CallStatus +from .common import AdvancedMachineDetection, Phone, Sip, Vbc, Websocket +from .enums import CallState from .ncco import Connect, Conversation, Input, Notify, Record, Stream, Talk +class ToPhone(Phone): + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + + class CreateCallRequest(BaseModel): ncco: List[Union[Record, Conversation, Connect, Input, Talk, Stream, Notify]] = None answer_url: List[str] = None @@ -41,7 +47,7 @@ def validate_from_and_random_from_number(self): class ListCallsFilter(BaseModel): - status: Optional[CallStatus] = None + status: Optional[CallState] = None date_start: Optional[str] = None date_end: Optional[str] = None page_size: Optional[int] = Field(100, ge=1, le=100) diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py index 1fe82a6a..8644e7e2 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, model_validator from vonage_utils.models import Link -from .common import Phone, Sip, ToPhone, Vbc, Websocket +from .common import Phone, Sip, Vbc, Websocket class CreateCallResponse(BaseModel): @@ -13,7 +13,7 @@ class CreateCallResponse(BaseModel): conversation_uuid: str -class CallStatus(BaseModel): +class CallMessage(BaseModel): message: str uuid: str @@ -29,7 +29,7 @@ class Links(BaseModel): class CallInfo(BaseModel): uuid: str conversation_uuid: str - to: ToPhone + to: Phone from_: Union[Phone, Sip, Websocket, Vbc] = Field(..., validation_alias='from') status: str direction: str diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index 6964b630..9f62bc3e 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -2,9 +2,10 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_voice.models.ncco import NccoAction from .models.requests import CreateCallRequest, ListCallsFilter -from .models.responses import CallInfo, CallList, CreateCallResponse +from .models.responses import CallInfo, CallList, CallMessage, CreateCallResponse class Voice: @@ -72,3 +73,87 @@ def get_call(self, call_id: str) -> CallInfo: ) 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'} + ) 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_pagination.json b/voice/tests/data/list_calls_filter.json similarity index 100% rename from voice/tests/data/list_calls_pagination.json rename to voice/tests/data/list_calls_filter.json diff --git a/voice/tests/test_voice.py b/voice/tests/test_voice.py index 6315ebcc..ad7d2d96 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -1,6 +1,7 @@ from os.path import abspath import responses +from responses.matchers import json_params_matcher from pytest import raises from vonage_http_client.http_client import HttpClient from vonage_voice.errors import VoiceError @@ -156,18 +157,21 @@ def test_create_call_from_and_random_from_number_error(): @responses.activate def test_list_calls(): build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) - response, _ = voice.list_calls() - assert len(response) == 3 - assert response[0].to.number == '1234567890' - assert response[0].from_.number == '9876543210' - assert response[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' - from pprint import pprint - - pprint(response) - assert 0 + 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', @@ -187,3 +191,130 @@ def test_list_calls_filter(): '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 From 2efdfaaa0e47770b9bab99a94686b1c3a523f01a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 22 Apr 2024 21:13:55 +0100 Subject: [PATCH 38/98] finish voice api, updating for release --- http_client/CHANGES.md | 3 + http_client/pyproject.toml | 6 +- messages/CHANGES.md | 3 + messages/pyproject.toml | 6 +- messages/src/vonage_messages/messages.py | 9 ++ messages/tests/test_messages.py | 6 ++ number_insight_v2/CHANGES.md | 2 + number_insight_v2/pyproject.toml | 6 +- .../number_insight_v2.py | 9 ++ .../tests/test_number_insight_v2.py | 4 + requirements.txt | 2 +- sms/CHANGES.md | 3 + sms/pyproject.toml | 6 +- sms/src/vonage_sms/sms.py | 9 ++ sms/tests/test_sms.py | 5 + users/CHANGES.md | 5 + users/pyproject.toml | 6 +- users/src/vonage_users/users.py | 9 ++ users/tests/test_users.py | 5 + verify/CHANGES.md | 3 + verify/pyproject.toml | 6 +- verify/src/vonage_verify/verify.py | 9 ++ verify/tests/test_verify.py | 5 + verify_v2/CHANGES.md | 3 + verify_v2/pyproject.toml | 6 +- verify_v2/src/vonage_verify_v2/verify_v2.py | 9 ++ verify_v2/tests/test_verify_v2.py | 5 + voice/README.md | 20 ---- voice/pyproject.toml | 6 +- voice/src/vonage_voice/__init__.py | 3 +- voice/src/vonage_voice/models/__init__.py | 74 +++++++++---- .../vonage_voice/models/connect_endpoints.py | 3 +- voice/src/vonage_voice/models/enums.py | 49 +++++++++ voice/src/vonage_voice/models/ncco.py | 3 +- voice/src/vonage_voice/models/requests.py | 18 +++- voice/src/vonage_voice/voice.py | 100 +++++++++++++++++- voice/tests/data/play_audio_into_call.json | 4 + voice/tests/data/play_dtmf_into_call.json | 4 + voice/tests/data/play_tts_into_call.json | 4 + voice/tests/data/stop_audio_stream.json | 4 + voice/tests/data/stop_tts.json | 4 + voice/tests/test_voice.py | 94 +++++++++++++++- vonage/CHANGES.md | 1 + vonage/pyproject.toml | 16 +-- vonage/src/vonage/_version.py | 2 +- vonage_utils/CHANGES.md | 5 + vonage_utils/pyproject.toml | 4 +- vonage_utils/src/vonage_utils/types.py | 3 +- 48 files changed, 480 insertions(+), 91 deletions(-) create mode 100644 number_insight_v2/CHANGES.md create mode 100644 voice/tests/data/play_audio_into_call.json create mode 100644 voice/tests/data/play_dtmf_into_call.json create mode 100644 voice/tests/data/play_tts_into_call.json create mode 100644 voice/tests/data/stop_audio_stream.json create mode 100644 voice/tests/data/stop_tts.json diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 528d4668..bf6dde61 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.3.0 +- Add new PUT method + # 1.2.1 - Expose classes and errors at the package level diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index 683dc16c..25e3fbe9 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,16 +1,16 @@ [project] name = "vonage-http-client" -version = "1.2.1" +version = "1.3.0" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.0.1", + "vonage-utils>=1.1.0", "vonage-jwt>=1.1.0", "requests>=2.27.0", + "typing-extensions>=4.9.0", "pydantic>=2.6.1", - "typing_extensions>=4.9.0", ] classifiers = [ "Programming Language :: Python", diff --git a/messages/CHANGES.md b/messages/CHANGES.md index be516a55..2d2c13ed 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,2 +1,5 @@ +# 1.1.0 +- Add `http_client` property + # 1.0.0 - Initial upload diff --git a/messages/pyproject.toml b/messages/pyproject.toml index be11ac64..8d74d007 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-messages' -version = '1.0.0' +version = '1.1.0' description = 'Vonage messages package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.1", - "vonage-utils>=1.0.1", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index e1d32d35..874c9b2c 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -17,6 +17,15 @@ class Messages: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Messages API. + + Returns: + HttpClient: The HTTP client used to make requests to the Messages API. + """ + return self._http_client + @validate_call def send(self, message: BaseMessage) -> SendMessageResponse: """Send a message using Vonage's Messages API. diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index a57ca024..3bc2e01a 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -75,3 +75,9 @@ def test_send_message_invalid_error(): 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 diff --git a/number_insight_v2/CHANGES.md b/number_insight_v2/CHANGES.md new file mode 100644 index 00000000..6e846e2d --- /dev/null +++ b/number_insight_v2/CHANGES.md @@ -0,0 +1,2 @@ +# 0.1.0b0 +- Beta release \ No newline at end of file diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml index 73f68e4b..e200b817 100644 --- a/number_insight_v2/pyproject.toml +++ b/number_insight_v2/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-number-insight-v2' -version = '0.1.0' +version = '0.1.0b0' description = 'Vonage Number Insight v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.0.0", - "vonage-utils>=1.0.0", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py index 8836f802..6f6721b2 100644 --- a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py +++ b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py @@ -59,6 +59,15 @@ def __init__(self, http_client: HttpClient) -> None: self._http_client = deepcopy(http_client) self._auth_type = 'basic' + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Number Insight V2 API. + + Returns: + HttpClient: The HTTP client used to make requests to the Number Insight V2 API. + """ + return self._http_client + @validate_call def fraud_check(self, request: FraudCheckRequest) -> FraudCheckResponse: """Initiate a fraud check request.""" diff --git a/number_insight_v2/tests/test_number_insight_v2.py b/number_insight_v2/tests/test_number_insight_v2.py index c63982a0..31a89181 100644 --- a/number_insight_v2/tests/test_number_insight_v2.py +++ b/number_insight_v2/tests/test_number_insight_v2.py @@ -92,3 +92,7 @@ def test_ni2_sim_swap_only(): assert 'fraud_score' not in clear_response assert 'sim_swap' in clear_response assert 'reason' not in clear_response['sim_swap'] + + +def test_number_insight_v2_http_client(): + assert type(ni2.http_client) == HttpClient diff --git a/requirements.txt b/requirements.txt index 458067d5..d0f9aefb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,5 @@ pytest>=8.0.0 requests>=2.31.0 responses>=0.24.1 pydantic>=2.6.1 -typing_extensions>=4.9.0 +typing-extensions>=4.9.0 vonage-jwt>=1.1.0 \ No newline at end of file diff --git a/sms/CHANGES.md b/sms/CHANGES.md index 336bbca8..d1ba477e 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.0 +- Add `http_client` property + # 1.0.2 - Internal refactoring diff --git a/sms/pyproject.toml b/sms/pyproject.toml index 1e20fdac..b2c52642 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-sms' -version = '1.0.2' +version = '1.1.0' description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.1.1", - "vonage-utils>=1.0.0", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py index be5839ca..b18a434a 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -19,6 +19,15 @@ def __init__(self, http_client: HttpClient) -> None: 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.""" diff --git a/sms/tests/test_sms.py b/sms/tests/test_sms.py index c6cb21ac..520d5c15 100644 --- a/sms/tests/test_sms.py +++ b/sms/tests/test_sms.py @@ -171,3 +171,8 @@ def test_submit_sms_conversion_402(): sms.submit_sms_conversion('3295d748-4e14-4681-af78-166dca3c5aab') except HttpRequestError as err: assert err.message == '402 response from https://api.nexmo.com/conversions/sms.' + + +def test_http_client_property(): + sms = Sms(HttpClient(Auth(api_key='qwerasdf', api_secret='1234qwerasdfzxcv'))) + assert isinstance(sms.http_client, HttpClient) diff --git a/users/CHANGES.md b/users/CHANGES.md index a28aa741..368fd9f2 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,8 @@ +# 1.1.0 +- Add `http_client` property +- Rename `ListUsersRequest` -> `ListUsersFilter` +- Internal refactoring + # 1.0.1 - Internal refactoring diff --git a/users/pyproject.toml b/users/pyproject.toml index 38337222..3a261fe0 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-users' -version = '1.0.1' +version = '1.1.0' description = 'Vonage Users package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.0", - "vonage-utils>=1.0.1", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index 110dcbb9..3f685ac9 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -20,6 +20,15 @@ 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() diff --git a/users/tests/test_users.py b/users/tests/test_users.py index 5aec3e71..0112b705 100644 --- a/users/tests/test_users.py +++ b/users/tests/test_users.py @@ -251,3 +251,8 @@ def test_delete_user(): status=204, ) assert users.delete_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') is None + + +def test_http_client_property(): + http_client = users.http_client + assert isinstance(http_client, HttpClient) diff --git a/verify/CHANGES.md b/verify/CHANGES.md index a28aa741..09439bfb 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.0 +- Add `http_client` property + # 1.0.1 - Internal refactoring diff --git a/verify/pyproject.toml b/verify/pyproject.toml index 01ba042d..ba5df186 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-verify' -version = '1.0.1' +version = '1.1.0' description = 'Vonage verify package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.1", - "vonage-utils>=1.0.1", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index b26b4c66..e13e3fdb 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -26,6 +26,15 @@ def __init__(self, http_client: HttpClient) -> None: self._sent_data_type = 'form' self._auth_type = 'body' + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Verify API. + + Returns: + HttpClient: The HTTP client used to make requests to the Verify API. + """ + return self._http_client + @validate_call def start_verification( self, verify_request: VerifyRequest diff --git a/verify/tests/test_verify.py b/verify/tests/test_verify.py index d8a5af19..80a58ace 100644 --- a/verify/tests/test_verify.py +++ b/verify/tests/test_verify.py @@ -297,3 +297,8 @@ def test_request_network_unblock_error(): == 'The network you provided does not have an active block.' ) assert e.response.json()['title'] == 'Not Found' + + +def test_http_client_property(): + verify = Verify(HttpClient(get_mock_api_key_auth())) + assert isinstance(verify.http_client, HttpClient) diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md index be516a55..2d2c13ed 100644 --- a/verify_v2/CHANGES.md +++ b/verify_v2/CHANGES.md @@ -1,2 +1,5 @@ +# 1.1.0 +- Add `http_client` property + # 1.0.0 - Initial upload diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml index 1b4894dc..b4c774ea 100644 --- a/verify_v2/pyproject.toml +++ b/verify_v2/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-verify-v2' -version = '1.0.0' +version = '1.1.0' description = 'Vonage verify v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.1", - "vonage-utils>=1.0.1", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/verify_v2/src/vonage_verify_v2/verify_v2.py b/verify_v2/src/vonage_verify_v2/verify_v2.py index 472033a4..98b56fa6 100644 --- a/verify_v2/src/vonage_verify_v2/verify_v2.py +++ b/verify_v2/src/vonage_verify_v2/verify_v2.py @@ -11,6 +11,15 @@ class VerifyV2: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Verify V2 API. + + Returns: + HttpClient: The HTTP client used to make requests to the Verify V2 API. + """ + return self._http_client + @validate_call def start_verification( self, verify_request: VerifyRequest diff --git a/verify_v2/tests/test_verify_v2.py b/verify_v2/tests/test_verify_v2.py index 57da7c89..a17a3832 100644 --- a/verify_v2/tests/test_verify_v2.py +++ b/verify_v2/tests/test_verify_v2.py @@ -182,3 +182,8 @@ def test_trigger_next_event_error(): 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/voice/README.md b/voice/README.md index cf2e611b..dec31846 100644 --- a/voice/README.md +++ b/voice/README.md @@ -2,23 +2,3 @@ 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. -## 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`. - -### Create a Call - - -### Note on URLs - -The Voice API requires most URLs to be passed in a list with only one element. When creating models, simply pass the url and the model will marshal it into the correct structure for you. - -e.g. - -```python -# Don't do this - - -# Do this - -``` \ No newline at end of file diff --git a/voice/pyproject.toml b/voice/pyproject.toml index 1de9acac..3f422d70 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-voice' -version = '1.0.0' +version = '1.0.1' description = 'Vonage voice package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.2.1", - "vonage-utils>=1.0.1", + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", "pydantic>=2.6.1", ] classifiers = [ diff --git a/voice/src/vonage_voice/__init__.py b/voice/src/vonage_voice/__init__.py index 2df5dea0..b73b81f1 100644 --- a/voice/src/vonage_voice/__init__.py +++ b/voice/src/vonage_voice/__init__.py @@ -1,3 +1,4 @@ +from . import errors, models from .voice import Voice -__all__ = ['Voice'] +__all__ = ['Voice', 'errors', 'models'] diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index 5b138b6c..55121a7a 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -1,4 +1,4 @@ -from .common import AdvancedMachineDetection +from .common import AdvancedMachineDetection, Phone, Sip, Vbc, Websocket from .connect_endpoints import ( AppEndpoint, OnAnswer, @@ -7,39 +7,67 @@ VbcEndpoint, WebsocketEndpoint, ) -from .enums import Channel, ConnectEndpointType, NccoActionType +from .enums import ( + CallState, + Channel, + ConnectEndpointType, + NccoActionType, + TtsLanguageCode, +) from .input_types import Dtmf, Speech from .ncco import Connect, Conversation, Input, NccoAction, Notify, Record, Stream, Talk -from .requests import CreateCallRequest, Phone, Sip, ToPhone, Vbc, Websocket -from .responses import CallStatus, CreateCallResponse +from .requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + ToPhone, + TtsStreamOptions, +) +from .responses import ( + CallInfo, + CallList, + CallMessage, + CreateCallResponse, + Embedded, + Links, +) __all__ = [ 'AdvancedMachineDetection', + 'AppEndpoint', + 'AudioStreamOptions', + 'CallInfo', + 'CallList', + 'CallMessage', + 'CallState', + 'Channel', + 'Connect', + 'ConnectEndpointType', + 'Conversation', 'CreateCallRequest', - 'ToPhone', - 'Sip', - 'Websocket', - 'Vbc', - 'Phone', + 'CreateCallResponse', + 'Dtmf', + 'Embedded', + 'Input', + 'ListCallsFilter', + 'Links', 'NccoAction', - 'Channel', 'NccoActionType', - 'ConnectEndpointType', + 'Notify', 'OnAnswer', + 'Phone', 'PhoneEndpoint', - 'AppEndpoint', - 'WebsocketEndpoint', + 'Record', + 'Sip', 'SipEndpoint', - 'VbcEndpoint', - 'Dtmf', 'Speech', - 'CreateCallResponse', - 'CallStatus', - 'Record', - 'Conversation', - 'Connect', - 'Talk', 'Stream', - 'Input', - 'Notify', + 'Talk', + 'ToPhone', + 'TtsLanguageCode', + 'TtsStreamOptions', + 'Vbc', + 'VbcEndpoint', + 'Websocket', + 'WebsocketEndpoint', ] diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index 711cd91a..b5c65e58 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -1,7 +1,6 @@ -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, Field -from typing_extensions import Literal from vonage_utils.types import Dtmf, PhoneNumber, SipUri from .enums import ConnectEndpointType diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py index a6183664..faa007ca 100644 --- a/voice/src/vonage_voice/models/enums.py +++ b/voice/src/vonage_voice/models/enums.py @@ -38,3 +38,52 @@ class CallState(str, Enum): 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/ncco.py b/voice/src/vonage_voice/models/ncco.py index fb2607f3..875e4d0b 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -1,7 +1,6 @@ -from typing import List, Optional, Union +from typing import List, Literal, Optional, Union from pydantic import BaseModel, Field, model_validator -from typing_extensions import Literal from vonage_utils.types import PhoneNumber from vonage_voice.errors import NccoActionError from vonage_voice.models.common import AdvancedMachineDetection diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index 09a4c0d1..ef8b90f7 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -1,12 +1,11 @@ from typing import List, 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 +from .enums import CallState, TtsLanguageCode from .ncco import Connect, Conversation, Input, Notify, Record, Stream, Talk @@ -54,3 +53,18 @@ class ListCallsFilter(BaseModel): record_index: Optional[int] = None order: Optional[Literal['asc', 'desc']] = None conversation_uuid: Optional[str] = None + + +class AudioStreamOptions(BaseModel): + stream_url: List[str] + loop: Optional[int] = Field(None, ge=0) + level: Optional[float] = Field(None, ge=-1, le=1) + + +class TtsStreamOptions(BaseModel): + 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/voice.py b/voice/src/vonage_voice/voice.py index 9f62bc3e..f134210c 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -2,9 +2,15 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_utils.types import Dtmf from vonage_voice.models.ncco import NccoAction -from .models.requests import CreateCallRequest, ListCallsFilter +from .models.requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + TtsStreamOptions, +) from .models.responses import CallInfo, CallList, CallMessage, CreateCallResponse @@ -14,6 +20,15 @@ class Voice: 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. @@ -157,3 +172,86 @@ def unearmuff(self, uuid: str) -> None: 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. + """ + 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. + """ + response = self._http_client.delete( + self._http_client.api_host, f'/v1/calls/{uuid}/talk' + ) + + return CallMessage(**response) + + @validate_call + def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: + """Plays DTMF tones into a call. + + Args: + uuid (str): The UUID of the call to play DTMF tones into. + dtmf (Dtmf): The DTMF tones to play. + + Returns: + CallMessage: Object with information about the call. + """ + response = self._http_client.put( + self._http_client.api_host, + f'/v1/calls/{uuid}/dtmf', + {'digits': dtmf}, + ) + + return CallMessage(**response) diff --git a/voice/tests/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_voice.py b/voice/tests/test_voice.py index ad7d2d96..313751c8 100644 --- a/voice/tests/test_voice.py +++ b/voice/tests/test_voice.py @@ -1,12 +1,17 @@ from os.path import abspath import responses -from responses.matchers import json_params_matcher from pytest import raises +from responses.matchers import json_params_matcher from vonage_http_client.http_client import HttpClient from vonage_voice.errors import VoiceError from vonage_voice.models.ncco import Talk -from vonage_voice.models.requests import CreateCallRequest, ListCallsFilter +from vonage_voice.models.requests import ( + AudioStreamOptions, + CreateCallRequest, + ListCallsFilter, + TtsStreamOptions, +) from vonage_voice.models.responses import CreateCallResponse from vonage_voice.voice import Voice @@ -18,6 +23,10 @@ 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( @@ -318,3 +327,84 @@ def test_unearmuff(): voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_play_audio_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/stream', + 'play_audio_into_call.json', + ) + + options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 + ) + response = voice.play_audio_into_call(uuid, options) + assert response.message == 'Stream started' + assert response.uuid == uuid + + +@responses.activate +def test_stop_audio_stream(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'DELETE', + f'https://api.nexmo.com/v1/calls/{uuid}/stream', + 'stop_audio_stream.json', + ) + + response = voice.stop_audio_stream(uuid) + assert response.message == 'Stream stopped' + assert response.uuid == uuid + + +@responses.activate +def test_play_tts_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/talk', + 'play_tts_into_call.json', + ) + + options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 + ) + response = voice.play_tts_into_call(uuid, options) + assert response.message == 'Talk started' + assert response.uuid == uuid + + +@responses.activate +def test_stop_tts(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'DELETE', + f'https://api.nexmo.com/v1/calls/{uuid}/talk', + 'stop_tts.json', + ) + + response = voice.stop_tts(uuid) + assert response.message == 'Talk stopped' + assert response.uuid == uuid + + +@responses.activate +def test_play_dtmf_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', + 'play_dtmf_into_call.json', + ) + + response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') + assert response.message == 'DTMF sent' + assert response.uuid == uuid diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 40b182e4..95acc56d 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,6 @@ # 3.99.0a7 - Add support for the [Vonage Voice API](https://developer.vonage.com/en/voice/voice-api/overview). +- Add `http_client` property to each module that has an HTTP Client, e.g. Voice, Sms, Verify. # 3.99.0a6 - Add support for the [Vonage Messages API](https://developer.vonage.com/en/messages/overview). diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 20b8ba97..9b1680c5 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.0.1", - "vonage-http-client>=1.2.1", - "vonage-messages>=1.0.0", - "vonage-number-insight-v2>=0.1.0", - "vonage-sms>=1.0.2", - "vonage-users>=1.0.1", - "vonage-verify>=1.0.1", - "vonage-verify-v2>=1.0.0", + "vonage-utils>=1.1.0", + "vonage-http-client>=1.3.0", + "vonage-messages>=1.1.0", + "vonage-number-insight-v2>=0.1.0b0", + "vonage-sms>=1.1.0", + "vonage-users>=1.1.0", + "vonage-verify>=1.1.0", + "vonage-verify-v2>=1.1.0", "vonage-voice>=1.0.1", ] classifiers = [ diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index e04178b8..5038f475 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a6' +__version__ = '3.99.0a7' diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md index bc23e4c1..4fcf86af 100644 --- a/vonage_utils/CHANGES.md +++ b/vonage_utils/CHANGES.md @@ -1,3 +1,8 @@ +# 1.1.0 +- Add `Dtmf` and `SipUri` types +- Add `Link` model +- Internal refactoring + # 1.0.1 - Add `PhoneNumber` type diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 75fd4474..26a14932 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -1,10 +1,10 @@ [project] name = 'vonage-utils' -version = '1.0.1' +version = '1.1.0' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -dependencies = ["pydantic>=2.6.1"] +dependencies = ["typing_extensions>=4.9.0", "pydantic>=2.6.1"] requires-python = ">=3.8" classifiers = [ "Programming Language :: Python", diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py index b3d37bfc..63bd319c 100644 --- a/vonage_utils/src/vonage_utils/types.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -1,6 +1,5 @@ -from typing import Annotated - from pydantic import Field +from typing_extensions import Annotated PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] Dtmf = Annotated[str, Field(pattern=r'^[0-9#*p]+$')] From b30434430fa6540f063c5724a03dcc565ae5b07f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 23 Apr 2024 15:32:42 +0100 Subject: [PATCH 39/98] update readme for release --- voice/README.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/voice/README.md b/voice/README.md index dec31846..d68e96d8 100644 --- a/voice/README.md +++ b/voice/README.md @@ -2,3 +2,143 @@ This package contains the code to use [Vonage's Voice API](https://developer.vonage.com/en/voice/voice-api/overview) in Python. This package includes methods for working with the Voice API. It also contains an NCCO (Call Control Object) builder to help you to control call flow. +## Structure + +There is a `Voice` class which contains the methods used to call Vonage APIs. To call many of the APIs, you need to pass a Pydantic model with the required options. These can be accessed from the `vonage_voice.models` subpackage. Errors can be accessed from the `vonage_voice.errors` module. + +## Usage + +It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`, like so: + +```python +from vonage import Vonage, Auth + +vonage_client = Vonage(Auth('MY_AUTH_INFO')) +``` + +### Create a Call + +To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. + +```python +from vonage_voice.models import CreateCallRequest, Talk + +ncco = [Talk(text='Hello world', loop=3, language='en-GB')] + +call = CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + ncco=ncco, + random_from_number=True, +) + +response = vonage_client.voice.create_call(call) +print(response.model_dump()) +``` + +### List Calls + +```python +# Gets the first 100 results and the record_index of the +# next page if there's more than 100 +calls, next_record_index = vonage_client.voice.list_calls() + +# Specify filtering options +from vonage_voice.models import ListCallsFilter + +call_filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +) + +calls, next_record_index = vonage_client.voice.list_calls(call_filter) +``` + +### Get Information About a Specific Call + +```python +call = vonage_client.voice.get_call('CALL_ID') +``` + +### Transfer a Call to a New NCCO + +```python +ncco = [Talk(text='Hello world')] +vonage_client.voice.transfer_call_ncco('UUID', ncco) +``` + +### Transfer a Call to a New Answer URL + +```python +vonage_client.voice.transfer_call_answer_url('UUID', 'ANSWER_URL') +``` + +### Hang Up a Call + +End the call for a specified UUID, removing them from it. + +```python +vonage_client.voice.hangup('UUID') +``` + +### Mute/Unmute a Participant + +```python +vonage_client.voice.mute('UUID') +vonage_client.voice.unmute('UUID') +``` + +### Earmuff/Unearmuff a UUID + +Prevent/allow a specified UUID participant to be able to hear audio. + +```python +vonage_client.voice.earmuff('UUID') +vonage_client.voice.unearmuff('UUID') +``` + +### Play Audio Into a Call + +```python +from vonage_voice.models import AudioStreamOptions + +# Only the `stream_url` option is required +options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 +) +response = vonage_client.voice.play_audio_into_call('UUID', options) +``` + +### Stop Playing Audio Into a Call + +```python +vonage_client.voice.stop_audio_stream('UUID') +``` + +### Play TTS Into a Call + +```python +from vonage_voice.models import TtsStreamOptions + +# Only the `text` field is required +options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +) +response = voice.play_tts_into_call('UUID', options) +``` + +### Stop Playing TTS Into a Call + +```python +vonage_client.voice.stop_tts('UUID') +``` + +### Play DTMF Tones Into a Call + +```python +response = voice.play_dtmf_into_call('UUID', '1234*#') +``` \ No newline at end of file From 8c5b8069ac785fe0e14266453e18c5c7faa89e38 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 24 Apr 2024 04:14:36 +0100 Subject: [PATCH 40/98] start adding number insight --- number_insight/BUILD | 16 ++ number_insight/CHANGES.md | 2 + number_insight/README.md | 9 ++ number_insight/pyproject.toml | 29 ++++ .../src/vonage_number_insight/BUILD | 1 + .../src/vonage_number_insight/__init__.py | 3 + .../src/vonage_number_insight/enums.py | 0 .../src/vonage_number_insight/errors.py | 5 + .../vonage_number_insight/number_insight.py | 139 ++++++++++++++++++ .../src/vonage_number_insight/requests.py | 21 +++ .../src/vonage_number_insight/responses.py | 27 ++++ number_insight/tests/BUILD | 1 + number_insight/tests/data/basic_insight.json | 11 ++ .../tests/data/basic_insight_error.json | 4 + number_insight/tests/test_number_insight.py | 50 +++++++ pants.toml | 1 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + 18 files changed, 323 insertions(+) create mode 100644 number_insight/BUILD create mode 100644 number_insight/CHANGES.md create mode 100644 number_insight/README.md create mode 100644 number_insight/pyproject.toml create mode 100644 number_insight/src/vonage_number_insight/BUILD create mode 100644 number_insight/src/vonage_number_insight/__init__.py create mode 100644 number_insight/src/vonage_number_insight/enums.py create mode 100644 number_insight/src/vonage_number_insight/errors.py create mode 100644 number_insight/src/vonage_number_insight/number_insight.py create mode 100644 number_insight/src/vonage_number_insight/requests.py create mode 100644 number_insight/src/vonage_number_insight/responses.py create mode 100644 number_insight/tests/BUILD create mode 100644 number_insight/tests/data/basic_insight.json create mode 100644 number_insight/tests/data/basic_insight_error.json create mode 100644 number_insight/tests/test_number_insight.py 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..be516a55 --- /dev/null +++ b/number_insight/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/number_insight/README.md b/number_insight/README.md new file mode 100644 index 00000000..16d5325a --- /dev/null +++ b/number_insight/README.md @@ -0,0 +1,9 @@ +# 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. + +## 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 diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml new file mode 100644 index 00000000..4d0a7c83 --- /dev/null +++ b/number_insight/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-number-insight' +version = '1.0.0' +description = 'Vonage Number Insight package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.3.0", + "vonage-utils>=1.1.0", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..b791210a --- /dev/null +++ b/number_insight/src/vonage_number_insight/__init__.py @@ -0,0 +1,3 @@ +from .number_insight import NumberInsight + +__all__ = ['NumberInsight'] diff --git a/number_insight/src/vonage_number_insight/enums.py b/number_insight/src/vonage_number_insight/enums.py new file mode 100644 index 00000000..e69de29b 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..4176b147 --- /dev/null +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -0,0 +1,139 @@ +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, +) + + +class NumberInsight: + """Calls Vonage's Number Insight API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'body' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Verify V2 API. + + Returns: + HttpClient: The HTTP client used to make requests to the Verify V2 API. + """ + return self._http_client + + @validate_call + def basic_number_insight(self, options: BasicInsightRequest) -> BasicInsightResponse: + """Get basic number insight information about a phone number. + + Args: + Options (BasicInsightRequest): The options for the request. The `number` paramerter + is required, and the `country_code` parameter is optional. + + Returns: + BasicInsightResponse: The response object containing the basic number insight + information about the phone number. + """ + response = self._http_client.get( + self._http_client.api_host, + '/ni/basic/json', + params=options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return BasicInsightResponse(**response) + + @validate_call + def standard_number_insight( + self, options: StandardInsightRequest + ) -> StandardInsightResponse: + """Get standard number insight information about a phone number. + + Args: + Options (StandardInsightRequest): The options for the request. The `number` paramerter + is required, and the `country_code` and `cnam` parameters are optional. + + Returns: + StandardInsightResponse: The response object containing the standard number insight + information about the phone number. + """ + response = self._http_client.get( + self._http_client.api_host, + '/ni/standard/json', + params=options.model_dump(exclude_none=True), + auth_type=self._auth_type, + ) + self._check_for_error(response) + + return StandardInsightResponse(**response) + + @validate_call + def advanced_async_number_insight( + 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, + ) + + return AdvancedAsyncInsightResponse(**response) + + @validate_call + def advanced_sync_number_insight( + 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`, `cnam`, and `real_time_data` 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, + ) + + 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: + 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..0be558fa --- /dev/null +++ b/number_insight/src/vonage_number_insight/requests.py @@ -0,0 +1,21 @@ +from typing import Optional + +from pydantic import BaseModel +from vonage_utils.types import PhoneNumber + + +class BasicInsightRequest(BaseModel): + number: PhoneNumber + country: Optional[str] = None + + +class StandardInsightRequest(BasicInsightRequest): + cnam: Optional[bool] = None + + +class AdvancedAsyncInsightRequest(StandardInsightRequest): + callback: str + + +class AdvancedSyncInsightRequest(StandardInsightRequest): + real_time_data: Optional[bool] = None 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..367ea097 --- /dev/null +++ b/number_insight/src/vonage_number_insight/responses.py @@ -0,0 +1,27 @@ +from typing import Optional + +from pydantic import BaseModel + + +class BasicInsightResponse(BaseModel): + 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 StandardInsightResponse(BasicInsightResponse): + ... + + +class AdvancedAsyncInsightResponse(BaseModel): + ... + + +class AdvancedSyncInsightResponse(BaseModel): + ... 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/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/test_number_insight.py b/number_insight/tests/test_number_insight.py new file mode 100644 index 00000000..b7f7fc57 --- /dev/null +++ b/number_insight/tests/test_number_insight.py @@ -0,0 +1,50 @@ +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 BasicInsightRequest + +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.basic_number_insight(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.basic_number_insight(options) + assert e.match('Invalid request :: Not valid number format detected') + assert 0 diff --git a/pants.toml b/pants.toml index e6254cd8..5901ad43 100644 --- a/pants.toml +++ b/pants.toml @@ -33,6 +33,7 @@ filter = [ 'vonage/src', 'http_client/src', 'messages/src', + 'number_insight/src', 'number_insight_v2/src', 'sms/src', 'users/src', diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index fcd6bcaa..0d906ad8 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -4,6 +4,7 @@ Auth, HttpClientOptions, Messages, + NumberInsight, NumberInsightV2, Sms, Users, @@ -18,6 +19,7 @@ 'Auth', 'HttpClientOptions', 'Messages', + 'NumberInsight', 'NumberInsightV2', 'Sms', 'Users', diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 9f599778..05e3ba3a 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -2,6 +2,7 @@ from vonage_http_client import Auth, HttpClient, HttpClientOptions from vonage_messages import Messages +from vonage_number_insight import NumberInsight from vonage_number_insight_v2 import NumberInsightV2 from vonage_sms import Sms from vonage_users import Users @@ -31,6 +32,7 @@ def __init__( self._http_client = HttpClient(auth, http_client_options, __version__) self.messages = Messages(self._http_client) + self.number_insight = NumberInsight(self._http_client) self.number_insight_v2 = NumberInsightV2(self._http_client) self.sms = Sms(self._http_client) self.users = Users(self._http_client) From 61718d653d7d58ba3eebf3e085d493bba7f5035c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 26 Apr 2024 00:34:21 +0100 Subject: [PATCH 41/98] finish NI module --- number_insight/README.md | 4 +- .../src/vonage_number_insight/__init__.py | 34 +++++- .../vonage_number_insight/number_insight.py | 14 +++ .../src/vonage_number_insight/responses.py | 60 +++++++++- .../tests/data/advanced_async_insight.json | 7 ++ .../data/advanced_async_insight_error.json | 4 + .../advanced_async_insight_partial_error.json | 8 ++ .../tests/data/advanced_sync_insight.json | 44 +++++++ .../tests/data/standard_insight.json | 36 ++++++ number_insight/tests/test_number_insight.py | 112 +++++++++++++++++- vonage/CHANGES.md | 3 + vonage/pyproject.toml | 1 + vonage/src/vonage/_version.py | 2 +- 13 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 number_insight/tests/data/advanced_async_insight.json create mode 100644 number_insight/tests/data/advanced_async_insight_error.json create mode 100644 number_insight/tests/data/advanced_async_insight_partial_error.json create mode 100644 number_insight/tests/data/advanced_sync_insight.json create mode 100644 number_insight/tests/data/standard_insight.json diff --git a/number_insight/README.md b/number_insight/README.md index 16d5325a..91479b01 100644 --- a/number_insight/README.md +++ b/number_insight/README.md @@ -1,6 +1,8 @@ # 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. +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 diff --git a/number_insight/src/vonage_number_insight/__init__.py b/number_insight/src/vonage_number_insight/__init__.py index b791210a..83dc11c3 100644 --- a/number_insight/src/vonage_number_insight/__init__.py +++ b/number_insight/src/vonage_number_insight/__init__.py @@ -1,3 +1,35 @@ +from . import errors from .number_insight import NumberInsight +from .requests import ( + AdvancedAsyncInsightRequest, + AdvancedSyncInsightRequest, + BasicInsightRequest, + StandardInsightRequest, +) +from .responses import ( + AdvancedAsyncInsightResponse, + AdvancedSyncInsightResponse, + BasicInsightResponse, + CallerIdentity, + Carrier, + RealTimeData, + RoamingStatus, + StandardInsightResponse, +) -__all__ = ['NumberInsight'] +__all__ = [ + 'NumberInsight', + 'BasicInsightRequest', + 'StandardInsightRequest', + 'AdvancedAsyncInsightRequest', + 'AdvancedSyncInsightRequest', + 'BasicInsightResponse', + 'CallerIdentity', + 'Carrier', + 'RealTimeData', + 'RoamingStatus', + 'StandardInsightResponse', + 'AdvancedSyncInsightResponse', + 'AdvancedAsyncInsightResponse', + 'errors', +] diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py index 4176b147..bd00d11e 100644 --- a/number_insight/src/vonage_number_insight/number_insight.py +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -1,3 +1,5 @@ +from logging import getLogger + from pydantic import validate_call from vonage_http_client.http_client import HttpClient @@ -15,6 +17,8 @@ StandardInsightResponse, ) +logger = getLogger('vonage_number_insight') + class NumberInsight: """Calls Vonage's Number Insight API.""" @@ -99,6 +103,7 @@ def advanced_async_number_insight( params=options.model_dump(exclude_none=True), auth_type=self._auth_type, ) + self._check_for_error(response) return AdvancedAsyncInsightResponse(**response) @@ -122,6 +127,7 @@ def advanced_sync_number_insight( params=options.model_dump(exclude_none=True), auth_type=self._auth_type, ) + self._check_for_error(response) return AdvancedSyncInsightResponse(**response) @@ -135,5 +141,13 @@ def _check_for_error(self, response: dict) -> None: 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/responses.py b/number_insight/src/vonage_number_insight/responses.py index 367ea097..6fdea775 100644 --- a/number_insight/src/vonage_number_insight/responses.py +++ b/number_insight/src/vonage_number_insight/responses.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Literal, Optional, Union from pydantic import BaseModel @@ -15,13 +15,61 @@ class BasicInsightResponse(BaseModel): country_prefix: Optional[str] = None +class Carrier(BaseModel): + network_code: Optional[str] = None + name: Optional[str] = None + country: Optional[str] = None + network_type: Optional[str] = None + + +class CallerIdentity(BaseModel): + 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): - ... + 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 + caller_type: Optional[str] = None + caller_name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None -class AdvancedAsyncInsightResponse(BaseModel): - ... +class RoamingStatus(BaseModel): + status: Optional[str] = None + roaming_country_code: Optional[str] = None + roaming_network_code: Optional[str] = None + roaming_network_name: Optional[str] = None + +class RealTimeData(BaseModel): + active_status: Optional[str] = None + handset_status: Optional[str] = None -class AdvancedSyncInsightResponse(BaseModel): - ... + +class AdvancedSyncInsightResponse(StandardInsightResponse): + 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 + real_time_data: Optional[RealTimeData] = None + ip_warnings: Optional[str] = None + + +class AdvancedAsyncInsightResponse(BaseModel): + 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/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/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 index b7f7fc57..b376430e 100644 --- a/number_insight/tests/test_number_insight.py +++ b/number_insight/tests/test_number_insight.py @@ -5,7 +5,12 @@ 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 BasicInsightRequest +from vonage_number_insight.requests import ( + AdvancedAsyncInsightRequest, + AdvancedSyncInsightRequest, + BasicInsightRequest, + StandardInsightRequest, +) from testutils import build_response, get_mock_api_key_auth @@ -47,4 +52,107 @@ def test_basic_insight_error(): options = BasicInsightRequest(number='1234567890', country_code='US') number_insight.basic_number_insight(options) assert e.match('Invalid request :: Not valid number format detected') - assert 0 + + +@responses.activate +def test_standard_insight(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/standard/json', + 'standard_insight.json', + ) + options = StandardInsightRequest(number='12345678900', country_code='US', cnam=True) + response = number_insight.standard_number_insight(options) + assert response.status == 0 + assert response.status_message == 'Success' + assert response.current_carrier.network_code == '23415' + assert response.original_carrier.network_type == 'mobile' + assert response.caller_identity.caller_name == 'John Smith' + + +@responses.activate +def test_advanced_async_insight(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/ni/advanced/async/json', + 'advanced_async_insight.json', + ) + options = AdvancedAsyncInsightRequest( + callback='https://example.com/callback', + number='447700900000', + country_code='GB', + cnam=True, + ) + response = number_insight.advanced_async_number_insight(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.advanced_async_number_insight(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.advanced_async_number_insight(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, real_time_data=True + ) + response = number_insight.advanced_sync_number_insight(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.first_name == 'John' + assert response.last_name == 'Smith' + 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/vonage/CHANGES.md b/vonage/CHANGES.md index 95acc56d..12cf5b55 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.0a8 +- Add support for the [Vonage Number Insight API](https://developer.vonage.com/en/number-insight/overview). + # 3.99.0a7 - Add support for the [Vonage Voice API](https://developer.vonage.com/en/voice/voice-api/overview). - Add `http_client` property to each module that has an HTTP Client, e.g. Voice, Sms, Verify. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 9b1680c5..581992b6 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "vonage-utils>=1.1.0", "vonage-http-client>=1.3.0", "vonage-messages>=1.1.0", + "vonage-number-insight>=1.0.0", "vonage-number-insight-v2>=0.1.0b0", "vonage-sms>=1.1.0", "vonage-users>=1.1.0", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 5038f475..94c2cd85 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a7' +__version__ = '3.99.0a8' From 5f6667dfe31d9f4e76542e1ae2a45bde3462b5cb Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 29 Apr 2024 03:00:52 +0100 Subject: [PATCH 42/98] update dependency versions, prep for NI release --- http_client/CHANGES.md | 3 ++ http_client/pyproject.toml | 6 +-- messages/CHANGES.md | 3 ++ messages/pyproject.toml | 8 +-- number_insight/README.md | 52 +++++++++++++++++++ number_insight/pyproject.toml | 6 +-- .../src/vonage_number_insight/enums.py | 0 .../src/vonage_number_insight/responses.py | 5 -- number_insight/tests/test_number_insight.py | 5 +- number_insight_v2/CHANGES.md | 3 ++ number_insight_v2/pyproject.toml | 8 +-- requirements.txt | 16 +++++- sms/CHANGES.md | 3 ++ sms/pyproject.toml | 8 +-- users/CHANGES.md | 3 ++ users/pyproject.toml | 8 +-- verify/CHANGES.md | 3 ++ verify/pyproject.toml | 8 +-- verify_v2/CHANGES.md | 3 ++ verify_v2/pyproject.toml | 8 +-- voice/CHANGES.md | 3 ++ voice/pyproject.toml | 8 +-- vonage/CHANGES.md | 1 + vonage/pyproject.toml | 18 +++---- vonage_utils/CHANGES.md | 3 ++ vonage_utils/pyproject.toml | 4 +- 26 files changed, 142 insertions(+), 54 deletions(-) delete mode 100644 number_insight/src/vonage_number_insight/enums.py diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index bf6dde61..c4870964 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.3.1 +- Update minimum dependency version + # 1.3.0 - Add new PUT method diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index 25e3fbe9..9b63b0dd 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,16 +1,16 @@ [project] name = "vonage-http-client" -version = "1.3.0" +version = "1.3.1" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.1.0", + "vonage-utils>=1.1.1", "vonage-jwt>=1.1.0", "requests>=2.27.0", "typing-extensions>=4.9.0", - "pydantic>=2.6.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 2d2c13ed..71b83f45 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update minimum dependency version + # 1.1.0 - Add `http_client` property diff --git a/messages/pyproject.toml b/messages/pyproject.toml index 8d74d007..ca8ba739 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-messages' -version = '1.1.0' +version = '1.1.1' description = 'Vonage messages package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/number_insight/README.md b/number_insight/README.md index 91479b01..3c4aa76d 100644 --- a/number_insight/README.md +++ b/number_insight/README.md @@ -9,3 +9,55 @@ The advanced insight can be obtained synchronously or asynchronously. An async a 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') +) + +# Optionally, you can get real time information by setting the `real_time_data` parameter = True +vonage_client.number_insight.advanced_async_number_insight( + AdvancedSyncInsightRequest(number='12345678900', real_time_data=True, cnam=True) +) +``` \ No newline at end of file diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml index 4d0a7c83..36b0c0f5 100644 --- a/number_insight/pyproject.toml +++ b/number_insight/pyproject.toml @@ -6,9 +6,9 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/number_insight/src/vonage_number_insight/enums.py b/number_insight/src/vonage_number_insight/enums.py deleted file mode 100644 index e69de29b..00000000 diff --git a/number_insight/src/vonage_number_insight/responses.py b/number_insight/src/vonage_number_insight/responses.py index 6fdea775..5217ce94 100644 --- a/number_insight/src/vonage_number_insight/responses.py +++ b/number_insight/src/vonage_number_insight/responses.py @@ -38,10 +38,6 @@ class StandardInsightResponse(BasicInsightResponse): original_carrier: Optional[Carrier] = None ported: Optional[str] = None caller_identity: Optional[CallerIdentity] = None - caller_type: Optional[str] = None - caller_name: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None class RoamingStatus(BaseModel): @@ -63,7 +59,6 @@ class AdvancedSyncInsightResponse(StandardInsightResponse): valid_number: Optional[str] = None reachable: Optional[str] = None real_time_data: Optional[RealTimeData] = None - ip_warnings: Optional[str] = None class AdvancedAsyncInsightResponse(BaseModel): diff --git a/number_insight/tests/test_number_insight.py b/number_insight/tests/test_number_insight.py index b376430e..97280695 100644 --- a/number_insight/tests/test_number_insight.py +++ b/number_insight/tests/test_number_insight.py @@ -149,8 +149,9 @@ def test_advanced_sync_insight(caplog): assert response.status == 44 assert response.request_id == '97e973e7-2e27-4fd3-9e1a-972ea14dd992' assert response.current_carrier.network_code == '310090' - assert response.first_name == 'John' - assert response.last_name == 'Smith' + 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' diff --git a/number_insight_v2/CHANGES.md b/number_insight_v2/CHANGES.md index 6e846e2d..36feaaeb 100644 --- a/number_insight_v2/CHANGES.md +++ b/number_insight_v2/CHANGES.md @@ -1,2 +1,5 @@ +# 0.1.1b0 +- Update minimum dependency version + # 0.1.0b0 - Beta release \ No newline at end of file diff --git a/number_insight_v2/pyproject.toml b/number_insight_v2/pyproject.toml index e200b817..442aeb98 100644 --- a/number_insight_v2/pyproject.toml +++ b/number_insight_v2/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-number-insight-v2' -version = '0.1.0b0' +version = '0.1.1b0' description = 'Vonage Number Insight v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/requirements.txt b/requirements.txt index d0f9aefb..a0d28c40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,18 @@ pytest>=8.0.0 requests>=2.31.0 responses>=0.24.1 -pydantic>=2.6.1 +pydantic>=2.7.1 typing-extensions>=4.9.0 -vonage-jwt>=1.1.0 \ No newline at end of file +vonage-jwt>=1.1.0 + +-e http_client +-e messages +-e number_insight +-e number_insight_v2 +-e sms +-e users +-e verify +-e verify_v2 +-e voice +-e vonage +-e vonage_utils diff --git a/sms/CHANGES.md b/sms/CHANGES.md index d1ba477e..62d1cf17 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update minimum dependency version + # 1.1.0 - Add `http_client` property diff --git a/sms/pyproject.toml b/sms/pyproject.toml index b2c52642..29c2215c 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-sms' -version = '1.1.0' +version = '1.1.1' description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/users/CHANGES.md b/users/CHANGES.md index 368fd9f2..9dc9c61c 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update minimum dependency version + # 1.1.0 - Add `http_client` property - Rename `ListUsersRequest` -> `ListUsersFilter` diff --git a/users/pyproject.toml b/users/pyproject.toml index 3a261fe0..9b8d2699 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-users' -version = '1.1.0' +version = '1.1.1' description = 'Vonage Users package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/verify/CHANGES.md b/verify/CHANGES.md index 09439bfb..727f57dc 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update minimum dependency version + # 1.1.0 - Add `http_client` property diff --git a/verify/pyproject.toml b/verify/pyproject.toml index ba5df186..d5abe7d2 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-verify' -version = '1.1.0' +version = '1.1.1' description = 'Vonage verify package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md index 2d2c13ed..71b83f45 100644 --- a/verify_v2/CHANGES.md +++ b/verify_v2/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update minimum dependency version + # 1.1.0 - Add `http_client` property diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml index b4c774ea..4bca7315 100644 --- a/verify_v2/pyproject.toml +++ b/verify_v2/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-verify-v2' -version = '1.1.0' +version = '1.1.1' description = 'Vonage verify v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/voice/CHANGES.md b/voice/CHANGES.md index 5d3cfbb8..ca5e1f85 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Update minimum dependency version + # 1.0.1 - Initial upload diff --git a/voice/pyproject.toml b/voice/pyproject.toml index 3f422d70..949dfdbe 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -1,14 +1,14 @@ [project] name = 'vonage-voice' -version = '1.0.1' +version = '1.0.2' description = 'Vonage voice package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.0", - "vonage-utils>=1.1.0", - "pydantic>=2.6.1", + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 12cf5b55..26dd77d7 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,6 @@ # 3.99.0a8 - Add support for the [Vonage Number Insight API](https://developer.vonage.com/en/number-insight/overview). +- Update minimum dependency version # 3.99.0a7 - Add support for the [Vonage Voice API](https://developer.vonage.com/en/voice/voice-api/overview). diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 581992b6..5efd58c4 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,16 +6,16 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.1.0", - "vonage-http-client>=1.3.0", - "vonage-messages>=1.1.0", + "vonage-utils>=1.1.1", + "vonage-http-client>=1.3.1", + "vonage-messages>=1.1.1", "vonage-number-insight>=1.0.0", - "vonage-number-insight-v2>=0.1.0b0", - "vonage-sms>=1.1.0", - "vonage-users>=1.1.0", - "vonage-verify>=1.1.0", - "vonage-verify-v2>=1.1.0", - "vonage-voice>=1.0.1", + "vonage-number-insight-v2>=0.1.1b0", + "vonage-sms>=1.1.1", + "vonage-users>=1.1.1", + "vonage-verify>=1.1.1", + "vonage-verify-v2>=1.1.1", + "vonage-voice>=1.0.2", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md index 4fcf86af..bf3099a4 100644 --- a/vonage_utils/CHANGES.md +++ b/vonage_utils/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.0 +- Update minimum dependency version + # 1.1.0 - Add `Dtmf` and `SipUri` types - Add `Link` model diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 26a14932..927e3680 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -1,10 +1,10 @@ [project] name = 'vonage-utils' -version = '1.1.0' +version = '1.1.1' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -dependencies = ["typing_extensions>=4.9.0", "pydantic>=2.6.1"] +dependencies = ["typing_extensions>=4.9.0", "pydantic>=2.7.1"] requires-python = ">=3.8" classifiers = [ "Programming Language :: Python", From 47ce0f88d1905df1e82553b15159effbb9a3cc3a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 30 Apr 2024 03:01:51 +0100 Subject: [PATCH 43/98] start creating application package --- application/BUILD | 16 +++ application/CHANGES.md | 2 + application/README.md | 81 +++++++++++++ application/pyproject.toml | 29 +++++ application/src/vonage_application/BUILD | 1 + .../src/vonage_application/__init__.py | 5 + .../src/vonage_application/application.py | 109 +++++++++++++++++ application/src/vonage_application/common.py | 84 +++++++++++++ .../src/vonage_application/requests.py | 64 ++++++++++ .../src/vonage_application/responses.py | 35 ++++++ application/tests/BUILD | 1 + application/tests/test_users.py | 113 ++++++++++++++++++ pants.toml | 1 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + 15 files changed, 545 insertions(+) create mode 100644 application/BUILD create mode 100644 application/CHANGES.md create mode 100644 application/README.md create mode 100644 application/pyproject.toml create mode 100644 application/src/vonage_application/BUILD create mode 100644 application/src/vonage_application/__init__.py create mode 100644 application/src/vonage_application/application.py create mode 100644 application/src/vonage_application/common.py create mode 100644 application/src/vonage_application/requests.py create mode 100644 application/src/vonage_application/responses.py create mode 100644 application/tests/BUILD create mode 100644 application/tests/test_users.py 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..be516a55 --- /dev/null +++ b/application/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/application/README.md b/application/README.md new file mode 100644 index 00000000..aeb4d581 --- /dev/null +++ b/application/README.md @@ -0,0 +1,81 @@ +# Vonage Users 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 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/application/pyproject.toml b/application/pyproject.toml new file mode 100644 index 00000000..27a3788d --- /dev/null +++ b/application/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-application' +version = '1.0.0' +description = 'Vonage Users package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.1", + "pydantic>=2.7.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..3aa3a60b --- /dev/null +++ b/application/src/vonage_application/__init__.py @@ -0,0 +1,5 @@ +from .application import Application + +__all__ = [ + 'Application', +] diff --git a/application/src/vonage_application/application.py b/application/src/vonage_application/application.py new file mode 100644 index 00000000..b57e2f27 --- /dev/null +++ b/application/src/vonage_application/application.py @@ -0,0 +1,109 @@ +from typing import List, Optional, Tuple + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .common import User +from .requests import ApplicationOptions, ListApplicationsFilter +from .responses import ApplicationInfo + + +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[ApplicationInfo], Optional[str]]: + """""" + 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.links.next is None: + # return applications_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_application( + self, params: Optional[ApplicationOptions] = None + ) -> ApplicationData: + """. + . + """ + response = self._http_client.post( + self._http_client.api_host, + '/v2/applications', + 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. + + Returns: + None + """ + self._http_client.delete( + self._http_client.api_host, f'/v1/users/{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..6e2bc10e --- /dev/null +++ b/application/src/vonage_application/common.py @@ -0,0 +1,84 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.models import Link +from vonage_utils.types import PhoneNumber + + +class ResourceLink(BaseModel): + self: Link + + +class PstnChannel(BaseModel): + number: int + + +class SipChannel(BaseModel): + uri: str = Field(..., pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)') + username: str = None + password: str = None + + +class VbcChannel(BaseModel): + extension: str + + +class WebsocketChannel(BaseModel): + uri: str = Field(pattern=r'^(ws|wss):\/\/[a-zA-Z0-9~#%@&-_?\/.,:;)(\]\[]*$') + content_type: Optional[str] = Field( + None, alias='content-type', pattern='^audio/l16;rate=(8000|16000)$' + ) + headers: Optional[dict] = None + + +class SmsChannel(BaseModel): + number: PhoneNumber + + +class MmsChannel(BaseModel): + number: PhoneNumber + + +class WhatsappChannel(BaseModel): + number: PhoneNumber + + +class ViberChannel(BaseModel): + number: PhoneNumber + + +class MessengerChannel(BaseModel): + id: str + + +class Channels(BaseModel): + 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): + custom_data: Optional[dict] = None + + +class User(BaseModel): + 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/application/src/vonage_application/requests.py b/application/src/vonage_application/requests.py new file mode 100644 index 00000000..b9170d03 --- /dev/null +++ b/application/src/vonage_application/requests.py @@ -0,0 +1,64 @@ +from typing import Optional + +from pydantic import BaseModel + + +class ListApplicationsFilter(BaseModel): + """Request object for listing users.""" + + page_size: Optional[int] = 100 + page: int = None + + +class Webhook(BaseModel): + address: str + http_method: str + connection_timeout: Optional[int] = None + socket_timeout: Optional[int] = None + + +class Voice(BaseModel): + webhooks: Webhook + fallback_answer_url: Optional[Webhook] = None + event_url: Optional[Webhook] = None + signed_callbacks: bool + conversations_ttl: int + leg_persistence_time: int + region: str + + +class Messages(BaseModel): + version: str + webhooks: Webhook + + +class RTC(BaseModel): + webhooks: Webhook + signed_callbacks: bool + + +class Meetings(BaseModel): + webhooks: Webhook + + +class Verify(BaseModel): + webhooks: Webhook + + +class Privacy(BaseModel): + improve_ai: bool + + +class ApplicationBase(BaseModel): + name: str + capabilities: Optional[dict] = None + voice: Optional[Voice] = None + messages: Optional[Messages] = None + rtc: Optional[RTC] = None + meetings: Optional[Meetings] = None + verify: Optional[Verify] = None + privacy: Optional[Privacy] = None + + +class ApplicationOptions(ApplicationBase): + keys: Optional[dict] = None diff --git a/application/src/vonage_application/responses.py b/application/src/vonage_application/responses.py new file mode 100644 index 00000000..387f3335 --- /dev/null +++ b/application/src/vonage_application/responses.py @@ -0,0 +1,35 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_users.common import Link, ResourceLink + + +class Links(BaseModel): + self: Link + first: Link + next: Optional[Link] = None + prev: Optional[Link] = None + + +class UserSummary(BaseModel): + 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): + users: List[UserSummary] = [] + + +class ListUsersResponse(BaseModel): + page_size: int + embedded: Embedded = Field(..., validation_alias='_embedded') + links: Links = 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/test_users.py b/application/tests/test_users.py new file mode 100644 index 00000000..96f621d4 --- /dev/null +++ b/application/tests/test_users.py @@ -0,0 +1,113 @@ +from os.path import abspath + +import responses +from vonage_application.application import Application +from vonage_http_client.errors import NotFoundError +from vonage_http_client.http_client import HttpClient +from vonage_users.requests import ListUsersFilter + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +application = Application(HttpClient(get_mock_jwt_auth())) + + +def test_http_client_property(): + http_client = application.http_client + assert isinstance(http_client, HttpClient) + + +@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' + + +@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', + 'channels': { + 'sms': [{'number': '1234567890'}], + 'mms': [{'number': '1234567890'}], + }, + } + + 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' + + +@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 + ) diff --git a/pants.toml b/pants.toml index 5901ad43..8be53d31 100644 --- a/pants.toml +++ b/pants.toml @@ -32,6 +32,7 @@ report = ['html', 'console'] filter = [ 'vonage/src', 'http_client/src', + 'application/src', 'messages/src', 'number_insight/src', 'number_insight_v2/src', diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 0d906ad8..ee39bfea 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,6 +1,7 @@ from vonage_utils import VonageError from .vonage import ( + Application, Auth, HttpClientOptions, Messages, @@ -18,6 +19,7 @@ 'Vonage', 'Auth', 'HttpClientOptions', + 'Application', 'Messages', 'NumberInsight', 'NumberInsightV2', diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 05e3ba3a..f329646d 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -1,5 +1,6 @@ from typing import Optional +from vonage_application.application import Application from vonage_http_client import Auth, HttpClient, HttpClientOptions from vonage_messages import Messages from vonage_number_insight import NumberInsight @@ -31,6 +32,7 @@ def __init__( ): self._http_client = HttpClient(auth, http_client_options, __version__) + self.application = Application(self._http_client) self.messages = Messages(self._http_client) self.number_insight = NumberInsight(self._http_client) self.number_insight_v2 = NumberInsightV2(self._http_client) From 10113f0ce22e7b93c9d7d2823478fcddc1400f77 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 1 May 2024 03:53:07 +0100 Subject: [PATCH 44/98] creating more application models and refactoring common components --- .../src/vonage_application/application.py | 92 +++++++------ application/src/vonage_application/common.py | 107 +++++++-------- application/src/vonage_application/enums.py | 10 ++ application/src/vonage_application/errors.py | 5 + .../src/vonage_application/requests.py | 54 +------- .../src/vonage_application/responses.py | 40 +++--- .../tests/data/create_application_basic.json | 17 +++ .../data/create_application_options.json | 17 +++ application/tests/test_application.py | 129 ++++++++++++++++++ application/tests/test_users.py | 113 --------------- users/src/vonage_users/common.py | 6 +- users/src/vonage_users/responses.py | 2 +- vonage_utils/src/vonage_utils/models.py | 4 + 13 files changed, 307 insertions(+), 289 deletions(-) create mode 100644 application/src/vonage_application/enums.py create mode 100644 application/src/vonage_application/errors.py create mode 100644 application/tests/data/create_application_basic.json create mode 100644 application/tests/data/create_application_options.json create mode 100644 application/tests/test_application.py delete mode 100644 application/tests/test_users.py diff --git a/application/src/vonage_application/application.py b/application/src/vonage_application/application.py index b57e2f27..c8f0b151 100644 --- a/application/src/vonage_application/application.py +++ b/application/src/vonage_application/application.py @@ -3,9 +3,8 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .common import User from .requests import ApplicationOptions, ListApplicationsFilter -from .responses import ApplicationInfo +from .responses import ApplicationData class Application: @@ -27,7 +26,7 @@ def http_client(self) -> HttpClient: @validate_call def list_applications( self, filter: ListApplicationsFilter = ListApplicationsFilter() - ) -> Tuple[List[ApplicationInfo], Optional[str]]: + ) -> Tuple[List[ApplicationData], Optional[str]]: """""" response = self._http_client.get( self._http_client.api_host, @@ -49,61 +48,66 @@ def list_applications( def create_application( self, params: Optional[ApplicationOptions] = None ) -> ApplicationData: - """. - . - """ - response = self._http_client.post( - self._http_client.api_host, - '/v2/applications', - 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. + """Create a new application. Args: - id (str): The ID of the user to retrieve. + params (Optional[ApplicationOptions]): The application options. Returns: - User: The user object. + ApplicationData: The created application 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( + response = self._http_client.post( self._http_client.api_host, - f'/v1/users/{id}', - params.model_dump(exclude_none=True), + '/v2/applications', + params.model_dump(exclude_none=True) if params is not None else None, self._auth_type, ) - return User(**response) + return ApplicationData(**response) @validate_call - def delete_user(self, id: str) -> None: - """Delete a user. + def get_application(self, id: str) -> ApplicationData: + """Get application info by ID. Args: - id (str): The ID of the user to delete. + id (str): The ID of the application to retrieve. Returns: - None + ApplicationData: The created application object. """ - self._http_client.delete( + response = self._http_client.get( self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type ) + return ApplicationData(**response) + + # @validate_call + # def update_application(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_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 index 6e2bc10e..d04c447c 100644 --- a/application/src/vonage_application/common.py +++ b/application/src/vonage_application/common.py @@ -1,84 +1,79 @@ -from typing import List, Optional +from typing import Literal, Optional, Union -from pydantic import BaseModel, Field, model_validator -from vonage_utils.models import Link -from vonage_utils.types import PhoneNumber +from pydantic import BaseModel, Field, field_validator +from .enums import Region +from .errors import ApplicationError -class ResourceLink(BaseModel): - self: Link +class Url(BaseModel): + address: str + http_method: Optional[Literal['GET', 'POST']] = None -class PstnChannel(BaseModel): - number: int +class VoiceUrl(Url): + connection_timeout: Optional[int] = Field(None, ge=300, le=1000) + socket_timeout: Optional[int] = Field(None, ge=1000, le=5000) -class SipChannel(BaseModel): - uri: str = Field(..., pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)') - username: str = None - password: str = None +class VoiceWebhooks(BaseModel): + answer_url: Optional[Url] = None + fallback_answer_url: Optional[Url] = None + event_url: Optional[Url] = None -class VbcChannel(BaseModel): - extension: str +class Voice(BaseModel): + 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 WebsocketChannel(BaseModel): - uri: str = Field(pattern=r'^(ws|wss):\/\/[a-zA-Z0-9~#%@&-_?\/.,:;)(\]\[]*$') - content_type: Optional[str] = Field( - None, alias='content-type', pattern='^audio/l16;rate=(8000|16000)$' - ) - headers: Optional[dict] = None +class RtcWebhooks(BaseModel): + event_url: Optional[Url] = None -class SmsChannel(BaseModel): - number: PhoneNumber +class Rtc(BaseModel): + webhooks: Optional[RtcWebhooks] = None + signed_callbacks: Optional[bool] = None -class MmsChannel(BaseModel): - number: PhoneNumber +class MessagesWebhooks(BaseModel): + inbound_url: Optional[Url] = None + status_url: Optional[Url] = None -class WhatsappChannel(BaseModel): - number: PhoneNumber +class Messages(BaseModel): + version: Optional[str] = None + webhooks: Optional[MessagesWebhooks] = None -class ViberChannel(BaseModel): - number: PhoneNumber +class Vbc(BaseModel): + pass -class MessengerChannel(BaseModel): - id: str +class VerifyWebhooks(BaseModel): + status_url: Optional[Url] = None -class Channels(BaseModel): - 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 + @field_validator('status_url') + @classmethod + def check_http_method(cls, v: Url): + if v.http_method is not None and v.http_method != 'POST': + raise ApplicationError('HTTP method must be POST') + return v -class Properties(BaseModel): - custom_data: Optional[dict] = None +class Verify(BaseModel): + webhooks: Optional[VerifyWebhooks] = None + version: Optional[str] = None -class User(BaseModel): - 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 +class Privacy(BaseModel): + improve_ai: Optional[bool] = None - @model_validator(mode='after') - def get_link(self): - if self.links is not None: - self.link = self.links.self.href - return self + +class ApplicationBase(BaseModel): + name: str + capabilities: Optional[Union[Voice, Rtc, Messages, Vbc, Verify]] = None + privacy: Optional[Privacy] = None diff --git a/application/src/vonage_application/enums.py b/application/src/vonage_application/enums.py new file mode 100644 index 00000000..7d75c213 --- /dev/null +++ b/application/src/vonage_application/enums.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class Region(str, Enum): + 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 index b9170d03..a595994a 100644 --- a/application/src/vonage_application/requests.py +++ b/application/src/vonage_application/requests.py @@ -2,6 +2,8 @@ from pydantic import BaseModel +from .common import ApplicationBase + class ListApplicationsFilter(BaseModel): """Request object for listing users.""" @@ -10,55 +12,9 @@ class ListApplicationsFilter(BaseModel): page: int = None -class Webhook(BaseModel): - address: str - http_method: str - connection_timeout: Optional[int] = None - socket_timeout: Optional[int] = None - - -class Voice(BaseModel): - webhooks: Webhook - fallback_answer_url: Optional[Webhook] = None - event_url: Optional[Webhook] = None - signed_callbacks: bool - conversations_ttl: int - leg_persistence_time: int - region: str - - -class Messages(BaseModel): - version: str - webhooks: Webhook - - -class RTC(BaseModel): - webhooks: Webhook - signed_callbacks: bool - - -class Meetings(BaseModel): - webhooks: Webhook - - -class Verify(BaseModel): - webhooks: Webhook - - -class Privacy(BaseModel): - improve_ai: bool - - -class ApplicationBase(BaseModel): - name: str - capabilities: Optional[dict] = None - voice: Optional[Voice] = None - messages: Optional[Messages] = None - rtc: Optional[RTC] = None - meetings: Optional[Meetings] = None - verify: Optional[Verify] = None - privacy: Optional[Privacy] = None +class KeysRequest(BaseModel): + public_key: str class ApplicationOptions(ApplicationBase): - keys: Optional[dict] = None + keys: Optional[KeysRequest] = None diff --git a/application/src/vonage_application/responses.py b/application/src/vonage_application/responses.py index 387f3335..4b48e0a9 100644 --- a/application/src/vonage_application/responses.py +++ b/application/src/vonage_application/responses.py @@ -1,20 +1,28 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field, model_validator -from vonage_users.common import Link, ResourceLink +from vonage_utils.models import ResourceLink +from .common import ApplicationBase -class Links(BaseModel): - self: Link - first: Link - next: Optional[Link] = None - prev: Optional[Link] = None +# class Embedded(BaseModel): +# users: List[UserSummary] = [] -class UserSummary(BaseModel): - id: Optional[str] - name: Optional[str] - display_name: Optional[str] = None +# class ListApplicationsResponse(BaseModel): +# page_size: int +# embedded: Embedded = Field(..., validation_alias='_embedded') +# links: Links = Field(..., validation_alias='_links') + + +class KeysResponse(BaseModel): + public_key: Optional[str] = None + private_key: Optional[str] = None + + +class ApplicationData(ApplicationBase): + id: str + keys: Optional[KeysResponse] = None links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True) link: Optional[str] = None @@ -23,13 +31,3 @@ def get_link(self): if self.links is not None: self.link = self.links.self.href return self - - -class Embedded(BaseModel): - users: List[UserSummary] = [] - - -class ListUsersResponse(BaseModel): - page_size: int - embedded: Embedded = Field(..., validation_alias='_embedded') - links: Links = Field(..., validation_alias='_links') 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..96250b8c --- /dev/null +++ b/application/tests/data/create_application_options.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/test_application.py b/application/tests/test_application.py new file mode 100644 index 00000000..b7f92d6c --- /dev/null +++ b/application/tests/test_application.py @@ -0,0 +1,129 @@ +from os.path import abspath + +import responses +from vonage_application.application import Application +from vonage_application.requests import ApplicationOptions +from vonage_http_client.http_client import HttpClient + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +application = Application(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = application.http_client + assert isinstance(http_client, HttpClient) + + +@responses.activate +def test_create_application_basic(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/applications', + 'create_application_basic.json', + ) + app = application.create_application(ApplicationOptions(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' + + +@responses.activate +def test_create_application_options(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/applications', + 'create_application_options.json', + ) + app = application.create_application( + ApplicationOptions( + name='My Application', + keys={'public_key': 'public_key_info_goes_here'}, + ) + ) + + assert app.id == 'ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7' + assert app.name == 'My Application' + + +# @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', +# 'channels': { +# 'sms': [{'number': '1234567890'}], +# 'mms': [{'number': '1234567890'}], +# }, +# } + +# 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_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 +# ) diff --git a/application/tests/test_users.py b/application/tests/test_users.py deleted file mode 100644 index 96f621d4..00000000 --- a/application/tests/test_users.py +++ /dev/null @@ -1,113 +0,0 @@ -from os.path import abspath - -import responses -from vonage_application.application import Application -from vonage_http_client.errors import NotFoundError -from vonage_http_client.http_client import HttpClient -from vonage_users.requests import ListUsersFilter - -from testutils import build_response, get_mock_jwt_auth - -path = abspath(__file__) - -application = Application(HttpClient(get_mock_jwt_auth())) - - -def test_http_client_property(): - http_client = application.http_client - assert isinstance(http_client, HttpClient) - - -@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' - - -@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', - 'channels': { - 'sms': [{'number': '1234567890'}], - 'mms': [{'number': '1234567890'}], - }, - } - - 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' - - -@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 - ) diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 6e2bc10e..18527630 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,14 +1,10 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from vonage_utils.models import Link +from vonage_utils.models import ResourceLink from vonage_utils.types import PhoneNumber -class ResourceLink(BaseModel): - self: Link - - class PstnChannel(BaseModel): number: int diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 387f3335..0abe2d59 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -1,7 +1,7 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from vonage_users.common import Link, ResourceLink +from vonage_utils.models import Link, ResourceLink class Links(BaseModel): diff --git a/vonage_utils/src/vonage_utils/models.py b/vonage_utils/src/vonage_utils/models.py index 8e3dd369..c3548696 100644 --- a/vonage_utils/src/vonage_utils/models.py +++ b/vonage_utils/src/vonage_utils/models.py @@ -3,3 +3,7 @@ class Link(BaseModel): href: str + + +class ResourceLink(BaseModel): + self: Link From ec0d9f271a46aea75c15b8a8447e3ab5c94db45a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 2 May 2024 03:42:48 +0100 Subject: [PATCH 45/98] finish create method, add list method and tests, refactoring --- .../src/vonage_application/application.py | 28 +- application/src/vonage_application/common.py | 65 +++- .../src/vonage_application/requests.py | 6 +- .../src/vonage_application/responses.py | 30 +- .../data/create_application_options.json | 68 +++- .../tests/data/list_applications_basic.json | 48 +++ .../list_applications_multiple_pages.json | 100 ++++++ application/tests/test_application.py | 340 +++++++++++++----- voice/src/vonage_voice/models/__init__.py | 4 +- voice/src/vonage_voice/models/responses.py | 14 +- vonage_utils/src/vonage_utils/models.py | 10 + 11 files changed, 574 insertions(+), 139 deletions(-) create mode 100644 application/tests/data/list_applications_basic.json create mode 100644 application/tests/data/list_applications_multiple_pages.json diff --git a/application/src/vonage_application/application.py b/application/src/vonage_application/application.py index c8f0b151..51ff982b 100644 --- a/application/src/vonage_application/application.py +++ b/application/src/vonage_application/application.py @@ -4,7 +4,7 @@ from vonage_http_client.http_client import HttpClient from .requests import ApplicationOptions, ListApplicationsFilter -from .responses import ApplicationData +from .responses import ApplicationData, ListApplicationsResponse class Application: @@ -27,7 +27,18 @@ def http_client(self) -> HttpClient: 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', @@ -35,14 +46,13 @@ def list_applications( self._auth_type, ) - # applications_response = ListApplicationsResponse(**response) - # if applications_response.links.next is None: - # return applications_response.embedded.users, None + applications_response = ListApplicationsResponse(**response) + + if applications_response.page == applications_response.total_pages: + return applications_response.embedded.applications, 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 + next_page = applications_response.page + 1 + return applications_response.embedded.applications, next_page @validate_call def create_application( diff --git a/application/src/vonage_application/common.py b/application/src/vonage_application/common.py index d04c447c..25df53e4 100644 --- a/application/src/vonage_application/common.py +++ b/application/src/vonage_application/common.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import Literal, Optional from pydantic import BaseModel, Field, field_validator @@ -6,23 +6,25 @@ from .errors import ApplicationError -class Url(BaseModel): +class ApplicationUrl(BaseModel): address: str http_method: Optional[Literal['GET', 'POST']] = None -class VoiceUrl(Url): - connection_timeout: Optional[int] = Field(None, ge=300, le=1000) - socket_timeout: Optional[int] = Field(None, ge=1000, le=5000) +class VoiceUrl(ApplicationUrl): + connect_timeout: Optional[int] = Field(None, ge=300, le=1000) + socket_timeout: Optional[int] = Field(None, ge=1000, le=10000) class VoiceWebhooks(BaseModel): - answer_url: Optional[Url] = None - fallback_answer_url: Optional[Url] = None - event_url: Optional[Url] = None + answer_url: Optional[VoiceUrl] = None + fallback_answer_url: Optional[VoiceUrl] = None + event_url: Optional[VoiceUrl] = None class Voice(BaseModel): + """Voice application capabilities.""" + webhooks: Optional[VoiceWebhooks] = None signed_callbacks: Optional[bool] = None conversations_ttl: Optional[int] = Field(None, ge=1, le=9000) @@ -31,40 +33,60 @@ class Voice(BaseModel): class RtcWebhooks(BaseModel): - event_url: Optional[Url] = None + event_url: Optional[ApplicationUrl] = None class Rtc(BaseModel): + """Real-Time Communications application capabilities.""" + webhooks: Optional[RtcWebhooks] = None signed_callbacks: Optional[bool] = None class MessagesWebhooks(BaseModel): - inbound_url: Optional[Url] = None - status_url: Optional[Url] = None + 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): - version: Optional[str] = None + """Messages application capabilities.""" + webhooks: Optional[MessagesWebhooks] = None + version: Optional[str] = None + authenticate_inbound_media: Optional[bool] = None class Vbc(BaseModel): - pass + """VBC capabilities. + + This object should be empty when creating or updating an application. + """ class VerifyWebhooks(BaseModel): - status_url: Optional[Url] = None + status_url: Optional[ApplicationUrl] = None @field_validator('status_url') @classmethod - def check_http_method(cls, v: Url): + 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. + """ + webhooks: Optional[VerifyWebhooks] = None version: Optional[str] = None @@ -73,7 +95,18 @@ class Privacy(BaseModel): improve_ai: Optional[bool] = None +class Capabilities(BaseModel): + voice: Optional[Voice] = None + rtc: Optional[Rtc] = None + messages: Optional[Messages] = None + vbc: Optional[Vbc] = None + verify: Optional[Verify] = None + + class ApplicationBase(BaseModel): + """Base application object used in requests and responses when communicating with the Vonage + Application API.""" + name: str - capabilities: Optional[Union[Voice, Rtc, Messages, Vbc, Verify]] = None + capabilities: Optional[Capabilities] = None privacy: Optional[Privacy] = None diff --git a/application/src/vonage_application/requests.py b/application/src/vonage_application/requests.py index a595994a..17817cfb 100644 --- a/application/src/vonage_application/requests.py +++ b/application/src/vonage_application/requests.py @@ -6,15 +6,15 @@ class ListApplicationsFilter(BaseModel): - """Request object for listing users.""" + """Request object for filtering applications.""" page_size: Optional[int] = 100 page: int = None -class KeysRequest(BaseModel): +class RequestKeys(BaseModel): public_key: str class ApplicationOptions(ApplicationBase): - keys: Optional[KeysRequest] = None + keys: Optional[RequestKeys] = None diff --git a/application/src/vonage_application/responses.py b/application/src/vonage_application/responses.py index 4b48e0a9..7574f4e4 100644 --- a/application/src/vonage_application/responses.py +++ b/application/src/vonage_application/responses.py @@ -1,28 +1,19 @@ -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from vonage_utils.models import ResourceLink +from vonage_utils.models import HalLinks, ResourceLink from .common import ApplicationBase -# class Embedded(BaseModel): -# users: List[UserSummary] = [] - -# class ListApplicationsResponse(BaseModel): -# page_size: int -# embedded: Embedded = Field(..., validation_alias='_embedded') -# links: Links = Field(..., validation_alias='_links') - - -class KeysResponse(BaseModel): +class ResponseKeys(BaseModel): public_key: Optional[str] = None private_key: Optional[str] = None class ApplicationData(ApplicationBase): id: str - keys: Optional[KeysResponse] = None + keys: Optional[ResponseKeys] = None links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True) link: Optional[str] = None @@ -31,3 +22,16 @@ def get_link(self): if self.links is not None: self.link = self.links.self.href return self + + +class Embedded(BaseModel): + applications: List[ApplicationData] = [] + + +class ListApplicationsResponse(BaseModel): + 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/data/create_application_options.json b/application/tests/data/create_application_options.json index 96250b8c..139e0354 100644 --- a/application/tests/data/create_application_options.json +++ b/application/tests/data/create_application_options.json @@ -1,17 +1,75 @@ { - "id": "ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7", - "name": "My Application", + "id": "33e3329f-d1cc-48f3-9105-55e5a6e475c1", + "name": "My Customised 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": {}, + "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/ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7" + "href": "/v2/applications/33e3329f-d1cc-48f3-9105-55e5a6e475c1" } } } \ 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/test_application.py b/application/tests/test_application.py index b7f92d6c..4d9cbf01 100644 --- a/application/tests/test_application.py +++ b/application/tests/test_application.py @@ -1,8 +1,30 @@ from os.path import abspath import responses +from pytest import raises from vonage_application.application import Application -from vonage_application.requests import ApplicationOptions +from vonage_application.common import ( + ApplicationUrl, + Capabilities, + 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 ( + ApplicationOptions, + ListApplicationsFilter, + RequestKeys, +) from vonage_http_client.http_client import HttpClient from testutils import build_response, get_mock_api_key_auth @@ -36,94 +58,252 @@ def test_create_application_basic(): 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 = ApplicationOptions(**application_options_dict) + assert ( + application_options_model.model_dump(exclude_unset=True) + == application_options_dict + ) + + @responses.activate -def test_create_application_options(): +def test_create_application_options_with_models(): build_response( path, 'POST', 'https://api.nexmo.com/v2/applications', 'create_application_options.json', ) - app = application.create_application( - ApplicationOptions( - name='My Application', - keys={'public_key': 'public_key_info_goes_here'}, + + 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 = RequestKeys(public_key=public_key) + + params = ApplicationOptions( + 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 app.id == 'ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7' - assert app.name == 'My Application' + 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_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', -# 'channels': { -# 'sms': [{'number': '1234567890'}], -# 'mms': [{'number': '1234567890'}], -# }, -# } - -# 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_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_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 diff --git a/voice/src/vonage_voice/models/__init__.py b/voice/src/vonage_voice/models/__init__.py index 55121a7a..aeb86a7b 100644 --- a/voice/src/vonage_voice/models/__init__.py +++ b/voice/src/vonage_voice/models/__init__.py @@ -29,7 +29,7 @@ CallMessage, CreateCallResponse, Embedded, - Links, + HalLinks, ) __all__ = [ @@ -50,7 +50,7 @@ 'Embedded', 'Input', 'ListCallsFilter', - 'Links', + 'HalLinks', 'NccoAction', 'NccoActionType', 'Notify', diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py index 8644e7e2..a528cf6c 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -1,7 +1,7 @@ from typing import List, Optional, Union from pydantic import BaseModel, Field, model_validator -from vonage_utils.models import Link +from vonage_utils.models import HalLinks from .common import Phone, Sip, Vbc, Websocket @@ -18,14 +18,6 @@ class CallMessage(BaseModel): uuid: str -class Links(BaseModel): - self: Link - first: Optional[Link] = None - last: Optional[Link] = None - prev: Optional[Link] = None - next: Optional[Link] = None - - class CallInfo(BaseModel): uuid: str conversation_uuid: str @@ -39,7 +31,7 @@ class CallInfo(BaseModel): start_time: Optional[str] = None end_time: Optional[str] = None network: Optional[str] = None - links: Links = Field(..., validation_alias='_links', exclude=True) + links: HalLinks = Field(..., validation_alias='_links', exclude=True) link: Optional[str] = None @model_validator(mode='after') @@ -57,4 +49,4 @@ class CallList(BaseModel): page_size: int record_index: int embedded: Embedded = Field(..., validation_alias='_embedded') - links: Links = Field(..., validation_alias='_links') + links: HalLinks = Field(..., validation_alias='_links') diff --git a/vonage_utils/src/vonage_utils/models.py b/vonage_utils/src/vonage_utils/models.py index c3548696..c3a21698 100644 --- a/vonage_utils/src/vonage_utils/models.py +++ b/vonage_utils/src/vonage_utils/models.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel @@ -7,3 +9,11 @@ class Link(BaseModel): class ResourceLink(BaseModel): self: Link + + +class HalLinks(BaseModel): + self: Link + first: Optional[Link] = None + last: Optional[Link] = None + prev: Optional[Link] = None + next: Optional[Link] = None From 7e1186a946b6ec753c9e30ab6dc69a7a38bb4683 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 2 May 2024 17:01:13 +0100 Subject: [PATCH 46/98] add other applications endpoints --- application/README.md | 38 +++------- .../src/vonage_application/__init__.py | 40 ++++++++++ .../src/vonage_application/application.py | 74 ++++++++++--------- application/src/vonage_application/common.py | 9 ++- .../src/vonage_application/requests.py | 8 +- .../src/vonage_application/responses.py | 9 +-- application/tests/data/get_application.json | 36 +++++++++ .../tests/data/update_application.json | 13 ++++ application/tests/test_application.py | 66 ++++++++++++++--- 9 files changed, 207 insertions(+), 86 deletions(-) create mode 100644 application/tests/data/get_application.json create mode 100644 application/tests/data/update_application.json diff --git a/application/README.md b/application/README.md index aeb4d581..a00a7968 100644 --- a/application/README.md +++ b/application/README.md @@ -8,40 +8,24 @@ It includes methods for managing applications. 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. +### 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_users import ListUsersRequest +from vonage_application import ListApplicationsFilter, ApplicationData -users, _ = vonage_client.users.list_users() +applications, next_page = vonage_client.application.list_applications() # With options -params = ListUsersRequest( - page_size=10, - cursor=my_cursor, - order='desc', -) -users, next_cursor = vonage_client.users.list_users(params) +options = ListApplicationsFilter(page_size=3, page=2) +applications, next_page = vonage_client.applications.list_applications(options) ``` + +-------- + + ### Create a New User ```python diff --git a/application/src/vonage_application/__init__.py b/application/src/vonage_application/__init__.py index 3aa3a60b..f4c43be8 100644 --- a/application/src/vonage_application/__init__.py +++ b/application/src/vonage_application/__init__.py @@ -1,5 +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/application.py b/application/src/vonage_application/application.py index 51ff982b..6e022f48 100644 --- a/application/src/vonage_application/application.py +++ b/application/src/vonage_application/application.py @@ -3,7 +3,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from .requests import ApplicationOptions, ListApplicationsFilter +from .requests import ApplicationConfig, ListApplicationsFilter from .responses import ApplicationData, ListApplicationsResponse @@ -56,12 +56,13 @@ def list_applications( @validate_call def create_application( - self, params: Optional[ApplicationOptions] = None + self, params: Optional[ApplicationConfig] = None ) -> ApplicationData: """Create a new application. Args: - params (Optional[ApplicationOptions]): The application options. + params (Optional[ApplicationConfig]): Parameters describing the + application options to set. Returns: ApplicationData: The created application object. @@ -85,39 +86,40 @@ def get_application(self, id: str) -> ApplicationData: ApplicationData: The created application object. """ response = self._http_client.get( - self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type + self._http_client.api_host, f'/v2/applications/{id}', None, self._auth_type ) return ApplicationData(**response) - # @validate_call - # def update_application(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_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 - # ) + @validate_call + def update_application(self, id: str, params: ApplicationConfig) -> ApplicationData: + """Update an application. + + Args: + id (str): The ID of the application to update. + params (ApplicationConfig): Parameters describing the + application options to update. + + Returns: + ApplicationData: The updated application object. + """ + response = self._http_client.put( + self._http_client.api_host, + f'/v2/applications/{id}', + params.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 index 25df53e4..79cc9be9 100644 --- a/application/src/vonage_application/common.py +++ b/application/src/vonage_application/common.py @@ -1,6 +1,6 @@ from typing import Literal, Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from .enums import Region from .errors import ApplicationError @@ -103,6 +103,12 @@ class Capabilities(BaseModel): verify: Optional[Verify] = None +class Keys(BaseModel): + 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.""" @@ -110,3 +116,4 @@ class ApplicationBase(BaseModel): name: str capabilities: Optional[Capabilities] = None privacy: Optional[Privacy] = None + keys: Optional[Keys] = None diff --git a/application/src/vonage_application/requests.py b/application/src/vonage_application/requests.py index 17817cfb..c297a268 100644 --- a/application/src/vonage_application/requests.py +++ b/application/src/vonage_application/requests.py @@ -12,9 +12,5 @@ class ListApplicationsFilter(BaseModel): page: int = None -class RequestKeys(BaseModel): - public_key: str - - -class ApplicationOptions(ApplicationBase): - keys: Optional[RequestKeys] = None +class ApplicationConfig(ApplicationBase): + pass diff --git a/application/src/vonage_application/responses.py b/application/src/vonage_application/responses.py index 7574f4e4..de7634bd 100644 --- a/application/src/vonage_application/responses.py +++ b/application/src/vonage_application/responses.py @@ -3,17 +3,12 @@ from pydantic import BaseModel, Field, model_validator from vonage_utils.models import HalLinks, ResourceLink -from .common import ApplicationBase - - -class ResponseKeys(BaseModel): - public_key: Optional[str] = None - private_key: Optional[str] = None +from .common import ApplicationBase, Keys class ApplicationData(ApplicationBase): id: str - keys: Optional[ResponseKeys] = None + keys: Optional[Keys] = None links: Optional[ResourceLink] = Field(None, validation_alias='_links', exclude=True) link: Optional[str] = None 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/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 index 4d9cbf01..6c14a4c6 100644 --- a/application/tests/test_application.py +++ b/application/tests/test_application.py @@ -6,6 +6,7 @@ from vonage_application.common import ( ApplicationUrl, Capabilities, + Keys, Messages, MessagesWebhooks, Privacy, @@ -20,11 +21,7 @@ ) from vonage_application.enums import Region from vonage_application.errors import ApplicationError -from vonage_application.requests import ( - ApplicationOptions, - ListApplicationsFilter, - RequestKeys, -) +from vonage_application.requests import ApplicationConfig, ListApplicationsFilter from vonage_http_client.http_client import HttpClient from testutils import build_response, get_mock_api_key_auth @@ -47,7 +44,7 @@ def test_create_application_basic(): 'https://api.nexmo.com/v2/applications', 'create_application_basic.json', ) - app = application.create_application(ApplicationOptions(name='My Application')) + app = application.create_application(ApplicationConfig(name='My Application')) assert app.id == 'ba1a6aa3-8ac6-487d-ac5c-be469e77ddb7' assert app.name == 'My Application' @@ -132,7 +129,7 @@ def test_create_application_options_model_from_dict(): 'keys': keys, } application_options_dict = params - application_options_model = ApplicationOptions(**application_options_dict) + application_options_model = ApplicationConfig(**application_options_dict) assert ( application_options_model.model_dump(exclude_unset=True) == application_options_dict @@ -212,9 +209,9 @@ def test_create_application_options_with_models(): privacy = Privacy(improve_ai=False) public_key = '-----BEGIN PUBLIC KEY-----\npublic_key_info_goes_here\n-----END PUBLIC KEY-----\n' - keys = RequestKeys(public_key=public_key) + keys = Keys(public_key=public_key) - params = ApplicationOptions( + params = ApplicationConfig( name='My Customised Application', capabilities=capabilities, privacy=privacy, @@ -307,3 +304,54 @@ def test_list_applications_multiple_pages(): ) 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 From 87806b1e567a7d2145cfac3f5290ad1c0ef29278 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 9 May 2024 16:19:15 +0100 Subject: [PATCH 47/98] prepare for application api release --- application/README.md | 66 +++++++++++++++++++++-------------- application/pyproject.toml | 2 +- users/CHANGES.md | 3 ++ users/pyproject.toml | 4 +-- voice/CHANGES.md | 3 ++ voice/pyproject.toml | 4 +-- vonage/CHANGES.md | 6 ++++ vonage/pyproject.toml | 1 + vonage/src/vonage/_version.py | 2 +- vonage_utils/CHANGES.md | 5 ++- vonage_utils/pyproject.toml | 2 +- 11 files changed, 63 insertions(+), 35 deletions(-) diff --git a/application/README.md b/application/README.md index a00a7968..b54f18e5 100644 --- a/application/README.md +++ b/application/README.md @@ -19,47 +19,59 @@ applications, next_page = vonage_client.application.list_applications() # With options options = ListApplicationsFilter(page_size=3, page=2) -applications, next_page = vonage_client.applications.list_applications(options) +applications, next_page = vonage_client.application.list_applications(options) ``` - --------- - - -### Create a New User +### Create a New Application ```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')]), +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, ) -user = vonage_client.users.create_user(user_options) +app_data = vonage_client.application.create_application(config) ``` -### Get a User +### Get an Application ```python -user = client.users.get_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') -user_as_dict = user.model_dump(exclude_none=True) +app_data = client.application.get_application('MY_APP_ID') +app_data_as_dict = app.model_dump(exclude_none=True) ``` -### Update a User +### Update an Application + +To update an application, pass config for the updated field(s) in an ApplicationConfig object + ```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) +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 a User +### Delete an Application ```python -vonage_client.users.delete_user(id) +vonage_client.applications.delete_application('MY_APP_ID') ``` \ No newline at end of file diff --git a/application/pyproject.toml b/application/pyproject.toml index 27a3788d..d8a0ec76 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -7,7 +7,7 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", + "vonage-utils>=1.1.2", "pydantic>=2.7.1", ] classifiers = [ diff --git a/users/CHANGES.md b/users/CHANGES.md index 9dc9c61c..4e958551 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Internal refactoring + # 1.1.1 - Update minimum dependency version diff --git a/users/pyproject.toml b/users/pyproject.toml index 9b8d2699..da4b9ec6 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-users' -version = '1.1.1' +version = '1.1.2' description = 'Vonage Users package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", + "vonage-utils>=1.1.2", "pydantic>=2.7.1", ] classifiers = [ diff --git a/voice/CHANGES.md b/voice/CHANGES.md index ca5e1f85..ba84e407 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Internal refactoring + # 1.0.2 - Update minimum dependency version diff --git a/voice/pyproject.toml b/voice/pyproject.toml index 949dfdbe..b18c0ddc 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-voice' -version = '1.0.2' +version = '1.0.3' description = 'Vonage voice package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", + "vonage-utils>=1.1.2", "pydantic>=2.7.1", ] classifiers = [ diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 26dd77d7..5b7a2e1b 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,9 @@ +# 3.99.0a10 +- Add support for the [Vonage Application API](https://developer.vonage.com/en/application/overview). + +# 3.99.0a9 +- Internal refactoring + # 3.99.0a8 - Add support for the [Vonage Number Insight API](https://developer.vonage.com/en/number-insight/overview). - Update minimum dependency version diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 5efd58c4..32df6522 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -8,6 +8,7 @@ requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.1.1", "vonage-http-client>=1.3.1", + "vonage-application>=1.0.0", "vonage-messages>=1.1.1", "vonage-number-insight>=1.0.0", "vonage-number-insight-v2>=0.1.1b0", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 94c2cd85..f4db3a00 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a8' +__version__ = '3.99.0a10' diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md index bf3099a4..14605ecf 100644 --- a/vonage_utils/CHANGES.md +++ b/vonage_utils/CHANGES.md @@ -1,4 +1,7 @@ -# 1.1.0 +# 1.1.2 +- Refactoring common pydantic models across the monorepo into this package + +# 1.1.1 - Update minimum dependency version # 1.1.0 diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 927e3680..bbe7c5b3 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-utils' -version = '1.1.1' +version = '1.1.2' description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] From 27bcffac3847511e1747bda1b37ee695bfa98b22 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 10 May 2024 01:41:42 +0100 Subject: [PATCH 48/98] add new dependency versions --- vonage/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 32df6522..b3ee559f 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -13,10 +13,10 @@ dependencies = [ "vonage-number-insight>=1.0.0", "vonage-number-insight-v2>=0.1.1b0", "vonage-sms>=1.1.1", - "vonage-users>=1.1.1", + "vonage-users>=1.1.2", "vonage-verify>=1.1.1", "vonage-verify-v2>=1.1.1", - "vonage-voice>=1.0.2", + "vonage-voice>=1.0.3", ] classifiers = [ "Programming Language :: Python", From 445cab802b0f9d61d8e036b146bf67102d5051f6 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 14 May 2024 15:02:30 +0100 Subject: [PATCH 49/98] start migration of jwt module --- http_client/pyproject.toml | 2 +- jwt/CHANGES.md | 5 +++ jwt/README.md | 60 +++++++++++++++++++++++++++++ jwt/pyproject.toml | 27 +++++++++++++ jwt/src/vonage_jwt/BUILD | 1 + jwt/src/vonage_jwt/__init__.py | 4 ++ jwt/src/vonage_jwt/jwt.py | 57 +++++++++++++++++++++++++++ jwt/src/vonage_jwt/verify_jwt.py | 19 +++++++++ jwt/tests/BUILD | 1 + jwt/tests/data/private_key.txt | 28 ++++++++++++++ jwt/tests/data/public_key.txt | 9 +++++ jwt/tests/test_jwt_generator.py | 66 ++++++++++++++++++++++++++++++++ jwt/tests/test_verify_jwt.py | 20 ++++++++++ requirements.txt | 3 +- 14 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 jwt/CHANGES.md create mode 100644 jwt/README.md create mode 100644 jwt/pyproject.toml create mode 100644 jwt/src/vonage_jwt/BUILD create mode 100644 jwt/src/vonage_jwt/__init__.py create mode 100644 jwt/src/vonage_jwt/jwt.py create mode 100644 jwt/src/vonage_jwt/verify_jwt.py create mode 100644 jwt/tests/BUILD create mode 100644 jwt/tests/data/private_key.txt create mode 100644 jwt/tests/data/public_key.txt create mode 100644 jwt/tests/test_jwt_generator.py create mode 100644 jwt/tests/test_verify_jwt.py diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index 9b63b0dd..e98c904e 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -7,7 +7,7 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.1.1", - "vonage-jwt>=1.1.0", + "vonage-jwt>=1.1.1", "requests>=2.27.0", "typing-extensions>=4.9.0", "pydantic>=2.7.1", diff --git a/jwt/CHANGES.md b/jwt/CHANGES.md new file mode 100644 index 00000000..2808b330 --- /dev/null +++ b/jwt/CHANGES.md @@ -0,0 +1,5 @@ +# 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..9d52db72 --- /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 a 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.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.verify_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..20269708 --- /dev/null +++ b/jwt/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "vonage-jwt" +version = "1.1.1" +description = "Tooling for working with JWTs for Vonage APIs in Python." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "pyjwt[crypto] >=1.6.4" +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..0e9b94b4 --- /dev/null +++ b/jwt/src/vonage_jwt/__init__.py @@ -0,0 +1,4 @@ +from .jwt import JwtClient, VonageJwtError +from .verify_jwt import verify_signature + +__all__ = ['JwtClient', 'VonageJwtError', 'verify_signature'] diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py new file mode 100644 index 00000000..67d9b987 --- /dev/null +++ b/jwt/src/vonage_jwt/jwt.py @@ -0,0 +1,57 @@ +import re +from time import time +from jwt import encode +from uuid import uuid4 +from typing import Union + + +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 = {}): + """ + 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. + """ + + iat = int(time()) + + payload = jwt_options + payload["application_id"] = self._application_id + payload.setdefault("iat", iat) + payload.setdefault("jti", str(uuid4())) + payload.setdefault("exp", 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]): + 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 + + +class VonageJwtError(Exception): + """An error relating to the Vonage JWT Generator.""" diff --git a/jwt/src/vonage_jwt/verify_jwt.py b/jwt/src/vonage_jwt/verify_jwt.py new file mode 100644 index 00000000..982eefff --- /dev/null +++ b/jwt/src/vonage_jwt/verify_jwt.py @@ -0,0 +1,19 @@ +from jwt import InvalidSignatureError, decode + + +def verify_signature(token: str, signature_secret: str = None) -> bool: + """ + Method to verify that an incoming JWT was sent by Vonage. + """ + + try: + decode(token, signature_secret, algorithms='HS256') + return True + except InvalidSignatureError: + return False + except Exception as e: + raise VonageVerifyJwtError(repr(e)) + + +class VonageVerifyJwtError(Exception): + """The signature could not be verified.""" diff --git a/jwt/tests/BUILD b/jwt/tests/BUILD new file mode 100644 index 00000000..72fce921 --- /dev/null +++ b/jwt/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['messages', '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..1fa006af --- /dev/null +++ b/jwt/tests/test_jwt_generator.py @@ -0,0 +1,66 @@ +from vonage_jwt.jwt import JwtClient, VonageJwtError + +import os +from pytest import raises +from jwt import decode +from jwt.exceptions import ImmatureSignatureError +from time import time + +# Ensure the 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() + + +application_id = 'asdf1234' +private_key_string = read_file('data/private_key.txt') +private_key_file_path = './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..1e5175d7 --- /dev/null +++ b/jwt/tests/test_verify_jwt.py @@ -0,0 +1,20 @@ +from vonage_jwt.verify_jwt import verify_signature, VonageVerifyJwtError +import pytest + +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/requirements.txt b/requirements.txt index a0d28c40..ecac15df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,10 @@ requests>=2.31.0 responses>=0.24.1 pydantic>=2.7.1 typing-extensions>=4.9.0 -vonage-jwt>=1.1.0 +pyjwt[crypto]>=1.6.4 -e http_client +-e jwt -e messages -e number_insight -e number_insight_v2 From a7a686cfe307484c97196f721af0fae3c4a279d4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 15 May 2024 18:16:41 +0100 Subject: [PATCH 50/98] refactor jwt package, get tests passing --- jwt/BUILD | 16 ++++++++++++++++ jwt/CHANGES.md | 4 ++++ jwt/README.md | 8 ++++---- jwt/pyproject.toml | 4 +--- jwt/src/vonage_jwt/__init__.py | 5 +++-- jwt/src/vonage_jwt/errors.py | 9 +++++++++ jwt/src/vonage_jwt/jwt.py | 15 +++++++-------- jwt/src/vonage_jwt/verify_jwt.py | 10 +++------- jwt/tests/BUILD | 2 +- jwt/tests/test_jwt_generator.py | 16 +++++++++------- jwt/tests/test_verify_jwt.py | 3 ++- pants.toml | 1 + 12 files changed, 60 insertions(+), 33 deletions(-) create mode 100644 jwt/BUILD create mode 100644 jwt/src/vonage_jwt/errors.py 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 index 2808b330..62166af4 100644 --- a/jwt/CHANGES.md +++ b/jwt/CHANGES.md @@ -1,3 +1,7 @@ +# 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` diff --git a/jwt/README.md b/jwt/README.md index 9d52db72..d577c8ff 100644 --- a/jwt/README.md +++ b/jwt/README.md @@ -2,9 +2,9 @@ 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 a SDK. +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). +For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). - [Installation](#installation) - [Generating JWTs](#generating-jwts) @@ -27,7 +27,7 @@ It can also be used as a standalone JWT generator for use with Vonage APIs, like ### Import the `JwtClient` object ```python -from vonage_jwt.jwt import JwtClient +from vonage_jwt import JwtClient ``` ### Create a `JwtClient` object @@ -54,7 +54,7 @@ jwt_client.generate_application_jwt(claims) You can use the `verify_jwt.verify_signature` method to verify a JWT signature is valid. ```python -from vonage_jwt.verify_jwt import verify_signature +from vonage_jwt import verify_signature verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean ``` diff --git a/jwt/pyproject.toml b/jwt/pyproject.toml index 20269708..569b7723 100644 --- a/jwt/pyproject.toml +++ b/jwt/pyproject.toml @@ -5,9 +5,7 @@ description = "Tooling for working with JWTs for Vonage APIs in Python." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" -dependencies = [ - "pyjwt[crypto] >=1.6.4" -] +dependencies = ["vonage-utils>=1.1.2", "pyjwt[crypto]>=1.6.4"] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", diff --git a/jwt/src/vonage_jwt/__init__.py b/jwt/src/vonage_jwt/__init__.py index 0e9b94b4..4419f04f 100644 --- a/jwt/src/vonage_jwt/__init__.py +++ b/jwt/src/vonage_jwt/__init__.py @@ -1,4 +1,5 @@ -from .jwt import JwtClient, VonageJwtError +from .errors import VonageJwtError, VonageVerifyJwtError +from .jwt import JwtClient from .verify_jwt import verify_signature -__all__ = ['JwtClient', 'VonageJwtError', 'verify_signature'] +__all__ = ['JwtClient', 'VonageJwtError', 'VonageVerifyJwtError', 'verify_signature'] 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 index 67d9b987..b1c93359 100644 --- a/jwt/src/vonage_jwt/jwt.py +++ b/jwt/src/vonage_jwt/jwt.py @@ -1,8 +1,11 @@ import re from time import time -from jwt import encode -from uuid import uuid4 from typing import Union +from uuid import uuid4 + +from jwt import encode + +from .errors import VonageJwtError class JwtClient: @@ -22,8 +25,8 @@ def __init__(self, application_id: str, private_key: str): ) def generate_application_jwt(self, jwt_options: dict = {}): - """ - Generates a JWT for the specified Vonage application. + """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. """ @@ -51,7 +54,3 @@ def _set_private_key(self, key: Union[str, bytes]): ) else: self._private_key = key - - -class VonageJwtError(Exception): - """An error relating to the Vonage JWT Generator.""" diff --git a/jwt/src/vonage_jwt/verify_jwt.py b/jwt/src/vonage_jwt/verify_jwt.py index 982eefff..31de3302 100644 --- a/jwt/src/vonage_jwt/verify_jwt.py +++ b/jwt/src/vonage_jwt/verify_jwt.py @@ -1,10 +1,10 @@ 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. - """ + """Method to verify that an incoming JWT was sent by Vonage.""" try: decode(token, signature_secret, algorithms='HS256') @@ -13,7 +13,3 @@ def verify_signature(token: str, signature_secret: str = None) -> bool: return False except Exception as e: raise VonageVerifyJwtError(repr(e)) - - -class VonageVerifyJwtError(Exception): - """The signature could not be verified.""" diff --git a/jwt/tests/BUILD b/jwt/tests/BUILD index 72fce921..dec8ad99 100644 --- a/jwt/tests/BUILD +++ b/jwt/tests/BUILD @@ -1 +1 @@ -python_tests(dependencies=['messages', 'testutils']) +python_tests(dependencies=['jwt', 'testutils']) diff --git a/jwt/tests/test_jwt_generator.py b/jwt/tests/test_jwt_generator.py index 1fa006af..f32f0225 100644 --- a/jwt/tests/test_jwt_generator.py +++ b/jwt/tests/test_jwt_generator.py @@ -1,23 +1,25 @@ -from vonage_jwt.jwt import JwtClient, VonageJwtError +from os import environ +from os.path import dirname, join +from time import time -import os +from jwt.exceptions import ImmatureSignatureError from pytest import raises +from vonage_jwt.jwt import JwtClient, VonageJwtError + from jwt import decode -from jwt.exceptions import ImmatureSignatureError -from time import time # Ensure the client isn't being configured with real values -os.environ.clear() +environ.clear() def read_file(path): - with open(os.path.join(os.path.dirname(__file__), path)) as input_file: + 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 = './tests/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') diff --git a/jwt/tests/test_verify_jwt.py b/jwt/tests/test_verify_jwt.py index 1e5175d7..6e7e7286 100644 --- a/jwt/tests/test_verify_jwt.py +++ b/jwt/tests/test_verify_jwt.py @@ -1,5 +1,6 @@ -from vonage_jwt.verify_jwt import verify_signature, VonageVerifyJwtError import pytest +from vonage_jwt.errors import VonageVerifyJwtError +from vonage_jwt.verify_jwt import verify_signature token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2OTc2MzQ2ODAsImV4cCI6MzMyNTQ1NDA4MjgsImF1ZCI6IiIsInN1YiI6IiJ9.88vJc3I2HhuqEDixHXVhc9R30tA6U_HQHZTC29y6CGM' valid_signature = "qwertyuiopasdfghjklzxcvbnm123456" diff --git a/pants.toml b/pants.toml index 8be53d31..bcbc4d63 100644 --- a/pants.toml +++ b/pants.toml @@ -33,6 +33,7 @@ filter = [ 'vonage/src', 'http_client/src', 'application/src', + 'jwt/src', 'messages/src', 'number_insight/src', 'number_insight_v2/src', From 280ebf293b62c343bddf8af2f7f932fc7e06af8b Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 18 May 2024 22:56:50 -0400 Subject: [PATCH 51/98] prepare for new release --- vonage/CHANGES.md | 4 ++++ vonage/src/vonage/_version.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 5b7a2e1b..9dcf11da 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,7 @@ +# 3.99.1a10 +- Migrate the Vonage JWT package +- Internal refactoring + # 3.99.0a10 - Add support for the [Vonage Application API](https://developer.vonage.com/en/application/overview). diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index f4db3a00..2b247376 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a10' +__version__ = '3.99.1a10' From f65afe5d1c96e2cb4eb6cba58ef5b8b2e2942f90 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 21 Jun 2024 19:55:31 +0100 Subject: [PATCH 52/98] 4.x gnp (#300) * add camara auth module * start adding gnp sim swap * adding new gnp packages * update camara auth * add network packages * finish adding network apis and prepare for release * adjust verify v2 channel timeout and prepare for release --- http_client/CHANGES.md | 3 + http_client/pyproject.toml | 4 +- .../src/vonage_http_client/http_client.py | 12 +- network_auth/BUILD | 16 +++ network_auth/CHANGES.md | 2 + network_auth/README.md | 36 ++++++ network_auth/pyproject.toml | 25 ++++ network_auth/src/vonage_network_auth/BUILD | 1 + .../src/vonage_network_auth/__init__.py | 4 + .../src/vonage_network_auth/network_auth.py | 91 ++++++++++++++ .../src/vonage_network_auth/responses.py | 16 +++ network_auth/tests/BUILD | 1 + network_auth/tests/data/oidc_request.json | 5 + .../data/oidc_request_permissions_error.json | 6 + network_auth/tests/data/token_request.json | 5 + network_auth/tests/test_network_auth.py | 111 ++++++++++++++++++ network_sim_swap/BUILD | 16 +++ network_sim_swap/CHANGES.md | 2 + network_sim_swap/README.md | 41 +++++++ network_sim_swap/pyproject.toml | 29 +++++ .../src/vonage_network_sim_swap/BUILD | 1 + .../src/vonage_network_sim_swap/__init__.py | 4 + .../src/vonage_network_sim_swap/responses.py | 9 ++ .../src/vonage_network_sim_swap/sim_swap.py | 75 ++++++++++++ network_sim_swap/tests/BUILD | 1 + .../tests/data/check_sim_swap.json | 3 + .../tests/data/get_swap_date.json | 3 + network_sim_swap/tests/test_sim_swap.py | 49 ++++++++ pants.toml | 5 +- requirements.txt | 3 + verify_v2/CHANGES.md | 3 + verify_v2/pyproject.toml | 6 +- verify_v2/src/vonage_verify_v2/requests.py | 2 +- vonage/CHANGES.md | 4 + vonage/README.md | 8 +- vonage/pyproject.toml | 7 +- vonage/src/vonage/__init__.py | 2 - vonage/src/vonage/_version.py | 2 +- vonage/src/vonage/vonage.py | 2 - vonage_network/BUILD | 11 ++ vonage_network/CHANGES.md | 2 + vonage_network/README.md | 47 ++++++++ vonage_network/pyproject.toml | 33 ++++++ vonage_network/src/vonage_network/BUILD | 1 + vonage_network/src/vonage_network/__init__.py | 18 +++ vonage_network/src/vonage_network/_version.py | 1 + .../src/vonage_network/vonage_network.py | 36 ++++++ vonage_network/tests/BUILD | 1 + vonage_network/tests/test_vonage_network.py | 16 +++ 49 files changed, 758 insertions(+), 23 deletions(-) create mode 100644 network_auth/BUILD create mode 100644 network_auth/CHANGES.md create mode 100644 network_auth/README.md create mode 100644 network_auth/pyproject.toml create mode 100644 network_auth/src/vonage_network_auth/BUILD create mode 100644 network_auth/src/vonage_network_auth/__init__.py create mode 100644 network_auth/src/vonage_network_auth/network_auth.py create mode 100644 network_auth/src/vonage_network_auth/responses.py create mode 100644 network_auth/tests/BUILD create mode 100644 network_auth/tests/data/oidc_request.json create mode 100644 network_auth/tests/data/oidc_request_permissions_error.json create mode 100644 network_auth/tests/data/token_request.json create mode 100644 network_auth/tests/test_network_auth.py create mode 100644 network_sim_swap/BUILD create mode 100644 network_sim_swap/CHANGES.md create mode 100644 network_sim_swap/README.md create mode 100644 network_sim_swap/pyproject.toml create mode 100644 network_sim_swap/src/vonage_network_sim_swap/BUILD create mode 100644 network_sim_swap/src/vonage_network_sim_swap/__init__.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/responses.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/sim_swap.py create mode 100644 network_sim_swap/tests/BUILD create mode 100644 network_sim_swap/tests/data/check_sim_swap.json create mode 100644 network_sim_swap/tests/data/get_swap_date.json create mode 100644 network_sim_swap/tests/test_sim_swap.py create mode 100644 vonage_network/BUILD create mode 100644 vonage_network/CHANGES.md create mode 100644 vonage_network/README.md create mode 100644 vonage_network/pyproject.toml create mode 100644 vonage_network/src/vonage_network/BUILD create mode 100644 vonage_network/src/vonage_network/__init__.py create mode 100644 vonage_network/src/vonage_network/_version.py create mode 100644 vonage_network/src/vonage_network/vonage_network.py create mode 100644 vonage_network/tests/BUILD create mode 100644 vonage_network/tests/test_vonage_network.py diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index c4870964..68238c1e 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.4.0 +- Add new `oauth2` logic for calling APIs that require Oauth + # 1.3.1 - Update minimum dependency version diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index e98c904e..20b5cd17 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "vonage-http-client" -version = "1.3.1" +version = "1.4.0" description = "An HTTP client for making requests to Vonage APIs." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.1.1", + "vonage-utils>=1.1.2", "vonage-jwt>=1.1.1", "requests>=2.27.0", "typing-extensions>=4.9.0", diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 89bfd7d0..8a516f13 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -130,11 +130,12 @@ def post( host: str, request_path: str = '', params: dict = None, - auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', - sent_data_type: Literal['json', 'data'] = 'json', + 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 + 'POST', host, request_path, params, auth_type, sent_data_type, token ) def get( @@ -192,8 +193,9 @@ def make_request( host: str, request_path: str = '', params: Optional[dict] = None, - auth_type: Literal['jwt', 'basic', 'body', 'signature'] = 'jwt', + auth_type: Literal['jwt', 'basic', 'body', 'signature', 'oauth2'] = 'jwt', sent_data_type: Literal['json', 'form', 'query_params'] = 'json', + token: Optional[str] = None, ): url = f'https://{host}{request_path}' logger.debug( @@ -206,6 +208,8 @@ def make_request( 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) 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..4df12901 --- /dev/null +++ b/network_auth/CHANGES.md @@ -0,0 +1,2 @@ +# 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..ba06fc90 --- /dev/null +++ b/network_auth/README.md @@ -0,0 +1,36 @@ +# Vonage Network API Authentication Client + +This package (`vonage-network-auth`) provides a client for authenticating Network APIs that require Oauth2 authentcation. Using it, it is possible to generate authenticated JWTs for use with GNP APIs, e.g. Sim Swap, Number Verification. + +This package is intended to be used as part of an SDK, 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). + +Please note this package is in beta. + +## 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'))) +``` + +### Generate an Authenticated Access Token + +```python +token = network_auth.get_oauth2_user_token( + number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap' +) +``` diff --git a/network_auth/pyproject.toml b/network_auth/pyproject.toml new file mode 100644 index 00000000..c14a52de --- /dev/null +++ b/network_auth/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "vonage-network-auth" +version = "0.1.0b0" +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.8" +dependencies = ["vonage-http-client>=1.4.0", "vonage-utils>=1.1.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" + +[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..e321e403 --- /dev/null +++ b/network_auth/src/vonage_network_auth/__init__.py @@ -0,0 +1,4 @@ +from .network_auth import NetworkAuth +from .responses import OidcResponse, TokenResponse + +__all__ = ['NetworkAuth', 'OidcResponse', 'TokenResponse'] 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..1813d59c --- /dev/null +++ b/network_auth/src/vonage_network_auth/network_auth.py @@ -0,0 +1,91 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +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_oauth2_user_token(self, number: str, scope: str) -> str: + """Get an OAuth2 user token for a given number and scope. + + 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_request(number, scope) + token_response = self.request_access_token(oidc_response.auth_req_id) + return token_response.access_token + + @validate_call + def make_oidc_request(self, number: str, scope: str) -> OidcResponse: + """Make an OIDC request to authenticate a user. + + 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_access_token( + self, auth_req_id: str, grant_type: str = 'urn:openid:params:grant-type:ciba' + ) -> TokenResponse: + """Request a Camara access token using an authentication request ID given as a response to + an OIDC request.""" + params = {'auth_req_id': auth_req_id, 'grant_type': grant_type} + + 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/responses.py b/network_auth/src/vonage_network_auth/responses.py new file mode 100644 index 00000000..396fdf40 --- /dev/null +++ b/network_auth/src/vonage_network_auth/responses.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel + + +class OidcResponse(BaseModel): + auth_req_id: str + expires_in: int + interval: Optional[int] = None + + +class TokenResponse(BaseModel): + 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..4bdbf90d --- /dev/null +++ b/network_auth/tests/test_network_auth.py @@ -0,0 +1,111 @@ +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_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_request_access_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_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_oauth2_user_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: + response = network_auth.make_oidc_request( + number='447700900000', + scope='dpv:FraudPreventionAndDetection#check-sim-swap', + ) + assert err.match('"title": "Bad Request"') 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..4df12901 --- /dev/null +++ b/network_sim_swap/CHANGES.md @@ -0,0 +1,2 @@ +# 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..7a2d70b4 --- /dev/null +++ b/network_sim_swap/README.md @@ -0,0 +1,41 @@ +# 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). + +Please note this package is in beta. + +## 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-network` package. The examples below assume you've created an instance of the `vonage_network.VonageNetwork` class called `network_client`. + +### Check if a SIM Has Been Swapped + +```python +from vonage_network_sim_swap import SwapStatus +swap_status: SwapStatus = vonage_network.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_network.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..2d8b4d26 --- /dev/null +++ b/network_sim_swap/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "vonage-network-sim-swap" +version = "0.1.0b0" +description = "Package for working with the Vonage Sim Swap Network API." +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.4.0", + "vonage-network-auth>=0.1.0b0", + "vonage-utils>=1.1.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" + +[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..41b72410 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/__init__.py @@ -0,0 +1,4 @@ +from .responses import LastSwapDate, SwapStatus +from .sim_swap import NetworkSimSwap + +__all__ = ['NetworkSimSwap', 'LastSwapDate', 'SwapStatus'] 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..80c76349 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/responses.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, Field + + +class SwapStatus(BaseModel): + swapped: str + + +class LastSwapDate(BaseModel): + 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..576a9a8b --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py @@ -0,0 +1,75 @@ +from pydantic import validate_call +from vonage_http_client import HttpClient +from vonage_network_auth import NetworkAuth + +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, phone_number: str, max_age: int = None) -> SwapStatus: + """Check if a SIM swap has been performed in a given time frame. + + 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. + + Returns: + SwapStatus: Class containing the Swap Status response. + """ + token = self._network_auth.get_oauth2_user_token( + number=phone_number, scope='dpv:FraudPreventionAndDetection#check-sim-swap' + ) + + params = {'phoneNumber': phone_number} + if max_age: + params['maxAge'] = max_age + + return self._http_client.post( + self._host, + '/camara/sim-swap/v040/check', + params, + 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: + """ + token = self._network_auth.get_oauth2_user_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..09927a7b --- /dev/null +++ b/network_sim_swap/tests/test_sim_swap.py @@ -0,0 +1,49 @@ +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 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_oauth2_user_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('447700900000', max_age=24) + + assert response['swapped'] == True + + +@patch('vonage_network_auth.NetworkAuth.get_oauth2_user_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/pants.toml b/pants.toml index bcbc4d63..4790034f 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.19.1' +pants_version = '2.21.0' backend_packages = [ 'pants.backend.python', @@ -31,10 +31,13 @@ interpreter_constraints = ['>=3.8'] report = ['html', 'console'] filter = [ 'vonage/src', + 'vonage_network/src', 'http_client/src', 'application/src', 'jwt/src', 'messages/src', + 'network_auth/src', + 'network_sim_swap/src', 'number_insight/src', 'number_insight_v2/src', 'sms/src', diff --git a/requirements.txt b/requirements.txt index ecac15df..e4a7ea2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ pyjwt[crypto]>=1.6.4 -e http_client -e jwt -e messages +-e network_auth +-e network_sim_swap -e number_insight -e number_insight_v2 -e sms @@ -16,4 +18,5 @@ pyjwt[crypto]>=1.6.4 -e verify_v2 -e voice -e vonage +-e vonage_network -e vonage_utils diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md index 71b83f45..85cc501f 100644 --- a/verify_v2/CHANGES.md +++ b/verify_v2/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Allow minimum `channel_timeout` value to be 15 seconds + # 1.1.1 - Update minimum dependency version diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml index 4bca7315..3d146641 100644 --- a/verify_v2/pyproject.toml +++ b/verify_v2/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-verify-v2' -version = '1.1.1' +version = '1.1.2' description = 'Vonage verify v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", + "vonage-http-client>=1.4.0", + "vonage-utils>=1.1.2", "pydantic>=2.7.1", ] classifiers = [ diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index e4869d6e..aa067603 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -79,7 +79,7 @@ class VerifyRequest(BaseModel): ] ] locale: Optional[Locale] = None - channel_timeout: Optional[int] = Field(None, ge=60, le=900) + 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}$') diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 9dcf11da..b9b1649e 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,7 @@ +# 3.99.0a11 +- Remove the Number Insight v2 beta which was not in use and is going to be deprecated +- Lower the VerifyV2 minimum channel timeout to 15s + # 3.99.1a10 - Migrate the Vonage JWT package - Internal refactoring diff --git a/vonage/README.md b/vonage/README.md index 8552f886..a86bc71f 100644 --- a/vonage/README.md +++ b/vonage/README.md @@ -30,12 +30,14 @@ options = HttpClientOptions(api_host='api.nexmo.com', timeout=30) 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 Number Insight API v2: +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_number_insight_v2 import FraudCheckRequest +from vonage_sms import SmsMessage -vonage.number_insight_v2.fraud_check(FraudCheckRequest(phone='1234567890')) +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: diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index b3ee559f..b52e94da 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,16 +6,15 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.1.1", - "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.2", + "vonage-http-client>=1.4.0", "vonage-application>=1.0.0", "vonage-messages>=1.1.1", "vonage-number-insight>=1.0.0", - "vonage-number-insight-v2>=0.1.1b0", "vonage-sms>=1.1.1", "vonage-users>=1.1.2", "vonage-verify>=1.1.1", - "vonage-verify-v2>=1.1.1", + "vonage-verify-v2>=1.1.2", "vonage-voice>=1.0.3", ] classifiers = [ diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index ee39bfea..d3ead5e8 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -6,7 +6,6 @@ HttpClientOptions, Messages, NumberInsight, - NumberInsightV2, Sms, Users, Verify, @@ -22,7 +21,6 @@ 'Application', 'Messages', 'NumberInsight', - 'NumberInsightV2', 'Sms', 'Users', 'Verify', diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 2b247376..6806e897 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.1a10' +__version__ = '3.99.0a11' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index f329646d..5896df52 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -4,7 +4,6 @@ from vonage_http_client import Auth, HttpClient, HttpClientOptions from vonage_messages import Messages from vonage_number_insight import NumberInsight -from vonage_number_insight_v2 import NumberInsightV2 from vonage_sms import Sms from vonage_users import Users from vonage_verify import Verify @@ -35,7 +34,6 @@ def __init__( self.application = Application(self._http_client) self.messages = Messages(self._http_client) self.number_insight = NumberInsight(self._http_client) - self.number_insight_v2 = NumberInsightV2(self._http_client) self.sms = Sms(self._http_client) self.users = Users(self._http_client) self.verify = Verify(self._http_client) diff --git a/vonage_network/BUILD b/vonage_network/BUILD new file mode 100644 index 00000000..6f2c0062 --- /dev/null +++ b/vonage_network/BUILD @@ -0,0 +1,11 @@ +resource(name='pyproject', source='pyproject.toml') + +file(name='readme', source='README.md') + +python_distribution( + name='vonage_network', + dependencies=[':pyproject', ':readme', 'vonage_network/src/vonage_network'], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/vonage_network/CHANGES.md b/vonage_network/CHANGES.md new file mode 100644 index 00000000..26cbc56f --- /dev/null +++ b/vonage_network/CHANGES.md @@ -0,0 +1,2 @@ +# 0.1.0b0 +- Initial beta release \ No newline at end of file diff --git a/vonage_network/README.md b/vonage_network/README.md new file mode 100644 index 00000000..9265cf4a --- /dev/null +++ b/vonage_network/README.md @@ -0,0 +1,47 @@ +# Vonage Network API Python SDK + +The Vonage Network API Python SDK Package `vonage-network` provides a streamlined interface for using Vonage APIs in Python projects. This package includes the `VonageNetwork` class, which simplifies API interactions. + +The `VonageNetwork` class in this package serves as an entry point for using [Vonage Network APIs](https://developer.vonage.com/en/getting-started-network/what-are-network-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). + +Please note this package is in beta and could be subject to change or removal. + +## Installation + +Install the package using pip: + +```bash +pip install vonage-network +``` + +## Usage + +```python +from vonage_network import VonageNetwork, 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 network client instance +vonage_network = VonageNetwork(auth=auth, http_client_options=options) +``` + +The `VonageNetwork` class provides access to various Vonage Network APIs through its properties. For example, to call the Network Sim Swap API: + +```python +from vonage_network_sim_swap import SwapStatus +swap_status: SwapStatus = vonage_network.sim_swap.check(phone_number='MY_NUMBER') +print(swap_status.swapped) +``` + +You can also access the underlying `HttpClient` instance through the `http_client` property: + +```python +user_agent = vonage_network.http_client.user_agent +``` \ No newline at end of file diff --git a/vonage_network/pyproject.toml b/vonage_network/pyproject.toml new file mode 100644 index 00000000..7ff7b6d2 --- /dev/null +++ b/vonage_network/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "vonage-network" +dynamic = ["version"] +description = "Python Server SDK for using Vonage Network APIs" +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-utils>=1.1.2", + "vonage-http-client>=1.4.0", + "vonage-network-auth>=0.1.0b0", + "vonage-network-sim-swap>=0.1.0b0", +] +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._version.__version__" } + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/vonage_network/src/vonage_network/BUILD b/vonage_network/src/vonage_network/BUILD new file mode 100644 index 00000000..79353bfe --- /dev/null +++ b/vonage_network/src/vonage_network/BUILD @@ -0,0 +1 @@ +python_sources(name='vonage_network') diff --git a/vonage_network/src/vonage_network/__init__.py b/vonage_network/src/vonage_network/__init__.py new file mode 100644 index 00000000..2ae6c587 --- /dev/null +++ b/vonage_network/src/vonage_network/__init__.py @@ -0,0 +1,18 @@ +from vonage_utils import VonageError + +from .vonage_network import ( + Auth, + HttpClient, + HttpClientOptions, + NetworkSimSwap, + VonageNetwork, +) + +__all__ = [ + 'VonageError', + 'VonageNetwork', + 'NetworkSimSwap', + 'Auth', + 'HttpClient', + 'HttpClientOptions', +] diff --git a/vonage_network/src/vonage_network/_version.py b/vonage_network/src/vonage_network/_version.py new file mode 100644 index 00000000..db4e81a7 --- /dev/null +++ b/vonage_network/src/vonage_network/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.0b0' diff --git a/vonage_network/src/vonage_network/vonage_network.py b/vonage_network/src/vonage_network/vonage_network.py new file mode 100644 index 00000000..a056ee06 --- /dev/null +++ b/vonage_network/src/vonage_network/vonage_network.py @@ -0,0 +1,36 @@ +from platform import python_version +from typing import Optional + +from vonage_http_client import Auth, HttpClient, HttpClientOptions +from vonage_network_sim_swap import NetworkSimSwap + +from ._version import __version__ + + +class VonageNetwork: + """Main Server SDK class for using Vonage Network APIs. + + When creating an instance, it will create the authentication objects and + an HTTP Client needed for using Vonage Network APIs. + + Use an instance of this class to access the Vonage Network APIs, e.g. to access + methods associated with the Vonage Sim Swap API, call `vonage_network.sim_swap.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._http_client._user_agent = ( + f'vonage-network-python-sdk/{__version__} python/{python_version()}' + ) + + self.sim_swap = NetworkSimSwap(self._http_client) + + @property + def http_client(self): + return self._http_client diff --git a/vonage_network/tests/BUILD b/vonage_network/tests/BUILD new file mode 100644 index 00000000..dabf212d --- /dev/null +++ b/vonage_network/tests/BUILD @@ -0,0 +1 @@ +python_tests() diff --git a/vonage_network/tests/test_vonage_network.py b/vonage_network/tests/test_vonage_network.py new file mode 100644 index 00000000..7bcf7ce8 --- /dev/null +++ b/vonage_network/tests/test_vonage_network.py @@ -0,0 +1,16 @@ +from vonage_http_client.http_client import HttpClient +from vonage_network._version import __version__ + +from vonage_network import Auth, VonageNetwork + + +def test_create_vonage_class_instance(): + vonage = VonageNetwork(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-network-python-sdk/{__version__}' in vonage.http_client.user_agent From 21ea88c91b737d9fa78b22735d50ea4d805e07ec Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 5 Aug 2024 17:23:08 +0100 Subject: [PATCH 53/98] start adding account api --- account/BUILD | 16 ++ account/CHANGES.md | 2 + account/README.md | 77 +++++++ account/pyproject.toml | 29 +++ account/src/vonage_account/BUILD | 1 + account/src/vonage_account/__init__.py | 3 + account/src/vonage_account/account.py | 98 +++++++++ account/src/vonage_account/errors.py | 5 + account/src/vonage_account/requests.py | 8 + account/src/vonage_account/responses.py | 22 ++ account/tests/BUILD | 1 + account/tests/data/get_balance.json | 4 + account/tests/data/top_up.json | 4 + .../data/update_default_sms_webhook.json | 7 + account/tests/test_account.py | 188 ++++++++++++++++++ application/README.md | 2 +- application/pyproject.toml | 2 +- pants.toml | 1 + requirements.txt | 8 +- vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + vonage_network/pyproject.toml | 2 +- 22 files changed, 478 insertions(+), 6 deletions(-) create mode 100644 account/BUILD create mode 100644 account/CHANGES.md create mode 100644 account/README.md create mode 100644 account/pyproject.toml create mode 100644 account/src/vonage_account/BUILD create mode 100644 account/src/vonage_account/__init__.py create mode 100644 account/src/vonage_account/account.py create mode 100644 account/src/vonage_account/errors.py create mode 100644 account/src/vonage_account/requests.py create mode 100644 account/src/vonage_account/responses.py create mode 100644 account/tests/BUILD create mode 100644 account/tests/data/get_balance.json create mode 100644 account/tests/data/top_up.json create mode 100644 account/tests/data/update_default_sms_webhook.json create mode 100644 account/tests/test_account.py 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..be516a55 --- /dev/null +++ b/account/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/account/README.md b/account/README.md new file mode 100644 index 00000000..b54f18e5 --- /dev/null +++ b/account/README.md @@ -0,0 +1,77 @@ +# Vonage Users 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/account/pyproject.toml b/account/pyproject.toml new file mode 100644 index 00000000..afdd8e90 --- /dev/null +++ b/account/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-account' +version = '1.0.0' +description = 'Vonage Account API package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.3.1", + "vonage-utils>=1.1.2", + "pydantic>=2.7.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..9e46ba74 --- /dev/null +++ b/account/src/vonage_account/__init__.py @@ -0,0 +1,3 @@ +from .account import Account + +__all__ = ['Account'] diff --git a/account/src/vonage_account/account.py b/account/src/vonage_account/account.py new file mode 100644 index 00000000..7b38bb36 --- /dev/null +++ b/account/src/vonage_account/account.py @@ -0,0 +1,98 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .requests import Balance +from .responses import SettingsResponse, TopUpResponse + + +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 + + @validate_call + 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) + + def list_secrets(self) -> SecretList: + """List all secrets associated with the account. + + Returns: + SecretList: List of Secret objects. + """ + pass diff --git a/account/src/vonage_account/errors.py b/account/src/vonage_account/errors.py new file mode 100644 index 00000000..dd212546 --- /dev/null +++ b/account/src/vonage_account/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class AccountError(VonageError): + """Indicates an error with the Account package.""" diff --git a/account/src/vonage_account/requests.py b/account/src/vonage_account/requests.py new file mode 100644 index 00000000..8ff15ae6 --- /dev/null +++ b/account/src/vonage_account/requests.py @@ -0,0 +1,8 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class Balance(BaseModel): + value: float + auto_reload: Optional[bool] = Field(None, validation_alias='autoReload') diff --git a/account/src/vonage_account/responses.py b/account/src/vonage_account/responses.py new file mode 100644 index 00000000..3691365d --- /dev/null +++ b/account/src/vonage_account/responses.py @@ -0,0 +1,22 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class TopUpResponse(BaseModel): + 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): + 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' + ) 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/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/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..d9d1cd69 --- /dev/null +++ b/account/tests/test_account.py @@ -0,0 +1,188 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_account.account import Account + +# from vonage_account.errors import ApplicationError +# from vonage_account.requests import ApplicationConfig, ListApplicationsFilter +from vonage_http_client.http_client import HttpClient + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + +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 + + +# 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/application/README.md b/application/README.md index b54f18e5..99d27cb8 100644 --- a/application/README.md +++ b/application/README.md @@ -1,4 +1,4 @@ -# Vonage Users Package +# Vonage Application API Package This package contains the code to use Vonage's Application API in Python. diff --git a/application/pyproject.toml b/application/pyproject.toml index d8a0ec76..c9f094ec 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -1,7 +1,7 @@ [project] name = 'vonage-application' version = '1.0.0' -description = 'Vonage Users package' +description = 'Vonage Application API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" diff --git a/pants.toml b/pants.toml index 4790034f..6763837d 100644 --- a/pants.toml +++ b/pants.toml @@ -33,6 +33,7 @@ filter = [ 'vonage/src', 'vonage_network/src', 'http_client/src', + 'account/src', 'application/src', 'jwt/src', 'messages/src', diff --git a/requirements.txt b/requirements.txt index e4a7ea2b..7e65f489 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,8 +5,10 @@ pydantic>=2.7.1 typing-extensions>=4.9.0 pyjwt[crypto]>=1.6.4 --e http_client -e jwt +-e http_client +-e account +-e application -e messages -e network_auth -e network_sim_swap @@ -17,6 +19,6 @@ pyjwt[crypto]>=1.6.4 -e verify -e verify_v2 -e voice --e vonage --e vonage_network -e vonage_utils +-e vonage_network +-e vonage diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index d3ead5e8..7d1f88d1 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -1,6 +1,7 @@ from vonage_utils import VonageError from .vonage import ( + Account, Application, Auth, HttpClientOptions, @@ -18,6 +19,7 @@ 'Vonage', 'Auth', 'HttpClientOptions', + 'Account', 'Application', 'Messages', 'NumberInsight', diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 5896df52..f343751a 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -1,5 +1,6 @@ from typing import Optional +from vonage_account.account import Account from vonage_application.application import Application from vonage_http_client import Auth, HttpClient, HttpClientOptions from vonage_messages import Messages @@ -31,6 +32,7 @@ def __init__( ): 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.number_insight = NumberInsight(self._http_client) diff --git a/vonage_network/pyproject.toml b/vonage_network/pyproject.toml index 7ff7b6d2..4fbf966f 100644 --- a/vonage_network/pyproject.toml +++ b/vonage_network/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ homepage = "https://github.com/Vonage/vonage-python-sdk" [tool.setuptools.dynamic] -version = { attr = "vonage._version.__version__" } +version = { attr = "vonage_network._version.__version__" } [build-system] requires = ["setuptools>=61.0", "wheel"] From 1ae0990a05800dc5552699e850a2bb7c6fea206a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 9 Aug 2024 19:38:38 +0100 Subject: [PATCH 54/98] add account api, prepare for new releases --- account/README.md | 83 +++---- account/pyproject.toml | 2 +- account/src/vonage_account/__init__.py | 11 +- account/src/vonage_account/account.py | 97 +++++++- account/src/vonage_account/errors.py | 4 +- account/src/vonage_account/requests.py | 8 - account/src/vonage_account/responses.py | 10 + .../data/create_secret_error_max_number.json | 6 + account/tests/data/list_secrets.json | 20 ++ account/tests/data/revoke_secret_error.json | 6 + account/tests/data/secret.json | 9 + account/tests/test_account.py | 223 +++++++++--------- application/CHANGES.md | 3 + application/pyproject.toml | 2 +- vonage/CHANGES.md | 4 + vonage/pyproject.toml | 1 + vonage/src/vonage/_version.py | 2 +- vonage_network/CHANGES.md | 3 + vonage_network/src/vonage_network/_version.py | 2 +- 19 files changed, 308 insertions(+), 188 deletions(-) delete mode 100644 account/src/vonage_account/requests.py create mode 100644 account/tests/data/create_secret_error_max_number.json create mode 100644 account/tests/data/list_secrets.json create mode 100644 account/tests/data/revoke_secret_error.json create mode 100644 account/tests/data/secret.json diff --git a/account/README.md b/account/README.md index b54f18e5..2974f04f 100644 --- a/account/README.md +++ b/account/README.md @@ -1,77 +1,66 @@ -# Vonage Users Package +# Vonage Account Package -This package contains the code to use Vonage's Application API in Python. +This package contains the code to use Vonage's Account API in Python. -It includes methods for managing applications. +It includes methods for managing Vonage accounts. ## 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. +### Get Account Balance ```python -from vonage_application import ListApplicationsFilter, ApplicationData +balance = vonage_client.account.get_balance() +print(balance) +``` -applications, next_page = vonage_client.application.list_applications() +### Top-Up Account -# With options -options = ListApplicationsFilter(page_size=3, page=2) -applications, next_page = vonage_client.application.list_applications(options) +```python +response = vonage_client.account.top_up(trx='1234567890') +print(response) ``` -### Create a New Application +### Update the Default SMS Webhook + +This will return a Pydantic object (`SettingsResponse`) containing multiple settings for your account. ```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, +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', ) -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) + +print(settings) ``` -### Get an Application +### List Secrets Associated with the Account ```python -app_data = client.application.get_application('MY_APP_ID') -app_data_as_dict = app.model_dump(exclude_none=True) +response = vonage_client.account.list_secrets() +print(response) ``` -### Update an Application - -To update an application, pass config for the updated field(s) in an ApplicationConfig object +### Create a New Account Secret ```python -from vonage_application import ApplicationConfig, Keys, Voice, VoiceWebhooks +secret = vonage_client.account.create_secret('Mytestsecret12345') +print(secret) +``` + +### Get Information About One Secret -config = ApplicationConfig(name='My Updated Application') -app_data = vonage_client.application.update_application('MY_APP_ID', config) +```python +secret = vonage_client.account.get_secret(MY_SECRET_ID) +print(secret) ``` -### Delete an Application +### 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 -vonage_client.applications.delete_application('MY_APP_ID') -``` \ No newline at end of file +client.account.revoke_secret(MY_SECRET_ID) +``` diff --git a/account/pyproject.toml b/account/pyproject.toml index afdd8e90..10477365 100644 --- a/account/pyproject.toml +++ b/account/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.1", + "vonage-http-client>=1.4.0", "vonage-utils>=1.1.2", "pydantic>=2.7.1", ] diff --git a/account/src/vonage_account/__init__.py b/account/src/vonage_account/__init__.py index 9e46ba74..84b76801 100644 --- a/account/src/vonage_account/__init__.py +++ b/account/src/vonage_account/__init__.py @@ -1,3 +1,12 @@ from .account import Account +from .errors import InvalidSecretError +from .responses import Balance, SettingsResponse, TopUpResponse, VonageApiSecret -__all__ = ['Account'] +__all__ = [ + 'Account', + 'Balance', + 'InvalidSecretError', + 'SettingsResponse', + 'TopUpResponse', + 'VonageApiSecret', +] diff --git a/account/src/vonage_account/account.py b/account/src/vonage_account/account.py index 7b38bb36..a182c0c1 100644 --- a/account/src/vonage_account/account.py +++ b/account/src/vonage_account/account.py @@ -1,8 +1,11 @@ +import re +from typing import List + from pydantic import validate_call +from vonage_account.errors import InvalidSecretError from vonage_http_client.http_client import HttpClient -from .requests import Balance -from .responses import SettingsResponse, TopUpResponse +from .responses import Balance, SettingsResponse, TopUpResponse, VonageApiSecret class Account: @@ -21,7 +24,6 @@ def http_client(self) -> HttpClient: """ return self._http_client - @validate_call def get_balance(self) -> Balance: """Get the balance of the account. @@ -62,8 +64,8 @@ def top_up(self, trx: str) -> TopUpResponse: 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. + """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 @@ -89,10 +91,89 @@ def update_default_sms_webhook( ) return SettingsResponse(**response) - def list_secrets(self) -> SecretList: + def list_secrets(self) -> List[VonageApiSecret]: """List all secrets associated with the account. Returns: - SecretList: List of Secret objects. + 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. """ - pass + 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 index dd212546..6041ca2b 100644 --- a/account/src/vonage_account/errors.py +++ b/account/src/vonage_account/errors.py @@ -1,5 +1,5 @@ from vonage_utils.errors import VonageError -class AccountError(VonageError): - """Indicates an error with the Account package.""" +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 deleted file mode 100644 index 8ff15ae6..00000000 --- a/account/src/vonage_account/requests.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, Field - - -class Balance(BaseModel): - value: float - auto_reload: Optional[bool] = Field(None, validation_alias='autoReload') diff --git a/account/src/vonage_account/responses.py b/account/src/vonage_account/responses.py index 3691365d..715e3fdd 100644 --- a/account/src/vonage_account/responses.py +++ b/account/src/vonage_account/responses.py @@ -3,6 +3,11 @@ from pydantic import BaseModel, Field +class Balance(BaseModel): + value: float + auto_reload: Optional[bool] = Field(None, validation_alias='autoReload') + + class TopUpResponse(BaseModel): error_code: Optional[str] = Field(None, validation_alias='error-code') error_code_label: Optional[str] = Field(None, validation_alias='error-code-label') @@ -20,3 +25,8 @@ class SettingsResponse(BaseModel): max_calls_per_second: Optional[int] = Field( None, validation_alias='max-calls-per-second' ) + + +class VonageApiSecret(BaseModel): + id: str + created_at: str 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/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/test_account.py b/account/tests/test_account.py index d9d1cd69..b085d2a1 100644 --- a/account/tests/test_account.py +++ b/account/tests/test_account.py @@ -3,9 +3,8 @@ import responses from pytest import raises from vonage_account.account import Account - -# from vonage_account.errors import ApplicationError -# from vonage_account.requests import ApplicationConfig, ListApplicationsFilter +from vonage_account.errors import InvalidSecretError +from vonage_http_client.errors import ForbiddenError from vonage_http_client.http_client import HttpClient from testutils import build_response, get_mock_api_key_auth @@ -71,118 +70,106 @@ def test_update_default_sms_webhook(): assert settings_response.max_calls_per_second == 30 -# 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 +@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/CHANGES.md b/application/CHANGES.md index be516a55..5221bcaa 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Update project metadata + # 1.0.0 - Initial upload diff --git a/application/pyproject.toml b/application/pyproject.toml index c9f094ec..0ce71532 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-application' -version = '1.0.0' +version = '1.0.1' description = 'Vonage Application API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index b9b1649e..d04533ac 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,7 @@ +# 3.99.0a12 +- Add support for the [Vonage Account API](https://developer.vonage.com/en/account/overview). +- Update package metadata for the `vonage-application` package. + # 3.99.0a11 - Remove the Number Insight v2 beta which was not in use and is going to be deprecated - Lower the VerifyV2 minimum channel timeout to 15s diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index b52e94da..3fe7992c 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -8,6 +8,7 @@ requires-python = ">=3.8" dependencies = [ "vonage-utils>=1.1.2", "vonage-http-client>=1.4.0", + "vonage-account>=1.0.0", "vonage-application>=1.0.0", "vonage-messages>=1.1.1", "vonage-number-insight>=1.0.0", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 6806e897..5c572a51 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a11' +__version__ = '3.99.0a12' diff --git a/vonage_network/CHANGES.md b/vonage_network/CHANGES.md index 26cbc56f..80dc8e71 100644 --- a/vonage_network/CHANGES.md +++ b/vonage_network/CHANGES.md @@ -1,2 +1,5 @@ +# 0.1.1b0 +- Update project metadata + # 0.1.0b0 - Initial beta release \ No newline at end of file diff --git a/vonage_network/src/vonage_network/_version.py b/vonage_network/src/vonage_network/_version.py index db4e81a7..b7a18f07 100644 --- a/vonage_network/src/vonage_network/_version.py +++ b/vonage_network/src/vonage_network/_version.py @@ -1 +1 @@ -__version__ = '0.1.0b0' +__version__ = '0.1.1b0' From bfdbcd16490f25fbc781d20392854ff59d8cd148 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 16 Aug 2024 17:07:33 +0100 Subject: [PATCH 55/98] add new structure --- pants.toml | 3 +- subaccounts/BUILD | 16 ++ subaccounts/CHANGES.md | 2 + subaccounts/README.md | 66 +++++++ subaccounts/pyproject.toml | 29 +++ subaccounts/src/vonage_subaccounts/BUILD | 1 + .../src/vonage_subaccounts/__init__.py | 5 + subaccounts/src/vonage_subaccounts/errors.py | 5 + .../src/vonage_subaccounts/responses.py | 21 +++ .../src/vonage_subaccounts/subaccounts.py | 44 +++++ subaccounts/tests/BUILD | 1 + subaccounts/tests/test_subaccounts.py | 175 ++++++++++++++++++ vonage/CHANGES.md | 3 + vonage/pyproject.toml | 1 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/_version.py | 2 +- vonage/src/vonage/vonage.py | 2 + 17 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 subaccounts/BUILD create mode 100644 subaccounts/CHANGES.md create mode 100644 subaccounts/README.md create mode 100644 subaccounts/pyproject.toml create mode 100644 subaccounts/src/vonage_subaccounts/BUILD create mode 100644 subaccounts/src/vonage_subaccounts/__init__.py create mode 100644 subaccounts/src/vonage_subaccounts/errors.py create mode 100644 subaccounts/src/vonage_subaccounts/responses.py create mode 100644 subaccounts/src/vonage_subaccounts/subaccounts.py create mode 100644 subaccounts/tests/BUILD create mode 100644 subaccounts/tests/test_subaccounts.py diff --git a/pants.toml b/pants.toml index 6763837d..0604dc71 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.21.0' +pants_version = '2.21.1' backend_packages = [ 'pants.backend.python', @@ -42,6 +42,7 @@ filter = [ 'number_insight/src', 'number_insight_v2/src', 'sms/src', + 'subaccounts/src', 'users/src', 'utils/src', 'testutils', 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..be516a55 --- /dev/null +++ b/subaccounts/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload diff --git a/subaccounts/README.md b/subaccounts/README.md new file mode 100644 index 00000000..c81e06a7 --- /dev/null +++ b/subaccounts/README.md @@ -0,0 +1,66 @@ +# Vonage Subaccount Package + +This package contains the code to use Vonage's Subaccount API in Python. + +It includes methods for managing Vonage 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`. + + + diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml new file mode 100644 index 00000000..b333c575 --- /dev/null +++ b/subaccounts/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-subaccounts' +version = '1.0.0' +description = 'Vonage Subaccounts API package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.4.0", + "vonage-utils>=1.1.2", + "pydantic>=2.7.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/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..d2a8398e --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/__init__.py @@ -0,0 +1,5 @@ +from .subaccounts import Subaccounts + +__all__ = [ + 'Subaccounts', +] diff --git a/subaccounts/src/vonage_subaccounts/errors.py b/subaccounts/src/vonage_subaccounts/errors.py new file mode 100644 index 00000000..13f1ff80 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/errors.py @@ -0,0 +1,5 @@ +from vonage_utils.errors import VonageError + + +class SubaccountsError(VonageError): + """Indicates an error with the Subaccounts API package.""" diff --git a/subaccounts/src/vonage_subaccounts/responses.py b/subaccounts/src/vonage_subaccounts/responses.py new file mode 100644 index 00000000..9d44f6e5 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/responses.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class VonageAccount(BaseModel): + api_key: str + name: str + primary_account_api_key: str + use_primary_account_balance: bool + created_at: datetime + suspended: bool + balance: Optional[float] + credit_limit: Optional[float] + + +class PrimaryAccount(VonageAccount): ... + + +class Subaccount(VonageAccount): ... diff --git a/subaccounts/src/vonage_subaccounts/subaccounts.py b/subaccounts/src/vonage_subaccounts/subaccounts.py new file mode 100644 index 00000000..9d7b6f07 --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/subaccounts.py @@ -0,0 +1,44 @@ +from typing import List, Union +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +# from .responses import Balance, SettingsResponse, TopUpResponse, VonageApiSecret + + +class Subaccounts: + """Class containing methods to manage Vonage subaccounts.""" + + 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 list_subaccounts(self) -> Tuple[PrimaryAccount, List[Subaccount]]: + """List all subaccounts associated with the primary account. + + Returns: + List[Union[PrimaryAccount, Subaccount]]: List of PrimaryAccount and Subaccount objects. + """ + response = self._http_client.get( + self._http_client.api_host, + f'/accounts/{self._http_client.auth.api_key}/subaccounts', + auth_type=self._auth_type, + ) + accounts = [] + # accounts.append(PrimaryAccount(**response['_embedded']['account'])) + + # for element in response['_embedded']['accounts']: + # if element['type'] == 'PRIMARY': + # accounts.append(PrimaryAccount(**element)) + # else: + # accounts.append(Subaccount(**element)) + + # return accounts 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/test_subaccounts.py b/subaccounts/tests/test_subaccounts.py new file mode 100644 index 00000000..e09030a9 --- /dev/null +++ b/subaccounts/tests/test_subaccounts.py @@ -0,0 +1,175 @@ +# from os.path import abspath + +# import responses +# from pytest import raises +# from vonage_account.account import Account +# from vonage_account.errors import InvalidSecretError +# from vonage_http_client.errors import ForbiddenError +# from vonage_http_client.http_client import HttpClient + +# from testutils import build_response, get_mock_api_key_auth + +# path = abspath(__file__) + +# account = Account(HttpClient(get_mock_api_key_auth())) + + +# def test_http_client_property(): +# http_client = account.http_client +# assert isinstance(http_client, HttpClient) + + +# @responses.activate +# def test_get_balance(): +# build_response( +# path, +# 'GET', +# 'https://rest.nexmo.com/account/get-balance', +# 'get_balance.json', +# ) +# balance = account.get_balance() + +# assert balance.value == 29.18202293 +# assert balance.auto_reload is False + + +# @responses.activate +# def test_top_up(): +# build_response( +# path, +# 'POST', +# 'https://rest.nexmo.com/account/top-up', +# 'top_up.json', +# ) +# top_up_response = account.top_up('1234567890') + +# assert top_up_response.error_code == '200' +# assert top_up_response.error_code_label == 'success' + + +# @responses.activate +# def test_update_default_sms_webhook(): +# build_response( +# path, +# 'POST', +# 'https://rest.nexmo.com/account/settings', +# 'update_default_sms_webhook.json', +# ) +# settings_response = account.update_default_sms_webhook( +# mo_callback_url='https://example.com/inbound_sms_webhook', +# dr_callback_url='https://example.com/delivery_receipt_webhook', +# ) + +# assert settings_response.mo_callback_url == 'https://example.com/inbound_sms_webhook' +# assert ( +# settings_response.dr_callback_url +# == 'https://example.com/delivery_receipt_webhook' +# ) +# assert settings_response.max_outbound_request == 30 +# assert settings_response.max_inbound_request == 30 +# assert settings_response.max_calls_per_second == 30 + + +# @responses.activate +# def test_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/vonage/CHANGES.md b/vonage/CHANGES.md index d04533ac..dde44451 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.2a0 +- Add support for the [Vonage Subaccounts API](https://developer.vonage.com/en/use-cases/using-subaccounts). + # 3.99.0a12 - Add support for the [Vonage Account API](https://developer.vonage.com/en/account/overview). - Update package metadata for the `vonage-application` package. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 3fe7992c..2d1d5ae1 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "vonage-messages>=1.1.1", "vonage-number-insight>=1.0.0", "vonage-sms>=1.1.1", + "vonage-subaccounts>=1.0.0", "vonage-users>=1.1.2", "vonage-verify>=1.1.1", "vonage-verify-v2>=1.1.2", diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 7d1f88d1..7d37335d 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -8,6 +8,7 @@ Messages, NumberInsight, Sms, + Subaccounts, Users, Verify, VerifyV2, @@ -24,6 +25,7 @@ 'Messages', 'NumberInsight', 'Sms', + 'Subaccounts', 'Users', 'Verify', 'VerifyV2', diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 5c572a51..91f9a42a 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a12' +__version__ = '3.99.2a0' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index f343751a..02087121 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -6,6 +6,7 @@ from vonage_messages import Messages from vonage_number_insight import NumberInsight from vonage_sms import Sms +from vonage_subaccounts import Subaccounts from vonage_users import Users from vonage_verify import Verify from vonage_verify_v2 import VerifyV2 @@ -37,6 +38,7 @@ def __init__( self.messages = Messages(self._http_client) self.number_insight = NumberInsight(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_v2 = VerifyV2(self._http_client) From 1bff284a7bd21ce085e2238aaf6c6e4267a8178a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 21 Aug 2024 14:43:21 +0100 Subject: [PATCH 56/98] add rcs message type, revoke rcs message, mark whatsapp as read --- messages/CHANGES.md | 4 + messages/src/vonage_messages/messages.py | 40 ++++++ .../src/vonage_messages/models/__init__.py | 7 + messages/src/vonage_messages/models/enums.py | 1 + messages/src/vonage_messages/models/rcs.py | 43 ++++++ messages/tests/data/not_found.json | 6 + messages/tests/test_messages.py | 66 ++++++++- messages/tests/test_rcs_models.py | 128 ++++++++++++++++++ 8 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 messages/src/vonage_messages/models/rcs.py create mode 100644 messages/tests/data/not_found.json create mode 100644 messages/tests/test_rcs_models.py diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 71b83f45..12d34e57 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,7 @@ +# 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 diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 874c9b2c..9e1ef40a 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -44,3 +44,43 @@ def send(self, message: BaseMessage) -> SendMessageResponse: message.model_dump(by_alias=True, exclude_none=True) or message, ) return SendMessageResponse(**response) + + @validate_call + def mark_whatsapp_message_read(self, message_uuid: str) -> None: + """Mark a WhatsApp message as read. + + Note: to use this method, update the `api_host` attribute of the + `vonage_http_client.HttpClientOptions` object to the API endpoint + corresponding to the region where the WhatsApp number is hosted. + + For example, to use the EU API endpoint, set the `api_host` + attribute to 'https://api-eu.vonage.com'. + + Args: + message_uuid (str): The unique identifier of the WhatsApp message to mark as read. + """ + self._http_client.patch( + self._http_client.api_host, + f'/v1/messages/{message_uuid}', + {'status': 'read'}, + ) + + @validate_call + def revoke_rcs_message(self, message_uuid: str) -> None: + """Revoke an RCS message. + + Note: to use this method, update the `api_host` attribute of the + `vonage_http_client.HttpClientOptions` object to the API endpoint + corresponding to the region where the RCS number is hosted. + + For example, to use the EU API endpoint, set the `api_host` + attribute to 'https://api-eu.vonage.com'. + + Args: + message_uuid (str): The unique identifier of the RCS message to revoke. + """ + self._http_client.patch( + self._http_client.api_host, + f'/v1/messages/{message_uuid}', + {'status': 'revoked'}, + ) diff --git a/messages/src/vonage_messages/models/__init__.py b/messages/src/vonage_messages/models/__init__.py index 92b5b84c..bbd6d65d 100644 --- a/messages/src/vonage_messages/models/__init__.py +++ b/messages/src/vonage_messages/models/__init__.py @@ -10,6 +10,7 @@ MessengerVideo, ) from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo +from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo from .sms import Sms, SmsOptions from .viber import ( ViberAction, @@ -62,6 +63,12 @@ 'MmsResource', 'MmsVcard', 'MmsVideo', + 'RcsCustom', + 'RcsFile', + 'RcsImage', + 'RcsResource', + 'RcsText', + 'RcsVideo', 'Sms', 'SmsOptions', 'ViberAction', diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index d43cbfc3..40a5ff95 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -16,6 +16,7 @@ class MessageType(str, Enum): class ChannelType(str, Enum): SMS = 'sms' MMS = 'mms' + RCS = 'rcs' WHATSAPP = 'whatsapp' MESSENGER = 'messenger' VIBER = 'viber_service' diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py new file mode 100644 index 00000000..bec9ee90 --- /dev/null +++ b/messages/src/vonage_messages/models/rcs.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_utils.types import PhoneNumber + +from .base_message import BaseMessage +from .enums import ChannelType, MessageType + + +class RcsResource(BaseModel): + url: str + + +class BaseRcs(BaseMessage): + to: PhoneNumber + from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9]+$') + ttl: Optional[int] = Field(None, ge=300, le=259200) + channel: ChannelType = ChannelType.RCS + + +class RcsText(BaseRcs): + text: str = Field(..., min_length=1, max_length=3072) + message_type: MessageType = MessageType.TEXT + + +class RcsImage(BaseRcs): + image: RcsResource + message_type: MessageType = MessageType.IMAGE + + +class RcsVideo(BaseRcs): + video: RcsResource + message_type: MessageType = MessageType.VIDEO + + +class RcsFile(BaseRcs): + file: RcsResource + message_type: MessageType = MessageType.FILE + + +class RcsCustom(BaseRcs): + custom: dict + message_type: MessageType = MessageType.CUSTOM 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/test_messages.py b/messages/tests/test_messages.py index 3bc2e01a..668175d4 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -3,7 +3,7 @@ import responses from pytest import raises from vonage_http_client.errors import HttpRequestError -from vonage_http_client.http_client import HttpClient +from vonage_http_client.http_client import HttpClient, HttpClientOptions from vonage_messages.messages import Messages from vonage_messages.models import Sms from vonage_messages.models.messenger import ( @@ -81,3 +81,67 @@ 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/1234567890', + ) + 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('1234567890') + + +@responses.activate +def test_mark_whatsapp_message_read_not_found(): + build_response( + path, + 'PATCH', + 'https://api-eu.vonage.com/v1/messages/asdf', + 'not_found.json', + 404, + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + with raises(HttpRequestError) as e: + messages.mark_whatsapp_message_read('asdf') + + assert e.value.response.status_code == 404 + assert e.value.response.json()['title'] == 'Not Found' + + +@responses.activate +def test_revoke_rcs_message(): + responses.add( + responses.PATCH, + 'https://api-eu.vonage.com/v1/messages/asdf', + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + messages.http_client.http_client_options.api_host = 'api-eu.vonage.com' + messages.revoke_rcs_message('asdf') + + +@responses.activate +def test_revoke_rcs_message_not_found(): + build_response( + path, + 'PATCH', + 'https://api-eu.vonage.com/v1/messages/asdf', + 'not_found.json', + 404, + ) + messages = Messages( + HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com')) + ) + with raises(HttpRequestError) as e: + messages.revoke_rcs_message('asdf') + + assert e.value.response.status_code == 404 + assert e.value.response.json()['title'] == 'Not Found' diff --git a/messages/tests/test_rcs_models.py b/messages/tests/test_rcs_models.py new file mode 100644 index 00000000..4723fa47 --- /dev/null +++ b/messages/tests/test_rcs_models.py @@ -0,0 +1,128 @@ +from vonage_messages.models import ( + RcsCustom, + RcsFile, + RcsImage, + RcsResource, + RcsText, + RcsVideo, +) + + +def test_create_rcs_text(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_text_all_fields(): + rcs_model = RcsText( + to='1234567890', + from_='asdf1234', + text='Hello, World!', + client_ref='client-ref', + webhook_url='https://example.com', + ttl=600, + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'text': 'Hello, World!', + 'client_ref': 'client-ref', + 'webhook_url': 'https://example.com', + 'ttl': 600, + 'channel': 'rcs', + 'message_type': 'text', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_image(): + rcs_model = RcsImage( + to='1234567890', + from_='asdf1234', + image=RcsResource( + url='https://example.com/image.jpg', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'image': { + 'url': 'https://example.com/image.jpg', + }, + 'channel': 'rcs', + 'message_type': 'image', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_video(): + rcs_model = RcsVideo( + to='1234567890', + from_='asdf1234', + video=RcsResource( + url='https://example.com/video.mp4', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'video': { + 'url': 'https://example.com/video.mp4', + }, + 'channel': 'rcs', + 'message_type': 'video', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_file(): + rcs_model = RcsFile( + to='1234567890', + from_='asdf1234', + file=RcsResource( + url='https://example.com/file.pdf', + ), + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'file': { + 'url': 'https://example.com/file.pdf', + }, + 'channel': 'rcs', + 'message_type': 'file', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict + + +def test_create_rcs_custom(): + rcs_model = RcsCustom( + to='1234567890', + from_='asdf1234', + custom={'key': 'value'}, + ) + rcs_dict = { + 'to': '1234567890', + 'from': 'asdf1234', + 'custom': {'key': 'value'}, + 'channel': 'rcs', + 'message_type': 'custom', + } + + assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict From 0cde8002ad66e0459ad2505c9b58887553217a5a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 22 Aug 2024 15:45:08 +0100 Subject: [PATCH 57/98] add RCS support, revoke RCS message, mark WhatsApp as read --- messages/README.md | 37 ++++++++++++++++++++++-- messages/pyproject.toml | 4 +-- messages/src/vonage_messages/messages.py | 4 +-- messages/tests/test_messages.py | 4 +-- vonage/CHANGES.md | 5 ++++ vonage/pyproject.toml | 2 +- vonage/src/vonage/_version.py | 2 +- 7 files changed, 48 insertions(+), 10 deletions(-) diff --git a/messages/README.md b/messages/README.md index e25c0985..cdbfd415 100644 --- a/messages/README.md +++ b/messages/README.md @@ -26,7 +26,7 @@ This message can now be sent with 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`, `MessengerAudio`, `WhatsappSticker`, `ViberVideo`, etc. +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. @@ -64,6 +64,38 @@ message = Sms( 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: @@ -71,7 +103,8 @@ To send a message, instantiate a message model of the correct type as described ``` 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 -``` \ No newline at end of file +``` diff --git a/messages/pyproject.toml b/messages/pyproject.toml index ca8ba739..458ccabd 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -1,12 +1,12 @@ [project] name = 'vonage-messages' -version = '1.1.1' +version = '1.2.0' description = 'Vonage messages package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.1", + "vonage-http-client>=1.4.0", "vonage-utils>=1.1.1", "pydantic>=2.7.1", ] diff --git a/messages/src/vonage_messages/messages.py b/messages/src/vonage_messages/messages.py index 9e1ef40a..c39af991 100644 --- a/messages/src/vonage_messages/messages.py +++ b/messages/src/vonage_messages/messages.py @@ -54,7 +54,7 @@ def mark_whatsapp_message_read(self, message_uuid: str) -> None: corresponding to the region where the WhatsApp number is hosted. For example, to use the EU API endpoint, set the `api_host` - attribute to 'https://api-eu.vonage.com'. + attribute to 'api-eu.vonage.com'. Args: message_uuid (str): The unique identifier of the WhatsApp message to mark as read. @@ -74,7 +74,7 @@ def revoke_rcs_message(self, message_uuid: str) -> None: corresponding to the region where the RCS number is hosted. For example, to use the EU API endpoint, set the `api_host` - attribute to 'https://api-eu.vonage.com'. + attribute to 'api-eu.vonage.com'. Args: message_uuid (str): The unique identifier of the RCS message to revoke. diff --git a/messages/tests/test_messages.py b/messages/tests/test_messages.py index 668175d4..beba2945 100644 --- a/messages/tests/test_messages.py +++ b/messages/tests/test_messages.py @@ -87,13 +87,13 @@ def test_http_client_property(): def test_mark_whatsapp_message_read(): responses.add( responses.PATCH, - 'https://api-eu.vonage.com/v1/messages/1234567890', + '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('1234567890') + messages.mark_whatsapp_message_read('asdf') @responses.activate diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index d04533ac..c16b1f10 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,8 @@ +# 3.99.2a0 +- Add support for the [RCS messaging channel](https://developer.vonage.com/en/messages/concepts/rcs) of the Vonage Messages API +- Add method to revoke an RCS message +- Add method to mark a WhatsApp message as read + # 3.99.0a12 - Add support for the [Vonage Account API](https://developer.vonage.com/en/account/overview). - Update package metadata for the `vonage-application` package. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 3fe7992c..d333c6de 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "vonage-http-client>=1.4.0", "vonage-account>=1.0.0", "vonage-application>=1.0.0", - "vonage-messages>=1.1.1", + "vonage-messages>=1.2.0", "vonage-number-insight>=1.0.0", "vonage-sms>=1.1.1", "vonage-users>=1.1.2", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 5c572a51..91f9a42a 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.0a12' +__version__ = '3.99.2a0' From f5c8ca853fafc0034498a283d9c70166e525cd11 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 30 Aug 2024 04:06:25 +0100 Subject: [PATCH 58/98] add subaccounts api --- subaccounts/README.md | 89 ++-- .../src/vonage_subaccounts/__init__.py | 30 ++ subaccounts/src/vonage_subaccounts/errors.py | 4 +- .../src/vonage_subaccounts/requests.py | 59 +++ .../src/vonage_subaccounts/responses.py | 43 +- .../src/vonage_subaccounts/subaccounts.py | 277 ++++++++++- subaccounts/tests/data/create_subaccount.json | 11 + subaccounts/tests/data/get_subaccount.json | 10 + .../tests/data/list_balance_transfers.json | 27 ++ .../tests/data/list_credit_transfers.json | 27 ++ subaccounts/tests/data/list_subaccounts.json | 41 ++ subaccounts/tests/data/modify_subaccount.json | 10 + subaccounts/tests/data/transfer.json | 14 + subaccounts/tests/data/transfer_number.json | 6 + ...ansfer_number_error_suspended_account.json | 6 + subaccounts/tests/test_subaccounts.py | 430 +++++++++++------- vonage/src/vonage/_version.py | 2 +- 17 files changed, 861 insertions(+), 225 deletions(-) create mode 100644 subaccounts/src/vonage_subaccounts/requests.py create mode 100644 subaccounts/tests/data/create_subaccount.json create mode 100644 subaccounts/tests/data/get_subaccount.json create mode 100644 subaccounts/tests/data/list_balance_transfers.json create mode 100644 subaccounts/tests/data/list_credit_transfers.json create mode 100644 subaccounts/tests/data/list_subaccounts.json create mode 100644 subaccounts/tests/data/modify_subaccount.json create mode 100644 subaccounts/tests/data/transfer.json create mode 100644 subaccounts/tests/data/transfer_number.json create mode 100644 subaccounts/tests/data/transfer_number_error_suspended_account.json diff --git a/subaccounts/README.md b/subaccounts/README.md index c81e06a7..ba98e49f 100644 --- a/subaccounts/README.md +++ b/subaccounts/README.md @@ -2,65 +2,102 @@ This package contains the code to use Vonage's Subaccount API in Python. -It includes methods for managing Vonage subaccounts. +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`. - +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/src/vonage_subaccounts/__init__.py b/subaccounts/src/vonage_subaccounts/__init__.py index d2a8398e..4f2c2de4 100644 --- a/subaccounts/src/vonage_subaccounts/__init__.py +++ b/subaccounts/src/vonage_subaccounts/__init__.py @@ -1,5 +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/errors.py b/subaccounts/src/vonage_subaccounts/errors.py index 13f1ff80..6041ca2b 100644 --- a/subaccounts/src/vonage_subaccounts/errors.py +++ b/subaccounts/src/vonage_subaccounts/errors.py @@ -1,5 +1,5 @@ from vonage_utils.errors import VonageError -class SubaccountsError(VonageError): - """Indicates an error with the Subaccounts API package.""" +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..c2c5d45d --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/requests.py @@ -0,0 +1,59 @@ +import re +from typing import Optional + +from pydantic import BaseModel, Field, field_validator +from vonage_subaccounts.errors import InvalidSecretError + + +class SubaccountOptions(BaseModel): + 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: + 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): + suspended: Optional[bool] = None + use_primary_account_balance: Optional[bool] = None + name: Optional[str] = None + + +class ListTransfersFilter(BaseModel): + start_date: str + end_date: Optional[str] = None + subaccount: Optional[str] = None + + +class TransferRequest(BaseModel): + from_: str = Field(..., serialization_alias='from') + to: str + amount: float + reference: Optional[str] = None + + +class TransferNumberRequest(BaseModel): + 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 index 9d44f6e5..52590d25 100644 --- a/subaccounts/src/vonage_subaccounts/responses.py +++ b/subaccounts/src/vonage_subaccounts/responses.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Optional +from typing import Optional, Union from pydantic import BaseModel, Field @@ -7,15 +6,43 @@ class VonageAccount(BaseModel): api_key: str name: str - primary_account_api_key: str - use_primary_account_balance: bool - created_at: datetime + created_at: str suspended: bool balance: Optional[float] - credit_limit: Optional[float] + credit_limit: Optional[Union[int, float]] + + +class PrimaryAccount(VonageAccount): + ... + + +class Subaccount(VonageAccount): + primary_account_api_key: str + use_primary_account_balance: bool + + +class ListSubaccountsResponse(BaseModel): + primary_account: PrimaryAccount + subaccounts: list[Subaccount] + total_balance: float + total_credit_limit: Union[int, float] + + +class NewSubaccount(Subaccount): + secret: str -class PrimaryAccount(VonageAccount): ... +class Transfer(BaseModel): + id: str + amount: float + from_: str = Field(..., validation_alias='from') + to: str + created_at: str + reference: Optional[str] = None -class Subaccount(VonageAccount): ... +class TransferNumberResponse(BaseModel): + 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 index 9d7b6f07..1a2f8c4e 100644 --- a/subaccounts/src/vonage_subaccounts/subaccounts.py +++ b/subaccounts/src/vonage_subaccounts/subaccounts.py @@ -1,8 +1,22 @@ -from typing import List, Union +from typing import List + from pydantic import validate_call from vonage_http_client.http_client import HttpClient - -# from .responses import Balance, SettingsResponse, TopUpResponse, VonageApiSecret +from vonage_subaccounts.requests import ( + ListTransfersFilter, + ModifySubaccountOptions, + SubaccountOptions, + TransferNumberRequest, + TransferRequest, +) +from vonage_subaccounts.responses import ( + ListSubaccountsResponse, + NewSubaccount, + PrimaryAccount, + Subaccount, + Transfer, + TransferNumberResponse, +) class Subaccounts: @@ -21,24 +35,261 @@ def http_client(self) -> HttpClient: """ return self._http_client - def list_subaccounts(self) -> Tuple[PrimaryAccount, List[Subaccount]]: + def list_subaccounts(self) -> ListSubaccountsResponse: """List all subaccounts associated with the primary account. Returns: - List[Union[PrimaryAccount, Subaccount]]: List of PrimaryAccount and Subaccount objects. + 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, ) - accounts = [] - # accounts.append(PrimaryAccount(**response['_embedded']['account'])) - # for element in response['_embedded']['accounts']: - # if element['type'] == 'PRIMARY': - # accounts.append(PrimaryAccount(**element)) - # else: - # accounts.append(Subaccount(**element)) + 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 accounts + return TransferNumberResponse(**response) 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 index e09030a9..e25ac1a3 100644 --- a/subaccounts/tests/test_subaccounts.py +++ b/subaccounts/tests/test_subaccounts.py @@ -1,175 +1,255 @@ -# from os.path import abspath - -# import responses -# from pytest import raises -# from vonage_account.account import Account -# from vonage_account.errors import InvalidSecretError -# from vonage_http_client.errors import ForbiddenError -# from vonage_http_client.http_client import HttpClient - -# from testutils import build_response, get_mock_api_key_auth - -# path = abspath(__file__) - -# account = Account(HttpClient(get_mock_api_key_auth())) - - -# def test_http_client_property(): -# http_client = account.http_client -# assert isinstance(http_client, HttpClient) - - -# @responses.activate -# def test_get_balance(): -# build_response( -# path, -# 'GET', -# 'https://rest.nexmo.com/account/get-balance', -# 'get_balance.json', -# ) -# balance = account.get_balance() - -# assert balance.value == 29.18202293 -# assert balance.auto_reload is False - - -# @responses.activate -# def test_top_up(): -# build_response( -# path, -# 'POST', -# 'https://rest.nexmo.com/account/top-up', -# 'top_up.json', -# ) -# top_up_response = account.top_up('1234567890') - -# assert top_up_response.error_code == '200' -# assert top_up_response.error_code_label == 'success' - - -# @responses.activate -# def test_update_default_sms_webhook(): -# build_response( -# path, -# 'POST', -# 'https://rest.nexmo.com/account/settings', -# 'update_default_sms_webhook.json', -# ) -# settings_response = account.update_default_sms_webhook( -# mo_callback_url='https://example.com/inbound_sms_webhook', -# dr_callback_url='https://example.com/delivery_receipt_webhook', -# ) - -# assert settings_response.mo_callback_url == 'https://example.com/inbound_sms_webhook' -# assert ( -# settings_response.dr_callback_url -# == 'https://example.com/delivery_receipt_webhook' -# ) -# assert settings_response.max_outbound_request == 30 -# assert settings_response.max_inbound_request == 30 -# assert settings_response.max_calls_per_second == 30 - - -# @responses.activate -# def test_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() +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/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 91f9a42a..dbe894af 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.2a0' +__version__ = '3.99.3a0' From 8539ea8feff728fe4f918d9315f63fba958aed20 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 30 Aug 2024 17:25:27 +0100 Subject: [PATCH 59/98] update `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 --- subaccounts/CHANGES.md | 3 +++ subaccounts/pyproject.toml | 2 +- subaccounts/src/vonage_subaccounts/responses.py | 4 ++-- vonage/CHANGES.md | 3 +++ vonage/src/vonage/_version.py | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/subaccounts/CHANGES.md b/subaccounts/CHANGES.md index be516a55..3a9df1d5 100644 --- a/subaccounts/CHANGES.md +++ b/subaccounts/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 + # 1.0.0 - Initial upload diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml index b333c575..b735f44d 100644 --- a/subaccounts/pyproject.toml +++ b/subaccounts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-subaccounts' -version = '1.0.0' +version = '1.0.1' description = 'Vonage Subaccounts API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] diff --git a/subaccounts/src/vonage_subaccounts/responses.py b/subaccounts/src/vonage_subaccounts/responses.py index 52590d25..98c86dd3 100644 --- a/subaccounts/src/vonage_subaccounts/responses.py +++ b/subaccounts/src/vonage_subaccounts/responses.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -23,7 +23,7 @@ class Subaccount(VonageAccount): class ListSubaccountsResponse(BaseModel): primary_account: PrimaryAccount - subaccounts: list[Subaccount] + subaccounts: List[Subaccount] total_balance: float total_credit_limit: Union[int, float] diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index df0cbd1c..e83f0533 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.3a1 +- Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 + # 3.99.3a0 - Add support for the [Vonage Subaccounts API](https://developer.vonage.com/en/use-cases/using-subaccounts). diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index dbe894af..6b5d3a76 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.3a0' +__version__ = '3.99.3a1' From 102015e4697cb83068f001d0f57256e3be16f822 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 5 Sep 2024 16:36:35 +0100 Subject: [PATCH 60/98] add numbers api --- number_management/BUILD | 16 + number_management/CHANGES.md | 2 + number_management/README.md | 82 ++++++ number_management/pyproject.toml | 29 ++ number_management/src/vonage_numbers/BUILD | 1 + .../src/vonage_numbers/__init__.py | 25 ++ number_management/src/vonage_numbers/enums.py | 22 ++ .../src/vonage_numbers/errors.py | 5 + .../src/vonage_numbers/number_management.py | 181 ++++++++++++ .../src/vonage_numbers/requests.py | 73 +++++ .../src/vonage_numbers/responses.py | 35 +++ number_management/tests/BUILD | 1 + .../tests/data/list_owned_numbers_basic.json | 25 ++ .../tests/data/list_owned_numbers_filter.json | 17 ++ .../tests/data/list_owned_numbers_subset.json | 13 + number_management/tests/data/no_number.json | 4 + number_management/tests/data/nothing.json | 1 + number_management/tests/data/number.json | 4 + .../data/search_available_numbers_basic.json | 32 ++ .../search_available_numbers_end_of_list.json | 15 + .../data/search_available_numbers_filter.json | 14 + number_management/tests/test_numbers.py | 273 ++++++++++++++++++ pants.toml | 1 + vonage/CHANGES.md | 9 +- vonage/pyproject.toml | 3 +- vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/_version.py | 2 +- vonage/src/vonage/vonage.py | 2 + 28 files changed, 886 insertions(+), 3 deletions(-) create mode 100644 number_management/BUILD create mode 100644 number_management/CHANGES.md create mode 100644 number_management/README.md create mode 100644 number_management/pyproject.toml create mode 100644 number_management/src/vonage_numbers/BUILD create mode 100644 number_management/src/vonage_numbers/__init__.py create mode 100644 number_management/src/vonage_numbers/enums.py create mode 100644 number_management/src/vonage_numbers/errors.py create mode 100644 number_management/src/vonage_numbers/number_management.py create mode 100644 number_management/src/vonage_numbers/requests.py create mode 100644 number_management/src/vonage_numbers/responses.py create mode 100644 number_management/tests/BUILD create mode 100644 number_management/tests/data/list_owned_numbers_basic.json create mode 100644 number_management/tests/data/list_owned_numbers_filter.json create mode 100644 number_management/tests/data/list_owned_numbers_subset.json create mode 100644 number_management/tests/data/no_number.json create mode 100644 number_management/tests/data/nothing.json create mode 100644 number_management/tests/data/number.json create mode 100644 number_management/tests/data/search_available_numbers_basic.json create mode 100644 number_management/tests/data/search_available_numbers_end_of_list.json create mode 100644 number_management/tests/data/search_available_numbers_filter.json create mode 100644 number_management/tests/test_numbers.py 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..be516a55 --- /dev/null +++ b/number_management/CHANGES.md @@ -0,0 +1,2 @@ +# 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..136628a3 --- /dev/null +++ b/number_management/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-numbers' +version = '1.0.0' +description = 'Vonage Numbers package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.4.0", + "vonage-utils>=1.1.2", + "pydantic>=2.7.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/number_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/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..fdef03da --- /dev/null +++ b/number_management/src/vonage_numbers/number_management.py @@ -0,0 +1,181 @@ +from typing import List, Optional, Tuple + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_numbers.errors import NumbersError +from .requests import ( + SearchAvailableNumbersFilter, + ListOwnedNumbersFilter, + NumberParams, + UpdateNumberParams, +) +from .responses import AvailableNumber, NumbersStatus, OwnedNumber + + +class Numbers: + """Class containing methods for Vonage Application management.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._auth_type = 'basic' + self._sent_data_type = 'form' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Numbers API. + + Returns: + HttpClient: The HTTP client used to make requests to the Numbers API. + """ + return self._http_client + + @validate_call + def list_owned_numbers( + self, filter: ListOwnedNumbersFilter = ListOwnedNumbersFilter() + ) -> Tuple[List[OwnedNumber], int, Optional[int]]: + """List numbers you own. + + By default, returns the first 100 numbers and the page index of + the next page of results, if there are more than 100 numbers. + + Args: + filter (ListOwnedNumbersFilter): The filter object. + + Returns: + Tuple[List[OwnedNumber], int, Optional[int]]: A tuple containing a + list of owned numbers, the total count of owned phone numbers + and the next page index, if applicable. + i.e. + number_list: List[OwnedNumber], count: int, next_page_index: Optional[int]) + """ + response = self._http_client.get( + self._http_client.rest_host, + '/account/numbers', + filter.model_dump(exclude_none=True), + self._auth_type, + ) + + index = filter.index or 1 + page_size = filter.size + + numbers = [] + try: + for number in response['numbers']: + numbers.append(OwnedNumber(**number)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * index: + return numbers, count, index + 1 + return numbers, count, None + + @validate_call + def search_available_numbers( + self, filter: SearchAvailableNumbersFilter + ) -> Tuple[List[AvailableNumber], int, Optional[int]]: + """Search for available numbers to buy. + + By default, returns the first 100 numbers and the page index of + the next page of results, if there are more than 100 numbers. + + Args: + filter (SearchAvailableNumbersFilter): The filter object. + + Returns: + Tuple[List[AvailableNumber], int, Optional[int]]: A tuple containing a + list of available numbers, the total count of available phone numbers + and the next page index, if applicable. + i.e. + number_list: List[AvailableNumber], count: int, next_page_index: Optional[int]) + """ + response = self._http_client.get( + self._http_client.rest_host, + '/number/search', + filter.model_dump(exclude_none=True), + self._auth_type, + ) + + index = filter.index or 1 + page_size = filter.size + + numbers = [] + try: + for number in response['numbers']: + numbers.append(AvailableNumber(**number)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * index: + return numbers, count, index + 1 + return numbers, count, None + + @validate_call + def buy_number(self, params: NumberParams) -> NumbersStatus: + """Buy a number. + + Args: + params (NumberParams): The number parameters. + + Returns: + NumbersStatus: The status of the number purchase. + """ + response = self._http_client.post( + self._http_client.rest_host, + '/number/buy', + params.model_dump(exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + + self._check_for_error(response) + return NumbersStatus(**response) + + @validate_call + def cancel_number(self, params: NumberParams) -> NumbersStatus: + """Cancel a number. + + Args: + params (NumberParams): The number parameters. + + Returns: + NumbersStatus: The status of the number cancellation. + """ + response = self._http_client.post( + self._http_client.rest_host, + '/number/cancel', + params.model_dump(exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + + self._check_for_error(response) + return NumbersStatus(**response) + + @validate_call + def update_number(self, params: UpdateNumberParams) -> NumbersStatus: + """Update a number. + + Args: + params (UpdateNumberParams): The number parameters. + + Returns: + NumbersStatus: The status of the number update. + """ + response = self._http_client.post( + self._http_client.rest_host, + '/number/update', + params.model_dump(exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + + self._check_for_error(response) + return NumbersStatus(**response) + + def _check_for_error(self, response_data): + if response_data['error-code'] != '200': + raise NumbersError( + f'Numbers API operation failed: {response_data["error-code"]} {response_data["error-code-label"]}' + ) diff --git a/number_management/src/vonage_numbers/requests.py b/number_management/src/vonage_numbers/requests.py new file mode 100644 index 00000000..9c756e0f --- /dev/null +++ b/number_management/src/vonage_numbers/requests.py @@ -0,0 +1,73 @@ +from typing import Optional + +from pydantic import BaseModel, Field, model_validator + +from vonage_numbers.enums import NumberFeatures, NumberType, VoiceCallbackType + +from .errors import NumbersError +from vonage_utils.types import PhoneNumber + + +class ListNumbersFilter(BaseModel): + 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): + country: Optional[str] = Field(None, min_length=2, max_length=2) + application_id: Optional[str] = None + has_application: Optional[bool] = None + + +class SearchAvailableNumbersFilter(ListNumbersFilter): + country: str = Field(..., min_length=2, max_length=2) + type: Optional[NumberType] = None + features: Optional[NumberFeatures] = None + + +class NumberParams(BaseModel): + """Specify the two-letter country code and the number you are referring to. + + 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. + """ + + country: str = Field(..., min_length=2, max_length=2) + msisdn: PhoneNumber + target_api_key: Optional[str] = None + + +class UpdateNumberParams(BaseModel): + 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..da1c8721 --- /dev/null +++ b/number_management/src/vonage_numbers/responses.py @@ -0,0 +1,35 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class OwnedNumber(BaseModel): + 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): + 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): + 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..3b1a126f --- /dev/null +++ b/number_management/tests/test_numbers.py @@ -0,0 +1,273 @@ +from os.path import abspath + +import responses +from pytest import raises + +from vonage_numbers.errors import NumbersError +from vonage_numbers.number_management import Numbers +from vonage_http_client.http_client import HttpClient + +from testutils import build_response, get_mock_api_key_auth +from vonage_numbers.requests import ( + ListOwnedNumbersFilter, + NumberParams, + SearchAvailableNumbersFilter, + UpdateNumberParams, +) + +path = abspath(__file__) + +numbers = Numbers(HttpClient(get_mock_api_key_auth())) + + +def test_http_client_property(): + http_client = numbers.http_client + assert isinstance(http_client, HttpClient) + + +def test_filter_properties(): + with raises(NumbersError) as err: + ListOwnedNumbersFilter(pattern='123') + assert err.match( + '"search_pattern" is required when "pattern"" is provided and vice versa.' + ) + + +@responses.activate +def test_list_owned_numbers_basic(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'list_owned_numbers_basic.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers() + + assert len(numbers_list) == 2 + assert numbers_list[0].msisdn == '3400000000' + assert numbers_list[0].country == 'ES' + assert numbers_list[1].msisdn == '447007000000' + assert numbers_list[1].country == 'GB' + assert numbers_list[1].features == ['VOICE', 'SMS'] + assert numbers_list[1].type == 'mobile-lvn' + assert count == 2 + assert next_page is None + + +@responses.activate +def test_list_owned_numbers_with_filter(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'list_owned_numbers_filter.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers( + ListOwnedNumbersFilter(application_id='29f769u7-7ce1-46c9-ade3-f2dedee4fr4t') + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '447007000000' + assert numbers_list[0].voice_callback_type == 'app' + assert numbers_list[0].voice_callback_value == '29f769u7-7ce1-46c9-ade3-f2dedee4fr4t' + assert numbers_list[0].app_id == '29f769u7-7ce1-46c9-ade3-f2dedee4fr4t' + assert count == 1 + assert next_page is None + + +@responses.activate +def test_list_owned_numbers_subset(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'list_owned_numbers_subset.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers( + ListOwnedNumbersFilter(size=1) + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '3400000000' + assert count == 2 + assert next_page == 2 + + +@responses.activate +def test_search_available_numbers_basic(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'search_available_numbers_basic.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter(country='GB', size=3) + ) + + assert len(numbers_list) == 3 + assert numbers_list[0].msisdn == '442039050911' + assert count == 8353 + assert next_page is 2 + + +@responses.activate +def test_search_available_numbers_with_filter(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'search_available_numbers_filter.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', + size=1, + index=1, + pattern='44203905', + search_pattern=1, + type='landline', + features='VOICE', + ) + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '442039055555' + assert numbers_list[0].country == 'GB' + assert numbers_list[0].features == ['VOICE'] + assert numbers_list[0].type == 'landline' + assert count == 2 + assert next_page == 2 + + +@responses.activate +def test_search_available_numbers_end_of_list(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'search_available_numbers_end_of_list.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=3, pattern='44203905', search_pattern=0 + ) + ) + + assert len(numbers_list) == 1 + assert numbers_list[0].msisdn == '442039055555' + assert count == 1 + assert next_page is None + + +@responses.activate +def test_empty_response(): + build_response( + path, + 'GET', + 'https://rest.nexmo.com/account/numbers', + 'nothing.json', + ) + numbers_list, count, next_page = numbers.list_owned_numbers( + ListOwnedNumbersFilter(pattern='12345612345', search_pattern=1) + ) + + assert len(numbers_list) == 0 + assert count == 0 + assert next_page is None + + build_response( + path, + 'GET', + 'https://rest.nexmo.com/number/search', + 'nothing.json', + ) + numbers_list, count, next_page = numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=3, pattern='12345612345', search_pattern=1 + ) + ) + + assert len(numbers_list) == 0 + assert count == 0 + assert next_page is None + + +@responses.activate +def test_buy_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/buy', + 'number.json', + ) + response = numbers.buy_number(NumberParams(country='GB', msisdn='447000000000')) + + assert response.error_code == '200' + assert response.error_code_label == 'success' + + +@responses.activate +def test_cancel_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/cancel', + 'number.json', + ) + response = numbers.cancel_number(NumberParams(country='GB', msisdn='447000000000')) + + assert response.error_code == '200' + assert response.error_code_label == 'success' + + +@responses.activate +def test_cancel_number_error_no_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/cancel', + 'no_number.json', + ) + with raises(NumbersError) as e: + numbers.cancel_number(NumberParams(country='GB', msisdn='447000000000')) + + assert e.match('method failed') + + +@responses.activate +def test_update_number(): + build_response( + path, + 'POST', + 'https://rest.nexmo.com/number/update', + 'number.json', + ) + response = numbers.update_number( + UpdateNumberParams( + country='GB', + msisdn='447009000000', + app_id='29f769u7-7ce1-46c9-ade3-f2dedee4fr4t', + mo_http_url='https://example.com', + mo_smpp_sytem_type='inbound', + voice_callback_type='tel', + voice_callback_value='447009000000', + voice_status_callback='https://example.com', + ) + ) + + assert response.error_code == '200' + assert response.error_code_label == 'success' + + +def test_update_number_options_error(): + with raises(NumbersError) as e: + UpdateNumberParams( + country='GB', + msisdn='447009000000', + voice_callback_value='447009000000', + ) + + assert e.match( + '"voice_callback_value" is required when "voice_callback_type" is provided, and vice versa.' + ) diff --git a/pants.toml b/pants.toml index 0604dc71..a6ff430e 100644 --- a/pants.toml +++ b/pants.toml @@ -41,6 +41,7 @@ filter = [ 'network_sim_swap/src', 'number_insight/src', 'number_insight_v2/src', + 'number_management/src', 'sms/src', 'subaccounts/src', 'users/src', diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index e83f0533..b3eb18f9 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,8 +1,15 @@ +# 3.99.5a0 +- Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) +- Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 + +# 3.99.4a0 +- Add support for the [Vonage Numbers API](https://developer.vonage.com/en/numbers/overview) + # 3.99.3a1 - Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 # 3.99.3a0 -- Add support for the [Vonage Subaccounts API](https://developer.vonage.com/en/use-cases/using-subaccounts). +- Add support for the [Vonage Subaccounts API](https://developer.vonage.com/en/use-cases/using-subaccounts) # 3.99.2a0 - Add support for the [RCS messaging channel](https://developer.vonage.com/en/messages/concepts/rcs) of the Vonage Messages API diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 32c06391..37dd850a 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -12,8 +12,9 @@ dependencies = [ "vonage-application>=1.0.0", "vonage-messages>=1.2.0", "vonage-number-insight>=1.0.0", + "vonage-numbers>=1.0.0", "vonage-sms>=1.1.1", - "vonage-subaccounts>=1.0.0", + "vonage-subaccounts>=1.0.1", "vonage-users>=1.1.2", "vonage-verify>=1.1.1", "vonage-verify-v2>=1.1.2", diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 7d37335d..5deb89e8 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -7,6 +7,7 @@ HttpClientOptions, Messages, NumberInsight, + Numbers, Sms, Subaccounts, Users, @@ -24,6 +25,7 @@ 'Application', 'Messages', 'NumberInsight', + 'Numbers', 'Sms', 'Subaccounts', 'Users', diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 6b5d3a76..e02d7640 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.3a1' +__version__ = '3.99.4a0' diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 02087121..1990d0db 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -5,6 +5,7 @@ from vonage_http_client import Auth, HttpClient, HttpClientOptions from vonage_messages import Messages 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 @@ -37,6 +38,7 @@ def __init__( self.application = Application(self._http_client) self.messages = Messages(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) From a29db3ca1b4e42c361a9a77897e5714ddb74f20a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 5 Sep 2024 16:39:31 +0100 Subject: [PATCH 61/98] linting --- .../src/vonage_numbers/number_management.py | 3 ++- number_management/src/vonage_numbers/requests.py | 9 ++++----- number_management/tests/test_numbers.py | 7 +++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/number_management/src/vonage_numbers/number_management.py b/number_management/src/vonage_numbers/number_management.py index fdef03da..2a3c2fee 100644 --- a/number_management/src/vonage_numbers/number_management.py +++ b/number_management/src/vonage_numbers/number_management.py @@ -3,10 +3,11 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient from vonage_numbers.errors import NumbersError + from .requests import ( - SearchAvailableNumbersFilter, ListOwnedNumbersFilter, NumberParams, + SearchAvailableNumbersFilter, UpdateNumberParams, ) from .responses import AvailableNumber, NumbersStatus, OwnedNumber diff --git a/number_management/src/vonage_numbers/requests.py b/number_management/src/vonage_numbers/requests.py index 9c756e0f..1bc1462b 100644 --- a/number_management/src/vonage_numbers/requests.py +++ b/number_management/src/vonage_numbers/requests.py @@ -1,11 +1,10 @@ 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 -from vonage_utils.types import PhoneNumber class ListNumbersFilter(BaseModel): @@ -38,9 +37,9 @@ class SearchAvailableNumbersFilter(ListNumbersFilter): class NumberParams(BaseModel): """Specify the two-letter country code and the number you are referring to. - 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. + 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. """ country: str = Field(..., min_length=2, max_length=2) diff --git a/number_management/tests/test_numbers.py b/number_management/tests/test_numbers.py index 3b1a126f..1b31ea0b 100644 --- a/number_management/tests/test_numbers.py +++ b/number_management/tests/test_numbers.py @@ -2,12 +2,9 @@ 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_http_client.http_client import HttpClient - -from testutils import build_response, get_mock_api_key_auth from vonage_numbers.requests import ( ListOwnedNumbersFilter, NumberParams, @@ -15,6 +12,8 @@ UpdateNumberParams, ) +from testutils import build_response, get_mock_api_key_auth + path = abspath(__file__) numbers = Numbers(HttpClient(get_mock_api_key_auth())) From fdf6f6f6210c094ed192fd9277fd7c612c38d1ef Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 16 Sep 2024 04:46:07 +0100 Subject: [PATCH 62/98] add video package and token methods --- http_client/src/vonage_http_client/auth.py | 4 +- .../src/vonage_http_client/http_client.py | 1 + pants.toml | 3 +- requirements.txt | 3 + video/BUILD | 16 + video/CHANGES.md | 2 + video/README.md | 148 +++++++ video/pyproject.toml | 29 ++ video/src/vonage_video/BUILD | 1 + video/src/vonage_video/__init__.py | 4 + video/src/vonage_video/errors.py | 17 + video/src/vonage_video/models/BUILD | 1 + video/src/vonage_video/models/__init__.py | 4 + video/src/vonage_video/models/enums.py | 18 + video/src/vonage_video/models/requests.py | 17 + video/src/vonage_video/models/responses.py | 0 video/src/vonage_video/models/token.py | 59 +++ video/src/vonage_video/video.py | 280 ++++++++++++ video/tests/BUILD | 1 + video/tests/test_token.py | 65 +++ video/tests/test_video.py | 405 ++++++++++++++++++ vonage/pyproject.toml | 1 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + 24 files changed, 1080 insertions(+), 3 deletions(-) create mode 100644 video/BUILD create mode 100644 video/CHANGES.md create mode 100644 video/README.md create mode 100644 video/pyproject.toml create mode 100644 video/src/vonage_video/BUILD create mode 100644 video/src/vonage_video/__init__.py create mode 100644 video/src/vonage_video/errors.py create mode 100644 video/src/vonage_video/models/BUILD create mode 100644 video/src/vonage_video/models/__init__.py create mode 100644 video/src/vonage_video/models/enums.py create mode 100644 video/src/vonage_video/models/requests.py create mode 100644 video/src/vonage_video/models/responses.py create mode 100644 video/src/vonage_video/models/token.py create mode 100644 video/src/vonage_video/video.py create mode 100644 video/tests/BUILD create mode 100644 video/tests/test_token.py create mode 100644 video/tests/test_video.py diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index f757378a..5899f5b3 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -63,9 +63,9 @@ def api_secret(self): def create_jwt_auth_string(self): return b'Bearer ' + self.generate_application_jwt() - def generate_application_jwt(self): + def generate_application_jwt(self, claims: dict = None): try: - return self._jwt_client.generate_application_jwt() + return self._jwt_client.generate_application_jwt(claims) except AttributeError as err: raise JWTGenerationError( 'JWT generation failed. Check that you passed in valid values for "application_id" and "private_key".' diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index 8a516f13..b5b4236f 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -25,6 +25,7 @@ class HttpClientOptions(BaseModel): 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 diff --git a/pants.toml b/pants.toml index a6ff430e..fc347cbf 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.21.1' +pants_version = '2.22.0' backend_packages = [ 'pants.backend.python', @@ -49,6 +49,7 @@ filter = [ 'testutils', 'verify/src', 'verify_v2/src', + 'video/src', 'voice/src', 'vonage_utils/src', ] diff --git a/requirements.txt b/requirements.txt index 7e65f489..1ff7b305 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,10 +14,13 @@ pyjwt[crypto]>=1.6.4 -e network_sim_swap -e number_insight -e number_insight_v2 +-e number_management -e sms +-e subaccounts -e users -e verify -e verify_v2 +-e video -e voice -e vonage_utils -e vonage_network 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..a376cb52 --- /dev/null +++ b/video/CHANGES.md @@ -0,0 +1,2 @@ +# 1.0.0 +- Initial upload \ No newline at end of file diff --git a/video/README.md b/video/README.md new file mode 100644 index 00000000..8c98bd83 --- /dev/null +++ b/video/README.md @@ -0,0 +1,148 @@ +# Vonage Video Package + + + + + +This package contains the code to use [Vonage's Voice API](https://developer.vonage.com/en/voice/voice-api/overview) in Python. This package includes methods for working with the Voice API. It also contains an NCCO (Call Control Object) builder to help you to control call flow. + +## Structure + +There is a `Voice` class which contains the methods used to call Vonage APIs. To call many of the APIs, you need to pass a Pydantic model with the required options. These can be accessed from the `vonage_voice.models` subpackage. Errors can be accessed from the `vonage_voice.errors` module. + +## Usage + +It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`, like so: + +```python +from vonage import Vonage, Auth + +vonage_client = Vonage(Auth('MY_AUTH_INFO')) +``` + +### Create a Call + +To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. + +```python +from vonage_voice.models import CreateCallRequest, Talk + +ncco = [Talk(text='Hello world', loop=3, language='en-GB')] + +call = CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + ncco=ncco, + random_from_number=True, +) + +response = vonage_client.voice.create_call(call) +print(response.model_dump()) +``` + +### List Calls + +```python +# Gets the first 100 results and the record_index of the +# next page if there's more than 100 +calls, next_record_index = vonage_client.voice.list_calls() + +# Specify filtering options +from vonage_voice.models import ListCallsFilter + +call_filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +) + +calls, next_record_index = vonage_client.voice.list_calls(call_filter) +``` + +### Get Information About a Specific Call + +```python +call = vonage_client.voice.get_call('CALL_ID') +``` + +### Transfer a Call to a New NCCO + +```python +ncco = [Talk(text='Hello world')] +vonage_client.voice.transfer_call_ncco('UUID', ncco) +``` + +### Transfer a Call to a New Answer URL + +```python +vonage_client.voice.transfer_call_answer_url('UUID', 'ANSWER_URL') +``` + +### Hang Up a Call + +End the call for a specified UUID, removing them from it. + +```python +vonage_client.voice.hangup('UUID') +``` + +### Mute/Unmute a Participant + +```python +vonage_client.voice.mute('UUID') +vonage_client.voice.unmute('UUID') +``` + +### Earmuff/Unearmuff a UUID + +Prevent/allow a specified UUID participant to be able to hear audio. + +```python +vonage_client.voice.earmuff('UUID') +vonage_client.voice.unearmuff('UUID') +``` + +### Play Audio Into a Call + +```python +from vonage_voice.models import AudioStreamOptions + +# Only the `stream_url` option is required +options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 +) +response = vonage_client.voice.play_audio_into_call('UUID', options) +``` + +### Stop Playing Audio Into a Call + +```python +vonage_client.voice.stop_audio_stream('UUID') +``` + +### Play TTS Into a Call + +```python +from vonage_voice.models import TtsStreamOptions + +# Only the `text` field is required +options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +) +response = voice.play_tts_into_call('UUID', options) +``` + +### Stop Playing TTS Into a Call + +```python +vonage_client.voice.stop_tts('UUID') +``` + +### Play DTMF Tones Into a Call + +```python +response = voice.play_dtmf_into_call('UUID', '1234*#') +``` \ No newline at end of file diff --git a/video/pyproject.toml b/video/pyproject.toml new file mode 100644 index 00000000..0105d84a --- /dev/null +++ b/video/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-video' +version = '1.0.0' +description = 'Vonage video package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.4.0", + "vonage-utils>=1.1.2", + "pydantic>=2.7.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/video/src/vonage_video/BUILD b/video/src/vonage_video/BUILD new file mode 100644 index 00000000..db46e8d6 --- /dev/null +++ b/video/src/vonage_video/BUILD @@ -0,0 +1 @@ +python_sources() diff --git a/video/src/vonage_video/__init__.py b/video/src/vonage_video/__init__.py new file mode 100644 index 00000000..16da3cf3 --- /dev/null +++ b/video/src/vonage_video/__init__.py @@ -0,0 +1,4 @@ +from . import errors, models +from .video import Video + +__all__ = ['Video', 'errors', 'models'] diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py new file mode 100644 index 00000000..68aebb47 --- /dev/null +++ b/video/src/vonage_video/errors.py @@ -0,0 +1,17 @@ +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.""" 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..1f904f93 --- /dev/null +++ b/video/src/vonage_video/models/__init__.py @@ -0,0 +1,4 @@ +from .enums import MediaMode, ArchiveMode, TokenRole +from .token import TokenOptions + +__all__ = ['MediaMode', 'ArchiveMode', 'TokenRole', 'TokenOptions'] diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py new file mode 100644 index 00000000..43c826d6 --- /dev/null +++ b/video/src/vonage_video/models/enums.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class TokenRole(str, Enum): + SUBSCRIBER = 'subscriber' + PUBLISHER = 'publisher' + PUBLISHER_ONLY = 'publisheronly' + MODERATOR = 'moderator' + + +class ArchiveMode(str, Enum): + MANUAL = 'manual' + ALWAYS = 'always' + + +class MediaMode(str, Enum): + ROUTED = 'routed' + RELAYED = 'relayed' diff --git a/video/src/vonage_video/models/requests.py b/video/src/vonage_video/models/requests.py new file mode 100644 index 00000000..6bd0e634 --- /dev/null +++ b/video/src/vonage_video/models/requests.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import BaseModel, Field, field_validator +from .enums import ArchiveMode, MediaMode + + +class SessionOptions(BaseModel): + archive_mode: Optional[ArchiveMode] = Field(None, serialization_alias='archiveMode') + location: Optional[str] = None + media_mode: Optional[MediaMode] = Field(None, serialization_alias='p2p.preference') + + @field_validator('media_mode') + @classmethod + def change_to_p2p_preference(cls, v: MediaMode): + if v == MediaMode.ROUTED: + return 'disabled' + if v == MediaMode.RELAYED: + return 'always' diff --git a/video/src/vonage_video/models/responses.py b/video/src/vonage_video/models/responses.py new file mode 100644 index 00000000..e69de29b diff --git a/video/src/vonage_video/models/token.py b/video/src/vonage_video/models/token.py new file mode 100644 index 00000000..c7934651 --- /dev/null +++ b/video/src/vonage_video/models/token.py @@ -0,0 +1,59 @@ +from pyexpat import model +from pydantic import BaseModel, field_validator, model_validator +from typing import Literal, Optional, List, Union +from uuid import UUID, uuid4 +from time import time + +from .enums import TokenRole +from ..errors import TokenExpiryError + + +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. + """ + + 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: Union[UUID, str] = uuid4() + iat: int = 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..7975eb2d --- /dev/null +++ b/video/src/vonage_video/video.py @@ -0,0 +1,280 @@ +from time import time +from typing import List, Optional, Tuple +from uuid import uuid4 + +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient +from vonage_utils.types import Dtmf +from vonage_video.models.token import TokenOptions +from vonage_voice.models.ncco import NccoAction + +# from .models.requests import ( +# AudioStreamOptions, +# CreateCallRequest, +# ListCallsFilter, +# TtsStreamOptions, +# ) +# from .models.responses import CallInfo, CallList, CallMessage, CreateCallResponse + + +class Video: + """Calls Vonage's Video API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + archive_mode_values = {'manual', 'always'} + media_mode_values = {'routed', 'relayed'} + + @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) + ) + + # def create_session(self, options: SessionOptions) -> Session: + # """Creates a new session for the Vonage Video API.""" + + # # ##################### + # @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. + # """ + # 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. + # """ + # response = self._http_client.delete( + # self._http_client.api_host, f'/v1/calls/{uuid}/talk' + # ) + + # return CallMessage(**response) + + # @validate_call + # def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: + # """Plays DTMF tones into a call. + + # Args: + # uuid (str): The UUID of the call to play DTMF tones into. + # dtmf (Dtmf): The DTMF tones to play. + + # Returns: + # CallMessage: Object with information about the call. + # """ + # response = self._http_client.put( + # self._http_client.api_host, + # f'/v1/calls/{uuid}/dtmf', + # {'digits': dtmf}, + # ) + + # return CallMessage(**response) diff --git a/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/test_token.py b/video/tests/test_token.py new file mode 100644 index 00000000..b8fe1e7a --- /dev/null +++ b/video/tests/test_token.py @@ -0,0 +1,65 @@ +from vonage_video.errors import TokenExpiryError +from vonage_video.models.token import TokenOptions +from vonage_video.models.enums import TokenRole +from vonage_video.video import Video + +from vonage_http_client import HttpClient +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'], + 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, + ) + ) + + print(token) + + assert ( + token + == b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoic2Vzc2lvbi1pZCIsInNjb3BlIjoic2Vzc2lvbi5jb25uZWN0Iiwicm9sZSI6InB1Ymxpc2hlciIsImNvbm5lY3Rpb25fZGF0YSI6ImNvbm5lY3Rpb24tZGF0YSIsImluaXRpYWxfbGF5b3V0X2NsYXNzX2xpc3QiOlsiZm9jdXMiXSwiZXhwIjoxMjM0NTc2ODksImp0aSI6IjRjYWI4OWNhLWI2MzctNDFjOC1iNjJmLTdiOWNlMTBjMzk3MSIsImlhdCI6MTIzNDU2Nzg5LCJzdWJqZWN0IjoidmlkZW8iLCJhY2wiOnsicGF0aHMiOnsiL3Nlc3Npb24vKioiOnt9fX0sImFwcGxpY2F0aW9uX2lkIjoidGVzdF9hcHBsaWNhdGlvbl9pZCJ9.NlAntZ_b1hUmcwOt62iI0R_AboscqA8I5ZaZpjQ9_fF0f0xwjfOJ9lVQJujLkR3lghLbYUJKi6gNOY4OvkcV57V0KDe5QMOH77lwRKI_F0H2hIlARR9EEhRTjqtjNzVPJdTOz99q3TyQ4F9CBreO4jLOqV1t4hlrwDou0cAnj6LmIYIItZFzRxlxX54E7l8SXOXS5xSAo3mNtmQ9WoxyZ1bNWSMLkpJ1MPmuOJSafPPbHu_-MyVHndlERVIY9vrebyc9qw8rNFuo04TuIJmcBCo2yTsMqHbaxQTvx569P-_FwBfQLF3maDO493mL63JvOd_xLAIU1xleK774SQTgEQ' + ) diff --git a/video/tests/test_video.py b/video/tests/test_video.py new file mode 100644 index 00000000..6fbde7cf --- /dev/null +++ b/video/tests/test_video.py @@ -0,0 +1,405 @@ +from os.path import abspath + +import responses +from pytest import raises +from responses.matchers import json_params_matcher +from vonage_http_client.http_client import HttpClient + +from vonage_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_http_client_property(): + assert type(video.http_client) == HttpClient + + +### + + +@responses.activate +def test_create_call_basic_ncco(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], + random_from_number=True, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_ncco_options(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + ncco = [Talk(text='Hello world')] + call = CreateCallRequest( + ncco=ncco, + to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], + from_={'number': '1234567890', 'type': 'phone'}, + event_url=['https://example.com/event'], + event_method='POST', + machine_detection='hangup', + length_timer=60, + ringing_timer=30, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_basic_answer_url(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + call = CreateCallRequest( + to=[ + { + 'type': 'websocket', + 'uri': 'wss://example.com/websocket', + 'content_type': 'audio/l16;rate=8000', + 'headers': {'key': 'value'}, + } + ], + answer_url=['https://example.com/answer'], + random_from_number=True, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_create_call_answer_url_options(): + build_response( + path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 + ) + call = CreateCallRequest( + to=[{'type': 'vbc', 'extension': '1234'}], + answer_url=['https://example.com/answer'], + answer_method='GET', + random_from_number=True, + event_url=['https://example.com/event'], + event_method='POST', + advanced_machine_detection={ + 'behavior': 'hangup', + 'mode': 'detect_beep', + 'beep_timeout': 50, + }, + length_timer=60, + ringing_timer=30, + ) + response = voice.create_call(call) + + assert type(response) == CreateCallResponse + assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' + assert response.status == 'started' + assert response.direction == 'outbound' + assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +def test_create_call_ncco_and_answer_url_error(): + with raises(VoiceError) as e: + CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + random_from_number=True, + ) + assert e.match('Either `ncco` or `answer_url` must be set') + + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + answer_url=['https://example.com/answer'], + to=[{'type': 'phone', 'number': '1234567890'}], + random_from_number=True, + ) + assert e.match('`ncco` and `answer_url` cannot be used together') + + +def test_create_call_from_and_random_from_number_error(): + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + to=[{'type': 'phone', 'number': '1234567890'}], + ) + assert e.match('Either `from_` or `random_from_number` must be set') + + with raises(VoiceError) as e: + CreateCallRequest( + ncco=[Talk(text='Hello world')], + to=[{'type': 'phone', 'number': '1234567890'}], + from_={'number': '9876543210', 'type': 'phone'}, + random_from_number=True, + ) + assert e.match('`from_` and `random_from_number` cannot be used together') + + +@responses.activate +def test_list_calls(): + build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) + calls, _ = voice.list_calls() + assert len(calls) == 3 + assert calls[0].to.number == '1234567890' + assert calls[0].from_.number == '9876543210' + assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' + assert calls[1].direction == 'outbound' + assert calls[1].status == 'completed' + assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +@responses.activate +def test_list_calls_filter(): + build_response( + path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 + ) + filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', + ) + filter_dict = { + 'status': 'completed', + 'date_start': '2024-03-14T07:45:14Z', + 'date_end': '2024-04-19T08:45:14Z', + 'page_size': 10, + 'record_index': 0, + 'order': 'asc', + 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', + } + assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict + + calls, next_record_index = voice.list_calls(filter) + assert len(calls) == 1 + assert calls[0].to.number == '1234567890' + assert next_record_index == 2 + + +@responses.activate +def test_get_call(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + 'get_call.json', + 200, + ) + call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') + assert call.to.number == '1234567890' + assert call.from_.number == '9876543210' + assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' + assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' + + +@responses.activate +def test_transfer_call_ncco(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + ) + + ncco = [Talk(text='Hello world')] + voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_transfer_call_answer_url(): + answer_url = 'https://example.com/answer' + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[ + json_params_matcher( + { + 'action': 'transfer', + 'destination': {'type': 'ncco', 'url': [answer_url]}, + }, + ), + ], + ) + + voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_hangup(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'hangup'})], + ) + + voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_mute(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'mute'})], + ) + + voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_unmute(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'unmute'})], + ) + + voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_earmuff(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'earmuff'})], + ) + + voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_unearmuff(): + build_response( + path, + 'PUT', + 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', + status_code=204, + match=[json_params_matcher({'action': 'unearmuff'})], + ) + + voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') + assert voice._http_client.last_response.status_code == 204 + + +@responses.activate +def test_play_audio_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/stream', + 'play_audio_into_call.json', + ) + + options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 + ) + response = voice.play_audio_into_call(uuid, options) + assert response.message == 'Stream started' + assert response.uuid == uuid + + +@responses.activate +def test_stop_audio_stream(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'DELETE', + f'https://api.nexmo.com/v1/calls/{uuid}/stream', + 'stop_audio_stream.json', + ) + + response = voice.stop_audio_stream(uuid) + assert response.message == 'Stream stopped' + assert response.uuid == uuid + + +@responses.activate +def test_play_tts_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/talk', + 'play_tts_into_call.json', + ) + + options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 + ) + response = voice.play_tts_into_call(uuid, options) + assert response.message == 'Talk started' + assert response.uuid == uuid + + +@responses.activate +def test_stop_tts(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'DELETE', + f'https://api.nexmo.com/v1/calls/{uuid}/talk', + 'stop_tts.json', + ) + + response = voice.stop_tts(uuid) + assert response.message == 'Talk stopped' + assert response.uuid == uuid + + +@responses.activate +def test_play_dtmf_into_call(): + uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' + build_response( + path, + 'PUT', + f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', + 'play_dtmf_into_call.json', + ) + + response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') + assert response.message == 'DTMF sent' + assert response.uuid == uuid diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 37dd850a..35918eb1 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "vonage-users>=1.1.2", "vonage-verify>=1.1.1", "vonage-verify-v2>=1.1.2", + "vonage-video>=1.0.0", "vonage-voice>=1.0.3", ] classifiers = [ diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 5deb89e8..3345cbbc 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -13,6 +13,7 @@ Users, Verify, VerifyV2, + Video, Voice, Vonage, ) @@ -31,6 +32,7 @@ 'Users', 'Verify', 'VerifyV2', + 'Video', 'Voice', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 1990d0db..c7faf0a0 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -11,6 +11,7 @@ from vonage_users import Users from vonage_verify import Verify from vonage_verify_v2 import VerifyV2 +from vonage_video import Video from vonage_voice import Voice from ._version import __version__ @@ -44,6 +45,7 @@ def __init__( self.users = Users(self._http_client) self.verify = Verify(self._http_client) self.verify_v2 = VerifyV2(self._http_client) + self.video = Video(self._http_client) self.voice = Voice(self._http_client) @property From 8b3bb19dec87bdf1ffa16f49e96c63ea59b39afd Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 16 Sep 2024 04:46:56 +0100 Subject: [PATCH 63/98] linting --- video/src/vonage_video/models/__init__.py | 2 +- video/src/vonage_video/models/requests.py | 2 ++ video/src/vonage_video/models/token.py | 10 +++++----- video/src/vonage_video/video.py | 6 ------ video/tests/test_token.py | 4 ++-- video/tests/test_video.py | 1 - 6 files changed, 10 insertions(+), 15 deletions(-) diff --git a/video/src/vonage_video/models/__init__.py b/video/src/vonage_video/models/__init__.py index 1f904f93..15848e4b 100644 --- a/video/src/vonage_video/models/__init__.py +++ b/video/src/vonage_video/models/__init__.py @@ -1,4 +1,4 @@ -from .enums import MediaMode, ArchiveMode, TokenRole +from .enums import ArchiveMode, MediaMode, TokenRole from .token import TokenOptions __all__ = ['MediaMode', 'ArchiveMode', 'TokenRole', 'TokenOptions'] diff --git a/video/src/vonage_video/models/requests.py b/video/src/vonage_video/models/requests.py index 6bd0e634..2365cb12 100644 --- a/video/src/vonage_video/models/requests.py +++ b/video/src/vonage_video/models/requests.py @@ -1,5 +1,7 @@ from typing import Optional + from pydantic import BaseModel, Field, field_validator + from .enums import ArchiveMode, MediaMode diff --git a/video/src/vonage_video/models/token.py b/video/src/vonage_video/models/token.py index c7934651..ad02c5a2 100644 --- a/video/src/vonage_video/models/token.py +++ b/video/src/vonage_video/models/token.py @@ -1,11 +1,11 @@ -from pyexpat import model -from pydantic import BaseModel, field_validator, model_validator -from typing import Literal, Optional, List, Union -from uuid import UUID, uuid4 from time import time +from typing import List, Literal, Optional, Union +from uuid import UUID, uuid4 + +from pydantic import BaseModel, field_validator, model_validator -from .enums import TokenRole from ..errors import TokenExpiryError +from .enums import TokenRole class TokenOptions(BaseModel): diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 7975eb2d..29693965 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -1,12 +1,6 @@ -from time import time -from typing import List, Optional, Tuple -from uuid import uuid4 - from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from vonage_utils.types import Dtmf from vonage_video.models.token import TokenOptions -from vonage_voice.models.ncco import NccoAction # from .models.requests import ( # AudioStreamOptions, diff --git a/video/tests/test_token.py b/video/tests/test_token.py index b8fe1e7a..d2911a06 100644 --- a/video/tests/test_token.py +++ b/video/tests/test_token.py @@ -1,9 +1,9 @@ +from vonage_http_client import HttpClient from vonage_video.errors import TokenExpiryError -from vonage_video.models.token import TokenOptions from vonage_video.models.enums import TokenRole +from vonage_video.models.token import TokenOptions from vonage_video.video import Video -from vonage_http_client import HttpClient from testutils import get_mock_jwt_auth video = Video(HttpClient(get_mock_jwt_auth())) diff --git a/video/tests/test_video.py b/video/tests/test_video.py index 6fbde7cf..d313c722 100644 --- a/video/tests/test_video.py +++ b/video/tests/test_video.py @@ -4,7 +4,6 @@ from pytest import raises from responses.matchers import json_params_matcher from vonage_http_client.http_client import HttpClient - from vonage_video.video import Video from testutils import build_response, get_mock_jwt_auth From c5a93dac849c74ad6d892680b6efa58a66c5fe9a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 23 Sep 2024 17:17:55 +0100 Subject: [PATCH 64/98] start adding video api --- http_client/src/vonage_http_client/auth.py | 7 +- .../src/vonage_http_client/http_client.py | 6 + jwt/src/vonage_jwt/jwt.py | 4 +- video/src/vonage_video/models/__init__.py | 10 +- video/src/vonage_video/models/enums.py | 5 + video/src/vonage_video/models/requests.py | 19 - video/src/vonage_video/models/session.py | 47 ++ .../models/{responses.py => signal.py} | 0 video/src/vonage_video/models/stream.py | 21 + video/src/vonage_video/models/token.py | 6 +- video/src/vonage_video/video.py | 373 +++------ video/tests/data/create_session.json | 19 + video/tests/test_session.py | 80 ++ video/tests/test_token.py | 2 +- video/tests/test_video.py | 767 +++++++++--------- 15 files changed, 714 insertions(+), 652 deletions(-) delete mode 100644 video/src/vonage_video/models/requests.py create mode 100644 video/src/vonage_video/models/session.py rename video/src/vonage_video/models/{responses.py => signal.py} (100%) create mode 100644 video/src/vonage_video/models/stream.py create mode 100644 video/tests/data/create_session.json create mode 100644 video/tests/test_session.py diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 5899f5b3..64d52656 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -45,6 +45,7 @@ def __init__( 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) @@ -60,10 +61,14 @@ def api_key(self): def api_secret(self): return self._api_secret + @property + def application_id(self): + return self._application_id + def create_jwt_auth_string(self): return b'Bearer ' + self.generate_application_jwt() - def generate_application_jwt(self, claims: dict = None): + def generate_application_jwt(self, claims: dict = {}) -> bytes: try: return self._jwt_client.generate_application_jwt(claims) except AttributeError as err: diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index b5b4236f..dda3ab61 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -43,6 +43,7 @@ class HttpClient: 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. @@ -70,6 +71,7 @@ def __init__( 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() @@ -102,6 +104,10 @@ def api_host(self): 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 diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py index b1c93359..a13a5bec 100644 --- a/jwt/src/vonage_jwt/jwt.py +++ b/jwt/src/vonage_jwt/jwt.py @@ -24,7 +24,7 @@ def __init__(self, application_id: str, private_key: str): 'Both of "application_id" and "private_key" are required.' ) - def generate_application_jwt(self, jwt_options: dict = {}): + def generate_application_jwt(self, jwt_options: dict = {}) -> bytes: """Generates a JWT for the specified Vonage application. You can override values for application_id and private_key on the JWTClient object by @@ -44,7 +44,7 @@ def generate_application_jwt(self, jwt_options: dict = {}): token = encode(payload, self._private_key, algorithm='RS256', headers=headers) return bytes(token, 'utf-8') - def _set_private_key(self, key: Union[str, bytes]): + 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() diff --git a/video/src/vonage_video/models/__init__.py b/video/src/vonage_video/models/__init__.py index 15848e4b..a63621f2 100644 --- a/video/src/vonage_video/models/__init__.py +++ b/video/src/vonage_video/models/__init__.py @@ -1,4 +1,12 @@ from .enums import ArchiveMode, MediaMode, TokenRole +from .session import SessionOptions, VideoSession from .token import TokenOptions -__all__ = ['MediaMode', 'ArchiveMode', 'TokenRole', 'TokenOptions'] +__all__ = [ + 'MediaMode', + 'ArchiveMode', + 'TokenRole', + 'TokenOptions', + 'SessionOptions', + 'VideoSession', +] diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index 43c826d6..f1199e53 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -16,3 +16,8 @@ class ArchiveMode(str, Enum): class MediaMode(str, Enum): ROUTED = 'routed' RELAYED = 'relayed' + + +class P2pPreference(str, Enum): + DISABLED = 'disabled' + ALWAYS = 'always' diff --git a/video/src/vonage_video/models/requests.py b/video/src/vonage_video/models/requests.py deleted file mode 100644 index 2365cb12..00000000 --- a/video/src/vonage_video/models/requests.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, Field, field_validator - -from .enums import ArchiveMode, MediaMode - - -class SessionOptions(BaseModel): - archive_mode: Optional[ArchiveMode] = Field(None, serialization_alias='archiveMode') - location: Optional[str] = None - media_mode: Optional[MediaMode] = Field(None, serialization_alias='p2p.preference') - - @field_validator('media_mode') - @classmethod - def change_to_p2p_preference(cls, v: MediaMode): - if v == MediaMode.ROUTED: - return 'disabled' - if v == MediaMode.RELAYED: - return 'always' diff --git a/video/src/vonage_video/models/session.py b/video/src/vonage_video/models/session.py new file mode 100644 index 00000000..c4f519ff --- /dev/null +++ b/video/src/vonage_video/models/session.py @@ -0,0 +1,47 @@ +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): + session_id: str + archive_mode: Optional[ArchiveMode] = None + media_mode: Optional[MediaMode] = None + location: Optional[str] = None diff --git a/video/src/vonage_video/models/responses.py b/video/src/vonage_video/models/signal.py similarity index 100% rename from video/src/vonage_video/models/responses.py rename to video/src/vonage_video/models/signal.py diff --git a/video/src/vonage_video/models/stream.py b/video/src/vonage_video/models/stream.py new file mode 100644 index 00000000..0483a69e --- /dev/null +++ b/video/src/vonage_video/models/stream.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class StreamInfo(BaseModel): + 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): + id: str + layout_class_list: List[str] = Field(..., validation_alias='layoutClassList') + + +class StreamLayoutOptions(BaseModel): + items: List[StreamLayout] diff --git a/video/src/vonage_video/models/token.py b/video/src/vonage_video/models/token.py index ad02c5a2..a9b48a58 100644 --- a/video/src/vonage_video/models/token.py +++ b/video/src/vonage_video/models/token.py @@ -1,6 +1,6 @@ from time import time -from typing import List, Literal, Optional, Union -from uuid import UUID, uuid4 +from typing import List, Literal, Optional +from uuid import uuid4 from pydantic import BaseModel, field_validator, model_validator @@ -29,7 +29,7 @@ class TokenOptions(BaseModel): connection_data: Optional[str] = None initial_layout_class_list: Optional[List[str]] = None exp: Optional[int] = None - jti: Union[UUID, str] = uuid4() + jti: str = str(uuid4()) iat: int = int(time()) subject: Literal['video'] = 'video' scope: Literal['session.connect'] = 'session.connect' diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 29693965..79e20275 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -1,23 +1,17 @@ +from typing import List + from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_video.models.session import SessionOptions, VideoSession +from vonage_video.models.stream import StreamInfo, StreamLayoutOptions from vonage_video.models.token import TokenOptions -# from .models.requests import ( -# AudioStreamOptions, -# CreateCallRequest, -# ListCallsFilter, -# TtsStreamOptions, -# ) -# from .models.responses import CallInfo, CallList, CallMessage, CreateCallResponse - class Video: """Calls Vonage's Video API.""" def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client - archive_mode_values = {'manual', 'always'} - media_mode_values = {'routed', 'relayed'} @property def http_client(self) -> HttpClient: @@ -42,233 +36,132 @@ def generate_client_token(self, token_options: TokenOptions) -> bytes: token_options.model_dump(exclude_none=True) ) - # def create_session(self, options: SessionOptions) -> Session: - # """Creates a new session for the Vonage Video API.""" - - # # ##################### - # @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. - # """ - # 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. - # """ - # response = self._http_client.delete( - # self._http_client.api_host, f'/v1/calls/{uuid}/talk' - # ) - - # return CallMessage(**response) - - # @validate_call - # def play_dtmf_into_call(self, uuid: str, dtmf: Dtmf) -> CallMessage: - # """Plays DTMF tones into a call. - - # Args: - # uuid (str): The UUID of the call to play DTMF tones into. - # dtmf (Dtmf): The DTMF tones to play. - - # Returns: - # CallMessage: Object with information about the call. - # """ - # response = self._http_client.put( - # self._http_client.api_host, - # f'/v1/calls/{uuid}/dtmf', - # {'digits': dtmf}, - # ) - - # return CallMessage(**response) + @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. + """ + + 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 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 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 change_stream_layout( + self, session_id: str, stream_layout_options: StreamLayoutOptions + ) -> None: + """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. + """ + + 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), + ) + + 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) + + 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']] + + def change_stream_layout( + self, session_id: str, stream_layout_options: StreamLayoutOptions + ) -> None: + """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. + """ + + 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), + ) 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/test_session.py b/video/tests/test_session.py new file mode 100644 index 00000000..bee10cfb --- /dev/null +++ b/video/tests/test_session.py @@ -0,0 +1,80 @@ +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 + + 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' diff --git a/video/tests/test_token.py b/video/tests/test_token.py index d2911a06..de2c0f37 100644 --- a/video/tests/test_token.py +++ b/video/tests/test_token.py @@ -61,5 +61,5 @@ def test_generate_token(): assert ( token - == b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoic2Vzc2lvbi1pZCIsInNjb3BlIjoic2Vzc2lvbi5jb25uZWN0Iiwicm9sZSI6InB1Ymxpc2hlciIsImNvbm5lY3Rpb25fZGF0YSI6ImNvbm5lY3Rpb24tZGF0YSIsImluaXRpYWxfbGF5b3V0X2NsYXNzX2xpc3QiOlsiZm9jdXMiXSwiZXhwIjoxMjM0NTc2ODksImp0aSI6IjRjYWI4OWNhLWI2MzctNDFjOC1iNjJmLTdiOWNlMTBjMzk3MSIsImlhdCI6MTIzNDU2Nzg5LCJzdWJqZWN0IjoidmlkZW8iLCJhY2wiOnsicGF0aHMiOnsiL3Nlc3Npb24vKioiOnt9fX0sImFwcGxpY2F0aW9uX2lkIjoidGVzdF9hcHBsaWNhdGlvbl9pZCJ9.NlAntZ_b1hUmcwOt62iI0R_AboscqA8I5ZaZpjQ9_fF0f0xwjfOJ9lVQJujLkR3lghLbYUJKi6gNOY4OvkcV57V0KDe5QMOH77lwRKI_F0H2hIlARR9EEhRTjqtjNzVPJdTOz99q3TyQ4F9CBreO4jLOqV1t4hlrwDou0cAnj6LmIYIItZFzRxlxX54E7l8SXOXS5xSAo3mNtmQ9WoxyZ1bNWSMLkpJ1MPmuOJSafPPbHu_-MyVHndlERVIY9vrebyc9qw8rNFuo04TuIJmcBCo2yTsMqHbaxQTvx569P-_FwBfQLF3maDO493mL63JvOd_xLAIU1xleK774SQTgEQ' + == b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoic2Vzc2lvbi1pZCIsInJvbGUiOiJwdWJsaXNoZXIiLCJjb25uZWN0aW9uX2RhdGEiOiJjb25uZWN0aW9uLWRhdGEiLCJpbml0aWFsX2xheW91dF9jbGFzc19saXN0IjpbImZvY3VzIl0sImV4cCI6MTIzNDU3Njg5LCJqdGkiOiI0Y2FiODljYS1iNjM3LTQxYzgtYjYyZi03YjljZTEwYzM5NzEiLCJpYXQiOjEyMzQ1Njc4OSwic3ViamVjdCI6InZpZGVvIiwic2NvcGUiOiJzZXNzaW9uLmNvbm5lY3QiLCJhY2wiOnsicGF0aHMiOnsiL3Nlc3Npb24vKioiOnt9fX0sImFwcGxpY2F0aW9uX2lkIjoidGVzdF9hcHBsaWNhdGlvbl9pZCJ9.DL-b9AJxZIKb0gmc_NGrD8fvIpg_ILX5FBMXpR56CgSdI63wS04VuaAKCTRojSJrqpzENv_GLR2HYY4-d1Qm1pyj1tM1yFRDk8z_vun30DWavYkCFW1T5FenK1VUjg0P9pbdGiPvq0Ku-taMuLyqXzQqHsbEGOovo-JMIag6wD6JPrPIKaYXsqGpXYaJ_BCcuIpg0NquQgJXA004Q415CxguCkQLdv0d7xTyfPw44Sj-_JfRdBdqDjyiDsmYmh7Yt5TrqRqZ1SwxNhNP7MSx8KDake3VqkQB9Iyys43MJBHZtRDrtE6VedLt80RpCz9Yo8F8CIjStwQPOfMjbV-iEA' ) diff --git a/video/tests/test_video.py b/video/tests/test_video.py index d313c722..f256b420 100644 --- a/video/tests/test_video.py +++ b/video/tests/test_video.py @@ -1,12 +1,9 @@ from os.path import abspath -import responses -from pytest import raises -from responses.matchers import json_params_matcher from vonage_http_client.http_client import HttpClient from vonage_video.video import Video -from testutils import build_response, get_mock_jwt_auth +from testutils import get_mock_jwt_auth path = abspath(__file__) @@ -21,384 +18,384 @@ def test_http_client_property(): ### -@responses.activate -def test_create_call_basic_ncco(): - build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 - ) - ncco = [Talk(text='Hello world')] - call = CreateCallRequest( - ncco=ncco, - to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], - random_from_number=True, - ) - response = voice.create_call(call) - - assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -@responses.activate -def test_create_call_ncco_options(): - build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 - ) - ncco = [Talk(text='Hello world')] - call = CreateCallRequest( - ncco=ncco, - to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], - from_={'number': '1234567890', 'type': 'phone'}, - event_url=['https://example.com/event'], - event_method='POST', - machine_detection='hangup', - length_timer=60, - ringing_timer=30, - ) - response = voice.create_call(call) - - assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -@responses.activate -def test_create_call_basic_answer_url(): - build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 - ) - call = CreateCallRequest( - to=[ - { - 'type': 'websocket', - 'uri': 'wss://example.com/websocket', - 'content_type': 'audio/l16;rate=8000', - 'headers': {'key': 'value'}, - } - ], - answer_url=['https://example.com/answer'], - random_from_number=True, - ) - response = voice.create_call(call) - - assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -@responses.activate -def test_create_call_answer_url_options(): - build_response( - path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 - ) - call = CreateCallRequest( - to=[{'type': 'vbc', 'extension': '1234'}], - answer_url=['https://example.com/answer'], - answer_method='GET', - random_from_number=True, - event_url=['https://example.com/event'], - event_method='POST', - advanced_machine_detection={ - 'behavior': 'hangup', - 'mode': 'detect_beep', - 'beep_timeout': 50, - }, - length_timer=60, - ringing_timer=30, - ) - response = voice.create_call(call) - - assert type(response) == CreateCallResponse - assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' - assert response.status == 'started' - assert response.direction == 'outbound' - assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -def test_create_call_ncco_and_answer_url_error(): - with raises(VoiceError) as e: - CreateCallRequest( - to=[{'type': 'phone', 'number': '1234567890'}], - random_from_number=True, - ) - assert e.match('Either `ncco` or `answer_url` must be set') - - with raises(VoiceError) as e: - CreateCallRequest( - ncco=[Talk(text='Hello world')], - answer_url=['https://example.com/answer'], - to=[{'type': 'phone', 'number': '1234567890'}], - random_from_number=True, - ) - assert e.match('`ncco` and `answer_url` cannot be used together') - - -def test_create_call_from_and_random_from_number_error(): - with raises(VoiceError) as e: - CreateCallRequest( - ncco=[Talk(text='Hello world')], - to=[{'type': 'phone', 'number': '1234567890'}], - ) - assert e.match('Either `from_` or `random_from_number` must be set') - - with raises(VoiceError) as e: - CreateCallRequest( - ncco=[Talk(text='Hello world')], - to=[{'type': 'phone', 'number': '1234567890'}], - from_={'number': '9876543210', 'type': 'phone'}, - random_from_number=True, - ) - assert e.match('`from_` and `random_from_number` cannot be used together') - - -@responses.activate -def test_list_calls(): - build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) - calls, _ = voice.list_calls() - assert len(calls) == 3 - assert calls[0].to.number == '1234567890' - assert calls[0].from_.number == '9876543210' - assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' - assert calls[1].direction == 'outbound' - assert calls[1].status == 'completed' - assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' - - -@responses.activate -def test_list_calls_filter(): - build_response( - path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 - ) - filter = ListCallsFilter( - status='completed', - date_start='2024-03-14T07:45:14Z', - date_end='2024-04-19T08:45:14Z', - page_size=10, - record_index=0, - order='asc', - conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', - ) - filter_dict = { - 'status': 'completed', - 'date_start': '2024-03-14T07:45:14Z', - 'date_end': '2024-04-19T08:45:14Z', - 'page_size': 10, - 'record_index': 0, - 'order': 'asc', - 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', - } - assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict - - calls, next_record_index = voice.list_calls(filter) - assert len(calls) == 1 - assert calls[0].to.number == '1234567890' - assert next_record_index == 2 - - -@responses.activate -def test_get_call(): - build_response( - path, - 'GET', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - 'get_call.json', - 200, - ) - call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') - assert call.to.number == '1234567890' - assert call.from_.number == '9876543210' - assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' - assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' - - -@responses.activate -def test_transfer_call_ncco(): - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - ) - - ncco = [Talk(text='Hello world')] - voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_transfer_call_answer_url(): - answer_url = 'https://example.com/answer' - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - match=[ - json_params_matcher( - { - 'action': 'transfer', - 'destination': {'type': 'ncco', 'url': [answer_url]}, - }, - ), - ], - ) - - voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_hangup(): - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - match=[json_params_matcher({'action': 'hangup'})], - ) - - voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_mute(): - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - match=[json_params_matcher({'action': 'mute'})], - ) - - voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_unmute(): - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - match=[json_params_matcher({'action': 'unmute'})], - ) - - voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_earmuff(): - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - match=[json_params_matcher({'action': 'earmuff'})], - ) - - voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_unearmuff(): - build_response( - path, - 'PUT', - 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', - status_code=204, - match=[json_params_matcher({'action': 'unearmuff'})], - ) - - voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') - assert voice._http_client.last_response.status_code == 204 - - -@responses.activate -def test_play_audio_into_call(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' - build_response( - path, - 'PUT', - f'https://api.nexmo.com/v1/calls/{uuid}/stream', - 'play_audio_into_call.json', - ) - - options = AudioStreamOptions( - stream_url=['https://example.com/audio'], loop=2, level=0.5 - ) - response = voice.play_audio_into_call(uuid, options) - assert response.message == 'Stream started' - assert response.uuid == uuid - - -@responses.activate -def test_stop_audio_stream(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' - build_response( - path, - 'DELETE', - f'https://api.nexmo.com/v1/calls/{uuid}/stream', - 'stop_audio_stream.json', - ) - - response = voice.stop_audio_stream(uuid) - assert response.message == 'Stream stopped' - assert response.uuid == uuid - - -@responses.activate -def test_play_tts_into_call(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' - build_response( - path, - 'PUT', - f'https://api.nexmo.com/v1/calls/{uuid}/talk', - 'play_tts_into_call.json', - ) - - options = TtsStreamOptions( - text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 - ) - response = voice.play_tts_into_call(uuid, options) - assert response.message == 'Talk started' - assert response.uuid == uuid - - -@responses.activate -def test_stop_tts(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' - build_response( - path, - 'DELETE', - f'https://api.nexmo.com/v1/calls/{uuid}/talk', - 'stop_tts.json', - ) - - response = voice.stop_tts(uuid) - assert response.message == 'Talk stopped' - assert response.uuid == uuid - - -@responses.activate -def test_play_dtmf_into_call(): - uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' - build_response( - path, - 'PUT', - f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', - 'play_dtmf_into_call.json', - ) - - response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') - assert response.message == 'DTMF sent' - assert response.uuid == uuid +# @responses.activate +# def test_create_call_basic_ncco(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# ncco = [Talk(text='Hello world')] +# call = CreateCallRequest( +# ncco=ncco, +# to=[{'type': 'sip', 'uri': 'sip:test@example.com'}], +# random_from_number=True, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_create_call_ncco_options(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# ncco = [Talk(text='Hello world')] +# call = CreateCallRequest( +# ncco=ncco, +# to=[{'type': 'phone', 'number': '1234567890', 'dtmf_answer': '1234'}], +# from_={'number': '1234567890', 'type': 'phone'}, +# event_url=['https://example.com/event'], +# event_method='POST', +# machine_detection='hangup', +# length_timer=60, +# ringing_timer=30, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_create_call_basic_answer_url(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# call = CreateCallRequest( +# to=[ +# { +# 'type': 'websocket', +# 'uri': 'wss://example.com/websocket', +# 'content_type': 'audio/l16;rate=8000', +# 'headers': {'key': 'value'}, +# } +# ], +# answer_url=['https://example.com/answer'], +# random_from_number=True, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_create_call_answer_url_options(): +# build_response( +# path, 'POST', 'https://api.nexmo.com/v1/calls', 'create_call.json', 201 +# ) +# call = CreateCallRequest( +# to=[{'type': 'vbc', 'extension': '1234'}], +# answer_url=['https://example.com/answer'], +# answer_method='GET', +# random_from_number=True, +# event_url=['https://example.com/event'], +# event_method='POST', +# advanced_machine_detection={ +# 'behavior': 'hangup', +# 'mode': 'detect_beep', +# 'beep_timeout': 50, +# }, +# length_timer=60, +# ringing_timer=30, +# ) +# response = voice.create_call(call) + +# assert type(response) == CreateCallResponse +# assert response.uuid == '106a581a-34d0-432a-a625-220221fd434f' +# assert response.status == 'started' +# assert response.direction == 'outbound' +# assert response.conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# def test_create_call_ncco_and_answer_url_error(): +# with raises(VoiceError) as e: +# CreateCallRequest( +# to=[{'type': 'phone', 'number': '1234567890'}], +# random_from_number=True, +# ) +# assert e.match('Either `ncco` or `answer_url` must be set') + +# with raises(VoiceError) as e: +# CreateCallRequest( +# ncco=[Talk(text='Hello world')], +# answer_url=['https://example.com/answer'], +# to=[{'type': 'phone', 'number': '1234567890'}], +# random_from_number=True, +# ) +# assert e.match('`ncco` and `answer_url` cannot be used together') + + +# def test_create_call_from_and_random_from_number_error(): +# with raises(VoiceError) as e: +# CreateCallRequest( +# ncco=[Talk(text='Hello world')], +# to=[{'type': 'phone', 'number': '1234567890'}], +# ) +# assert e.match('Either `from_` or `random_from_number` must be set') + +# with raises(VoiceError) as e: +# CreateCallRequest( +# ncco=[Talk(text='Hello world')], +# to=[{'type': 'phone', 'number': '1234567890'}], +# from_={'number': '9876543210', 'type': 'phone'}, +# random_from_number=True, +# ) +# assert e.match('`from_` and `random_from_number` cannot be used together') + + +# @responses.activate +# def test_list_calls(): +# build_response(path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls.json', 200) +# calls, _ = voice.list_calls() +# assert len(calls) == 3 +# assert calls[0].to.number == '1234567890' +# assert calls[0].from_.number == '9876543210' +# assert calls[0].uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' +# assert calls[1].direction == 'outbound' +# assert calls[1].status == 'completed' +# assert calls[2].conversation_uuid == 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044' + + +# @responses.activate +# def test_list_calls_filter(): +# build_response( +# path, 'GET', 'https://api.nexmo.com/v1/calls', 'list_calls_filter.json', 200 +# ) +# filter = ListCallsFilter( +# status='completed', +# date_start='2024-03-14T07:45:14Z', +# date_end='2024-04-19T08:45:14Z', +# page_size=10, +# record_index=0, +# order='asc', +# conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +# ) +# filter_dict = { +# 'status': 'completed', +# 'date_start': '2024-03-14T07:45:14Z', +# 'date_end': '2024-04-19T08:45:14Z', +# 'page_size': 10, +# 'record_index': 0, +# 'order': 'asc', +# 'conversation_uuid': 'CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +# } +# assert filter.model_dump(by_alias=True, exclude_none=True) == filter_dict + +# calls, next_record_index = voice.list_calls(filter) +# assert len(calls) == 1 +# assert calls[0].to.number == '1234567890' +# assert next_record_index == 2 + + +# @responses.activate +# def test_get_call(): +# build_response( +# path, +# 'GET', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# 'get_call.json', +# 200, +# ) +# call = voice.get_call('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert call.to.number == '1234567890' +# assert call.from_.number == '9876543210' +# assert call.uuid == 'e154eb57-2962-41e7-baf4-90f63e25e439' +# assert call.link == '/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439' + + +# @responses.activate +# def test_transfer_call_ncco(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# ) + +# ncco = [Talk(text='Hello world')] +# voice.transfer_call_ncco('e154eb57-2962-41e7-baf4-90f63e25e439', ncco) +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_transfer_call_answer_url(): +# answer_url = 'https://example.com/answer' +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[ +# json_params_matcher( +# { +# 'action': 'transfer', +# 'destination': {'type': 'ncco', 'url': [answer_url]}, +# }, +# ), +# ], +# ) + +# voice.transfer_call_answer_url('e154eb57-2962-41e7-baf4-90f63e25e439', answer_url) +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_hangup(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'hangup'})], +# ) + +# voice.hangup('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_mute(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'mute'})], +# ) + +# voice.mute('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_unmute(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'unmute'})], +# ) + +# voice.unmute('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_earmuff(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'earmuff'})], +# ) + +# voice.earmuff('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_unearmuff(): +# build_response( +# path, +# 'PUT', +# 'https://api.nexmo.com/v1/calls/e154eb57-2962-41e7-baf4-90f63e25e439', +# status_code=204, +# match=[json_params_matcher({'action': 'unearmuff'})], +# ) + +# voice.unearmuff('e154eb57-2962-41e7-baf4-90f63e25e439') +# assert voice._http_client.last_response.status_code == 204 + + +# @responses.activate +# def test_play_audio_into_call(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'PUT', +# f'https://api.nexmo.com/v1/calls/{uuid}/stream', +# 'play_audio_into_call.json', +# ) + +# options = AudioStreamOptions( +# stream_url=['https://example.com/audio'], loop=2, level=0.5 +# ) +# response = voice.play_audio_into_call(uuid, options) +# assert response.message == 'Stream started' +# assert response.uuid == uuid + + +# @responses.activate +# def test_stop_audio_stream(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'DELETE', +# f'https://api.nexmo.com/v1/calls/{uuid}/stream', +# 'stop_audio_stream.json', +# ) + +# response = voice.stop_audio_stream(uuid) +# assert response.message == 'Stream stopped' +# assert response.uuid == uuid + + +# @responses.activate +# def test_play_tts_into_call(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'PUT', +# f'https://api.nexmo.com/v1/calls/{uuid}/talk', +# 'play_tts_into_call.json', +# ) + +# options = TtsStreamOptions( +# text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +# ) +# response = voice.play_tts_into_call(uuid, options) +# assert response.message == 'Talk started' +# assert response.uuid == uuid + + +# @responses.activate +# def test_stop_tts(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'DELETE', +# f'https://api.nexmo.com/v1/calls/{uuid}/talk', +# 'stop_tts.json', +# ) + +# response = voice.stop_tts(uuid) +# assert response.message == 'Talk stopped' +# assert response.uuid == uuid + + +# @responses.activate +# def test_play_dtmf_into_call(): +# uuid = 'e154eb57-2962-41e7-baf4-90f63e25e439' +# build_response( +# path, +# 'PUT', +# f'https://api.nexmo.com/v1/calls/{uuid}/dtmf', +# 'play_dtmf_into_call.json', +# ) + +# response = voice.play_dtmf_into_call(uuid, dtmf='1234*#') +# assert response.message == 'DTMF sent' +# assert response.uuid == uuid From 8aef1183a4b6351060512a0aa651c577e77eb6e4 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 24 Sep 2024 22:22:07 +0100 Subject: [PATCH 65/98] test streaming methods, add signaling --- http_client/src/vonage_http_client/auth.py | 7 +- http_client/tests/test_http_client.py | 1 + jwt/src/vonage_jwt/jwt.py | 16 +++- video/src/vonage_video/models/session.py | 1 + video/src/vonage_video/models/signal.py | 8 ++ video/src/vonage_video/models/stream.py | 2 +- video/src/vonage_video/models/token.py | 6 +- video/src/vonage_video/video.py | 76 +++++++----------- video/tests/data/change_stream_layout.json | 13 ++++ video/tests/data/get_stream.json | 6 ++ video/tests/data/list_streams.json | 11 +++ video/tests/test_session.py | 2 + video/tests/test_stream.py | 90 ++++++++++++++++++++++ video/tests/test_token.py | 2 - 14 files changed, 180 insertions(+), 61 deletions(-) create mode 100644 video/tests/data/change_stream_layout.json create mode 100644 video/tests/data/get_stream.json create mode 100644 video/tests/data/list_streams.json create mode 100644 video/tests/test_stream.py diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 64d52656..15a05a14 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -68,9 +68,12 @@ def application_id(self): def create_jwt_auth_string(self): return b'Bearer ' + self.generate_application_jwt() - def generate_application_jwt(self, claims: dict = {}) -> bytes: + def generate_application_jwt(self, claims: dict = None) -> bytes: + if claims is None: + claims = {} try: - return self._jwt_client.generate_application_jwt(claims) + 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".' diff --git a/http_client/tests/test_http_client.py b/http_client/tests/test_http_client.py index 3bd4d7fa..f871fa8c 100644 --- a/http_client/tests/test_http_client.py +++ b/http_client/tests/test_http_client.py @@ -41,6 +41,7 @@ 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, diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py index a13a5bec..b3c66712 100644 --- a/jwt/src/vonage_jwt/jwt.py +++ b/jwt/src/vonage_jwt/jwt.py @@ -24,20 +24,28 @@ def __init__(self, application_id: str, private_key: str): 'Both of "application_id" and "private_key" are required.' ) - def generate_application_jwt(self, jwt_options: dict = {}) -> bytes: + 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 = jwt_options payload["application_id"] = self._application_id - payload.setdefault("iat", iat) - payload.setdefault("jti", str(uuid4())) - payload.setdefault("exp", iat + (15 * 60)) + 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'} diff --git a/video/src/vonage_video/models/session.py b/video/src/vonage_video/models/session.py index c4f519ff..5729ef25 100644 --- a/video/src/vonage_video/models/session.py +++ b/video/src/vonage_video/models/session.py @@ -45,3 +45,4 @@ class VideoSession(BaseModel): 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 index e69de29b..c39bf637 100644 --- a/video/src/vonage_video/models/signal.py +++ b/video/src/vonage_video/models/signal.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field + + +class SignalData(BaseModel): + """The data to send in a signal.""" + + type: str + data: str = Field(None, max_length=8192) diff --git a/video/src/vonage_video/models/stream.py b/video/src/vonage_video/models/stream.py index 0483a69e..436ec480 100644 --- a/video/src/vonage_video/models/stream.py +++ b/video/src/vonage_video/models/stream.py @@ -14,7 +14,7 @@ class StreamInfo(BaseModel): class StreamLayout(BaseModel): id: str - layout_class_list: List[str] = Field(..., validation_alias='layoutClassList') + layout_class_list: List[str] = Field(..., serialization_alias='layoutClassList') class StreamLayoutOptions(BaseModel): diff --git a/video/src/vonage_video/models/token.py b/video/src/vonage_video/models/token.py index a9b48a58..c5b641f4 100644 --- a/video/src/vonage_video/models/token.py +++ b/video/src/vonage_video/models/token.py @@ -2,7 +2,7 @@ from typing import List, Literal, Optional from uuid import uuid4 -from pydantic import BaseModel, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from ..errors import TokenExpiryError from .enums import TokenRole @@ -29,8 +29,8 @@ class TokenOptions(BaseModel): connection_data: Optional[str] = None initial_layout_class_list: Optional[List[str]] = None exp: Optional[int] = None - jti: str = str(uuid4()) - iat: int = int(time()) + 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/**': {}}} diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 79e20275..17f4c101 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -3,6 +3,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient from vonage_video.models.session import SessionOptions, VideoSession +from vonage_video.models.signal import SignalData from vonage_video.models.stream import StreamInfo, StreamLayoutOptions from vonage_video.models.token import TokenOptions @@ -44,7 +45,7 @@ def create_session(self, options: SessionOptions = None) -> VideoSession: options (SessionOptions): The options for the session. Returns: - VideoSession: The new session. + VideoSession: The new session ID, plus the config options specified in `options`. """ response = self._http_client.post( @@ -61,25 +62,6 @@ def create_session(self, options: SessionOptions = None) -> VideoSession: return VideoSession(**session_response) - @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 list_streams(self, session_id: str) -> List[StreamInfo]: """Lists the streams in a session from the Vonage Video API. @@ -99,22 +81,6 @@ def list_streams(self, session_id: str) -> List[StreamInfo]: return [StreamInfo(**stream) for stream in response['items']] @validate_call - def change_stream_layout( - self, session_id: str, stream_layout_options: StreamLayoutOptions - ) -> None: - """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. - """ - - 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), - ) - def get_stream(self, session_id: str, stream_id: str) -> StreamInfo: """Gets a stream from the Vonage Video API. @@ -133,35 +99,47 @@ def get_stream(self, session_id: str, stream_id: str) -> StreamInfo: return StreamInfo(**response) - def list_streams(self, session_id: str) -> List[StreamInfo]: - """Lists the streams in a session from the Vonage Video API. + @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.get( + 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']] - def change_stream_layout( - self, session_id: str, stream_layout_options: StreamLayoutOptions + @validate_call + def send_signal( + self, session_id: str, data: SignalData, connection_id: str = None ) -> None: - """Changes the layout of a stream in a session in the Vonage Video API. + """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. - stream_layout_options (StreamLayoutOptions): The options for the stream layout. + data (SignalData): The data to send in the signal. + connection_id (str, Optional): The connection ID to send the signal to. """ - - 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), - ) + 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) + ) 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/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/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/test_session.py b/video/tests/test_session.py index bee10cfb..9050379a 100644 --- a/video/tests/test_session.py +++ b/video/tests/test_session.py @@ -55,6 +55,7 @@ def test_create_session(): 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, @@ -78,3 +79,4 @@ def test_create_session(): 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_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 index de2c0f37..0bf3d912 100644 --- a/video/tests/test_token.py +++ b/video/tests/test_token.py @@ -57,8 +57,6 @@ def test_generate_token(): ) ) - print(token) - assert ( token == b'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoic2Vzc2lvbi1pZCIsInJvbGUiOiJwdWJsaXNoZXIiLCJjb25uZWN0aW9uX2RhdGEiOiJjb25uZWN0aW9uLWRhdGEiLCJpbml0aWFsX2xheW91dF9jbGFzc19saXN0IjpbImZvY3VzIl0sImV4cCI6MTIzNDU3Njg5LCJqdGkiOiI0Y2FiODljYS1iNjM3LTQxYzgtYjYyZi03YjljZTEwYzM5NzEiLCJpYXQiOjEyMzQ1Njc4OSwic3ViamVjdCI6InZpZGVvIiwic2NvcGUiOiJzZXNzaW9uLmNvbm5lY3QiLCJhY2wiOnsicGF0aHMiOnsiL3Nlc3Npb24vKioiOnt9fX0sImFwcGxpY2F0aW9uX2lkIjoidGVzdF9hcHBsaWNhdGlvbl9pZCJ9.DL-b9AJxZIKb0gmc_NGrD8fvIpg_ILX5FBMXpR56CgSdI63wS04VuaAKCTRojSJrqpzENv_GLR2HYY4-d1Qm1pyj1tM1yFRDk8z_vun30DWavYkCFW1T5FenK1VUjg0P9pbdGiPvq0Ku-taMuLyqXzQqHsbEGOovo-JMIag6wD6JPrPIKaYXsqGpXYaJ_BCcuIpg0NquQgJXA004Q415CxguCkQLdv0d7xTyfPw44Sj-_JfRdBdqDjyiDsmYmh7Yt5TrqRqZ1SwxNhNP7MSx8KDake3VqkQB9Iyys43MJBHZtRDrtE6VedLt80RpCz9Yo8F8CIjStwQPOfMjbV-iEA' From 29a1e49dbc146619e760c3898b9939fb95fbeb2f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 25 Sep 2024 18:51:35 +0100 Subject: [PATCH 66/98] add signaling and moderation endpoints, start live captions --- video/src/vonage_video/models/captions.py | 31 ++++++++ video/src/vonage_video/models/enums.py | 16 ++++ video/src/vonage_video/models/signal.py | 4 +- video/src/vonage_video/video.py | 94 +++++++++++++++++++++-- video/tests/test_captions.py | 26 +++++++ video/tests/test_moderation.py | 70 +++++++++++++++++ video/tests/test_signal.py | 47 ++++++++++++ 7 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 video/src/vonage_video/models/captions.py create mode 100644 video/tests/test_captions.py create mode 100644 video/tests/test_moderation.py create mode 100644 video/tests/test_signal.py diff --git a/video/src/vonage_video/models/captions.py b/video/src/vonage_video/models/captions.py new file mode 100644 index 00000000..3dd4ae77 --- /dev/null +++ b/video/src/vonage_video/models/captions.py @@ -0,0 +1,31 @@ +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): The token. + 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, le=300, ge=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' + ) diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index f1199e53..7abb6659 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -21,3 +21,19 @@ class MediaMode(str, Enum): class P2pPreference(str, Enum): 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' diff --git a/video/src/vonage_video/models/signal.py b/video/src/vonage_video/models/signal.py index c39bf637..7edafda3 100644 --- a/video/src/vonage_video/models/signal.py +++ b/video/src/vonage_video/models/signal.py @@ -4,5 +4,5 @@ class SignalData(BaseModel): """The data to send in a signal.""" - type: str - data: str = Field(None, max_length=8192) + type: str = Field(..., max_length=128) + data: str = Field(..., max_length=8192) diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 17f4c101..2a55943a 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -2,6 +2,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_video.models.captions import CaptionsOptions from vonage_video.models.session import SessionOptions, VideoSession from vonage_video.models.signal import SignalData from vonage_video.models.stream import StreamInfo, StreamLayoutOptions @@ -131,15 +132,94 @@ def send_signal( 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. + 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', - ) + 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) - ) + 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 enable_captions(self, options: CaptionsOptions) -> str: + """Enables captions in a session using the Vonage Video API. + + Args: + options (CaptionsOptions): Options for the captions. + + Returns: + str: The captions stream 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), + ) + + return response['captionsId'] diff --git a/video/tests/test_captions.py b/video/tests/test_captions.py new file mode 100644 index 00000000..ded1a130 --- /dev/null +++ b/video/tests/test_captions.py @@ -0,0 +1,26 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.captions import CaptionsOptions +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_start_captions(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/captions', + 'start_captions.json', + 202, + ) + + options = CaptionsOptions() 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_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 From e5c31e198b14dbea0c0577710dde6ba2d6657b58 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 26 Sep 2024 18:40:52 +0100 Subject: [PATCH 67/98] add captioning, start audio connector --- .../vonage_video/models/audio_connector.py | 41 ++++++++++ video/src/vonage_video/models/captions.py | 14 +++- video/src/vonage_video/models/enums.py | 5 ++ video/src/vonage_video/models/session.py | 2 + video/src/vonage_video/models/stream.py | 18 +++++ video/src/vonage_video/video.py | 40 ++++++++-- .../data/captions_error_already_enabled.json | 5 ++ video/tests/data/start_captions.json | 3 + video/tests/test_captions.py | 78 ++++++++++++++++++- 9 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 video/src/vonage_video/models/audio_connector.py create mode 100644 video/tests/data/captions_error_already_enabled.json create mode 100644 video/tests/data/start_captions.json 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..469bedc3 --- /dev/null +++ b/video/src/vonage_video/models/audio_connector.py @@ -0,0 +1,41 @@ +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): The streams. + headers (dict): The headers. + audio_rate (AudioSampleRate): The audio sample rate. + """ + + uri: str + streams: Optional[list] = None + headers: Optional[dict] = None + audio_rate: Optional[AudioSampleRate] = Field(None, serialization_alias='audioRate') + + +class AudioConnectorOptions(BaseModel): + """Options for the audio connector. + + Args: + session_id (str): The session ID. + token (str): The token. + websocket (AudioConnectorWebsocket): The audio connector websocket. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + token: str + websocket: AudioConnectorWebsocket + + +class AudioConnectorData(BaseModel): + """Class containing audio connector ID and audio captioning session ID.""" + + id: Optional[str] = None + captions_id: Optional[str] = Field(None, serialization_alias='captionsId') diff --git a/video/src/vonage_video/models/captions.py b/video/src/vonage_video/models/captions.py index 3dd4ae77..483da18b 100644 --- a/video/src/vonage_video/models/captions.py +++ b/video/src/vonage_video/models/captions.py @@ -10,7 +10,7 @@ class CaptionsOptions(BaseModel): Args: session_id (str): The session ID. - token (str): The token. + 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. @@ -23,9 +23,19 @@ class CaptionsOptions(BaseModel): None, serialization_alias='languageCode' ) max_duration: Optional[int] = Field( - None, le=300, ge=14400, serialization_alias='maxDuration' + 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/enums.py b/video/src/vonage_video/models/enums.py index 7abb6659..3326828a 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -37,3 +37,8 @@ class LanguageCode(str, Enum): KO_KR = 'ko-KR' PT_BR = 'pt-BR' TH_TH = 'th-TH' + + +class AudioSampleRate(str, Enum): + KHZ_8 = 8000 + KHZ_16 = 16000 diff --git a/video/src/vonage_video/models/session.py b/video/src/vonage_video/models/session.py index 5729ef25..356dd718 100644 --- a/video/src/vonage_video/models/session.py +++ b/video/src/vonage_video/models/session.py @@ -41,6 +41,8 @@ def set_p2p_preference_if_archive_mode_set(self): class VideoSession(BaseModel): + """The new session ID and options specified in the request.""" + session_id: str archive_mode: Optional[ArchiveMode] = None media_mode: Optional[MediaMode] = None diff --git a/video/src/vonage_video/models/stream.py b/video/src/vonage_video/models/stream.py index 436ec480..a3135201 100644 --- a/video/src/vonage_video/models/stream.py +++ b/video/src/vonage_video/models/stream.py @@ -4,6 +4,15 @@ class StreamInfo(BaseModel): + """The stream information. + + Args: + id (str): The stream ID. + video_type (str): The video type. + name (str): The name. + layout_class_list (list(str)): The layout class list. + """ + 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') @@ -13,9 +22,18 @@ class StreamInfo(BaseModel): class StreamLayout(BaseModel): + """The stream layout. + + Args: + id (str): The stream ID. + layout_class_list (list): The layout class list. + """ + id: str layout_class_list: List[str] = Field(..., serialization_alias='layoutClassList') class StreamLayoutOptions(BaseModel): + """The options for the stream layout.""" + items: List[StreamLayout] diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 2a55943a..81369c26 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -2,7 +2,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient -from vonage_video.models.captions import CaptionsOptions +from vonage_video.models.captions import CaptionsData, CaptionsOptions from vonage_video.models.session import SessionOptions, VideoSession from vonage_video.models.signal import SignalData from vonage_video.models.stream import StreamInfo, StreamLayoutOptions @@ -207,19 +207,49 @@ def _toggle_mute_all_streams(self, session_id: str, params: dict) -> None: ) @validate_call - def enable_captions(self, options: CaptionsOptions) -> str: + 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: - str: The captions stream ID. + 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), + options.model_dump(exclude_none=True, by_alias=True), ) - return response['captionsId'] + 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. + + 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(audio_connector_id=response['audioConnectorId']) 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/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/test_captions.py b/video/tests/test_captions.py index ded1a130..875eb5b1 100644 --- a/video/tests/test_captions.py +++ b/video/tests/test_captions.py @@ -1,8 +1,12 @@ from os.path import abspath import responses +from pytest import raises from vonage_http_client import HttpClient -from vonage_video.models.captions import CaptionsOptions +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 @@ -13,6 +17,26 @@ 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( @@ -23,4 +47,54 @@ def test_start_captions(): 202, ) - options = CaptionsOptions() + 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 From 0aa1ea1144c149aa1163622c756350504c5c0345 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 27 Sep 2024 17:27:21 +0100 Subject: [PATCH 68/98] finish adding audio connector --- README.md | 1 + video/src/vonage_video/models/__init__.py | 33 ++++++++- .../vonage_video/models/audio_connector.py | 20 +++--- video/src/vonage_video/models/enums.py | 2 +- video/src/vonage_video/video.py | 3 +- video/tests/data/audio_connector.json | 4 ++ video/tests/test_audio_connector.py | 70 +++++++++++++++++++ 7 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 video/tests/data/audio_connector.json create mode 100644 video/tests/test_audio_connector.py diff --git a/README.md b/README.md index 9c5d8684..780929f7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions) [![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +![Total lines](https://sloc.xyz/github/vonage/vonage-python-sdk) This is the Python server SDK for Vonage's API. To use it you'll need a Vonage account. Sign up [for free at vonage.com][signup]. diff --git a/video/src/vonage_video/models/__init__.py b/video/src/vonage_video/models/__init__.py index a63621f2..4fe171f2 100644 --- a/video/src/vonage_video/models/__init__.py +++ b/video/src/vonage_video/models/__init__.py @@ -1,12 +1,39 @@ -from .enums import ArchiveMode, MediaMode, TokenRole +from .audio_connector import ( + AudioConnectorData, + AudioConnectorOptions, + AudioConnectorWebSocket, +) +from .captions import CaptionsData, CaptionsOptions +from .enums import ( + ArchiveMode, + AudioSampleRate, + LanguageCode, + MediaMode, + P2pPreference, + TokenRole, +) from .session import SessionOptions, VideoSession +from .signal import SignalData +from .stream import StreamInfo, StreamLayout, StreamLayoutOptions from .token import TokenOptions __all__ = [ - 'MediaMode', + 'AudioConnectorData', + 'AudioConnectorOptions', + 'AudioConnectorWebSocket', + 'CaptionsData', + 'CaptionsOptions', 'ArchiveMode', + 'MediaMode', 'TokenRole', - 'TokenOptions', + 'P2pPreference', + 'LanguageCode', + 'AudioSampleRate', 'SessionOptions', 'VideoSession', + 'SignalData', + 'StreamInfo', + 'StreamLayoutOptions', + 'StreamLayout', + 'TokenOptions', ] diff --git a/video/src/vonage_video/models/audio_connector.py b/video/src/vonage_video/models/audio_connector.py index 469bedc3..ff272bc0 100644 --- a/video/src/vonage_video/models/audio_connector.py +++ b/video/src/vonage_video/models/audio_connector.py @@ -1,21 +1,21 @@ -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field from vonage_video.models.enums import AudioSampleRate -class AudioConnectorWebsocket(BaseModel): +class AudioConnectorWebSocket(BaseModel): """The audio connector websocket options. Args: uri (str): The URI. - streams (list): The streams. - headers (dict): The headers. - audio_rate (AudioSampleRate): The audio sample rate. + streams (List[str]): Stream IDs to include. If not provided, all streams are included. + headers (dict): The headers to send to your WebSocket server. + audio_rate (AudioSampleRate): The audio sample rate in Hertz. """ uri: str - streams: Optional[list] = None + streams: Optional[List[str]] = None headers: Optional[dict] = None audio_rate: Optional[AudioSampleRate] = Field(None, serialization_alias='audioRate') @@ -26,16 +26,16 @@ class AudioConnectorOptions(BaseModel): Args: session_id (str): The session ID. token (str): The token. - websocket (AudioConnectorWebsocket): The audio connector websocket. + websocket (AudioConnectorWebSocket): The audio connector websocket. """ session_id: str = Field(..., serialization_alias='sessionId') token: str - websocket: AudioConnectorWebsocket + websocket: AudioConnectorWebSocket class AudioConnectorData(BaseModel): - """Class containing audio connector ID and audio captioning session ID.""" + """Class containing Audio Connector WebSocket ID and connection ID.""" id: Optional[str] = None - captions_id: Optional[str] = Field(None, serialization_alias='captionsId') + connection_id: Optional[str] = Field(None, validation_alias='connectionId') diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index 3326828a..6a0760ed 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -39,6 +39,6 @@ class LanguageCode(str, Enum): TH_TH = 'th-TH' -class AudioSampleRate(str, Enum): +class AudioSampleRate(int, Enum): KHZ_8 = 8000 KHZ_16 = 16000 diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 81369c26..500afe09 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -2,6 +2,7 @@ from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_video.models.audio_connector import AudioConnectorData, AudioConnectorOptions from vonage_video.models.captions import CaptionsData, CaptionsOptions from vonage_video.models.session import SessionOptions, VideoSession from vonage_video.models.signal import SignalData @@ -252,4 +253,4 @@ def start_audio_connector(self, options: AudioConnectorOptions) -> AudioConnecto options.model_dump(exclude_none=True, by_alias=True), ) - return AudioConnectorData(audio_connector_id=response['audioConnectorId']) + return AudioConnectorData(**response) 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/test_audio_connector.py b/video/tests/test_audio_connector.py new file mode 100644 index 00000000..0c9cce6b --- /dev/null +++ b/video/tests/test_audio_connector.py @@ -0,0 +1,70 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient +from vonage_video.models.audio_connector import ( + AudioConnectorOptions, + AudioConnectorWebSocket, +) +from vonage_video.models.enums import AudioSampleRate, TokenRole +from vonage_video.models.token import TokenOptions +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +video = Video(HttpClient(get_mock_jwt_auth())) + + +def test_audio_connector_options_model(): + options = AudioConnectorOptions( + session_id='test_session_id', + token='test_token', + websocket=AudioConnectorWebSocket( + uri='test_uri', + streams=['test_stream_id'], + headers={'test_header': 'test_value'}, + audio_rate=AudioSampleRate.KHZ_16, + ), + ) + + assert options.model_dump(by_alias=True) == { + 'sessionId': 'test_session_id', + 'token': 'test_token', + 'websocket': { + 'uri': 'test_uri', + 'streams': ['test_stream_id'], + 'headers': {'test_header': 'test_value'}, + 'audioRate': 16000, + }, + } + + +@responses.activate +def test_start_audio_connector(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/connect', + 'audio_connector.json', + 200, + ) + + session_id = 'test_session_id' + options = AudioConnectorOptions( + session_id=session_id, + token=video.generate_client_token( + TokenOptions(session_id=session_id, role=TokenRole.MODERATOR) + ), + websocket=AudioConnectorWebSocket( + uri='wss://example.com/ws', + audio_rate=AudioSampleRate.KHZ_16, + ), + ) + + audio_connector = video.start_audio_connector(options) + + assert audio_connector.id == 'b3cd31f4-020e-4ba3-9a2a-12d98b8a184f' + assert audio_connector.connection_id == '1bf530df-97f4-4437-b6c9-2a66200200c8' From 68b2b419a6a5b19f460565c86f4c1ef97b4b4776 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 30 Sep 2024 23:37:51 +0100 Subject: [PATCH 69/98] start adding experience composer --- video/CHANGES.md | 3 +- video/README.md | 141 +++++++----------- video/src/vonage_video/models/enums.py | 16 ++ .../models/experience_composer.py | 69 +++++++++ video/src/vonage_video/video.py | 25 ++++ 5 files changed, 166 insertions(+), 88 deletions(-) create mode 100644 video/src/vonage_video/models/experience_composer.py diff --git a/video/CHANGES.md b/video/CHANGES.md index a376cb52..d9f7c34c 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,2 +1,3 @@ # 1.0.0 -- Initial upload \ No newline at end of file + +- Initial upload diff --git a/video/README.md b/video/README.md index 8c98bd83..1d4bccd8 100644 --- a/video/README.md +++ b/video/README.md @@ -1,148 +1,115 @@ -# Vonage Video Package +# Vonage Video API - - - - -This package contains the code to use [Vonage's Voice API](https://developer.vonage.com/en/voice/voice-api/overview) in Python. This package includes methods for working with the Voice API. It also contains an NCCO (Call Control Object) builder to help you to control call flow. - -## Structure - -There is a `Voice` class which contains the methods used to call Vonage APIs. To call many of the APIs, you need to pass a Pydantic model with the required options. These can be accessed from the `vonage_voice.models` subpackage. Errors can be accessed from the `vonage_voice.errors` module. +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`, like so: +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`. + +### Generate a Client Token ```python -from vonage import Vonage, Auth +from vonage_video.models.token import TokenOptions -vonage_client = Vonage(Auth('MY_AUTH_INFO')) +token_options = TokenOptions(session_id='your_session_id', role='publisher') +client_token = vonage_client.video.generate_client_token(token_options) ``` -### 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. +### Create a Session ```python -from vonage_voice.models import CreateCallRequest, Talk - -ncco = [Talk(text='Hello world', loop=3, language='en-GB')] - -call = CreateCallRequest( - to=[{'type': 'phone', 'number': '1234567890'}], - ncco=ncco, - random_from_number=True, -) +from vonage_video.models.session import SessionOptions -response = vonage_client.voice.create_call(call) -print(response.model_dump()) +session_options = SessionOptions(media_mode='routed') +video_session = vonage_client.video.create_session(session_options) ``` -### List Calls +### List Streams ```python -# Gets the first 100 results and the record_index of the -# next page if there's more than 100 -calls, next_record_index = vonage_client.voice.list_calls() - -# Specify filtering options -from vonage_voice.models import ListCallsFilter - -call_filter = ListCallsFilter( - status='completed', - date_start='2024-03-14T07:45:14Z', - date_end='2024-04-19T08:45:14Z', - page_size=10, - record_index=0, - order='asc', - conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', -) - -calls, next_record_index = vonage_client.voice.list_calls(call_filter) +streams = vonage_client.video.list_streams(session_id='your_session_id') ``` -### Get Information About a Specific Call +### Get a Stream ```python -call = vonage_client.voice.get_call('CALL_ID') +stream_info = vonage_client.video.get_stream(session_id='your_session_id', stream_id='your_stream_id') ``` -### Transfer a Call to a New NCCO +### Change Stream Layout ```python -ncco = [Talk(text='Hello world')] -vonage_client.voice.transfer_call_ncco('UUID', ncco) +from vonage_video.models.stream 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) ``` -### Transfer a Call to a New Answer URL +### Send a Signal ```python -vonage_client.voice.transfer_call_answer_url('UUID', 'ANSWER_URL') -``` +from vonage_video.models.signal import SignalData -### Hang Up a Call +signal_data = SignalData(type='chat', data='Hello, World!') +vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) +``` -End the call for a specified UUID, removing them from it. +### Disconnect a Client ```python -vonage_client.voice.hangup('UUID') +vonage_client.video.disconnect_client(session_id='your_session_id', connection_id='your_connection_id') ``` -### Mute/Unmute a Participant +### Mute a Stream ```python -vonage_client.voice.mute('UUID') -vonage_client.voice.unmute('UUID') +vonage_client.video.mute_stream(session_id='your_session_id', stream_id='your_stream_id') ``` -### Earmuff/Unearmuff a UUID - -Prevent/allow a specified UUID participant to be able to hear audio. +### Mute All Streams ```python -vonage_client.voice.earmuff('UUID') -vonage_client.voice.unearmuff('UUID') +vonage_client.video.mute_all_streams(session_id='your_session_id', excluded_stream_ids=['stream_id_1', 'stream_id_2']) ``` -### Play Audio Into a Call +### Disable Mute All Streams ```python -from vonage_voice.models import AudioStreamOptions - -# Only the `stream_url` option is required -options = AudioStreamOptions( - stream_url=['https://example.com/audio'], loop=2, level=0.5 -) -response = vonage_client.voice.play_audio_into_call('UUID', options) +vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ``` -### Stop Playing Audio Into a Call +### Start Captions ```python -vonage_client.voice.stop_audio_stream('UUID') +from vonage_video.models.captions import CaptionsOptions + +captions_options = CaptionsOptions(language='en-US') +captions_data = vonage_client.video.start_captions(captions_options) ``` -### Play TTS Into a Call +### Stop Captions ```python -from vonage_voice.models import TtsStreamOptions +from vonage_video.models.captions import CaptionsData -# 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) +captions_data = CaptionsData(captions_id='your_captions_id') +vonage_client.video.stop_captions(captions_data) ``` -### Stop Playing TTS Into a Call +### Start Audio Connector ```python -vonage_client.voice.stop_tts('UUID') +from vonage_video.models.audio_connector 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) ``` -### Play DTMF Tones Into a Call +### Start Experience Composer ```python -response = voice.play_dtmf_into_call('UUID', '1234*#') -``` \ No newline at end of file +from vonage_video.models.experience_composer 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) +``` diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index 6a0760ed..ede91e85 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -42,3 +42,19 @@ class LanguageCode(str, Enum): class AudioSampleRate(int, Enum): KHZ_8 = 8000 KHZ_16 = 16000 + + +class ExperienceComposerResolution(str, Enum): + 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' 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..9f35f131 --- /dev/null +++ b/video/src/vonage_video/models/experience_composer.py @@ -0,0 +1,69 @@ +from typing import Optional + +from pydantic import BaseModel, Field +from vonage_video.models.enums import ( + ExperienceComposerResolution, + ExperienceComposerStatus, +) + + +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(None, min_length=15, max_length=2048) + max_duration: Optional[int] = Field( + None, ge=60, le=36000, serialization_alias='maxDuration' + ) + resolution: Optional[ExperienceComposerResolution] = 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. + """ + + 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[ExperienceComposerResolution] = None + status: Optional[ExperienceComposerStatus] = None + stream_id: Optional[str] = Field(None, validation_alias='streamId') diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 500afe09..ee88155c 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -4,6 +4,10 @@ from vonage_http_client.http_client import HttpClient from vonage_video.models.audio_connector import AudioConnectorData, AudioConnectorOptions from vonage_video.models.captions import CaptionsData, CaptionsOptions +from vonage_video.models.experience_composer import ( + ExperienceComposer, + ExperienceComposerOptions, +) from vonage_video.models.session import SessionOptions, VideoSession from vonage_video.models.signal import SignalData from vonage_video.models.stream import StreamInfo, StreamLayoutOptions @@ -254,3 +258,24 @@ def start_audio_connector(self, options: AudioConnectorOptions) -> AudioConnecto ) 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) From 3123abe1fd2e69934688405a2497e71858cbc00f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 1 Oct 2024 19:33:17 +0100 Subject: [PATCH 70/98] finish adding experience composer, start adding archive models --- video/src/vonage_video/errors.py | 16 ++ video/src/vonage_video/models/archive.py | 182 ++++++++++++++++++ video/src/vonage_video/models/enums.py | 30 ++- .../models/experience_composer.py | 26 ++- video/src/vonage_video/video.py | 86 ++++++++- video/tests/data/get_experience_composer.json | 13 ++ .../tests/data/list_experience_composers.json | 42 ++++ .../tests/data/start_experience_composer.json | 12 ++ video/tests/test_archive.py | 74 +++++++ video/tests/test_experience_composer.py | 133 +++++++++++++ 10 files changed, 602 insertions(+), 12 deletions(-) create mode 100644 video/src/vonage_video/models/archive.py create mode 100644 video/tests/data/get_experience_composer.json create mode 100644 video/tests/data/list_experience_composers.json create mode 100644 video/tests/data/start_experience_composer.json create mode 100644 video/tests/test_archive.py create mode 100644 video/tests/test_experience_composer.py diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py index 68aebb47..be3b8cef 100644 --- a/video/src/vonage_video/errors.py +++ b/video/src/vonage_video/errors.py @@ -15,3 +15,19 @@ class TokenExpiryError(VideoError): 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.""" diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py new file mode 100644 index 00000000..b09e92d8 --- /dev/null +++ b/video/src/vonage_video/models/archive.py @@ -0,0 +1,182 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_video.errors import ( + IndividualArchivePropertyError, + LayoutScreenshareTypeError, + LayoutStylesheetError, + NoAudioOrVideoError, +) +from vonage_video.models.enums import ( + ArchiveStatus, + LayoutType, + OutputMode, + StreamMode, + VideoResolution, +) + + +class ListArchivesFilter(BaseModel): + """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. + """ + + offset: Optional[int] = None + page_size: Optional[int] = Field(1000, serialization_alias='count') + session_id: Optional[str] = None + + +class ArchiveStream(BaseModel): + """Model for a stream in an archive. + + 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 Archive(BaseModel): + """Model for an archive. + + Args: + created_at (int, Optional): The timestamp when the archive when the archive + started recording, expressed in milliseconds since the Unix epoch. + duration (int, Optional): The duration of the archive in seconds. + For archives that have are being recorded, this value is set to 0. + has_audio (bool, Optional): Whether the archive will record audio. + has_video (bool, Optional): Whether the archive will record video. + id (str, Optional): The unique archive ID. + 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. + name (str, Optional): The name of the archive. + application_id (str, Optional): The Vonage application ID. + reason (str, Optional): This is set when the `status` is `stopped` or `failed`. + resolution (VideoResolution, Optional): The resolution of the archive. + session_id (str, Optional): The session ID of the Vonage Video session. + size (int, Optional): The size of the archive. + status (ArchiveStatus, Optional): The status of the archive. + stream_mode (StreamMode, Optional): Whether streams included in the archive + are selected automatically (`auto`, the default) or manually (`manual`). + streams (List[ArchiveStream], 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`. + """ + + created_at: Optional[int] = Field(None, validation_alias='createdAt') + duration: Optional[int] = None + has_audio: Optional[bool] = Field(None, validation_alias='hasAudio') + has_video: Optional[bool] = Field(None, validation_alias='hasVideo') + id: Optional[str] = None + multi_archive_tag: Optional[str] = Field(None, validation_alias='multiArchiveTag') + name: Optional[str] = None + application_id: Optional[str] = Field(None, validation_alias='applicationId') + reason: Optional[str] = None + resolution: Optional[VideoResolution] = None + session_id: Optional[str] = Field(None, validation_alias='sessionId') + size: Optional[int] = None + status: Optional[ArchiveStatus] = None + stream_mode: Optional[StreamMode] = Field(None, validation_alias='streamMode') + streams: Optional[List[ArchiveStream]] = None + url: Optional[str] = None + + +class Layout(BaseModel): + """Model for layout options for an archive. + + Args: + type (str): Specify this to assign the initial layout type for the archive. + 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. + """ + + 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 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"). + """ + + 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') + layout: Optional[Layout] = None + multi_archive_tag: Optional[str] = Field(None, serialization_alias='multiArchiveTag') + name: Optional[str] = None + output_mode: Optional[OutputMode] = Field(None, serialization_alias='outputMode') + resolution: Optional[VideoResolution] = None + stream_mode: Optional[StreamMode] = Field(None, serialization_alias='streamMode') + + @model_validator(mode='after') + def validate_audio_or_video(self): + if self.has_audio is False and self.has_video is False: + raise NoAudioOrVideoError( + 'One of `has_audio` or `has_video` must be included.' + ) + return self + + @model_validator(mode='after') + def no_layout_or_resolution_for_individual_archives(self): + if self.output_mode == OutputMode.INDIVIDUAL and self.resolution is not None: + raise IndividualArchivePropertyError( + 'The `resolution` property cannot be set for `archive_mode: \'individual\'`.' + ) + if self.output_mode == OutputMode.INDIVIDUAL and self.layout is not None: + raise IndividualArchivePropertyError( + 'The `layout` property cannot be set for `archive_mode: \'individual\'`.' + ) + return self diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index ede91e85..52450344 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -44,7 +44,7 @@ class AudioSampleRate(int, Enum): KHZ_16 = 16000 -class ExperienceComposerResolution(str, Enum): +class VideoResolution(str, Enum): RES_640x480 = '640x480' RES_480x640 = '480x640' RES_1280x720 = '1280x720' @@ -58,3 +58,31 @@ class ExperienceComposerStatus(str, Enum): STARTED = 'started' STOPPED = 'stopped' FAILED = 'failed' + + +class OutputMode(str, Enum): + COMPOSED = 'composed' + INDIVIDUAL = 'individual' + + +class StreamMode(str, Enum): + 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' diff --git a/video/src/vonage_video/models/experience_composer.py b/video/src/vonage_video/models/experience_composer.py index 9f35f131..9cad3b4d 100644 --- a/video/src/vonage_video/models/experience_composer.py +++ b/video/src/vonage_video/models/experience_composer.py @@ -1,10 +1,7 @@ from typing import Optional from pydantic import BaseModel, Field -from vonage_video.models.enums import ( - ExperienceComposerResolution, - ExperienceComposerStatus, -) +from vonage_video.models.enums import ExperienceComposerStatus, VideoResolution class ExperienceComposerProperties(BaseModel): @@ -31,11 +28,11 @@ class ExperienceComposerOptions(BaseModel): session_id: str = Field(..., serialization_alias='sessionId') token: str - url: str = Field(None, min_length=15, max_length=2048) + url: str = Field(..., min_length=15, max_length=2048) max_duration: Optional[int] = Field( None, ge=60, le=36000, serialization_alias='maxDuration' ) - resolution: Optional[ExperienceComposerResolution] = None + resolution: Optional[VideoResolution] = None properties: Optional[ExperienceComposerProperties] = None @@ -54,6 +51,7 @@ class ExperienceComposer(BaseModel): 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 @@ -64,6 +62,20 @@ class ExperienceComposer(BaseModel): updated_at: Optional[int] = Field(None, validation_alias='updatedAt') name: Optional[str] = None url: Optional[str] = None - resolution: Optional[ExperienceComposerResolution] = 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(1000, serialization_alias='count') diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index ee88155c..062e0515 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -1,12 +1,14 @@ -from typing import List +from typing import List, Optional, Tuple from pydantic import validate_call from vonage_http_client.http_client import HttpClient +from vonage_video.models.archive import Archive, CreateArchiveRequest, ListArchivesFilter from vonage_video.models.audio_connector import AudioConnectorData, AudioConnectorOptions from vonage_video.models.captions import CaptionsData, CaptionsOptions 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 @@ -263,13 +265,13 @@ def start_audio_connector(self, options: AudioConnectorOptions) -> AudioConnecto def start_experience_composer( self, options: ExperienceComposerOptions ) -> ExperienceComposer: - """Starts an experience composer using the Vonage Video API. + """Starts an Experience Composer using the Vonage Video API. Args: - options (ExperienceComposerOptions): Options for the experience composer. + options (ExperienceComposerOptions): Options for the Experience Composer. Returns: - ExperienceComposer: Class containing experience composer data. + ExperienceComposer: Class containing Experience Composer data. """ response = self._http_client.post( @@ -279,3 +281,79 @@ def start_experience_composer( ) return ExperienceComposer(**response) + + @validate_call + def list_experience_composers( + self, filter: ListExperienceComposersFilter = ListExperienceComposersFilter() + ) -> Tuple[List[ExperienceComposer], int, Optional[int]]: + """Lists Experience Composers associated with your Vonage application. + + Args: + filter (ListExperienceComposersFilter): Filter for the Experience Composers. + + Returns: + Tuple[List[ExperienceComposer], int, Optional[int]]: A tuple containing a list of experience + composer objects, the total count of Experience Composers and the required offset value + for the next page, if applicable. + i.e. + experience_composers: List[ExperienceComposer], count: int, next_page_index: 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), + ) + + index = filter.offset + 1 or 1 + page_size = filter.page_size + experience_composers = [] + + try: + for ec in response['items']: + experience_composers.append(ExperienceComposer(**ec)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * (index): + return experience_composers, count, index + return experience_composers, count, None + + @validate_call + def get_experience_composer(self, experience_composer_id: str) -> ExperienceComposer: + """Gets an Experience Composer associated with your Vonage application. + + Args: + experience_composer_id (str): The ID of the Experience Composer. + + Returns: + ExperienceComposer: The Experience Composer object. + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/render/{experience_composer_id}', + ) + + return ExperienceComposer(**response) + + @validate_call + def stop_experience_composer(self, experience_composer_id: str) -> None: + """Stops an Experience Composer associated with your Vonage application. + + Args: + experience_composer_id (str): The ID of the Experience Composer. + """ + self._http_client.delete( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/render/{experience_composer_id}', + ) + + @validate_call + def list_archives( + self, filter: ListArchivesFilter + ) -> Tuple[List[Archive], int, Optional[int]]: + pass + + @validate_call + def create_archive(self, options: CreateArchiveRequest) -> Archive: + pass 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/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/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/test_archive.py b/video/tests/test_archive.py new file mode 100644 index 00000000..08c93dce --- /dev/null +++ b/video/tests/test_archive.py @@ -0,0 +1,74 @@ +from pytest import raises +from vonage_video.errors import ( + IndividualArchivePropertyError, + LayoutScreenshareTypeError, + LayoutStylesheetError, + NoAudioOrVideoError, +) +from vonage_video.models.archive import CreateArchiveRequest, Layout +from vonage_video.models.enums import LayoutType, OutputMode, StreamMode, VideoResolution + + +def test_create_archive_request_valid(): + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + layout=Layout(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=Layout(type=LayoutType.BEST_FIT), + ) + + +def test_layout_custom_without_stylesheet(): + with raises(LayoutStylesheetError): + Layout(type=LayoutType.CUSTOM) + + +def test_layout_best_fit_with_stylesheet(): + with raises(LayoutStylesheetError): + Layout(type=LayoutType.BEST_FIT, stylesheet='http://example.com/stylesheet.css') + + +def test_layout_screenshare_type_without_best_fit(): + with raises(LayoutScreenshareTypeError): + Layout(type=LayoutType.PIP, screenshare_type=LayoutType.BEST_FIT) diff --git a/video/tests/test_experience_composer.py b/video/tests/test_experience_composer.py new file mode 100644 index 00000000..47432bb5 --- /dev/null +++ b/video/tests/test_experience_composer.py @@ -0,0 +1,133 @@ +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 stop_experience_composer(): + build_response( + path, + 'POST', + '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 From 0935d8e6d5105f7c124182a67f6e4f822268279c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 2 Oct 2024 18:34:40 +0100 Subject: [PATCH 71/98] add archive methods and tests --- video/src/vonage_video/errors.py | 4 + video/src/vonage_video/models/archive.py | 94 +++++-- video/src/vonage_video/models/enums.py | 1 + .../models/experience_composer.py | 2 +- video/src/vonage_video/video.py | 175 ++++++++++++- video/tests/data/archive.json | 23 ++ video/tests/data/delete_archive_error.json | 5 + video/tests/data/list_archives.json | 51 ++++ video/tests/data/stop_archive.json | 23 ++ video/tests/data/stop_archive_error.json | 5 + video/tests/test_archive.py | 237 +++++++++++++++++- 11 files changed, 587 insertions(+), 33 deletions(-) create mode 100644 video/tests/data/archive.json create mode 100644 video/tests/data/delete_archive_error.json create mode 100644 video/tests/data/list_archives.json create mode 100644 video/tests/data/stop_archive.json create mode 100644 video/tests/data/stop_archive_error.json diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py index be3b8cef..c9641b67 100644 --- a/video/src/vonage_video/errors.py +++ b/video/src/vonage_video/errors.py @@ -31,3 +31,7 @@ class LayoutStylesheetError(VideoError): 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.""" diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index b09e92d8..36a9bf86 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -26,7 +26,7 @@ class ListArchivesFilter(BaseModel): """ offset: Optional[int] = None - page_size: Optional[int] = Field(1000, serialization_alias='count') + page_size: Optional[int] = Field(100, serialization_alias='count') session_id: Optional[str] = None @@ -44,54 +44,81 @@ class ArchiveStream(BaseModel): has_video: Optional[bool] = Field(None, validation_alias='hasVideo') +class Transcription(BaseModel): + """Model for transcription options for an archive. + + Args: + status (str, Optional): The status of the transcription. + reason (str, Optional): May give a brief reason for the transcription status. + """ + + status: Optional[str] = None + reason: Optional[str] = None + + class Archive(BaseModel): """Model for an archive. Args: + id (str, Optional): The unique archive ID. + status (ArchiveStatus, Optional): The status of the archive. + name (str, Optional): The name of the archive. + reason (str, Optional): May give a brief reason for the archive status. + session_id (str, Optional): The session ID of the Vonage Video session. + application_id (str, Optional): The Vonage application ID. created_at (int, Optional): The timestamp when the archive when the archive started recording, expressed in milliseconds since the Unix epoch. + size (int, Optional): The size of the archive. duration (int, Optional): The duration of the archive in seconds. For archives that have are being recorded, this value is set to 0. + output_mode (OutputMode, Optional): The output mode of the archive. + stream_mode (StreamMode, Optional): Whether streams included in the archive + are selected automatically (`auto`, the default) or manually (`manual`). has_audio (bool, Optional): Whether the archive will record audio. has_video (bool, Optional): Whether the archive will record video. - id (str, Optional): The unique archive ID. + 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. - name (str, Optional): The name of the archive. - application_id (str, Optional): The Vonage application ID. - reason (str, Optional): This is set when the `status` is `stopped` or `failed`. + event (str, Optional): The event that triggered the response. resolution (VideoResolution, Optional): The resolution of the archive. - session_id (str, Optional): The session ID of the Vonage Video session. - size (int, Optional): The size of the archive. - status (ArchiveStatus, Optional): The status of the archive. - stream_mode (StreamMode, Optional): Whether streams included in the archive - are selected automatically (`auto`, the default) or manually (`manual`). streams (List[ArchiveStream], 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. """ - created_at: Optional[int] = Field(None, validation_alias='createdAt') - duration: Optional[int] = None - has_audio: Optional[bool] = Field(None, validation_alias='hasAudio') - has_video: Optional[bool] = Field(None, validation_alias='hasVideo') id: Optional[str] = None - multi_archive_tag: Optional[str] = Field(None, validation_alias='multiArchiveTag') + status: Optional[ArchiveStatus] = None name: Optional[str] = None - application_id: Optional[str] = Field(None, validation_alias='applicationId') reason: Optional[str] = None - resolution: Optional[VideoResolution] = 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 - status: Optional[ArchiveStatus] = 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[ArchiveStream]] = None url: Optional[str] = None + transcription: Optional[Transcription] = None -class Layout(BaseModel): - """Model for layout options for an archive. +class ComposedLayout(BaseModel): + """Model for layout options for a composed archive. Args: type (str): Specify this to assign the initial layout type for the archive. @@ -154,7 +181,10 @@ class CreateArchiveRequest(BaseModel): 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') - layout: Optional[Layout] = None + 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') @@ -180,3 +210,25 @@ def no_layout_or_resolution_for_individual_archives(self): 'The `layout` property cannot be set for `archive_mode: \'individual\'`.' ) return self + + @model_validator(mode='after') + def transcription_only_for_individual_archives(self): + if self.output_mode == OutputMode.COMPOSED and self.has_transcription is True: + raise IndividualArchivePropertyError( + 'The `has_transcription` property can only be set for `archive_mode: \'individual\'`.' + ) + return self + + +class AddStreamRequest(BaseModel): + """Model for adding a stream to an archive. + + Args: + stream_id (str): ID of the stream to add to the archive. + 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') diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index 52450344..56982188 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -86,3 +86,4 @@ class ArchiveStatus(str, Enum): 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 index 9cad3b4d..03fb743f 100644 --- a/video/src/vonage_video/models/experience_composer.py +++ b/video/src/vonage_video/models/experience_composer.py @@ -78,4 +78,4 @@ class ListExperienceComposersFilter(BaseModel): """ offset: Optional[int] = None - page_size: Optional[int] = Field(1000, serialization_alias='count') + page_size: Optional[int] = Field(100, serialization_alias='count') diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 062e0515..e9cb0047 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -1,8 +1,16 @@ from typing import List, Optional, Tuple from pydantic import validate_call +from vonage_http_client.errors import HttpRequestError from vonage_http_client.http_client import HttpClient -from vonage_video.models.archive import Archive, CreateArchiveRequest, ListArchivesFilter +from vonage_video.errors import InvalidArchiveStateError +from vonage_video.models.archive import ( + AddStreamRequest, + Archive, + ComposedLayout, + CreateArchiveRequest, + ListArchivesFilter, +) from vonage_video.models.audio_connector import AudioConnectorData, AudioConnectorOptions from vonage_video.models.captions import CaptionsData, CaptionsOptions from vonage_video.models.experience_composer import ( @@ -296,7 +304,7 @@ def list_experience_composers( 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_index: Optional[int] + experience_composers: List[ExperienceComposer], count: int, next_page_offset: Optional[int] """ response = self._http_client.get( self._http_client.video_host, @@ -352,8 +360,165 @@ def stop_experience_composer(self, experience_composer_id: str) -> None: def list_archives( self, filter: ListArchivesFilter ) -> Tuple[List[Archive], int, Optional[int]]: - pass + """Lists archives associated with a Vonage Application. + + Args: + filter (ListArchivesFilter): The filters for the archives. + + Returns: + Tuple[List[Archive], int, Optional[int]]: A tuple containing a list of archive objects, + the total count of archives and the required offset value for the next page, if applicable. + i.e. + archives: List[Archive], count: int, next_page_offset: Optional[int] + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/archive', + filter.model_dump(exclude_none=True, by_alias=True), + ) + + index = filter.offset + 1 or 1 + page_size = filter.page_size + archives = [] + + try: + for archive in response['items']: + archives.append(Archive(**archive)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * (index): + return archives, count, index + return archives, count, None @validate_call - def create_archive(self, options: CreateArchiveRequest) -> Archive: - pass + 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: + if e.response.status_code == 409: + raise InvalidArchiveStateError( + 'You can only delete an archive that has one of the following statuses: `available` OR `uploaded` OR `deleted`.' + ) + raise e + + @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: + if e.response.status_code == 409: + raise InvalidArchiveStateError( + 'You can only stop an archive that is being recorded.' + ) + raise e + 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) diff --git a/video/tests/data/archive.json b/video/tests/data/archive.json new file mode 100644 index 00000000..1167780f --- /dev/null +++ b/video/tests/data/archive.json @@ -0,0 +1,23 @@ +{ + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "started", + "name": "first archive test", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "manual", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727870434977, + "multiArchiveTag": "my-multi-archive", + "event": "archive", + "resolution": "1280x720", + "url": null +} \ No newline at end of file diff --git a/video/tests/data/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/list_archives.json b/video/tests/data/list_archives.json new file mode 100644 index 00000000..e8734d4d --- /dev/null +++ b/video/tests/data/list_archives.json @@ -0,0 +1,51 @@ +{ + "count": 2, + "items": [ + { + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "paused", + "name": "first archive test", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727871263000, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "manual", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727871264000, + "multiArchiveTag": "my-multi-archive", + "event": "archive", + "resolution": "1280x720", + "url": null + }, + { + "id": "a9cdeb69-f6cf-408b-9197-6f99e6eac5aa", + "status": "available", + "name": "first archive test", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870435000, + "size": 0, + "duration": 134, + "outputMode": "composed", + "streamMode": "manual", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "test_sha256_sum", + "password": "", + "updatedAt": 1727870572000, + "multiArchiveTag": "my-multi-archive", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4" + } + ] +} \ No newline at end of file diff --git a/video/tests/data/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/test_archive.py b/video/tests/test_archive.py index 08c93dce..a289e623 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -1,12 +1,29 @@ +from os.path import abspath + +import responses from pytest import raises +from vonage_http_client.http_client import HttpClient from vonage_video.errors import ( IndividualArchivePropertyError, + InvalidArchiveStateError, LayoutScreenshareTypeError, LayoutStylesheetError, NoAudioOrVideoError, ) -from vonage_video.models.archive import CreateArchiveRequest, Layout +from vonage_video.models.archive import ( + AddStreamRequest, + ComposedLayout, + CreateArchiveRequest, + ListArchivesFilter, +) from vonage_video.models.enums import LayoutType, OutputMode, StreamMode, VideoResolution +from vonage_video.video import Video + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + +video = Video(HttpClient(get_mock_jwt_auth())) def test_create_archive_request_valid(): @@ -14,7 +31,7 @@ def test_create_archive_request_valid(): session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", has_audio=True, has_video=True, - layout=Layout(type=LayoutType.BEST_FIT), + layout=ComposedLayout(type=LayoutType.BEST_FIT), multi_archive_tag='test_multi_archive_tag', output_mode=OutputMode.COMPOSED, resolution=VideoResolution.RES_1280x720, @@ -55,20 +72,228 @@ def test_create_archive_request_individual_output_mode_with_layout(): session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", has_audio=True, output_mode=OutputMode.INDIVIDUAL, - layout=Layout(type=LayoutType.BEST_FIT), + layout=ComposedLayout(type=LayoutType.BEST_FIT), + ) + + +def test_create_archive_request_composed_output_mode_with_transcription_error(): + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id='test_session_id', + has_audio=True, + output_mode=OutputMode.COMPOSED, + has_transcription=True, ) def test_layout_custom_without_stylesheet(): with raises(LayoutStylesheetError): - Layout(type=LayoutType.CUSTOM) + ComposedLayout(type=LayoutType.CUSTOM) def test_layout_best_fit_with_stylesheet(): with raises(LayoutStylesheetError): - Layout(type=LayoutType.BEST_FIT, stylesheet='http://example.com/stylesheet.css') + ComposedLayout( + type=LayoutType.BEST_FIT, stylesheet='http://example.com/stylesheet.css' + ) def test_layout_screenshare_type_without_best_fit(): with raises(LayoutScreenshareTypeError): - Layout(type=LayoutType.PIP, screenshare_type=LayoutType.BEST_FIT) + ComposedLayout(type=LayoutType.PIP, screenshare_type=LayoutType.BEST_FIT) + + +@responses.activate +def test_list_archives(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives.json', + ) + + filter = ListArchivesFilter(offset=0, page_size=10, session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + assert count == 2 + assert next_page is None + assert archives[0].id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archives[0].status == 'paused' + assert archives[0].resolution == '1280x720' + assert archives[0].session_id == 'test_session_id' + assert archives[1].id == 'a9cdeb69-f6cf-408b-9197-6f99e6eac5aa' + assert archives[1].status == 'available' + assert archives[1].reason == 'session ended' + assert archives[1].duration == 134 + assert archives[1].sha256_sum == 'test_sha256_sum' + assert archives[1].url == 'https://example.com/archive.mp4' + + +@responses.activate +def test_start_archive(): + build_response( + path, + 'POST', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'archive.json', + ) + + archive_options = CreateArchiveRequest( + session_id='test_session_id', + has_audio=True, + has_video=True, + layout=ComposedLayout( + type=LayoutType.BEST_FIT, screenshare_type=LayoutType.HORIZONTAL_PRESENTATION + ), + multi_archive_tag='my-multi-archive', + name='first archive test', + output_mode=OutputMode.COMPOSED, + resolution=VideoResolution.RES_1280x720, + stream_mode=StreamMode.MANUAL, + ) + + archive = video.start_archive(archive_options) + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.session_id == 'test_session_id' + assert archive.application_id == 'test_application_id' + assert archive.created_at == 1727870434974 + assert archive.updated_at == 1727870434977 + assert archive.status == 'started' + assert archive.name == 'first archive test' + assert archive.resolution == '1280x720' + + +@responses.activate +def test_get_archive(): + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'archive.json', + ) + + archive = video.get_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.session_id == 'test_session_id' + assert archive.application_id == 'test_application_id' + assert archive.created_at == 1727870434974 + assert archive.updated_at == 1727870434977 + assert archive.status == 'started' + assert archive.name == 'first archive test' + assert archive.resolution == '1280x720' + + +@responses.activate +def test_delete_archive(): + build_response( + path, + 'DELETE', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + status_code=204, + ) + + video.delete_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert video.http_client.last_response.status_code == 204 + + +@responses.activate +def test_delete_archive_error_invalid_status(): + build_response( + path, + 'DELETE', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'delete_archive_error.json', + status_code=409, + ) + + with raises(InvalidArchiveStateError) as e: + video.delete_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert ( + str(e.value) + == 'You can only delete an archive that has one of the following statuses: `available` OR `uploaded` OR `deleted`.' + ) + + +@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 str(e.value) == 'You can only stop an archive that is being recorded.' + + +@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 From 91e5dc4bb2e9fe3d6c51eb6db7a61fca6419b700 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 3 Oct 2024 18:26:49 +0100 Subject: [PATCH 72/98] start adding broadcast --- video/src/vonage_video/errors.py | 8 + video/src/vonage_video/models/archive.py | 86 +--------- video/src/vonage_video/models/broadcast.py | 175 +++++++++++++++++++++ video/src/vonage_video/models/common.py | 86 ++++++++++ video/src/vonage_video/models/enums.py | 9 ++ video/src/vonage_video/video.py | 148 ++++++++++++++++- video/tests/test_archive.py | 2 +- 7 files changed, 431 insertions(+), 83 deletions(-) create mode 100644 video/src/vonage_video/models/broadcast.py create mode 100644 video/src/vonage_video/models/common.py diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py index c9641b67..57d5567f 100644 --- a/video/src/vonage_video/errors.py +++ b/video/src/vonage_video/errors.py @@ -35,3 +35,11 @@ class LayoutScreenshareTypeError(VideoError): 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.""" diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 36a9bf86..69375005 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -1,22 +1,17 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from vonage_video.errors import ( - IndividualArchivePropertyError, - LayoutScreenshareTypeError, - LayoutStylesheetError, - NoAudioOrVideoError, -) +from vonage_video.errors import IndividualArchivePropertyError, NoAudioOrVideoError +from vonage_video.models.common import ComposedLayout, ListVideoFilter, VideoStream from vonage_video.models.enums import ( ArchiveStatus, - LayoutType, OutputMode, StreamMode, VideoResolution, ) -class ListArchivesFilter(BaseModel): +class ListArchivesFilter(ListVideoFilter): """Model with filters for listing archives. Args: @@ -25,25 +20,9 @@ class ListArchivesFilter(BaseModel): session_id (str, Optional): The session ID of a Vonage Video session. """ - offset: Optional[int] = None - page_size: Optional[int] = Field(100, serialization_alias='count') session_id: Optional[str] = None -class ArchiveStream(BaseModel): - """Model for a stream in an archive. - - 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 Transcription(BaseModel): """Model for transcription options for an archive. @@ -86,7 +65,7 @@ class Archive(BaseModel): 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[ArchiveStream], Optional): The streams in 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. @@ -112,52 +91,11 @@ class Archive(BaseModel): multi_archive_tag: Optional[str] = Field(None, validation_alias='multiArchiveTag') event: Optional[str] = None resolution: Optional[VideoResolution] = None - streams: Optional[List[ArchiveStream]] = None + streams: Optional[List[VideoStream]] = None url: Optional[str] = None transcription: Optional[Transcription] = None -class ComposedLayout(BaseModel): - """Model for layout options for a composed archive. - - Args: - type (str): Specify this to assign the initial layout type for the archive. - 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. - """ - - 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 CreateArchiveRequest(BaseModel): """Model for creating an archive. @@ -218,17 +156,3 @@ def transcription_only_for_individual_archives(self): 'The `has_transcription` property can only be set for `archive_mode: \'individual\'`.' ) return self - - -class AddStreamRequest(BaseModel): - """Model for adding a stream to an archive. - - Args: - stream_id (str): ID of the stream to add to the archive. - 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') diff --git a/video/src/vonage_video/models/broadcast.py b/video/src/vonage_video/models/broadcast.py new file mode 100644 index 00000000..e566ee3f --- /dev/null +++ b/video/src/vonage_video/models/broadcast.py @@ -0,0 +1,175 @@ +from typing import List, 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`. + """ + + 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 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 such as the YouTube Live stream name or the Facebook stream key. + status (str, Optional): The status of the RTMP stream. + """ + + status: Optional[str] = None + + +class BroadcastUrls(BaseModel): + """Model for URLs for a broadcast. + + Args: + hls (str, Optional): URL for the HLS broadcast. + rtmp (List[str], Optional): An array of objects that include information on each of the RTMP streams. + """ + + hls: Optional[str] = None + rtmp: Optional[List[RtmpStream]] = 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, alias='sessionId') + multi_broadcast_tag: Optional[str] = Field(None, alias='multiBroadcastTag') + application_id: Optional[str] = Field(None, alias='applicationId') + created_at: Optional[int] = Field(None, alias='createdAt') + updated_at: Optional[int] = Field(None, alias='updatedAt') + max_duration: Optional[int] = Field(None, alias='maxDuration') + max_bitrate: Optional[int] = Field(None, alias='maxBitrate') + broadcast_urls: Optional[BroadcastUrls] = Field(None, alias='broadcastUrls') + settings: Optional[BroadcastHls] = None + resolution: Optional[VideoResolution] = None + has_audio: Optional[bool] = Field(None, alias='hasAudio') + has_video: Optional[bool] = Field(None, alias='hasVideo') + stream_mode: Optional[StreamMode] = Field(None, alias='streamMode') + status: Optional[str] = None + streams: Optional[List[VideoStream]] = None + + +class Outputs(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. + """ + + 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. + has_audio (bool, Optional): Whether the archive or broadcast should include audio. + has_video (bool, Optional): Whether the archive or broadcast should include video. + layout (Layout, Optional): Layout options for the archive or broadcast. + name (str, Optional): The name of the archive or broadcast. + output_mode (OutputMode, Optional): Whether all streams in the archive or broadcast are recorded to a + single file ("composed", the default) or to individual files ("individual"). + resolution (VideoResolution, Optional): The resolution of the archive or broadcast. + stream_mode (StreamMode, Optional): Whether streams included in the archive or 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. + You must also set this option when manually starting a broadcast in a session that is automatically broadcasted. + If you do not specify a unique multiBroadcastTag, you can only record one broadcast at a time for a given session. + """ + + session_id: str = Field(..., serialization_alias='sessionId') + layout: Optional[ComposedLayout] = None + max_duration: Optional[int] = Field(None, serialization_alias='maxDuration') + outputs: Outputs + 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, serialization_alias='maxBitrate') diff --git a/video/src/vonage_video/models/common.py b/video/src/vonage_video/models/common.py new file mode 100644 index 00000000..3481b8ab --- /dev/null +++ b/video/src/vonage_video/models/common.py @@ -0,0 +1,86 @@ +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. + """ + + 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 index 56982188..732a30cb 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -45,6 +45,12 @@ class AudioSampleRate(int, Enum): 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' @@ -66,6 +72,9 @@ class OutputMode(str, Enum): class StreamMode(str, Enum): + """Whether streams included in the archive are selected automatically ("auto", the default) or + manually ("manual").""" + AUTO = 'auto' MANUAL = 'manual' diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index e9cb0047..f75d25ca 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -5,14 +5,19 @@ from vonage_http_client.http_client import HttpClient from vonage_video.errors import InvalidArchiveStateError from vonage_video.models.archive import ( - AddStreamRequest, 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, VideoStream from vonage_video.models.experience_composer import ( ExperienceComposer, ExperienceComposerOptions, @@ -522,3 +527,144 @@ def change_archive_layout(self, archive_id: str, layout: ComposedLayout) -> Arch ) return Archive(**response) + + @validate_call + def list_broadcasts( + self, filter: ListBroadcastsFilter + ) -> Tuple[List[Broadcast], int, Optional[int]]: + """Lists broadcasts associated with a Vonage Application. + + Args: + filter (ListBroadcastsFilter): The filters for the broadcasts. + + Returns: + Tuple[List[Broadcast], int, Optional[int]]: A tuple containing a list of broadcast objects, + the total count of broadcasts and the required offset value for the next page, if applicable. + i.e. + broadcasts: List[Broadcast], count: int, next_page_offset: Optional[int] + # + """ + response = self._http_client.get( + self._http_client.video_host, + f'/v2/project/{self._http_client.auth.application_id}/broadcast', + filter.model_dump(exclude_none=True, by_alias=True), + ) + + index = filter.offset + 1 or 1 + page_size = filter.page_size + broadcasts = [] + + try: + for broadcast in response['items']: + broadcasts.append(Broadcast(**broadcast)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * (index): + return broadcasts, count, index + return broadcasts, count, None + + @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. + """ + 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), + ) + + 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}, + ) diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index a289e623..751f8a4c 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -3,6 +3,7 @@ import responses from pytest import raises from vonage_http_client.http_client import HttpClient +from vonage_video.models.common import AddStreamRequest from vonage_video.errors import ( IndividualArchivePropertyError, InvalidArchiveStateError, @@ -11,7 +12,6 @@ NoAudioOrVideoError, ) from vonage_video.models.archive import ( - AddStreamRequest, ComposedLayout, CreateArchiveRequest, ListArchivesFilter, From 94e3dd67d67e79ea7e1fabba28c8729b0d05b6ff Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 4 Oct 2024 23:13:55 +0100 Subject: [PATCH 73/98] finish broadcast, refactoring common code --- .../src/vonage_number_insight_v2/errors.py | 5 - video/src/vonage_video/errors.py | 4 + video/src/vonage_video/models/broadcast.py | 72 +++-- video/src/vonage_video/video.py | 156 ++++++---- video/tests/data/broadcast.json | 33 ++ video/tests/data/list_broadcasts.json | 73 +++++ .../tests/data/list_broadcasts_next_page.json | 39 +++ video/tests/data/nothing.json | 1 + video/tests/data/start_broadcast_error.json | 3 + video/tests/data/stop_broadcast.json | 23 ++ .../data/stop_broadcast_timeout_error.json | 5 + video/tests/test_archive.py | 9 +- video/tests/test_broadcast.py | 292 ++++++++++++++++++ video/tests/test_experience_composer.py | 5 +- video/tests/test_token.py | 3 + 15 files changed, 630 insertions(+), 93 deletions(-) delete mode 100644 number_insight_v2/src/vonage_number_insight_v2/errors.py create mode 100644 video/tests/data/broadcast.json create mode 100644 video/tests/data/list_broadcasts.json create mode 100644 video/tests/data/list_broadcasts_next_page.json create mode 100644 video/tests/data/nothing.json create mode 100644 video/tests/data/start_broadcast_error.json create mode 100644 video/tests/data/stop_broadcast.json create mode 100644 video/tests/data/stop_broadcast_timeout_error.json create mode 100644 video/tests/test_broadcast.py diff --git a/number_insight_v2/src/vonage_number_insight_v2/errors.py b/number_insight_v2/src/vonage_number_insight_v2/errors.py deleted file mode 100644 index 35061a33..00000000 --- a/number_insight_v2/src/vonage_number_insight_v2/errors.py +++ /dev/null @@ -1,5 +0,0 @@ -from vonage_utils.errors import VonageError - - -class NumberInsightV2Error(VonageError): - """Indicates an error with the Number Insight v2 Package.""" diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py index 57d5567f..7aa39b75 100644 --- a/video/src/vonage_video/errors.py +++ b/video/src/vonage_video/errors.py @@ -43,3 +43,7 @@ class InvalidHlsOptionsError(VideoError): class InvalidOutputOptionsError(VideoError): """The output options were invalid.""" + + +class InvalidBroadcastStateError(VideoError): + """The broadcast state was invalid for the specified operation.""" diff --git a/video/src/vonage_video/models/broadcast.py b/video/src/vonage_video/models/broadcast.py index e566ee3f..59a0a5ba 100644 --- a/video/src/vonage_video/models/broadcast.py +++ b/video/src/vonage_video/models/broadcast.py @@ -43,7 +43,8 @@ class BroadcastRtmp(BaseModel): Args: id (str, Optional): A unique ID for the stream. server_url (str): The RTMP server URL. - stream_name (str): The such as the YouTube Live stream name or the Facebook stream key. + stream_name (str): The stream name, such as the YouTube Live stream name or the + Facebook stream key. """ id: Optional[str] = None @@ -57,10 +58,13 @@ class RtmpStream(BroadcastRtmp): Args: id (str, Optional): A unique ID for the stream. server_url (str): The RTMP server URL. - stream_name (str): The such as the YouTube Live stream name or the Facebook stream key. + 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 @@ -69,13 +73,37 @@ class BroadcastUrls(BaseModel): 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. @@ -114,7 +142,7 @@ class Broadcast(BaseModel): max_duration: Optional[int] = Field(None, alias='maxDuration') max_bitrate: Optional[int] = Field(None, alias='maxBitrate') broadcast_urls: Optional[BroadcastUrls] = Field(None, alias='broadcastUrls') - settings: Optional[BroadcastHls] = None + settings: Optional[BroadcastSettings] = None resolution: Optional[VideoResolution] = None has_audio: Optional[bool] = Field(None, alias='hasAudio') has_video: Optional[bool] = Field(None, alias='hasVideo') @@ -123,7 +151,7 @@ class Broadcast(BaseModel): streams: Optional[List[VideoStream]] = None -class Outputs(BaseModel): +class BroadcastOutputSettings(BaseModel): """Model for output options for a broadcast. You must specify at least one output option. Args: @@ -148,28 +176,34 @@ class CreateBroadcastRequest(BaseModel): Args: session_id (str): The session ID of a Vonage Video session. - has_audio (bool, Optional): Whether the archive or broadcast should include audio. - has_video (bool, Optional): Whether the archive or broadcast should include video. - layout (Layout, Optional): Layout options for the archive or broadcast. - name (str, Optional): The name of the archive or broadcast. - output_mode (OutputMode, Optional): Whether all streams in the archive or broadcast are recorded to a - single file ("composed", the default) or to individual files ("individual"). - resolution (VideoResolution, Optional): The resolution of the archive or broadcast. - stream_mode (StreamMode, Optional): Whether streams included in the archive or broadcast are selected + 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. - You must also set this option when manually starting a broadcast in a session that is automatically broadcasted. - If you do not specify a unique multiBroadcastTag, you can only record one broadcast at a time for a given session. + 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, serialization_alias='maxDuration') - outputs: Outputs + 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, serialization_alias='maxBitrate') + max_bitrate: Optional[int] = Field( + None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' + ) diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index f75d25ca..74effe2b 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -1,9 +1,13 @@ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Type, Union from pydantic import validate_call from vonage_http_client.errors import HttpRequestError from vonage_http_client.http_client import HttpClient -from vonage_video.errors import InvalidArchiveStateError +from vonage_video.errors import ( + InvalidArchiveStateError, + InvalidBroadcastStateError, + VideoError, +) from vonage_video.models.archive import ( Archive, ComposedLayout, @@ -17,7 +21,7 @@ ListBroadcastsFilter, ) from vonage_video.models.captions import CaptionsData, CaptionsOptions -from vonage_video.models.common import AddStreamRequest, VideoStream +from vonage_video.models.common import AddStreamRequest from vonage_video.models.experience_composer import ( ExperienceComposer, ExperienceComposerOptions, @@ -317,20 +321,7 @@ def list_experience_composers( filter.model_dump(exclude_none=True, by_alias=True), ) - index = filter.offset + 1 or 1 - page_size = filter.page_size - experience_composers = [] - - try: - for ec in response['items']: - experience_composers.append(ExperienceComposer(**ec)) - except KeyError: - return [], 0, None - - count = response['count'] - if count > page_size * (index): - return experience_composers, count, index - return experience_composers, count, None + return self._list_video_objects(filter, response, ExperienceComposer) @validate_call def get_experience_composer(self, experience_composer_id: str) -> ExperienceComposer: @@ -382,20 +373,7 @@ def list_archives( filter.model_dump(exclude_none=True, by_alias=True), ) - index = filter.offset + 1 or 1 - page_size = filter.page_size - archives = [] - - try: - for archive in response['items']: - archives.append(Archive(**archive)) - except KeyError: - return [], 0, None - - count = response['count'] - if count > page_size * (index): - return archives, count, index - return archives, count, None + return self._list_video_objects(filter, response, Archive) @validate_call def start_archive(self, options: CreateArchiveRequest) -> Archive: @@ -448,11 +426,10 @@ def delete_archive(self, archive_id: str) -> None: f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}', ) except HttpRequestError as e: - if e.response.status_code == 409: - raise InvalidArchiveStateError( - 'You can only delete an archive that has one of the following statuses: `available` OR `uploaded` OR `deleted`.' - ) - raise 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: @@ -502,11 +479,12 @@ def stop_archive(self, archive_id: str) -> Archive: f'/v2/project/{self._http_client.auth.application_id}/archive/{archive_id}/stop', ) except HttpRequestError as e: - if e.response.status_code == 409: - raise InvalidArchiveStateError( - 'You can only stop an archive that is being recorded.' - ) - raise 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 @@ -550,20 +528,7 @@ def list_broadcasts( filter.model_dump(exclude_none=True, by_alias=True), ) - index = filter.offset + 1 or 1 - page_size = filter.page_size - broadcasts = [] - - try: - for broadcast in response['items']: - broadcasts.append(Broadcast(**broadcast)) - except KeyError: - return [], 0, None - - count = response['count'] - if count > page_size * (index): - return broadcasts, count, index - return broadcasts, count, None + return self._list_video_objects(filter, response, Broadcast) @validate_call def start_broadcast(self, options: CreateBroadcastRequest) -> Broadcast: @@ -574,12 +539,27 @@ def start_broadcast(self, options: CreateBroadcastRequest) -> 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. """ - 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), - ) + 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) @@ -668,3 +648,59 @@ def remove_stream_from_broadcast(self, broadcast_id: str, stream_id: str) -> Non f'/v2/project/{self._http_client.auth.application_id}/broadcast/{broadcast_id}/streams', params={'removeStream': stream_id}, ) + + @validate_call + def _list_video_objects( + self, + request_filter: Union[ + ListArchivesFilter, ListBroadcastsFilter, ListExperienceComposersFilter + ], + response: dict, + model: Union[Type[Archive], Type[Broadcast], Type[ExperienceComposer]], + ) -> Tuple[List[object], int, Optional[int]]: + """List objects of a specific model from a response. + + Args: + request_filter (Union[ListArchivesFilter, ListBroadcastsFilter, ListExperienceComposersFilter]): + The filter used to make the request. + response (dict): The response from the API. + model (Union[Type[Archive], Type[Broadcast], Type[ExperienceComposer]]): The type of a pydantic + model to populate the response into. + + Returns: + Tuple[List[object], int, Optional[int]]: A tuple containing a list of objects, + the total count of objects and the required offset value for the next page, if applicable. + i.e. + objects: List[object], count: int, next_page_offset: Optional[int] + """ + index = request_filter.offset + 1 or 1 + page_size = request_filter.page_size + objects = [] + + try: + for obj in response['items']: + objects.append(model(**obj)) + except KeyError: + return [], 0, None + + count = response['count'] + if count > page_size * index: + return objects, count, index + return objects, count, None + + def _check_conflict_error( + self, + http_error: HttpRequestError, + ConflictError: Type[VideoError], + conflict_error_message: str, + ) -> None: + """Checks if the error is a conflict error and raises the specified error. + + Args: + http_error (HttpRequestError): The error to check. + ConflictError (Type[VideoError]): The error to raise if there is a conflict. + conflict_error_message (str): The error message if there is a conflict. + """ + if http_error.response.status_code == 409: + raise ConflictError(f'{conflict_error_message} {http_error.response.text}') + raise http_error diff --git a/video/tests/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/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/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/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 index 751f8a4c..5ef2c4b8 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -3,7 +3,6 @@ import responses from pytest import raises from vonage_http_client.http_client import HttpClient -from vonage_video.models.common import AddStreamRequest from vonage_video.errors import ( IndividualArchivePropertyError, InvalidArchiveStateError, @@ -16,6 +15,7 @@ CreateArchiveRequest, ListArchivesFilter, ) +from vonage_video.models.common import AddStreamRequest from vonage_video.models.enums import LayoutType, OutputMode, StreamMode, VideoResolution from vonage_video.video import Video @@ -212,10 +212,7 @@ def test_delete_archive_error_invalid_status(): with raises(InvalidArchiveStateError) as e: video.delete_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') - assert ( - str(e.value) - == 'You can only delete an archive that has one of the following statuses: `available` OR `uploaded` OR `deleted`.' - ) + assert '"code": 15004' in str(e.value) @responses.activate @@ -280,7 +277,7 @@ def test_stop_archive_invalid_state_error(): with raises(InvalidArchiveStateError) as e: video.stop_archive('e05d6f8f-2280-4025-b1d2-defc4f5c8dfa') - assert str(e.value) == 'You can only stop an archive that is being recorded.' + assert '"code": 15002' in str(e.value) @responses.activate 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_experience_composer.py b/video/tests/test_experience_composer.py index 47432bb5..0ca081d9 100644 --- a/video/tests/test_experience_composer.py +++ b/video/tests/test_experience_composer.py @@ -120,14 +120,13 @@ def test_get_experience_composer(): @responses.activate -def stop_experience_composer(): +def test_stop_experience_composer(): build_response( path, - 'POST', + '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_token.py b/video/tests/test_token.py index 0bf3d912..56b0f037 100644 --- a/video/tests/test_token.py +++ b/video/tests/test_token.py @@ -1,3 +1,5 @@ +from time import time + from vonage_http_client import HttpClient from vonage_video.errors import TokenExpiryError from vonage_video.models.enums import TokenRole @@ -16,6 +18,7 @@ def test_token_options_model(): 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', ) From 928295cbe20331c801fedee1d77e222a036db6f5 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 7 Oct 2024 23:35:42 +0100 Subject: [PATCH 74/98] add sip endpoints --- video/src/vonage_video/errors.py | 4 ++ video/src/vonage_video/models/enums.py | 11 ++++ video/src/vonage_video/models/sip.py | 78 ++++++++++++++++++++++++++ video/src/vonage_video/video.py | 48 ++++++++++++++++ video/tests/test_sip.py | 76 +++++++++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 video/src/vonage_video/models/sip.py create mode 100644 video/tests/test_sip.py diff --git a/video/src/vonage_video/errors.py b/video/src/vonage_video/errors.py index 7aa39b75..512b6251 100644 --- a/video/src/vonage_video/errors.py +++ b/video/src/vonage_video/errors.py @@ -47,3 +47,7 @@ class InvalidOutputOptionsError(VideoError): 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/enums.py b/video/src/vonage_video/models/enums.py index 732a30cb..dfa3c92b 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -2,6 +2,8 @@ class TokenRole(str, Enum): + """The role assigned to the token.""" + SUBSCRIBER = 'subscriber' PUBLISHER = 'publisher' PUBLISHER_ONLY = 'publisheronly' @@ -9,16 +11,23 @@ class TokenRole(str, Enum): 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' @@ -40,6 +49,8 @@ class LanguageCode(str, Enum): class AudioSampleRate(int, Enum): + """Audio sample rate, in Hertz.""" + KHZ_8 = 8000 KHZ_16 = 16000 diff --git a/video/src/vonage_video/models/sip.py b/video/src/vonage_video/models/sip.py new file mode 100644 index 00000000..b5efd017 --- /dev/null +++ b/video/src/vonage_video/models/sip.py @@ -0,0 +1,78 @@ +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. + + Attributes: + 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. + + Attributes: + 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. + + Attributes: + 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. + + Attributes: + id (str): A unique 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. + """ + + id: str + connection_id: str = Field(..., serialization_alias='connectionId') + stream_id: str = Field(..., serialization_alias='streamId') diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 74effe2b..fe9525ae 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -3,9 +3,11 @@ 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 ( @@ -29,6 +31,7 @@ ) from vonage_video.models.session import SessionOptions, VideoSession from vonage_video.models.signal import SignalData +from vonage_video.models.sip import SipCall, InitiateSipRequest from vonage_video.models.stream import StreamInfo, StreamLayoutOptions from vonage_video.models.token import TokenOptions @@ -649,6 +652,51 @@ def remove_stream_from_broadcast(self, broadcast_id: str, stream_id: str) -> Non 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. + 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, diff --git a/video/tests/test_sip.py b/video/tests/test_sip.py new file mode 100644 index 00000000..5be1939c --- /dev/null +++ b/video/tests/test_sip.py @@ -0,0 +1,76 @@ +from os.path import abspath + +import responses +from vonage_http_client import HttpClient + +from vonage_video.models.sip import SipAuth, SipOptions, InitiateSipRequest +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_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', + 'sip.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 == '' + assert sip_call.connection_id == '' + assert sip_call.stream_id == '' From c0af739eb512343013bca88ec284b3216908a378 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 9 Oct 2024 01:16:17 +0100 Subject: [PATCH 75/98] add SIP video endpoints, start release prep --- account/src/vonage_account/responses.py | 14 ++ pants.toml | 10 +- video/CHANGES.md | 1 - video/README.md | 212 +++++++++++++++++++++- video/src/vonage_video/models/__init__.py | 95 ++++++++-- video/src/vonage_video/models/enums.py | 4 +- video/src/vonage_video/models/sip.py | 35 ++-- video/src/vonage_video/video.py | 8 +- video/tests/data/initiate_sip_call.json | 9 + video/tests/test_sip.py | 65 ++++++- voice/tests/BUILD | 4 - vonage/CHANGES.md | 1 + vonage/src/vonage/_version.py | 2 +- 13 files changed, 399 insertions(+), 61 deletions(-) create mode 100644 video/tests/data/initiate_sip_call.json diff --git a/account/src/vonage_account/responses.py b/account/src/vonage_account/responses.py index 715e3fdd..cada6687 100644 --- a/account/src/vonage_account/responses.py +++ b/account/src/vonage_account/responses.py @@ -4,11 +4,25 @@ 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): The error code. + error_code_label (str, Optional): The error code label. + """ + error_code: Optional[str] = Field(None, validation_alias='error-code') error_code_label: Optional[str] = Field(None, validation_alias='error-code-label') diff --git a/pants.toml b/pants.toml index fc347cbf..1ea7e865 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.22.0' +pants_version = '2.23.0a0' backend_packages = [ 'pants.backend.python', @@ -21,7 +21,7 @@ enabled = false root_patterns = ['/', 'src/', 'tests/'] [python] -interpreter_constraints = ['==3.11.*'] +interpreter_constraints = ['==3.12.*'] [pytest] args = ['-vv', '--no-header'] @@ -56,9 +56,15 @@ filter = [ [black] args = ['--line-length=90', '--skip-string-normalization'] +interpreter_constraints = ['>=3.8'] [isort] args = ['--profile=black', '--line-length=90'] +interpreter_constraints = ['>=3.8'] [docformatter] args = ['--wrap-summaries=100', '--wrap-descriptions=100'] +interpreter_constraints = ['>=3.8'] + +[autoflake] +interpreter_constraints = ['>=3.8'] diff --git a/video/CHANGES.md b/video/CHANGES.md index d9f7c34c..be516a55 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,3 +1,2 @@ # 1.0.0 - - Initial upload diff --git a/video/README.md b/video/README.md index 1d4bccd8..d26ac46b 100644 --- a/video/README.md +++ b/video/README.md @@ -6,10 +6,12 @@ This package contains the code to use [Vonage's Video API](https://developer.von It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. +You will use the custom Pydantic data models to make most of the API calls in this package. They are accessed from the `vonage_video.models` package. + ### Generate a Client Token ```python -from vonage_video.models.token import TokenOptions +from vonage_video.models import TokenOptions token_options = TokenOptions(session_id='your_session_id', role='publisher') client_token = vonage_client.video.generate_client_token(token_options) @@ -18,7 +20,7 @@ client_token = vonage_client.video.generate_client_token(token_options) ### Create a Session ```python -from vonage_video.models.session import SessionOptions +from vonage_video.models import SessionOptions session_options = SessionOptions(media_mode='routed') video_session = vonage_client.video.create_session(session_options) @@ -39,7 +41,7 @@ stream_info = vonage_client.video.get_stream(session_id='your_session_id', strea ### Change Stream Layout ```python -from vonage_video.models.stream import StreamLayoutOptions +from vonage_video.models import StreamLayoutOptions layout_options = StreamLayoutOptions(type='bestFit') updated_streams = vonage_client.video.change_stream_layout(session_id='your_session_id', stream_layout_options=layout_options) @@ -48,7 +50,7 @@ updated_streams = vonage_client.video.change_stream_layout(session_id='your_sess ### Send a Signal ```python -from vonage_video.models.signal import SignalData +from vonage_video.models import SignalData signal_data = SignalData(type='chat', data='Hello, World!') vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) @@ -81,7 +83,7 @@ vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ### Start Captions ```python -from vonage_video.models.captions import CaptionsOptions +from vonage_video.models import CaptionsOptions captions_options = CaptionsOptions(language='en-US') captions_data = vonage_client.video.start_captions(captions_options) @@ -90,7 +92,7 @@ captions_data = vonage_client.video.start_captions(captions_options) ### Stop Captions ```python -from vonage_video.models.captions import CaptionsData +from vonage_video.models import CaptionsData captions_data = CaptionsData(captions_id='your_captions_id') vonage_client.video.stop_captions(captions_data) @@ -99,7 +101,7 @@ vonage_client.video.stop_captions(captions_data) ### Start Audio Connector ```python -from vonage_video.models.audio_connector import AudioConnectorOptions +from vonage_video.models import AudioConnectorOptions audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) @@ -108,8 +110,202 @@ audio_connector_data = vonage_client.video.start_audio_connector(audio_connector ### Start Experience Composer ```python -from vonage_video.models.experience_composer import ExperienceComposerOptions +from vonage_video.models import ExperienceComposerOptions experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) ``` + +### List Experience Composers + +```python +from vonage_video.models import ListExperienceComposersFilter + +filter = ListExperienceComposersFilter(page_size=10) +experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) +print(experience_composers) +``` + +### Get Experience Composer + +```python +experience_composer = vonage_client.video.get_experience_composer(experience_composer_id='experience_composer_id') +``` + +### Stop Experience Composer + +```python +vonage_client.video.stop_experience_composer(experience_composer_id='experience_composer_id') +``` + +### List Archives + +```python +from vonage_video.models import ListArchivesFilter + +filter = ListArchivesFilter(offset=2) +archives, count, next_page_offset = vonage_client.video.list_archives(filter) +print(archives) +``` + +### Start Archive + +```python +from vonage_video.models import CreateArchiveRequest + +archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') +archive = vonage_client.video.start_archive(archive_options) +``` + +### Get Archive + +```python +archive = vonage_client.video.get_archive(archive_id='your_archive_id') +print(archive) +``` + +### Delete Archive + +```python +vonage_client.video.delete_archive(archive_id='your_archive_id') +``` + +### Add Stream to Archive + +```python +from vonage_video.models import AddStreamRequest + +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) +``` + +### Remove Stream from Archive + +```python +vonage_client.video.remove_stream_from_archive(archive_id='your_archive_id', stream_id='your_stream_id') +``` + +### Stop Archive + +```python +archive = vonage_client.video.stop_archive(archive_id='your_archive_id') +print(archive) +``` + +### Change Archive Layout + +```python +from vonage_video.models import ComposedLayout + +layout = ComposedLayout(type='bestFit') +archive = vonage_client.video.change_archive_layout(archive_id='your_archive_id', layout=layout) +print(archive) +``` + +### List Broadcasts + +```python +from vonage_video.models import ListBroadcastsFilter + +filter = ListBroadcastsFilter(page_size=10) +broadcasts, count, next_page_offset = vonage_client.video.list_broadcasts(filter) +print(broadcasts) +``` + +### Start Broadcast + +```python +from vonage_video.models import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp + +broadcast_options = CreateBroadcastRequest(session_id='your_session_id', outputs=BroadcastOutputSettings( + hls=BroadcastHls(dvr=True, low_latency=False), + rtmp=[ + BroadcastRtmp( + id='test', + server_url='rtmp://a.rtmp.youtube.com/live2', + stream_name='stream-key', + ) + ], +) +) +broadcast = vonage_client.video.start_broadcast(broadcast_options) +print(broadcast) +``` + +### Get Broadcast + +```python +broadcast = vonage_client.video.get_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) +``` + +### Stop Broadcast + +```python +broadcast = vonage_client.video.stop_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) +``` + +### Change Broadcast Layout + +```python +from vonage_video.models import ComposedLayout + +layout = ComposedLayout(type='bestFit') +broadcast = vonage_client.video.change_broadcast_layout(broadcast_id='your_broadcast_id', layout=layout) +print(broadcast) +``` + +### Add Stream to Broadcast + +```python +from vonage_video.models import AddStreamRequest + +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_broadcast(broadcast_id='your_broadcast_id', params=add_stream_request) +``` + +### Remove Stream from Broadcast + +```python +vonage_client.video.remove_stream_from_broadcast(broadcast_id='your_broadcast_id', stream_id='your_stream_id') +``` + +### Initiate SIP Call + +```python +from vonage_video.models import InitiateSipRequest, SipOptions, SipAuth + +sip_request_params = InitiateSipRequest( + session_id='your_session_id', + token='your_token', + sip=SipOptions( + uri=f'sip:{vonage_number}@sip.nexmo.com;transport=tls', + from_=f'test@vonage.com', + headers={'header_key': 'header_value'}, + auth=SipAuth(username='1485b9e6', password='fL8jvi4W2FmS9som'), + secure=False, + video=False, + observe_force_mute=True, + ), +) +sip_call = vonage_client.video.initiate_sip_call(sip_request_params) +print(sip_call) +``` + +### Play DTMF into a call + +```python +# Play into all connections +session_id = 'your_session_id' +digits = '1234#*p' + +vonage_client.video.play_dtmf(session_id=session_id, digits=digits) + +# Play into one connection +session_id = 'your_session_id' +digits = '1234#*p' +connection_id = 'your_connection_id' + +vonage_client.video.play_dtmf(session_id=session_id, digits=digits, connection_id=connection_id) +``` \ No newline at end of file diff --git a/video/src/vonage_video/models/__init__.py b/video/src/vonage_video/models/__init__.py index 4fe171f2..83697351 100644 --- a/video/src/vonage_video/models/__init__.py +++ b/video/src/vonage_video/models/__init__.py @@ -1,39 +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', - 'CaptionsData', - 'CaptionsOptions', - 'ArchiveMode', - 'MediaMode', - 'TokenRole', - 'P2pPreference', - 'LanguageCode', - 'AudioSampleRate', - 'SessionOptions', - 'VideoSession', - 'SignalData', - 'StreamInfo', - 'StreamLayoutOptions', - 'StreamLayout', - 'TokenOptions', + "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/enums.py b/video/src/vonage_video/models/enums.py index dfa3c92b..50e0ae8e 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -18,8 +18,8 @@ class ArchiveMode(str, Enum): class MediaMode(str, Enum): - """Whether the session uses the Vonage Video media router ("routed") - or peers connect directly (relayed).""" + """Whether the session uses the Vonage Video media router ("routed") or peers connect directly + (relayed).""" ROUTED = 'routed' RELAYED = 'relayed' diff --git a/video/src/vonage_video/models/sip.py b/video/src/vonage_video/models/sip.py index b5efd017..a8c5730a 100644 --- a/video/src/vonage_video/models/sip.py +++ b/video/src/vonage_video/models/sip.py @@ -1,11 +1,11 @@ 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. + """Model representing the authentication details for the SIP INVITE request for HTTP digest + authentication, if it is required by your SIP platform. Attributes: username (str): The username for HTTP digest authentication. @@ -17,14 +17,13 @@ class SipAuth(BaseModel): class SipOptions(BaseModel): - """ - Model representing the SIP options for the call. + """Model representing the SIP options for the call. Attributes: 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. + 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. @@ -47,8 +46,7 @@ class SipOptions(BaseModel): class InitiateSipRequest(BaseModel): - """ - Model representing the SIP options for joining a Vonage Video session. + """Model representing the SIP options for joining a Vonage Video session. Attributes: session_id (str): The Vonage Video session ID for the SIP call to join. @@ -62,17 +60,26 @@ class InitiateSipRequest(BaseModel): class SipCall(BaseModel): - """ - Model representing the details of a SIP call. + """Model representing the details of a SIP call. Attributes: 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: str - connection_id: str = Field(..., serialization_alias='connectionId') - stream_id: str = Field(..., serialization_alias='streamId') + 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/video.py b/video/src/vonage_video/video.py index fe9525ae..8fb43a04 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -31,7 +31,7 @@ ) from vonage_video.models.session import SessionOptions, VideoSession from vonage_video.models.signal import SignalData -from vonage_video.models.sip import SipCall, InitiateSipRequest +from vonage_video.models.sip import InitiateSipRequest, SipCall from vonage_video.models.stream import StreamInfo, StreamLayoutOptions from vonage_video.models.token import TokenOptions @@ -680,12 +680,12 @@ def initiate_sip_call(self, sip_request_params: InitiateSipRequest) -> SipCall: @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. + """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. + 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. 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/test_sip.py b/video/tests/test_sip.py index 5be1939c..26ff9c68 100644 --- a/video/tests/test_sip.py +++ b/video/tests/test_sip.py @@ -1,10 +1,10 @@ from os.path import abspath import responses +from pytest import raises from vonage_http_client import HttpClient - -from vonage_video.models.sip import SipAuth, SipOptions, InitiateSipRequest -from vonage_video.models.token import TokenOptions +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 @@ -51,7 +51,7 @@ def test_initiate_sip_call(): path, 'POST', 'https://video.api.vonage.com/v2/project/test_application_id/dial', - 'sip.json', + 'initiate_sip_call.json', 200, ) @@ -71,6 +71,57 @@ def test_initiate_sip_call(): sip_call = video.initiate_sip_call(sip_request_params) - assert sip_call.id == '' - assert sip_call.connection_id == '' - assert sip_call.stream_id == '' + 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/voice/tests/BUILD b/voice/tests/BUILD index fa7368b6..44127fb3 100644 --- a/voice/tests/BUILD +++ b/voice/tests/BUILD @@ -1,5 +1 @@ python_tests(dependencies=['voice', 'testutils']) - -python_sources( - name="tests0", -) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index b3eb18f9..1ffce63c 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,5 +1,6 @@ # 3.99.5a0 - Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) +- Add docstrings for data models across the SDK to increase quality-of-life developer experience - Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 # 3.99.4a0 diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index e02d7640..a8447645 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.4a0' +__version__ = '3.99.5a0' From fd3fcb31a5772ae54dbef895c53189bf600d5609 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 9 Oct 2024 18:50:03 +0100 Subject: [PATCH 76/98] add docstrings to data models --- _dev_scripts/update_versions.py | 53 ++++ account/CHANGES.md | 3 + account/pyproject.toml | 9 +- account/src/vonage_account/_version.py | 1 + account/src/vonage_account/account.py | 4 +- account/src/vonage_account/responses.py | 23 +- application/CHANGES.md | 3 + application/pyproject.toml | 9 +- .../src/vonage_application/_version.py | 1 + application/src/vonage_application/common.py | 121 +++++++- application/src/vonage_application/enums.py | 6 + .../src/vonage_application/requests.py | 17 +- .../src/vonage_application/responses.py | 32 +++ http_client/CHANGES.md | 3 + http_client/pyproject.toml | 9 +- .../src/vonage_http_client/_version.py | 1 + http_client/src/vonage_http_client/auth.py | 18 +- http_client/src/vonage_http_client/errors.py | 7 +- .../src/vonage_http_client/http_client.py | 17 ++ jwt/CHANGES.md | 3 + jwt/pyproject.toml | 7 +- jwt/src/vonage_jwt/_version.py | 1 + jwt/src/vonage_jwt/jwt.py | 3 +- messages/CHANGES.md | 3 + messages/pyproject.toml | 9 +- messages/src/vonage_messages/_version.py | 1 + .../vonage_messages/models/base_message.py | 9 + messages/src/vonage_messages/models/enums.py | 6 + .../src/vonage_messages/models/messenger.py | 84 ++++++ messages/src/vonage_messages/models/mms.py | 66 +++++ messages/src/vonage_messages/models/rcs.py | 77 +++++ messages/src/vonage_messages/models/sms.py | 30 ++ messages/src/vonage_messages/models/viber.py | 176 +++++++++++- .../src/vonage_messages/models/whatsapp.py | 265 ++++++++++++++++++ network_auth/CHANGES.md | 3 + network_auth/pyproject.toml | 5 +- .../src/vonage_network_auth/_version.py | 1 + .../src/vonage_network_auth/network_auth.py | 15 +- .../src/vonage_network_auth/responses.py | 17 ++ network_sim_swap/CHANGES.md | 3 + network_sim_swap/pyproject.toml | 5 +- .../src/vonage_network_sim_swap/_version.py | 1 + .../src/vonage_network_sim_swap/responses.py | 13 + .../src/vonage_network_sim_swap/sim_swap.py | 1 + number_insight/CHANGES.md | 3 + number_insight/README.md | 5 - number_insight/pyproject.toml | 9 +- .../src/vonage_number_insight/__init__.py | 2 - .../src/vonage_number_insight/_version.py | 1 + .../vonage_number_insight/number_insight.py | 2 +- .../src/vonage_number_insight/requests.py | 32 ++- .../src/vonage_number_insight/responses.py | 154 +++++++++- number_insight/tests/test_number_insight.py | 2 +- number_management/CHANGES.md | 3 + number_management/pyproject.toml | 9 +- .../src/vonage_numbers/_version.py | 1 + .../src/vonage_numbers/requests.py | 6 +- pants.toml | 2 +- requirements.txt | 1 + users/src/vonage_users/users.py | 4 +- verify/src/vonage_verify/verify.py | 7 +- verify_v2/src/vonage_verify_v2/verify_v2.py | 4 +- video/src/vonage_video/models/broadcast.py | 3 +- video/src/vonage_video/models/enums.py | 12 +- .../models/experience_composer.py | 4 +- video/src/vonage_video/models/sip.py | 4 +- video/src/vonage_video/video.py | 18 +- voice/src/vonage_voice/models/ncco.py | 8 +- vonage_utils/CHANGES.md | 3 + vonage_utils/pyproject.toml | 5 +- vonage_utils/src/vonage_utils/_version.py | 1 + vonage_utils/src/vonage_utils/models.py | 22 ++ vonage_utils/src/vonage_utils/utils.py | 4 +- 73 files changed, 1375 insertions(+), 97 deletions(-) create mode 100644 _dev_scripts/update_versions.py create mode 100644 account/src/vonage_account/_version.py create mode 100644 application/src/vonage_application/_version.py create mode 100644 http_client/src/vonage_http_client/_version.py create mode 100644 jwt/src/vonage_jwt/_version.py create mode 100644 messages/src/vonage_messages/_version.py create mode 100644 network_auth/src/vonage_network_auth/_version.py create mode 100644 network_sim_swap/src/vonage_network_sim_swap/_version.py create mode 100644 number_insight/src/vonage_number_insight/_version.py create mode 100644 number_management/src/vonage_numbers/_version.py create mode 100644 vonage_utils/src/vonage_utils/_version.py diff --git a/_dev_scripts/update_versions.py b/_dev_scripts/update_versions.py new file mode 100644 index 00000000..1aac15b1 --- /dev/null +++ b/_dev_scripts/update_versions.py @@ -0,0 +1,53 @@ +import os +import re +import toml + +# Define the paths to the vonage packages +packages = [ + "vonage-utils", + "vonage-http-client", + "vonage-account", + "vonage-application", + "vonage-messages", + "vonage-number-insight", + "vonage-numbers", + "vonage-sms", + "vonage-subaccounts", + "vonage-users", + "vonage-verify", + "vonage-verify-v2", + "vonage-video", + "vonage-voice", +] + + +# Function to read the version from _version.py +def get_version(package_path): + version_file = os.path.join( + package_path, "src", package_path.replace("-", "_"), "_version.py" + ) + with open(version_file, "r") as f: + content = f.read() + 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) + dependencies.append(f"{package}=={version}") + +pyproject["project"]["dependencies"] = dependencies + +# Write the updated pyproject.toml +with open("pyproject.toml", "w") as f: + toml.dump(pyproject, f) + +print("pyproject.toml updated with local package versions.") diff --git a/account/CHANGES.md b/account/CHANGES.md index be516a55..a6b2ad78 100644 --- a/account/CHANGES.md +++ b/account/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Add docstrings to data models + # 1.0.0 - Initial upload diff --git a/account/pyproject.toml b/account/pyproject.toml index 10477365..c971f197 100644 --- a/account/pyproject.toml +++ b/account/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-account' -version = '1.0.0' +dynamic = ["version"] description = 'Vonage Account API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-utils>=1.1.2", + "vonage-http-client>=1.4.1", + "vonage-utils>=1.1.3", "pydantic>=2.7.1", ] classifiers = [ @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/account/src/vonage_account/_version.py new file mode 100644 index 00000000..cd7ca498 --- /dev/null +++ b/account/src/vonage_account/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.1' diff --git a/account/src/vonage_account/account.py b/account/src/vonage_account/account.py index a182c0c1..80ce66d0 100644 --- a/account/src/vonage_account/account.py +++ b/account/src/vonage_account/account.py @@ -64,8 +64,8 @@ def top_up(self, trx: str) -> TopUpResponse: 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. + """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 diff --git a/account/src/vonage_account/responses.py b/account/src/vonage_account/responses.py index cada6687..9e980e95 100644 --- a/account/src/vonage_account/responses.py +++ b/account/src/vonage_account/responses.py @@ -19,8 +19,8 @@ class TopUpResponse(BaseModel): """Model for a response to a top-up request. Args: - error_code (str, Optional): The error code. - error_code_label (str, Optional): The error code label. + 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') @@ -28,6 +28,18 @@ class TopUpResponse(BaseModel): 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( @@ -42,5 +54,12 @@ class SettingsResponse(BaseModel): 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/application/CHANGES.md b/application/CHANGES.md index 5221bcaa..540d358a 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Add docstrings to data models + # 1.0.1 - Update project metadata diff --git a/application/pyproject.toml b/application/pyproject.toml index 0ce71532..dee25b9f 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-application' -version = '1.0.1' +dynamic = ["version"] description = 'Vonage Application API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.2", + "vonage-http-client>=1.4.1", + "vonage-utils>=1.1.3", "pydantic>=2.7.1", ] classifiers = [ @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/application/src/vonage_application/_version.py new file mode 100644 index 00000000..a6221b3d --- /dev/null +++ b/application/src/vonage_application/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.2' diff --git a/application/src/vonage_application/common.py b/application/src/vonage_application/common.py index 79cc9be9..5c58a7d5 100644 --- a/application/src/vonage_application/common.py +++ b/application/src/vonage_application/common.py @@ -7,23 +7,66 @@ 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.""" + """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 @@ -33,17 +76,38 @@ class Voice(BaseModel): 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.""" + """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 @@ -56,7 +120,13 @@ def check_http_method(cls, v: ApplicationUrl): class Messages(BaseModel): - """Messages application capabilities.""" + """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 @@ -71,6 +141,13 @@ class Vbc(BaseModel): 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') @@ -85,6 +162,9 @@ 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 @@ -92,10 +172,28 @@ class Verify(BaseModel): 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 @@ -104,14 +202,27 @@ class Capabilities(BaseModel): 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.""" + """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 diff --git a/application/src/vonage_application/enums.py b/application/src/vonage_application/enums.py index 7d75c213..66aaf0fa 100644 --- a/application/src/vonage_application/enums.py +++ b/application/src/vonage_application/enums.py @@ -2,6 +2,12 @@ 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' diff --git a/application/src/vonage_application/requests.py b/application/src/vonage_application/requests.py index c297a268..756a2737 100644 --- a/application/src/vonage_application/requests.py +++ b/application/src/vonage_application/requests.py @@ -6,11 +6,24 @@ class ListApplicationsFilter(BaseModel): - """Request object for filtering applications.""" + """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): - pass + """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 index de7634bd..2dc56c85 100644 --- a/application/src/vonage_application/responses.py +++ b/application/src/vonage_application/responses.py @@ -7,6 +7,20 @@ 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) @@ -20,10 +34,28 @@ def get_link(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 diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 68238c1e..4bfb7e29 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.4.1 +- Add docstrings to data models + # 1.4.0 - Add new `oauth2` logic for calling APIs that require Oauth diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index 20b5cd17..e59db2ea 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "vonage-http-client" -version = "1.4.0" +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.8" dependencies = [ - "vonage-utils>=1.1.2", - "vonage-jwt>=1.1.1", + "vonage-utils>=1.1.3", + "vonage-jwt>=1.1.2", "requests>=2.27.0", "typing-extensions>=4.9.0", "pydantic>=2.7.1", @@ -26,6 +26,9 @@ classifiers = [ [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/_version.py b/http_client/src/vonage_http_client/_version.py new file mode 100644 index 00000000..8e3c933c --- /dev/null +++ b/http_client/src/vonage_http_client/_version.py @@ -0,0 +1 @@ +__version__ = '1.4.1' diff --git a/http_client/src/vonage_http_client/auth.py b/http_client/src/vonage_http_client/auth.py index 15a05a14..556c85ae 100644 --- a/http_client/src/vonage_http_client/auth.py +++ b/http_client/src/vonage_http_client/auth.py @@ -66,9 +66,19 @@ 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: @@ -80,15 +90,17 @@ def generate_application_jwt(self, claims: dict = None) -> bytes: ) 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. + """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. diff --git a/http_client/src/vonage_http_client/errors.py b/http_client/src/vonage_http_client/errors.py index 3980a9a5..c262f5a3 100644 --- a/http_client/src/vonage_http_client/errors.py +++ b/http_client/src/vonage_http_client/errors.py @@ -104,7 +104,8 @@ def __init__(self, response: Response, content_type: str): class RateLimitedError(HttpRequestError): - """Exception indicating a rate limit was hit when making too many requests to a Vonage endpoint. + """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). @@ -122,8 +123,8 @@ def __init__(self, response: Response, content_type: str): class ServerError(HttpRequestError): - """Exception indicating an error was returned by a Vonage server in response to a Vonage SDK - request. + """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). diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index dda3ab61..b0a702f9 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -23,6 +23,18 @@ 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' @@ -240,6 +252,11 @@ def make_request( return self._parse_response(response) def append_to_user_agent(self, string: str): + """Append a string to the User-Agent header. + + Args: + string (str): The string to append to the User-Agent header. + """ self._user_agent += f' {string}' def _parse_response(self, response: Response) -> Union[dict, None]: diff --git a/jwt/CHANGES.md b/jwt/CHANGES.md index 62166af4..ca3bab5e 100644 --- a/jwt/CHANGES.md +++ b/jwt/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Dynamically specify package version + # 1.1.1 - Exceptions inherit from `VonageError` - Moving the package into the Vonage Python SDK monorepo diff --git a/jwt/pyproject.toml b/jwt/pyproject.toml index 569b7723..ca10165b 100644 --- a/jwt/pyproject.toml +++ b/jwt/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "vonage-jwt" -version = "1.1.1" +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.8" -dependencies = ["vonage-utils>=1.1.2", "pyjwt[crypto]>=1.6.4"] +dependencies = ["vonage-utils>=1.1.3", "pyjwt[crypto]>=1.6.4"] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", @@ -20,6 +20,9 @@ classifiers = [ [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/_version.py b/jwt/src/vonage_jwt/_version.py new file mode 100644 index 00000000..7b344eca --- /dev/null +++ b/jwt/src/vonage_jwt/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.2' diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py index b3c66712..f0f2c547 100644 --- a/jwt/src/vonage_jwt/jwt.py +++ b/jwt/src/vonage_jwt/jwt.py @@ -9,7 +9,8 @@ class JwtClient: - """Object used to pass in an application ID and private key to generate JWT methods.""" + """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 diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 12d34e57..b2b6912c 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 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 diff --git a/messages/pyproject.toml b/messages/pyproject.toml index 458ccabd..fdd111b1 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-messages' -version = '1.2.0' +dynamic = ["version"] description = 'Vonage messages package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-utils>=1.1.1", + "vonage-http-client>=1.4.1", + "vonage-utils>=1.1.3", "pydantic>=2.7.1", ] classifiers = [ @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/messages/src/vonage_messages/_version.py new file mode 100644 index 00000000..3f262a63 --- /dev/null +++ b/messages/src/vonage_messages/_version.py @@ -0,0 +1 @@ +__version__ = '1.2.1' diff --git a/messages/src/vonage_messages/models/base_message.py b/messages/src/vonage_messages/models/base_message.py index aa74e733..25959d77 100644 --- a/messages/src/vonage_messages/models/base_message.py +++ b/messages/src/vonage_messages/models/base_message.py @@ -7,6 +7,15 @@ 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 diff --git a/messages/src/vonage_messages/models/enums.py b/messages/src/vonage_messages/models/enums.py index 40a5ff95..f0bb501a 100644 --- a/messages/src/vonage_messages/models/enums.py +++ b/messages/src/vonage_messages/models/enums.py @@ -2,6 +2,8 @@ class MessageType(str, Enum): + """The type of message.""" + TEXT = 'text' IMAGE = 'image' AUDIO = 'audio' @@ -14,6 +16,8 @@ class MessageType(str, Enum): class ChannelType(str, Enum): + """The channel used to send a message.""" + SMS = 'sms' MMS = 'mms' RCS = 'rcs' @@ -23,6 +27,8 @@ class ChannelType(str, Enum): class WebhookVersion(str, Enum): + """Which version of the Messages API will be used to send Status Webhook messages.""" + V0_1 = 'v0.1' V1 = 'v1' diff --git a/messages/src/vonage_messages/models/messenger.py b/messages/src/vonage_messages/models/messenger.py index abb5bc9d..0a9afccf 100644 --- a/messages/src/vonage_messages/models/messenger.py +++ b/messages/src/vonage_messages/models/messenger.py @@ -7,10 +7,23 @@ 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 @@ -22,6 +35,17 @@ def check_tag_if_category_message_tag(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 @@ -29,25 +53,85 @@ class BaseMessenger(BaseMessage): 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 index 5dcd1e8a..6220ed11 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -8,11 +8,29 @@ class MmsResource(BaseModel): + """Model for a resource in an MMS message. + + Args: + url (str): The URL of the resource. + caption (str, Optional): Additional text to accompany the resource. + """ + url: str caption: Optional[str] = Field(None, min_length=1, max_length=2000) class BaseMms(BaseMessage): + """Model for a base MMS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + to: PhoneNumber from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') ttl: Optional[int] = Field(None, ge=300, le=259200) @@ -20,20 +38,68 @@ class BaseMms(BaseMessage): class MmsImage(BaseMms): + """Model for an MMS image message. + + Args: + image (MmsResource): The image resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + image: MmsResource message_type: MessageType = MessageType.IMAGE class MmsVcard(BaseMms): + """Model for an MMS vCard message. + + Args: + vcard (MmsResource): The vCard resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + vcard: MmsResource message_type: MessageType = MessageType.VCARD class MmsAudio(BaseMms): + """Model for an MMS audio message. + + Args: + audio (MmsResource): The audio resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + audio: MmsResource message_type: MessageType = MessageType.AUDIO class MmsVideo(BaseMms): + """Model for an MMS video message. + + Args: + video (MmsResource): The video resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + video: MmsResource message_type: MessageType = MessageType.VIDEO diff --git a/messages/src/vonage_messages/models/rcs.py b/messages/src/vonage_messages/models/rcs.py index bec9ee90..ef856061 100644 --- a/messages/src/vonage_messages/models/rcs.py +++ b/messages/src/vonage_messages/models/rcs.py @@ -8,10 +8,27 @@ class RcsResource(BaseModel): + """Model for a resource in an RCS message. + + Args: + url (str): The URL of the resource. + """ + url: str class BaseRcs(BaseMessage): + """Model for a base RCS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + to: PhoneNumber from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9]+$') ttl: Optional[int] = Field(None, ge=300, le=259200) @@ -19,25 +36,85 @@ class BaseRcs(BaseMessage): class RcsText(BaseRcs): + """Model for an RCS text message. + + Args: + text (str): The text of the message. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + text: str = Field(..., min_length=1, max_length=3072) message_type: MessageType = MessageType.TEXT class RcsImage(BaseRcs): + """Model for an RCS image message. + + Args: + image (RcsResource): The image resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + image: RcsResource message_type: MessageType = MessageType.IMAGE class RcsVideo(BaseRcs): + """Model for an RCS video message. + + Args: + video (RcsResource): The video resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + video: RcsResource message_type: MessageType = MessageType.VIDEO class RcsFile(BaseRcs): + """Model for an RCS file message. + + Args: + file (RcsResource): The file resource. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + file: RcsResource message_type: MessageType = MessageType.FILE class RcsCustom(BaseRcs): + """Model for an RCS custom message. + + Args: + custom (dict): The custom message data. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a leading plus sign. + from_ (str): The sender's phone number in E.164 format. Don't use a leading plus sign. + ttl (int, Optional): The duration in seconds for which the message is valid. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + custom: dict message_type: MessageType = MessageType.CUSTOM diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index cf6e5dbd..dd52541f 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -8,12 +8,42 @@ class SmsOptions(BaseModel): + """Model for SMS options. + + Args: + encoding_type (EncodingType, Optional): The encoding type to use for the message. + If set to either text or unicode the specified type will be used. + If set to auto (the default), the Messages API will automatically set + the type based on the content. + content_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. Not needed unless + sending SMS in a country that requires a specific content ID. + entity_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. Not needed unless + sending SMS in a country that requires a specific entity ID. + """ + encoding_type: Optional[EncodingType] = None content_id: Optional[str] = None entity_id: Optional[str] = None class Sms(BaseMessage): + """Model for an SMS message. + + Args: + to (PhoneNumber): The recipient's phone number in E.164 format. + Don't use a leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + text (str): The text of the message. + ttl (int, Optional): The duration in seconds for which the message is valid. + sms (SmsOptions, Optional): SMS options. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API will be used to send Status Webhook messages for this particular message. + """ + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') text: str = Field(..., max_length=1000) ttl: Optional[int] = None diff --git a/messages/src/vonage_messages/models/viber.py b/messages/src/vonage_messages/models/viber.py index b135f614..71ea8b35 100644 --- a/messages/src/vonage_messages/models/viber.py +++ b/messages/src/vonage_messages/models/viber.py @@ -7,54 +7,177 @@ 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 @@ -76,21 +199,72 @@ def validate_file_size(cls, 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): - pass + """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 index c5ee18cc..9ec3a661 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -8,61 +8,240 @@ 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 @@ -70,29 +249,115 @@ class WhatsappTemplateResource(BaseModel): class WhatsappTemplateSettings(BaseModel): + """Model for WhatsApp template settings. + + Args: + locale (Optional[str]): The BCP 47 language of the template. + policy (Optional[Literal['deterministic']]): Policy for resolving what language + template to use. As of now, the only valid choice is deterministic. + """ + locale: Optional[str] = 'en_US' policy: Optional[Literal['deterministic']] = None class WhatsappTemplate(BaseWhatsapp): + """Model for a WhatsApp template message. + + Args: + template (WhatsappTemplateResource): The template to use. + whatsapp (WhatsappTemplateSettings): WhatsApp template settings. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + template: WhatsappTemplateResource whatsapp: WhatsappTemplateSettings = WhatsappTemplateSettings() message_type: MessageType = MessageType.TEMPLATE class WhatsappStickerUrl(BaseModel): + """Model for a sticker attachment in a WhatsApp message. + + Args: + url (str): The publicly accessible URL of the sticker attachment. + """ + url: str class WhatsappStickerId(BaseModel): + """Model for a sticker attachment in a WhatsApp message. + + Args: + id (str): The id of the sticker in relation to a specific WhatsApp deployment. + """ + id: str class WhatsappSticker(BaseWhatsapp): + """Model for a WhatsApp sticker message. + + Args: + sticker (Union[WhatsappStickerUrl, WhatsappStickerId]): The sticker attachment. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + sticker: Union[WhatsappStickerUrl, WhatsappStickerId] message_type: MessageType = MessageType.STICKER class WhatsappCustom(BaseWhatsapp): + """Model for a WhatsApp custom message. + + Args: + custom (dict): A custom payload, which is passed directly to WhatsApp for certain + features such as templates and interactive messages. The schema of a custom + object can vary widely. + to (PhoneNumber): The recipient's phone number in E.164 format. Don't use a + leading plus sign. + from_ (Union[PhoneNumber, str]): The sender's phone number in E.164 format. + Don't use a leading plus sign. + context (WhatsappContext, Optional): Used for quoting/replying/reacting to a + specific message in a conversation. When used for quoting or replying, + the WhatsApp UI will display the new message along with a contextual bubble + that displays the quoted/replied to message's content. When used for + reacting, the WhatsApp UI will display the reaction emoji below the reacted + to message. + client_ref (str, Optional): An optional client reference. + webhook_url (str, Optional): The URL to which Status Webhook messages will be + sent for this particular message. + webhook_version (WebhookVersion, Optional): Which version of the Messages API + will be used to send Status Webhook messages for this particular message. + """ + custom: Optional[dict] = None message_type: MessageType = MessageType.CUSTOM diff --git a/network_auth/CHANGES.md b/network_auth/CHANGES.md index 4df12901..01a634f4 100644 --- a/network_auth/CHANGES.md +++ b/network_auth/CHANGES.md @@ -1,2 +1,5 @@ +# 0.1.1b0 +- Add docstrings to data models + # 0.1.0b0 - Initial upload \ No newline at end of file diff --git a/network_auth/pyproject.toml b/network_auth/pyproject.toml index c14a52de..3dfbea88 100644 --- a/network_auth/pyproject.toml +++ b/network_auth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vonage-network-auth" -version = "0.1.0b0" +dynamic = ["version"] description = "Package for working with Network APIs that require Oauth2 in Python." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -20,6 +20,9 @@ classifiers = [ [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/_version.py b/network_auth/src/vonage_network_auth/_version.py new file mode 100644 index 00000000..b7a18f07 --- /dev/null +++ b/network_auth/src/vonage_network_auth/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.1b0' diff --git a/network_auth/src/vonage_network_auth/network_auth.py b/network_auth/src/vonage_network_auth/network_auth.py index 1813d59c..9af1d8a7 100644 --- a/network_auth/src/vonage_network_auth/network_auth.py +++ b/network_auth/src/vonage_network_auth/network_auth.py @@ -5,7 +5,8 @@ class NetworkAuth: - """Class containing methods for authenticating Network APIs following Camara standards.""" + """Class containing methods for authenticating Network APIs following Camara + standards.""" def __init__(self, http_client: HttpClient): self._http_client = http_client @@ -64,8 +65,16 @@ def make_oidc_request(self, number: str, scope: str) -> OidcResponse: def request_access_token( self, auth_req_id: str, grant_type: str = 'urn:openid:params:grant-type:ciba' ) -> TokenResponse: - """Request a Camara access token using an authentication request ID given as a response to - an OIDC request.""" + """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. + """ params = {'auth_req_id': auth_req_id, 'grant_type': grant_type} response = self._http_client.post( diff --git a/network_auth/src/vonage_network_auth/responses.py b/network_auth/src/vonage_network_auth/responses.py index 396fdf40..edcc6b62 100644 --- a/network_auth/src/vonage_network_auth/responses.py +++ b/network_auth/src/vonage_network_auth/responses.py @@ -4,12 +4,29 @@ 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 diff --git a/network_sim_swap/CHANGES.md b/network_sim_swap/CHANGES.md index 4df12901..01a634f4 100644 --- a/network_sim_swap/CHANGES.md +++ b/network_sim_swap/CHANGES.md @@ -1,2 +1,5 @@ +# 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/pyproject.toml b/network_sim_swap/pyproject.toml index 2d8b4d26..30f8009c 100644 --- a/network_sim_swap/pyproject.toml +++ b/network_sim_swap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vonage-network-sim-swap" -version = "0.1.0b0" +dynamic = ["version"] description = "Package for working with the Vonage Sim Swap Network API." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/network_sim_swap/src/vonage_network_sim_swap/_version.py new file mode 100644 index 00000000..b7a18f07 --- /dev/null +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.1b0' diff --git a/network_sim_swap/src/vonage_network_sim_swap/responses.py b/network_sim_swap/src/vonage_network_sim_swap/responses.py index 80c76349..20924cb4 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/responses.py +++ b/network_sim_swap/src/vonage_network_sim_swap/responses.py @@ -2,8 +2,21 @@ 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 index 576a9a8b..34f29e10 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py +++ b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py @@ -61,6 +61,7 @@ def get_last_swap_date(self, phone_number: str) -> LastSwapDate: or without a leading +. Returns: + LastSwapDate: Class containing the Last Swap Date response. """ token = self._network_auth.get_oauth2_user_token( number=phone_number, diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index be516a55..a6b2ad78 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,2 +1,5 @@ +# 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 index 3c4aa76d..5a128b3d 100644 --- a/number_insight/README.md +++ b/number_insight/README.md @@ -55,9 +55,4 @@ from vonage_number_insight import AdvancedSyncInsightRequest vonage_client.number_insight.advanced_sync_number_insight( AdvancedSyncInsightRequest(number='12345678900') ) - -# Optionally, you can get real time information by setting the `real_time_data` parameter = True -vonage_client.number_insight.advanced_async_number_insight( - AdvancedSyncInsightRequest(number='12345678900', real_time_data=True, cnam=True) -) ``` \ No newline at end of file diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml index 36b0c0f5..6a5454c0 100644 --- a/number_insight/pyproject.toml +++ b/number_insight/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-number-insight' -version = '1.0.0' +dynamic = ["version"] description = 'Vonage Number Insight package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", + "vonage-http-client>=1.4.1", + "vonage-utils>=1.1.3", "pydantic>=2.7.1", ] classifiers = [ @@ -24,6 +24,9 @@ classifiers = [ [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/__init__.py b/number_insight/src/vonage_number_insight/__init__.py index 83dc11c3..b10cfa05 100644 --- a/number_insight/src/vonage_number_insight/__init__.py +++ b/number_insight/src/vonage_number_insight/__init__.py @@ -12,7 +12,6 @@ BasicInsightResponse, CallerIdentity, Carrier, - RealTimeData, RoamingStatus, StandardInsightResponse, ) @@ -26,7 +25,6 @@ 'BasicInsightResponse', 'CallerIdentity', 'Carrier', - 'RealTimeData', 'RoamingStatus', 'StandardInsightResponse', 'AdvancedSyncInsightResponse', 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..cd7ca498 --- /dev/null +++ b/number_insight/src/vonage_number_insight/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.1' diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py index bd00d11e..fef34c47 100644 --- a/number_insight/src/vonage_number_insight/number_insight.py +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -115,7 +115,7 @@ def advanced_sync_number_insight( Args: Options (AdvancedSyncInsightRequest): The options for the request. The `number` parameter - is required, and the `country_code`, `cnam`, and `real_time_data` parameters are optional. + is required, and the `country_code` and `cnam` parameters are optional. Returns: AdvancedSyncInsightResponse: The response object containing the advanced number insight diff --git a/number_insight/src/vonage_number_insight/requests.py b/number_insight/src/vonage_number_insight/requests.py index 0be558fa..2e57674e 100644 --- a/number_insight/src/vonage_number_insight/requests.py +++ b/number_insight/src/vonage_number_insight/requests.py @@ -5,17 +5,47 @@ class BasicInsightRequest(BaseModel): + """Model for a basic number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + """ + number: PhoneNumber country: Optional[str] = None class StandardInsightRequest(BasicInsightRequest): + """Model for a standard number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. + """ + cnam: Optional[bool] = None class AdvancedAsyncInsightRequest(StandardInsightRequest): + """Model for an advanced asynchronous number insight request. + + Args: + number (PhoneNumber): The phone number to get insight information for. + country (str, Optional): The country code for the phone number. + cnam (bool, Optional): Whether to include the Caller ID Name (CNAM) with the response. + callback (str): The URL to send the asynchronous response to. + """ + callback: str class AdvancedSyncInsightRequest(StandardInsightRequest): - real_time_data: Optional[bool] = None + """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 index 5217ce94..d50c77a5 100644 --- a/number_insight/src/vonage_number_insight/responses.py +++ b/number_insight/src/vonage_number_insight/responses.py @@ -4,6 +4,26 @@ 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 @@ -16,6 +36,20 @@ class BasicInsightResponse(BaseModel): 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 @@ -23,6 +57,19 @@ class Carrier(BaseModel): 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 @@ -31,6 +78,41 @@ class CallerIdentity(BaseModel): 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 @@ -41,27 +123,87 @@ class StandardInsightResponse(BasicInsightResponse): 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 RealTimeData(BaseModel): - active_status: Optional[str] = None - handset_status: 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 - real_time_data: Optional[RealTimeData] = 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 diff --git a/number_insight/tests/test_number_insight.py b/number_insight/tests/test_number_insight.py index 97280695..a9494954 100644 --- a/number_insight/tests/test_number_insight.py +++ b/number_insight/tests/test_number_insight.py @@ -141,7 +141,7 @@ def test_advanced_sync_insight(caplog): 'advanced_sync_insight.json', ) options = AdvancedSyncInsightRequest( - number='12345678900', country_code='US', cnam=True, real_time_data=True + number='12345678900', country_code='US', cnam=True ) response = number_insight.advanced_sync_number_insight(options) diff --git a/number_management/CHANGES.md b/number_management/CHANGES.md index be516a55..a2d234fe 100644 --- a/number_management/CHANGES.md +++ b/number_management/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Add docstrings for data models + # 1.0.0 - Initial upload diff --git a/number_management/pyproject.toml b/number_management/pyproject.toml index 136628a3..cd4de353 100644 --- a/number_management/pyproject.toml +++ b/number_management/pyproject.toml @@ -1,13 +1,13 @@ [project] name = 'vonage-numbers' -version = '1.0.0' +dynamic = ["version"] description = 'Vonage Numbers package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-utils>=1.1.2", + "vonage-http-client>=1.4.1", + "vonage-utils>=1.1.3", "pydantic>=2.7.1", ] classifiers = [ @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/number_management/src/vonage_numbers/_version.py new file mode 100644 index 00000000..cd7ca498 --- /dev/null +++ b/number_management/src/vonage_numbers/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.1' diff --git a/number_management/src/vonage_numbers/requests.py b/number_management/src/vonage_numbers/requests.py index 1bc1462b..a94c859a 100644 --- a/number_management/src/vonage_numbers/requests.py +++ b/number_management/src/vonage_numbers/requests.py @@ -37,9 +37,9 @@ class SearchAvailableNumbersFilter(ListNumbersFilter): class NumberParams(BaseModel): """Specify the two-letter country code and the number you are referring to. - 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. + 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. """ country: str = Field(..., min_length=2, max_length=2) diff --git a/pants.toml b/pants.toml index 1ea7e865..e0fba13a 100644 --- a/pants.toml +++ b/pants.toml @@ -63,7 +63,7 @@ args = ['--profile=black', '--line-length=90'] interpreter_constraints = ['>=3.8'] [docformatter] -args = ['--wrap-summaries=100', '--wrap-descriptions=100'] +args = ['--wrap-summaries=90', '--wrap-descriptions=90'] interpreter_constraints = ['>=3.8'] [autoflake] diff --git a/requirements.txt b/requirements.txt index 1ff7b305..5b8df2f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ responses>=0.24.1 pydantic>=2.7.1 typing-extensions>=4.9.0 pyjwt[crypto]>=1.6.4 +toml>=0.10.2 -e jwt -e http_client diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index 3f685ac9..0796bd7a 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -12,8 +12,8 @@ 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. + 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: diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index e13e3fdb..08fc2b70 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -17,8 +17,8 @@ class Verify: """Calls Vonage's Verify API. - This class provides methods to interact with Vonage's Verify API for starting verification - processes. + This class provides methods to interact with Vonage's Verify API for starting + verification processes. """ def __init__(self, http_client: HttpClient) -> None: @@ -167,7 +167,8 @@ def trigger_next_event(self, request_id: str) -> VerifyControlStatus: 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. + """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. diff --git a/verify_v2/src/vonage_verify_v2/verify_v2.py b/verify_v2/src/vonage_verify_v2/verify_v2.py index 98b56fa6..d405b6f9 100644 --- a/verify_v2/src/vonage_verify_v2/verify_v2.py +++ b/verify_v2/src/vonage_verify_v2/verify_v2.py @@ -68,8 +68,8 @@ def cancel_verification(self, request_id: str) -> None: @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. + """Trigger the next workflow event in the list of workflows passed in when making + the request. Args: request_id (str): The request ID. diff --git a/video/src/vonage_video/models/broadcast.py b/video/src/vonage_video/models/broadcast.py index 59a0a5ba..5928b717 100644 --- a/video/src/vonage_video/models/broadcast.py +++ b/video/src/vonage_video/models/broadcast.py @@ -152,7 +152,8 @@ class Broadcast(BaseModel): class BroadcastOutputSettings(BaseModel): - """Model for output options for a broadcast. You must specify at least one output option. + """Model for output options for a broadcast. You must specify at least one output + option. Args: hls (BroadcastHls, Optional): HLS output settings. diff --git a/video/src/vonage_video/models/enums.py b/video/src/vonage_video/models/enums.py index 50e0ae8e..24e594ac 100644 --- a/video/src/vonage_video/models/enums.py +++ b/video/src/vonage_video/models/enums.py @@ -18,8 +18,8 @@ class ArchiveMode(str, Enum): class MediaMode(str, Enum): - """Whether the session uses the Vonage Video media router ("routed") or peers connect directly - (relayed).""" + """Whether the session uses the Vonage Video media router ("routed") or peers connect + directly (relayed).""" ROUTED = 'routed' RELAYED = 'relayed' @@ -58,8 +58,8 @@ class AudioSampleRate(int, Enum): 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. + 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' @@ -83,8 +83,8 @@ class OutputMode(str, Enum): class StreamMode(str, Enum): - """Whether streams included in the archive are selected automatically ("auto", the default) or - manually ("manual").""" + """Whether streams included in the archive are selected automatically ("auto", the + default) or manually ("manual").""" AUTO = 'auto' MANUAL = 'manual' diff --git a/video/src/vonage_video/models/experience_composer.py b/video/src/vonage_video/models/experience_composer.py index 03fb743f..03f710d4 100644 --- a/video/src/vonage_video/models/experience_composer.py +++ b/video/src/vonage_video/models/experience_composer.py @@ -69,8 +69,8 @@ class ExperienceComposer(BaseModel): class ListExperienceComposersFilter(BaseModel): - """Request object for filtering Experience Composers associated with the specific Vonage - application. + """Request object for filtering Experience Composers associated with the specific + Vonage application. Args: offset (int, Optional): The offset. diff --git a/video/src/vonage_video/models/sip.py b/video/src/vonage_video/models/sip.py index a8c5730a..5db0b9b2 100644 --- a/video/src/vonage_video/models/sip.py +++ b/video/src/vonage_video/models/sip.py @@ -4,8 +4,8 @@ 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. + """Model representing the authentication details for the SIP INVITE request for HTTP + digest authentication, if it is required by your SIP platform. Attributes: username (str): The username for HTTP digest authentication. diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 8fb43a04..bda3237d 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -153,8 +153,8 @@ def change_stream_layout( 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. + """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. @@ -436,8 +436,9 @@ def delete_archive(self, archive_id: str) -> None: @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". + """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. @@ -624,9 +625,9 @@ def change_broadcast_layout( 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". + """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. @@ -680,7 +681,8 @@ def initiate_sip_call(self, sip_request_params: InitiateSipRequest) -> SipCall: @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. + """Plays DTMF tones into one or all SIP connections in a session using the Vonage + Video API. Args: session_id (str): The session ID. diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index 875e4d0b..0f6d961e 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -45,8 +45,8 @@ def enable_split(self): class Conversation(NccoAction): - """You can use the Conversation action to create standard or moderated conferences, while - preserving the communication context. + """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. """ @@ -71,8 +71,8 @@ def can_mute(self): class Connect(NccoAction): - """You can use the Connect action to connect a call to endpoints such as phone numbers or a VBC - extension.""" + """You can use the Connect action to connect a call to endpoints such as phone numbers + or a VBC extension.""" endpoint: List[ Union[PhoneEndpoint, AppEndpoint, WebsocketEndpoint, SipEndpoint, VbcEndpoint] diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md index 14605ecf..8d235239 100644 --- a/vonage_utils/CHANGES.md +++ b/vonage_utils/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.3 +- Add docstrings to data models + # 1.1.2 - Refactoring common pydantic models across the monorepo into this package diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index bbe7c5b3..54425918 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-utils' -version = '1.1.2' +dynamic = ["version"] description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -20,6 +20,9 @@ classifiers = [ [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/_version.py b/vonage_utils/src/vonage_utils/_version.py new file mode 100644 index 00000000..7bb021e2 --- /dev/null +++ b/vonage_utils/src/vonage_utils/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.3' diff --git a/vonage_utils/src/vonage_utils/models.py b/vonage_utils/src/vonage_utils/models.py index c3a21698..7c5b2951 100644 --- a/vonage_utils/src/vonage_utils/models.py +++ b/vonage_utils/src/vonage_utils/models.py @@ -4,14 +4,36 @@ 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 diff --git a/vonage_utils/src/vonage_utils/utils.py b/vonage_utils/src/vonage_utils/utils.py index 8e02c0b7..781cdc41 100644 --- a/vonage_utils/src/vonage_utils/utils.py +++ b/vonage_utils/src/vonage_utils/utils.py @@ -37,8 +37,8 @@ def format_phone_number(number: Union[str, int]) -> str: 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. + """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 From 2718da3692fc7f5d01139119c0d8a4db0811d542 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 10 Oct 2024 18:25:43 +0100 Subject: [PATCH 77/98] add more docstrings for data models --- .gitignore | 1 + .../src/vonage_numbers/requests.py | 85 ++++++++++++++++- .../src/vonage_numbers/responses.py | 36 ++++++++ sms/CHANGES.md | 3 + sms/pyproject.toml | 5 +- sms/src/vonage_sms/_version.py | 1 + sms/src/vonage_sms/requests.py | 39 +++++++- sms/src/vonage_sms/responses.py | 24 +++++ sms/src/vonage_sms/sms.py | 34 ++++++- subaccounts/CHANGES.md | 3 + subaccounts/pyproject.toml | 5 +- .../src/vonage_subaccounts/_version.py | 1 + .../src/vonage_subaccounts/requests.py | 50 ++++++++++ .../src/vonage_subaccounts/responses.py | 84 ++++++++++++++++- .../src/vonage_subaccounts/subaccounts.py | 10 +- testutils/mock_auth.py | 6 ++ testutils/testutils.py | 16 ++++ users/CHANGES.md | 3 + users/pyproject.toml | 5 +- users/src/vonage_users/_version.py | 1 + users/src/vonage_users/common.py | 92 +++++++++++++++++++ users/src/vonage_users/requests.py | 10 +- users/src/vonage_users/responses.py | 33 +++++++ users/src/vonage_users/users.py | 3 - verify/CHANGES.md | 3 + verify/pyproject.toml | 5 +- verify/src/vonage_verify/_version.py | 1 + verify/src/vonage_verify/language_codes.py | 4 + verify/src/vonage_verify/requests.py | 66 ++++++++++++- verify/src/vonage_verify/responses.py | 81 ++++++++++++++++ verify/src/vonage_verify/verify.py | 6 ++ verify_v2/CHANGES.md | 3 + verify_v2/pyproject.toml | 5 +- verify_v2/src/vonage_verify_v2/_version.py | 1 + verify_v2/src/vonage_verify_v2/requests.py | 91 +++++++++++++++++- verify_v2/src/vonage_verify_v2/responses.py | 15 +++ video/pyproject.toml | 5 +- video/src/vonage_video/_version.py | 1 + video/src/vonage_video/models/archive.py | 5 + .../vonage_video/models/audio_connector.py | 7 +- video/src/vonage_video/models/broadcast.py | 6 ++ video/src/vonage_video/models/common.py | 5 + video/src/vonage_video/models/session.py | 10 +- video/src/vonage_video/models/signal.py | 7 +- video/src/vonage_video/models/sip.py | 8 +- video/src/vonage_video/models/stream.py | 17 +++- video/src/vonage_video/models/token.py | 23 +++-- vonage_utils/src/vonage_utils/types.py | 18 ++++ 48 files changed, 900 insertions(+), 43 deletions(-) create mode 100644 sms/src/vonage_sms/_version.py create mode 100644 subaccounts/src/vonage_subaccounts/_version.py create mode 100644 users/src/vonage_users/_version.py create mode 100644 verify/src/vonage_verify/_version.py create mode 100644 verify_v2/src/vonage_verify_v2/_version.py create mode 100644 video/src/vonage_video/_version.py diff --git a/.gitignore b/.gitignore index fce8e163..1a72e6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ ENV* html/ .mutmut-cache _test_scripts/ +_dev_scripts/ # Pants workspace files /.pants.* diff --git a/number_management/src/vonage_numbers/requests.py b/number_management/src/vonage_numbers/requests.py index a94c859a..1189ff61 100644 --- a/number_management/src/vonage_numbers/requests.py +++ b/number_management/src/vonage_numbers/requests.py @@ -8,6 +8,21 @@ 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) @@ -23,23 +38,70 @@ def check_search_pattern_if_pattern(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): - """Specify the two-letter country code and the number you are referring to. + """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) @@ -48,6 +110,27 @@ class NumberParams(BaseModel): 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 diff --git a/number_management/src/vonage_numbers/responses.py b/number_management/src/vonage_numbers/responses.py index da1c8721..9758e0d3 100644 --- a/number_management/src/vonage_numbers/responses.py +++ b/number_management/src/vonage_numbers/responses.py @@ -4,6 +4,24 @@ 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') @@ -23,6 +41,16 @@ class OwnedNumber(BaseModel): 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 @@ -31,5 +59,13 @@ class AvailableNumber(BaseModel): 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/sms/CHANGES.md b/sms/CHANGES.md index 62d1cf17..1c7af6dc 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Add docstrings to data models + # 1.1.1 - Update minimum dependency version diff --git a/sms/pyproject.toml b/sms/pyproject.toml index 29c2215c..1ea50176 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-sms' -version = '1.1.1' +dynamic = ["version"] description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/sms/src/vonage_sms/_version.py new file mode 100644 index 00000000..7b344eca --- /dev/null +++ b/sms/src/vonage_sms/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.2' diff --git a/sms/src/vonage_sms/requests.py b/sms/src/vonage_sms/requests.py index b1a5a777..b06c832a 100644 --- a/sms/src/vonage_sms/requests.py +++ b/sms/src/vonage_sms/requests.py @@ -4,7 +4,44 @@ class SmsMessage(BaseModel): - """Message object containing the data and options for an SMS message.""" + """Message object containing the data and options for an SMS message. + + Args: + to (str): The recipient's phone number in E.164 format. + from_ (str): The name or number the message should be sent from. If a number, it + must be specified in E.164 format, without a leading `+` or `00`. If using + an alphanumeric sender IDs, spaces will be ignored. Sender IDs are not + supported in all countries. + text (str): The message body. If your message contains characters that can be + encoded according to the GSM Standard and Extended tables then you can set + `type` to `text`. If your message contains characters outside this range, + you will need to set `type` to `unicode`. + sig (str, Optional): The hash of the request parameters in alphabetical order, a + timestamp and the signature secret. + client_ref (str, Optional): A client reference string you can optionally include. + type (str, Optional): The format of the message body. Can be 'text', 'binary', or + 'unicode'. + ttl (int, Optional): The duration in milliseconds the delivery of an SMS will be + attempted. + status_report_req (bool, Optional): Boolean indicating if you like to receive a + delivery receipt. + callback (str, Optional): The webhook endpoint the delivery receipt for this SMS + is sent to. This parameter overrides the webhook endpoint you set in the + Vonage Developer Dashboard. + message_class (int, Optional): The Data Coding Scheme value of the message. + body (str, Optional): Hex-encoded binary data. Depends on `type` having the value + `binary`. + udh (str, Optional): The hex-encoded user data header for binary messages. + protocol_id (int, Optional): The protocol identifier for binary messages. Ensure + that the value is aligned with `udh`. + account_ref (str, Optional): An optional string used to identify separate + accounts using the SMS endpoint for billing purposes. To use this feature, + please email support. + entity_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. + content_id (str, Optional): A string parameter that satisfies regulatory + requirements when sending an SMS to specific countries. + """ to: str from_: str = Field(..., serialization_alias='from') diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py index d6428c07..4b594190 100644 --- a/sms/src/vonage_sms/responses.py +++ b/sms/src/vonage_sms/responses.py @@ -4,6 +4,22 @@ class MessageResponse(BaseModel): + """Individual message response model. + + Args: + to (str): The recipient's phone number in E.164 format. + message_id (str): The message ID. + status (str): The status of the message. + remaining_balance (str): The estimated remaining balance. + message_price (str): The estimated message cost. + network (str): The estimated ID of the network of the recipient + client_ref (str, Optional): If a `client_ref` was included when sending the SMS, + this field will be included and hold the value that was sent. + account_ref (str, Optional): An optional string used to identify separate + accounts using the SMS endpoint for billing purposes. To use this feature, + please email support. + """ + to: str message_id: str = Field(..., validation_alias='message-id') status: str @@ -15,5 +31,13 @@ class MessageResponse(BaseModel): 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 index b18a434a..5e78eeb8 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -9,7 +9,15 @@ class Sms: - """Calls Vonage's SMS API.""" + """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 @@ -30,7 +38,27 @@ def http_client(self) -> HttpClient: @validate_call def send(self, message: SmsMessage) -> SmsResponse: - """Send an SMS message.""" + """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', @@ -65,7 +93,7 @@ def _check_for_error(self, response_data): @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. diff --git a/subaccounts/CHANGES.md b/subaccounts/CHANGES.md index 3a9df1d5..b133ad93 100644 --- a/subaccounts/CHANGES.md +++ b/subaccounts/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Add docstrings to data models + # 1.0.1 - Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml index b735f44d..904a3701 100644 --- a/subaccounts/pyproject.toml +++ b/subaccounts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-subaccounts' -version = '1.0.1' +dynamic = ["version"] description = 'Vonage Subaccounts API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/subaccounts/src/vonage_subaccounts/_version.py new file mode 100644 index 00000000..a6221b3d --- /dev/null +++ b/subaccounts/src/vonage_subaccounts/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.2' diff --git a/subaccounts/src/vonage_subaccounts/requests.py b/subaccounts/src/vonage_subaccounts/requests.py index c2c5d45d..6bfa3ed5 100644 --- a/subaccounts/src/vonage_subaccounts/requests.py +++ b/subaccounts/src/vonage_subaccounts/requests.py @@ -6,6 +6,18 @@ 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 @@ -22,6 +34,8 @@ def check_valid_secret(cls, 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): @@ -34,18 +48,45 @@ def _is_valid_secret(secret: str) -> bool: 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 @@ -53,6 +94,15 @@ class TransferRequest(BaseModel): 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 diff --git a/subaccounts/src/vonage_subaccounts/responses.py b/subaccounts/src/vonage_subaccounts/responses.py index 98c86dd3..230197a9 100644 --- a/subaccounts/src/vonage_subaccounts/responses.py +++ b/subaccounts/src/vonage_subaccounts/responses.py @@ -4,6 +4,17 @@ 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 @@ -13,15 +24,49 @@ class VonageAccount(BaseModel): 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 @@ -29,10 +74,38 @@ class ListSubaccountsResponse(BaseModel): 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') @@ -42,6 +115,15 @@ class Transfer(BaseModel): 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') diff --git a/subaccounts/src/vonage_subaccounts/subaccounts.py b/subaccounts/src/vonage_subaccounts/subaccounts.py index 1a2f8c4e..02f6bd01 100644 --- a/subaccounts/src/vonage_subaccounts/subaccounts.py +++ b/subaccounts/src/vonage_subaccounts/subaccounts.py @@ -20,7 +20,11 @@ class Subaccounts: - """Class containing methods to manage Vonage 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 @@ -28,10 +32,10 @@ def __init__(self, http_client: HttpClient) -> None: @property def http_client(self) -> HttpClient: - """The HTTP client used to make requests to the Users API. + """The HTTP client used to make requests to the Subaccounts API. Returns: - HttpClient: The HTTP client used to make requests to the Users API. + HttpClient: The HTTP client used to make requests to the Subaccounts API. """ return self._http_client diff --git a/testutils/mock_auth.py b/testutils/mock_auth.py index 3237c801..ac724e81 100644 --- a/testutils/mock_auth.py +++ b/testutils/mock_auth.py @@ -4,15 +4,21 @@ def read_file(path): + """Read a file from the testutils/data directory.""" + with open(join(dirname(__file__), path)) as input_file: return input_file.read() def get_mock_api_key_auth(): + """Return an Auth object with an API key and secret.""" + return Auth(api_key='test_api_key', api_secret='test_api_secret') def get_mock_jwt_auth(): + """Return an Auth object with a JWT.""" + return Auth( application_id='test_application_id', private_key=read_file('data/fake_private_key.txt'), diff --git a/testutils/testutils.py b/testutils/testutils.py index 8a7f46c9..f2a547c6 100644 --- a/testutils/testutils.py +++ b/testutils/testutils.py @@ -6,11 +6,15 @@ def _load_mock_data(caller_file_path: str, mock_path: str): + """Load mock data from a file.""" + with open(join(dirname(caller_file_path), 'data', mock_path)) as file: return file.read() def _filter_none_values(data: dict) -> dict: + """Filter out None values from a dictionary.""" + return {k: v for (k, v) in data.items() if v is not None} @@ -24,6 +28,18 @@ def build_response( 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( diff --git a/users/CHANGES.md b/users/CHANGES.md index 4e958551..7d363b60 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.3 +- Add docstrings to data models + # 1.1.2 - Internal refactoring diff --git a/users/pyproject.toml b/users/pyproject.toml index da4b9ec6..8a8e2910 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-users' -version = '1.1.2' +dynamic = ["version"] description = 'Vonage Users package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/users/src/vonage_users/_version.py new file mode 100644 index 00000000..7bb021e2 --- /dev/null +++ b/users/src/vonage_users/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.3' diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index 18527630..a1ab9a82 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -6,20 +6,48 @@ class PstnChannel(BaseModel): + """Model for a PSTN channel. + + Args: + number (int): The PSTN number. + """ + number: int class SipChannel(BaseModel): + """Model for a SIP channel. + + Args: + uri (str): The SIP URI. + username (str, Optional): The username for the SIP channel. + password (str, Optional): The password for the SIP channel. + """ + uri: str = Field(..., pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)') username: str = None password: str = None class VbcChannel(BaseModel): + """Model for a VBC channel. + + Args: + extension (str): The VBC extension. + """ + extension: str class WebsocketChannel(BaseModel): + """Model for a WebSocket channel. + + Args: + uri (str): URI for the WebSocket. + content_type (str, Optional): Content type for the WebSocket. + headers (dict, Optional): Headers sent to the WebSocket. + """ + uri: str = Field(pattern=r'^(ws|wss):\/\/[a-zA-Z0-9~#%@&-_?\/.,:;)(\]\[]*$') content_type: Optional[str] = Field( None, alias='content-type', pattern='^audio/l16;rate=(8000|16000)$' @@ -28,26 +56,70 @@ class WebsocketChannel(BaseModel): 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 @@ -60,10 +132,30 @@ class Channels(BaseModel): 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 diff --git a/users/src/vonage_users/requests.py b/users/src/vonage_users/requests.py index 5a38507b..564c36dc 100644 --- a/users/src/vonage_users/requests.py +++ b/users/src/vonage_users/requests.py @@ -4,7 +4,15 @@ class ListUsersFilter(BaseModel): - """Request object for listing users.""" + """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 diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 0abe2d59..68938256 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -5,6 +5,15 @@ 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 @@ -12,6 +21,16 @@ class Links(BaseModel): 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 @@ -26,10 +45,24 @@ def get_link(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 index 0796bd7a..3e4a77d0 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -122,9 +122,6 @@ def delete_user(self, id: str) -> None: Args: id (str): The ID of the user to delete. - - Returns: - None """ self._http_client.delete( self._http_client.api_host, f'/v1/users/{id}', None, self._auth_type diff --git a/verify/CHANGES.md b/verify/CHANGES.md index 727f57dc..fda2114e 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.2 +- Add docstrings to data models + # 1.1.1 - Update minimum dependency version diff --git a/verify/pyproject.toml b/verify/pyproject.toml index d5abe7d2..32913259 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-verify' -version = '1.1.1' +dynamic = ["version"] description = 'Vonage verify package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/verify/src/vonage_verify/_version.py new file mode 100644 index 00000000..7b344eca --- /dev/null +++ b/verify/src/vonage_verify/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.2' diff --git a/verify/src/vonage_verify/language_codes.py b/verify/src/vonage_verify/language_codes.py index cad9ec14..3c17b851 100644 --- a/verify/src/vonage_verify/language_codes.py +++ b/verify/src/vonage_verify/language_codes.py @@ -2,6 +2,8 @@ class LanguageCode(str, Enum): + """Language code used in a specific Verify request.""" + ar_xa = 'ar-xa' cs_cz = 'cs-cz' cy_cy = 'cy-cy' @@ -44,6 +46,8 @@ class LanguageCode(str, Enum): 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' diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py index 242e7bb2..bc191d75 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -10,7 +10,25 @@ class BaseVerifyRequest(BaseModel): - """Base request object containing the data and options for a verification request.""" + """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) @@ -37,6 +55,31 @@ 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) @@ -49,6 +92,27 @@ 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) diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py index 64383e53..65e9b06c 100644 --- a/verify/src/vonage_verify/responses.py +++ b/verify/src/vonage_verify/responses.py @@ -4,11 +4,31 @@ 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 @@ -18,6 +38,17 @@ class CheckCodeResponse(BaseModel): 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 @@ -25,11 +56,46 @@ class Check(BaseModel): 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 @@ -47,10 +113,25 @@ class VerifyStatus(BaseModel): 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/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index 08fc2b70..56279b47 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -19,6 +19,12 @@ class Verify: This class provides methods to interact with Vonage's Verify API for starting verification processes. + + 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: diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md index 85cc501f..012a09e5 100644 --- a/verify_v2/CHANGES.md +++ b/verify_v2/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.3 +- Add docstrings for data models + # 1.1.2 - Allow minimum `channel_timeout` value to be 15 seconds diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml index 3d146641..14e7945e 100644 --- a/verify_v2/pyproject.toml +++ b/verify_v2/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-verify-v2' -version = '1.1.2' +dynamic = ["version"] description = 'Vonage verify v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [project.urls] homepage = "https://github.com/Vonage/vonage-python-sdk" +[tool.setuptools.dynamic] +version = { attr = "vonage_verify_v2._version.__version__" } + [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" diff --git a/verify_v2/src/vonage_verify_v2/_version.py b/verify_v2/src/vonage_verify_v2/_version.py new file mode 100644 index 00000000..7bb021e2 --- /dev/null +++ b/verify_v2/src/vonage_verify_v2/_version.py @@ -0,0 +1 @@ +__version__ = '1.1.3' diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index aa067603..523a676e 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -9,20 +9,58 @@ class Channel(BaseModel): + """Base model for a channel to use in a verification request. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + """ + to: PhoneNumber class SilentAuthChannel(Channel): + """Model for a Silent Authentication channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + redirect_url (str, Optional): Optional final redirect added at the end of the + check_url request/response lifecycle. Will contain the `request_id` and + `code` as a url fragment after the URL. + sandbox (bool, Optional): Whether you are using the sandbox to test Silent + Authentication integrations. + """ + redirect_url: Optional[str] = None sandbox: Optional[bool] = None channel: ChannelType = ChannelType.SILENT_AUTH class SmsChannel(Channel): + """Model for an SMS channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + from_ (Union[PhoneNumber, str], Optional): The sender of the SMS. This can be + a phone number in E.164 format without a leading `+` or `00`, or a string + of 3-11 alphanumeric characters. + app_hash (str, Optional): Optional Android Application Hash Key for automatic + code detection on a user's device. + entity_id (str, Optional): Optional PEID required for SMS delivery using Indian + carriers. + content_id (str, Optional): Optional PEID required for SMS delivery using Indian + carriers. + + Raises: + VerifyError: If the `from_` field is not a valid phone number. + """ + from_: Optional[Union[PhoneNumber, str]] = Field(None, serialization_alias='from') + app_hash: Optional[str] = Field(None, min_length=11, max_length=11) entity_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') content_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') - app_hash: Optional[str] = Field(None, min_length=11, max_length=11) channel: ChannelType = ChannelType.SMS @field_validator('from_') @@ -42,6 +80,20 @@ def check_valid_from_field(cls, v): class WhatsappChannel(Channel): + """Model for a WhatsApp channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + from_ (Union[PhoneNumber, str]): A WhatsApp Business Account (WABA)-connected + sender number, in the E.164 format. Don't use a leading + or 00 when entering + a phone number. + + Raises: + VerifyError: If the `from_` field is not a valid phone number or string of 3-11 + alphanumeric characters. + """ + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') channel: ChannelType = ChannelType.WHATSAPP @@ -58,16 +110,53 @@ def check_valid_sender(cls, 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[ diff --git a/verify_v2/src/vonage_verify_v2/responses.py b/verify_v2/src/vonage_verify_v2/responses.py index 09324d34..f7845087 100644 --- a/verify_v2/src/vonage_verify_v2/responses.py +++ b/verify_v2/src/vonage_verify_v2/responses.py @@ -4,10 +4,25 @@ 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/video/pyproject.toml b/video/pyproject.toml index 0105d84a..f98e6276 100644 --- a/video/pyproject.toml +++ b/video/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-video' -version = '1.0.0' +dynamic = ["version"] description = 'Vonage video package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/video/src/vonage_video/_version.py new file mode 100644 index 00000000..1f356cc5 --- /dev/null +++ b/video/src/vonage_video/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.0' diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 69375005..d1a87489 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -114,6 +114,11 @@ class CreateArchiveRequest(BaseModel): resolution (VideoResolution, Optional): The resolution of the archive. stream_mode (StreamMode, Optional): Whether streams included in the archive are selected automatically ("auto", the default) or manually ("manual"). + + Raises: + NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. + IndividualArchivePropertyError: If `resolution` or `layout` is set for individual archives + or if `has_transcription` is set for composed archives. """ session_id: str = Field(..., serialization_alias='sessionId') diff --git a/video/src/vonage_video/models/audio_connector.py b/video/src/vonage_video/models/audio_connector.py index ff272bc0..c19dcbec 100644 --- a/video/src/vonage_video/models/audio_connector.py +++ b/video/src/vonage_video/models/audio_connector.py @@ -35,7 +35,12 @@ class AudioConnectorOptions(BaseModel): class AudioConnectorData(BaseModel): - """Class containing Audio Connector WebSocket ID and connection ID.""" + """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 index 5928b717..991a93d9 100644 --- a/video/src/vonage_video/models/broadcast.py +++ b/video/src/vonage_video/models/broadcast.py @@ -25,6 +25,9 @@ class BroadcastHls(BaseModel): 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 @@ -158,6 +161,9 @@ class BroadcastOutputSettings(BaseModel): 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 diff --git a/video/src/vonage_video/models/common.py b/video/src/vonage_video/models/common.py index 3481b8ab..a60784c3 100644 --- a/video/src/vonage_video/models/common.py +++ b/video/src/vonage_video/models/common.py @@ -45,6 +45,11 @@ class ComposedLayout(BaseModel): 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 diff --git a/video/src/vonage_video/models/session.py b/video/src/vonage_video/models/session.py index 356dd718..78782f5f 100644 --- a/video/src/vonage_video/models/session.py +++ b/video/src/vonage_video/models/session.py @@ -41,7 +41,15 @@ def set_p2p_preference_if_archive_mode_set(self): class VideoSession(BaseModel): - """The new session ID and options specified in the request.""" + """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 diff --git a/video/src/vonage_video/models/signal.py b/video/src/vonage_video/models/signal.py index 7edafda3..e0e8a4e2 100644 --- a/video/src/vonage_video/models/signal.py +++ b/video/src/vonage_video/models/signal.py @@ -2,7 +2,12 @@ class SignalData(BaseModel): - """The data to send in a signal.""" + """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 index 5db0b9b2..15ec5504 100644 --- a/video/src/vonage_video/models/sip.py +++ b/video/src/vonage_video/models/sip.py @@ -7,7 +7,7 @@ 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. - Attributes: + Args: username (str): The username for HTTP digest authentication. password (str): The password for HTTP digest authentication. """ @@ -19,7 +19,7 @@ class SipAuth(BaseModel): class SipOptions(BaseModel): """Model representing the SIP options for the call. - Attributes: + 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 @@ -48,7 +48,7 @@ class SipOptions(BaseModel): class InitiateSipRequest(BaseModel): """Model representing the SIP options for joining a Vonage Video session. - Attributes: + 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. @@ -62,7 +62,7 @@ class InitiateSipRequest(BaseModel): class SipCall(BaseModel): """Model representing the details of a SIP call. - Attributes: + 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. diff --git a/video/src/vonage_video/models/stream.py b/video/src/vonage_video/models/stream.py index a3135201..680a7796 100644 --- a/video/src/vonage_video/models/stream.py +++ b/video/src/vonage_video/models/stream.py @@ -8,9 +8,11 @@ class StreamInfo(BaseModel): Args: id (str): The stream ID. - video_type (str): The video type. - name (str): The name. - layout_class_list (list(str)): The layout class list. + 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. """ id: Optional[str] = Field(None, validation_alias='id') @@ -26,7 +28,7 @@ class StreamLayout(BaseModel): Args: id (str): The stream ID. - layout_class_list (list): The layout class list. + layout_class_list (list): An array of the layout classes for the stream. """ id: str @@ -34,6 +36,11 @@ class StreamLayout(BaseModel): class StreamLayoutOptions(BaseModel): - """The options for the stream layout.""" + """The options for the stream layout. + + Args: + items (list): 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 index c5b641f4..6f5f2740 100644 --- a/video/src/vonage_video/models/token.py +++ b/video/src/vonage_video/models/token.py @@ -12,16 +12,19 @@ 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. + 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 diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py index 63bd319c..c19a567b 100644 --- a/vonage_utils/src/vonage_utils/types.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -2,5 +2,23 @@ from typing_extensions import Annotated 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.""" From 5ceb50ac37ad6ac706fdba6a1696d903e6c8a41a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 11 Oct 2024 13:22:25 +0100 Subject: [PATCH 78/98] finish docstrings for data models --- voice/CHANGES.md | 3 + voice/pyproject.toml | 5 +- voice/src/vonage_voice/_version.py | 1 + voice/src/vonage_voice/models/common.py | 41 +++++- .../vonage_voice/models/connect_endpoints.py | 48 +++++++ voice/src/vonage_voice/models/input_types.py | 32 +++++ voice/src/vonage_voice/models/ncco.py | 128 +++++++++++++++++- voice/src/vonage_voice/models/requests.py | 81 +++++++++++ voice/src/vonage_voice/models/responses.py | 60 +++++++- voice/src/vonage_voice/voice.py | 6 + 10 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 voice/src/vonage_voice/_version.py diff --git a/voice/CHANGES.md b/voice/CHANGES.md index ba84e407..7b359abe 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.4 +- Add docstrings to data models + # 1.0.3 - Internal refactoring diff --git a/voice/pyproject.toml b/voice/pyproject.toml index b18c0ddc..1327e188 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'vonage-voice' -version = '1.0.3' +dynamic = ["version"] description = 'Vonage voice package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] @@ -24,6 +24,9 @@ classifiers = [ [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/_version.py b/voice/src/vonage_voice/_version.py new file mode 100644 index 00000000..8a81504c --- /dev/null +++ b/voice/src/vonage_voice/_version.py @@ -0,0 +1 @@ +__version__ = '1.0.4' diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py index c0e92978..ef6d75ed 100644 --- a/voice/src/vonage_voice/models/common.py +++ b/voice/src/vonage_voice/models/common.py @@ -6,30 +6,69 @@ class Phone(BaseModel): + """Model for a phone number. + + Args: + number (PhoneNumber): The phone number. + """ + number: PhoneNumber type: Channel = Channel.PHONE class Sip(BaseModel): + """Model for a SIP URI. + + Args: + uri (SipUri): The SIP URI. + """ + uri: SipUri type: Channel = Channel.SIP class Websocket(BaseModel): + """Model for a WebSocket connection. + + Args: + uri (str): The URI of the WebSocket connection. + content_type (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The content + type of the audio stream. + headers (Optional[dict]): The headers to include with the WebSocket connection. + """ + uri: str = Field(..., min_length=1, max_length=50) content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( 'audio/l16;rate=16000', serialization_alias='content-type' ) - type: Channel = Channel.WEBSOCKET headers: Optional[dict] = None + type: Channel = Channel.WEBSOCKET class Vbc(BaseModel): + """Model for a VBC connection. + + Args: + extension (str): The extension to call. + """ + extension: str type: Channel = Channel.VBC class AdvancedMachineDetection(BaseModel): + """Model for advanced machine detection settings. Configure the behavior of Vonage's + advanced machine detection. Overrides `machine_detection` if both are set. + + Args: + behavior (Optional[Literal['continue', 'hangup']]): The behavior when a machine + is detected. + mode (Optional[Literal['default', 'detect', 'detect_beep']]): Detect if machine + answered and sends a human or machine status in the webhook payload. + beep_timeout (Optional[int]): Maximum time in seconds Vonage should wait for a + machine beep to be detected. + """ + behavior: Optional[Literal['continue', 'hangup']] = None mode: Optional[Literal['default', 'detect', 'detect_beep']] = None beep_timeout: Optional[int] = Field(None, ge=45, le=120) diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py index b5c65e58..dc026e3e 100644 --- a/voice/src/vonage_voice/models/connect_endpoints.py +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -7,11 +7,28 @@ class OnAnswer(BaseModel): + """Settings for what to do when the call is answered. + + Args: + url (str): The URL to fetch the NCCO from. The URL serves an NCCO to execute in the + number being connected to, before that call is joined to your existing conversation. + ringbackTone (Optional[str]): A URL value that points to a `ringbackTone` to be played + back on repeat to the caller, so they do not hear silence. + """ + url: str ringbackTone: Optional[str] = None class PhoneEndpoint(BaseModel): + """Model for a phone endpoint. + + Args: + number (PhoneNumber): The phone number to call. + dtmfAnswer (Optional[Dtmf]): The DTMF tones to send when the call is answered. + onAnswer (Optional[OnAnswer]): Settings for what to do when the call is answered. + """ + number: PhoneNumber dtmfAnswer: Optional[Dtmf] = None onAnswer: Optional[OnAnswer] = None @@ -19,11 +36,27 @@ class PhoneEndpoint(BaseModel): class AppEndpoint(BaseModel): + """Model for an RTC capable application endpoint. + + Args: + user (str): The username of the user to connect to. This username must have been + added as a user. + """ + user: str type: ConnectEndpointType = ConnectEndpointType.APP class WebsocketEndpoint(BaseModel): + """Model for a WebSocket connection. + + Args: + uri (str): The URI of the WebSocket connection. + contentType (Literal['audio/l16;rate=8000', 'audio/l16;rate=16000']): The internet + media type for the audio you are streaming. + headers (Optional[dict]): The headers to include with the WebSocket connection. + """ + uri: str contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] = Field( None, serialization_alias='content-type' @@ -33,11 +66,26 @@ class WebsocketEndpoint(BaseModel): class SipEndpoint(BaseModel): + """Model for a SIP endpoint. + + Args: + uri (SipUri): The SIP URI to connect to. + headers (Optional[dict]): The headers to include with the SIP connection. To use + TLS and/or SRTP, include respectively `transport=tls` or `media=srtp` to the URL with + the semicolon `;` as a delimiter. + """ + uri: SipUri headers: Optional[dict] = None type: ConnectEndpointType = ConnectEndpointType.SIP class VbcEndpoint(BaseModel): + """Model for a VBC endpoint. + + Args: + extension (str): The VBC extension to connect the call to. + """ + extension: str type: ConnectEndpointType = ConnectEndpointType.VBC diff --git a/voice/src/vonage_voice/models/input_types.py b/voice/src/vonage_voice/models/input_types.py index 71c912e0..3ffd6316 100644 --- a/voice/src/vonage_voice/models/input_types.py +++ b/voice/src/vonage_voice/models/input_types.py @@ -4,12 +4,44 @@ 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 diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index 0f6d961e..06007306 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -24,7 +24,29 @@ class NccoAction(BaseModel): class Record(NccoAction): - """Use the Record action to record a call or part of a call.""" + """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 @@ -49,6 +71,26 @@ class Conversation(NccoAction): while preserving the communication context. Using a conversation with the same name reuses the same persisted conversation. + + Args: + name (str): The name of the conversation room. + musicOnHoldUrl (Optional[List[str]]): The URL to the music that is played to + participants when they are on hold. + startOnEnter (Optional[bool]): The default value of `True` ensures that the + conversation starts when this caller joins conversation `name`. Set to + `False` for attendees in a moderated conversation. + endOnExit (Optional[bool]): End the conversation when the moderator leaves. + record (Optional[bool]): Record the conversation. + canSpeak (Optional[List[str]]): A list of leg UUIDs that this participant can be + heard by. If not provided, the participant can be heard by everyone. If an + empty list is provided, the participant will not be heard by anyone. + canHear (Optional[List[str]]): A list of leg UUIDs that this participant can + hear. If not provided, the participant can hear everyone. If an empty list + is provided, the participant will not hear any other participants. + mute (Optional[bool]): Mute the participant. + + Raises: + NccoActionError: If the `mute` option is used with the `canSpeak` option. """ name: str @@ -72,7 +114,39 @@ def can_mute(self): class Connect(NccoAction): """You can use the Connect action to connect a call to endpoints such as phone numbers - or a VBC extension.""" + 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] @@ -105,6 +179,18 @@ class Talk(NccoAction): 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) @@ -118,7 +204,18 @@ class Talk(NccoAction): class Stream(NccoAction): - """The stream action allows you to send an audio stream to a Conversation.""" + """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) @@ -128,7 +225,19 @@ class Stream(NccoAction): class Input(NccoAction): - """Collect digits or speech input by the person you are are calling.""" + """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 @@ -139,7 +248,16 @@ class Input(NccoAction): class Notify(NccoAction): - """Use the notify action to send a custom payload to your event URL.""" + """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] diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index ef8b90f7..c4bd9620 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -10,10 +10,52 @@ class ToPhone(Phone): + """Model for the phone number to call. + + Args: + number (PhoneNumber): The phone number. + dtmf_answer (Optional[Dtmf]): The DTMF tones to send when the call is answered. + """ + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') class CreateCallRequest(BaseModel): + """Request model for creating a call. You must supply either `ncco` or `answer_url`. + + Args: + ncco (Optional[List[Union[Record, Conversation, Connect, Input, Talk, Stream, Notify]]]): + The Nexmo Call Control Object (NCCO) to use for the call. + answer_url (Optional[List[str]]): The URL to fetch the NCCO from. + answer_method (Optional[Literal['POST', 'GET']]): The HTTP method used to send + event information to `answer_url`. + to (List[Union[ToPhone, Sip, Websocket, Vbc]]): The type of connection to call. + from_ (Optional[Phone]): The phone number to use when calling. Mutually exclusive + with the `random_from_number` property. + random_from_number (Optional[bool]): Whether to use a random number as the caller's + phone number. The number will be selected from the list of the numbers assigned + to the current application. Mutually exclusive with the `from_` property. + event_url (Optional[List[str]]): The webhook endpoint where call progress events + are sent. + event_method (Optional[Literal['POST', 'GET']]): The HTTP method used to send the call + events to `event_url`. + machine_detection (Optional[Literal['continue', 'hangup']]): Configure the behavior + when Vonage detects that the call is answered by voicemail. + advanced_machine_detection (Optional[AdvancedMachineDetection]): Configure the + behavior of Vonage's advanced machine detection. Overrides `machine_detection` + if both are set. + length_timer (Optional[int]): Set the number of seconds that elapse before Vonage + hangs up after the call state changes to "answered". + ringing_timer (Optional[int]): Set the number of seconds that elapse before Vonage + hangs up after the call state changes to `ringing`. + + Raises: + VoiceError: If neither `ncco` nor `answer_url` is set. + VoiceError: If both `ncco` and `answer_url` are set. + VoiceError: If neither `from_` nor `random_from_number` is set. + VoiceError: If both `from_` and `random_from_number` are set. + """ + ncco: List[Union[Record, Conversation, Connect, Input, Talk, Stream, Notify]] = None answer_url: List[str] = None answer_method: Optional[Literal['POST', 'GET']] = None @@ -46,6 +88,20 @@ def validate_from_and_random_from_number(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 @@ -56,12 +112,37 @@ class ListCallsFilter(BaseModel): 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 diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py index a528cf6c..0b5e3822 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -7,6 +7,16 @@ 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 @@ -14,14 +24,46 @@ class CreateCallResponse(BaseModel): 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: Phone + to: Union[Phone, Sip, Websocket, Vbc] from_: Union[Phone, Sip, Websocket, Vbc] = Field(..., validation_alias='from') status: str direction: str @@ -41,10 +83,26 @@ def get_link(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 diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index f134210c..ace4cbf7 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -199,6 +199,9 @@ def stop_audio_stream(self, uuid: str) -> CallMessage: 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' @@ -230,6 +233,9 @@ def stop_tts(self, uuid: str) -> CallMessage: 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' From effabbf001a6322b72aac49cc796067ad03bb172 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 11 Oct 2024 14:10:44 +0100 Subject: [PATCH 79/98] Stop tracking _dev_scripts folder --- _dev_scripts/update_versions.py | 53 --------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 _dev_scripts/update_versions.py diff --git a/_dev_scripts/update_versions.py b/_dev_scripts/update_versions.py deleted file mode 100644 index 1aac15b1..00000000 --- a/_dev_scripts/update_versions.py +++ /dev/null @@ -1,53 +0,0 @@ -import os -import re -import toml - -# Define the paths to the vonage packages -packages = [ - "vonage-utils", - "vonage-http-client", - "vonage-account", - "vonage-application", - "vonage-messages", - "vonage-number-insight", - "vonage-numbers", - "vonage-sms", - "vonage-subaccounts", - "vonage-users", - "vonage-verify", - "vonage-verify-v2", - "vonage-video", - "vonage-voice", -] - - -# Function to read the version from _version.py -def get_version(package_path): - version_file = os.path.join( - package_path, "src", package_path.replace("-", "_"), "_version.py" - ) - with open(version_file, "r") as f: - content = f.read() - 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) - dependencies.append(f"{package}=={version}") - -pyproject["project"]["dependencies"] = dependencies - -# Write the updated pyproject.toml -with open("pyproject.toml", "w") as f: - toml.dump(pyproject, f) - -print("pyproject.toml updated with local package versions.") From 69bf1e2c2e9fd1918498fb545da1249b4213b92f Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 12 Oct 2024 15:50:02 +0100 Subject: [PATCH 80/98] update global readme --- Makefile | 5 +- README.md | 1434 ++++++++++++++++++++++------------------- http_client/README.md | 2 +- vonage/pyproject.toml | 42 +- 4 files changed, 791 insertions(+), 692 deletions(-) diff --git a/Makefile b/Makefile index a5858a29..18d342f4 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,7 @@ coverage: pants test --use-coverage :: coverage-report: - pants test --use-coverage --open-coverage :: \ No newline at end of file + pants test --use-coverage --open-coverage :: + +install: + pip install -r requirements.txt \ No newline at end of file diff --git a/README.md b/README.md index 780929f7..75b26d97 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,23 @@ This is the Python server SDK for Vonage's API. To use it you'll need a Vonage account. Sign up [for free at vonage.com][signup]. - [Installation](#installation) +- [Calling Vonage APIs](#calling-vonage-apis) - [Usage](#usage) -- [SMS API](#sms-api) +- [Account API](#account-api) +- [Application API](#application-api) +- [HTTP Client](#http-client) +- [JWT Client](#jwt-client) - [Messages API](#messages-api) -- [Voice API](#voice-api) -- [NCCO Builder](#ncco-builder) -- [Verify V2 API](#verify-v2-api) -- [Verify V1 API](#verify-v1-api) -- [Video API](#video-api) -- [Meetings API](#meetings-api) - [Number Insight API](#number-insight-api) -- [Proactive Connect API](#proactive-connect-api) -- [Account API](#account-api) +- [Numbers API](#numbers-api) +- [SMS API](#sms-api) - [Subaccounts API](#subaccounts-api) -- [Number Management API](#number-management-api) -- [Pricing API](#pricing-api) -- [Managing Secrets](#managing-secrets) -- [Application API](#application-api) - [Users API](#users-api) -- [Validating Webhook Signatures](#validate-webhook-signatures) -- [JWT Parameters](#jwt-parameters) -- [Overriding API Attributes](#overriding-api-attributes) +- [Verify v2 API](#verify-v2-api) +- [Verify v1 API (Legacy)](#verify-v1-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) @@ -53,1149 +49,1248 @@ Alternatively, you can clone the repository via the command line: or by opening it on GitHub desktop. -## Usage +## 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. -Begin by importing the `vonage` module: +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. + +For example, to send an SMS, you will access the SMS method from `vonage` and the `SmsMessage` object from the `vonage-sms` package. This looks something like this: ```python -import vonage +from vonage_sms import SmsMessage, SmsResponse + +message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') +response: SmsResponse = vonage_client.sms.send(message) # vonage_client is an instance of `vonage.Vonage` + +print(response.model_dump(exclude_unset=True)) ``` -Then construct a client object with your key and secret: +## Usage ```python -client = vonage.Client(key=api_key, secret=api_secret) -``` +from vonage import Vonage, Auth, HttpClientOptions -For production, you can specify the `VONAGE_API_KEY` and `VONAGE_API_SECRET` -environment variables instead of specifying the key and secret explicitly. +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') -For newer endpoints that support JWT authentication such as the Voice API, -you can also specify the `application_id` and `private_key` arguments: +# Create HttpClientOptions instance +# (not required unless you want to change options from the defaults) +options = HttpClientOptions(api_host='api.nexmo.com', timeout=30) -```python -client = vonage.Client(application_id=application_id, private_key=private_key) +# Create a Vonage instance +vonage = Vonage(auth=auth, http_client_options=options) ``` -To check signatures for incoming webhook requests, you'll also need -to specify the `signature_secret` argument (or the `VONAGE_SIGNATURE_SECRET` -environment variable). +The Vonage class provides access to various Vonage APIs through its properties. For example, to use methods to call the SMS API: -To use the SDK to call Vonage APIs, pass in dicts with the required options to methods like `Sms.send_message()`. Examples of this are given below. +```python +from vonage_sms import SmsMessage -## Simplified structure for calling API Methods +message = SmsMessage(to='1234567890', from_='Vonage', text='Hello World') +response = client.sms.send(message) +print(response.model_dump_json(exclude_unset=True)) +``` -The client now instantiates a class object for each API when it is created, e.g. `vonage.Client(key="mykey", secret="mysecret")` -instantiates instances of `Account`, `Sms`, `NumberInsight` etc. These instances can now be called directly from `Client`, e.g. +You can also access the underlying `HttpClient` instance through the `http_client` property: ```python -client = vonage.Client(key="mykey", secret="mysecret") +user_agent = vonage.http_client.user_agent +``` + +## Account API -print(f"Account balance is: {client.account.get_balance()}") +### Get Account Balance -print("Sending an SMS") -client.sms.send_message({ - "from": "Vonage", - "to": "SOME_PHONE_NUMBER", - "text": "Hello from Vonage's SMS API" -}) +```python +balance = vonage_client.account.get_balance() +print(balance) ``` -This means you don't have to create a separate instance of each class to use its API methods. Instead, you can access class methods from the client instance with +### Top-Up Account + ```python -client.CLASS_NAME.CLASS_METHOD +response = vonage_client.account.top_up(trx='1234567890') +print(response) ``` -## SMS API +### Update the Default SMS Webhook -Although the Messages API adds more messaging channels, the SMS API is still supported. -### Send an SMS +This will return a Pydantic object (`SettingsResponse`) containing multiple settings for your account. ```python -# New way -client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -client.sms.send_message({ - "from": VONAGE_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Vonage SMS API", -}) +settings: SettingsResponse = vonage_client.account.update_default_sms_webhook( + mo_callback_url='https://example.com/inbound_sms_webhook', + dr_callback_url='https://example.com/delivery_receipt_webhook', +) -# Old way -from vonage import Sms -sms = Sms(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -sms.send_message({ - "from": VONAGE_BRAND_NAME, - "to": TO_NUMBER, - "text": "A text message sent using the Vonage SMS API", -}) +print(settings) ``` -### Send SMS with unicode +### List Secrets Associated with the Account ```python -client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_API_SECRET) -client.sms.send_message({ - 'from': VONAGE_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'こんにちは世界', - 'type': 'unicode', -}) +response = vonage_client.account.list_secrets() +print(response) ``` -### Submit SMS Conversion +### Create a New Account Secret ```python -client = vonage.Client(key=VONAGE_API_KEY, secret=VONAGE_SECRET) -response = client.sms.send_message({ - 'from': VONAGE_BRAND_NAME, - 'to': TO_NUMBER, - 'text': 'Hi from Vonage' -}) -client.sms.submit_sms_conversion(response['message-id']) +secret = vonage_client.account.create_secret('Mytestsecret12345') +print(secret) ``` -### Update the default SMS webhook URLs for callbacks/delivery reciepts +### Get Information About One Secret + ```python -client.sms.update_default_sms_webhook({ - 'moCallBackUrl': 'new.url.vonage.com', # Default inbound sms webhook url - 'drCallBackUrl': 'different.url.vonage.com' # Delivery receipt url - }}) +secret = vonage_client.account.get_secret(MY_SECRET_ID) +print(secret) ``` -The delivery receipt URL can be unset by sending an empty string. +### Revoke a Secret -## Messages API +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. -The Messages API is an API that allows you to send messages via SMS, MMS, WhatsApp, Messenger and Viber. Call the API from your Python code by -passing a dict of parameters into the `client.messages.send_message()` method. +```python +client.account.revoke_secret(MY_SECRET_ID) +``` + +## Application API -It accepts JWT or API key/secret authentication. -Some basic samples are below. For more detailed information and code snippets, please visit the [Vonage Developer Documentation](https://developer.vonage.com). +### 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. -### Send an SMS ```python -responseData = client.messages.send_message({ - 'channel': 'sms', - 'message_type': 'text', - 'to': '447123456789', - 'from': 'Vonage', - 'text': 'Hello from Vonage' - }) +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) ``` -### Send an MMS -Note: only available in the US. You will need a 10DLC number to send an MMS message. +### Create a New Application ```python -client.messages.send_message({ - 'channel': 'mms', - 'message_type': 'image', - 'to': '11112223333', - 'from': '1223345567', - 'image': {'url': 'https://example.com/image.jpg', 'caption': 'Test Image'} - }) -``` +from vonage_application import ApplicationConfig -### Send an audio file via WhatsApp +app_data = vonage_client.application.create_application() -You will need a WhatsApp Business Account to use WhatsApp messaging. WhatsApp restrictions mean that you -must send a template message to a user if they have not previously messaged you, but you can send any message -type to a user if they have messaged your business number in the last 24 hours. +# 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 -client.messages.send_message({ - 'channel': 'whatsapp', - 'message_type': 'audio', - 'to': '447123456789', - 'from': '440123456789', - 'audio': {'url': 'https://example.com/audio.mp3'} - }) +app_data = client.application.get_application('MY_APP_ID') +app_data_as_dict = app.model_dump(exclude_none=True) ``` -### Send a video file via Facebook Messenger +### Update an Application -You will need to link your Facebook business page to your Vonage account in the Vonage developer dashboard. (Click on the sidebar -"External Accounts" option to do this.) +To update an application, pass config for the updated field(s) in an ApplicationConfig object ```python -client.messages.send_message({ - 'channel': 'messenger', - 'message_type': 'video', - 'to': '594123123123123', - 'from': '1012312312312', - 'video': {'url': 'https://example.com/video.mp4'} - }) +from vonage_application import ApplicationConfig, Keys, Voice, VoiceWebhooks + +config = ApplicationConfig(name='My Updated Application') +app_data = vonage_client.application.update_application('MY_APP_ID', config) ``` -### Send a text message with Viber +### Delete an Application ```python -client.messages.send_message({ - 'channel': 'viber_service', - 'message_type': 'text', - 'to': '447123456789', - 'from': '440123456789', - 'text': 'Hello from Vonage!' -}) +vonage_client.applications.delete_application('MY_APP_ID') ``` -## Voice API +## HTTP Client -### Make a call ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -``` +from vonage_http_client import HttpClient, HttpClientOptions +from vonage_http_client.auth import Auth -### Retrieve a list of calls +# Create an Auth instance +auth = Auth(api_key='your_api_key', api_secret='your_api_secret') -```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -client.voice.get_calls() +# 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'}) ``` -### Retrieve a single call +### Get the Last Request and Last Response from the HTTP Client + +The `HttpClient` class exposes two properties, `last_request` and `last_response` that cache the last sent request and response. ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -client.voice.get_call(uuid) +# Get last request, has type requests.PreparedRequest +request = client.last_request + +# Get last response, has type requests.Response +response = client.last_response ``` -### Update a call +### Appending to the User-Agent Header + +The `HttpClient` class also supports appending additional information to the User-Agent header via the append_to_user_agent method: ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.update_call(response['uuid'], action='hangup') +client.append_to_user_agent('additional_info') ``` -### Stream audio to a call +### Changing the Authentication Method Used + +The `HttpClient` class automatically handles JWT and basic authentication based on the Auth instance provided. It uses JWT authentication by default, but you can specify the authentication type when making a request: ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_audio(response['uuid'],stream_url=[stream_url]) +# Use basic authentication for this request +response = client.get(host='api.nexmo.com', request_path='/v1/messages', auth_type='basic') ``` -### Stop streaming audio to a call +### Catching errors + +Error objects are exposed in the package scope, so you can catch errors like this: ```python -client = vonage.Client(application_id='0d4884d1-eae8-4f18-a46a-6fb14d5fdaa6', private_key='./private.key') -stream_url = 'https://nexmo-community.github.io/ncco-examples/assets/voice_api_audio_streaming.mp3' -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_audio(response['uuid'],stream_url=[stream_url]) -client.voice.stop_audio(response['uuid']) +from vonage_http_client import HttpRequestError + +try: + client.post(...) +except HttpRequestError: + ... ``` -### Send a synthesized speech message to a call +## JWT Client + +This JWT Generator can be used implicitly, just by using the [Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) to make JWT-authenticated API calls. + +It can also be used as a standalone JWT generator for use with Vonage APIs, like so: + +### Import the `JwtClient` object ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_speech(response['uuid'], text='Hello from vonage') +from vonage_jwt import JwtClient ``` -### Stop sending a synthesized speech message to a call +### Create a `JwtClient` object ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=APPLICATION_ID) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_speech(response['uuid'], text='Hello from vonage') -client.voice.stop_speech(response['uuid']) +jwt_client = JwtClient(application_id, private_key) ``` -### Send DTMF tones to a call +### Generate a JWT using the provided application id and private key ```python -client = vonage.Client(application_id=APPLICATION_ID, private_key=PRIVATE_KEY) -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': '14843331234'}], - 'from': {'type': 'phone', 'number': '14843335555'}, - 'answer_url': ['https://example.com/answer'] -}) -client.voice.send_dtmf(response['uuid'], digits='1234') +jwt_client.generate_application_jwt() ``` -### Get recording +Optional JWT claims can be provided in a python dictionary: ```python -response = client.get_recording(RECORDING_URL) +claims = {'jti': 'asdfzxcv1234', 'nbf': now + 100} +jwt_client.generate_application_jwt(claims) ``` -### Verify the Signature of a Webhook Sent by Vonage +## Verifying a JWT signature -If signed webhooks are enabled (the default), Vonage will sign webhooks with the signature secret found in the [API Settings](https://dashboard.nexmo.com/settings) section of the Vonage Developer Dashboard. +You can use the `verify_jwt.verify_signature` method to verify a JWT signature is valid. ```python -if client.voice.verify_signature('JWT_RECEIVED_FROM_VONAGE', 'MY_VONAGE_SIGNATURE_SECRET'): - print('Signature is valid!') -else: - print('Signature is invalid!') -``` +from vonage_jwt import verify_signature +verify_signature(TOKEN, SIGNATURE_SECRET) # Returns a boolean +``` -## NCCO Builder +## Messages API -The SDK contains a builder to help you create Call Control Objects (NCCOs) for use with the Vonage Voice API. -For more information, [check the full NCCO reference documentation on the Vonage website](https://developer.vonage.com/voice/voice-api/ncco-reference). +### How to Construct a Message -An NCCO is a list of "Actions": steps to be followed when a call is initiated or received. +In order to send a message, you must construct a message object of the correct type. These are all found under `vonage_messages.models`. -Use the builder to construct valid NCCO actions, which are modelled in the SDK as [Pydantic](https://docs.pydantic.dev) models, and build them into an NCCO. The NCCO actions supported by the builder are: +```python +from vonage_messages.models import Sms -* Record -* Conversation -* Connect -* Talk -* Stream -* Input -* Notify +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) +``` -### Construct actions +This message can now be sent with ```python -record = Ncco.Record(eventUrl=['https://example.com']) -talk = Ncco.Talk(text='Hello from Vonage!', bargeIn=True, loop=5, premium=True) +vonage_client.messages.send(message) ``` -The Connect action has each valid endpoint type (phone, application, WebSocket, SIP and VBC) specified as a Pydantic model so these can be validated, though it is also possible to pass in a dict with the endpoint properties directly into the `Ncco.Connect` object. +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. -This example shows a Connect action created with an endpoint object. +The different message models are listed at the bottom of the page. -```python -phone = ConnectEndpoints.PhoneEndpoint( - number='447000000000', - dtmfAnswer='1p2p3p#**903#', - ) -connect = Ncco.Connect(endpoint=phone, eventUrl=['https://example.com/events'], from_='447000000000') -``` +Some message types have submodels with additional fields. In this case, import the submodels as well and use them to construct the overall options. -This example shows a different Connect action, created with a dictionary. +e.g. ```python -connect = Ncco.Connect(endpoint={'type': 'phone', 'number': '447000000000', 'dtmfAnswer': '2p02p'}, randomFromNumber=True) +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'), +) ``` -### Build into an NCCO +### Send a message -Create an NCCO from the actions with the `Ncco.build_ncco` method. This will be returned as a list of dicts representing each action and can be used in calls to the Voice API. +To send a message, access the `Messages.send` method via the main Vonage object, passing in an instance of a subclass of `BaseMessage` like this: ```python -ncco = Ncco.build_ncco(record, connect, talk) +from vonage import Auth, Vonage +from vonage_messages.models import Sms -response = client.voice.create_call({ - 'to': [{'type': 'phone', 'number': TO_NUMBER}], - 'from': {'type': 'phone', 'number': VONAGE_NUMBER}, - 'ncco': ncco -}) +vonage_client = Vonage(Auth(application_id='my-application-id', private_key='my-private-key')) -pprint(response) -``` +message = Sms( + from_='Vonage APIs', + to='1234567890', + text='This is a test message sent from the Vonage Python SDK', +) -### Note on from_ parameter in connect action +vonage_client.messages.send(message) +``` -When using the `connect` action, use the parameter `from_` to specify the recipient (as `from` is a reserved keyword in Python!) +### Mark a WhatsApp Message as Read -## Verify V2 API +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. -V2 of the Vonage Verify API lets you send verification codes via SMS, WhatsApp, Voice and Email. +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. -You can also verify a user by WhatsApp Interactive Message or by Silent Authentication on their mobile device. +```python +from vonage import Vonage, Auth, HttpClientOptions -### Send a verification code +auth = Auth(application_id='MY-APP-ID', private_key='MY-PRIVATE-KEY') +options = HttpClientOptions(api_host='api-eu.vonage.com') -```python -params = { - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000'}] -} -verify_request = verify2.new_request(params) +vonage_client = Vonage(auth, options) +vonage_client.messages.mark_whatsapp_message_read('MESSAGE_UUID') ``` -### Use silent authentication, with email as a fallback +### Revoke an RCS Message -```python -params = { - 'brand': 'ACME, Inc', - 'workflow': [ - {'channel': 'silent_auth', 'to': '447700900000'}, - {'channel': 'email', 'to': 'customer@example.com', 'from': 'business@example.com'} - ] -} -verify_request = verify2.new_request(params) -check_url = verify_request['check_url'] # URL to continue with the silent auth workflow -``` +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. -### Send a verification code with custom options, including a custom code +For example, to use the EU API endpoint, set the `api_host` attribute to 'api-eu.vonage.com'. ```python -params = { - 'locale': 'en-gb', - 'channel_timeout': 120, - 'client_ref': 'my client reference', - 'code': 'asdf1234', - 'brand': 'ACME, Inc', - 'workflow': [{'channel': 'sms', 'to': '447700900000', 'app_hash': 'asdfghjklqw'}], -} -verify_request = verify2.new_request(params) +from vonage 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') ``` -### Send a verification request to a blocked network +## Message Models -This feature is only enabled if you have requested for it to be added to your account. +To send a message, instantiate a message model of the correct type as described above. This is a list of message models that can be used: -```python -params = { - 'brand': 'ACME, Inc', - 'fraud_check': False, - 'workflow': [{'channel': 'sms', 'to': '447700900000'}] -} -verify_request = verify2.new_request(params) ``` +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 +``` + +## Number Insight API -### Check a verification code +### Make a Basic Number Insight Request ```python -verify2.check_code(REQUEST_ID, CODE) +from vonage_number_insight import BasicInsightRequest + +response = vonage_client.number_insight.basic_number_insight( + BasicInsightRequest(number='12345678900') +) + +print(response.model_dump(exclude_none=True)) ``` -### Cancel an ongoing verification +### Make a Standard Number Insight Request ```python -verify2.cancel_verification(REQUEST_ID) +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) +) ``` -## Verify V1 API +### Make an Asynchronous Advanced Number Insight Request -### Search for a Verification request +When making an asynchronous advanced number insight request, the API will return basic information about the request to you immediately and send the full data to the webhook callback URL you specify. ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') - -response = client.verify.search('69e2626cbc23451fbbc02f627a959677') +from vonage_number_insight import AdvancedAsyncInsightRequest -if response is not None: - print(response['status']) +vonage_client.number_insight.advanced_async_number_insight( + AdvancedAsyncInsightRequest(callback='https://example.com', number='12345678900') +) ``` -### Send verification code +### Make a Synchronous Advanced Number Insight Request ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') - -response = client.verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc') +from vonage_number_insight import AdvancedSyncInsightRequest -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +vonage_client.number_insight.advanced_sync_number_insight( + AdvancedSyncInsightRequest(number='12345678900') +) ``` -### Send verification code with workflow +## Numbers API + +### List Numbers You Own ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +numbers, count, next_page = vonage_client.numbers.list_owned_numbers() +print(numbers) +print(count) +print(next_page) -response = client.verify.start_verification(number=RECIPIENT_NUMBER, brand='AcmeInc', workflow_id=1) +# With filtering +from vonage_numbers import ListOwnedNumbersFilter +numbers, count, next_page = vonage_client.numbers.list_owned_numbers( + ListOwnedNumbersFilter(country='GB', size=3, index=2) +) -if response["status"] == "0": - print("Started verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +numbers, count, next_page_index = vonage_client.numbers.list_owned_numbers() +print(numbers) +print(count) +print(next_page_index) ``` -### Check verification code +### Search for Available Numbers ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') - -response = client.verify.check(REQUEST_ID, code=CODE) +from vonage_numbers import SearchAvailableNumbersFilter -if response["status"] == "0": - print("Verification successful, event_id is %s" % (response["event_id"])) -else: - print("Error: %s" % response["error_text"]) +numbers, count, next_page_index = vonage_client.numbers.search_available_numbers( + SearchAvailableNumbersFilter( + country='GB', size=10, pattern='44701', search_pattern=1 + ) +) +print(numbers) +print(count) +print(next_page_index) ``` -### Cancel Verification Request +### Buy a Number ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') - -response = client.verify.cancel(REQUEST_ID) +from vonage_numbers import NumberParams -if response["status"] == "0": - print("Cancellation successful") -else: - print("Error: %s" % response["error_text"]) +status = vonage_client.numbers.buy_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) ``` -### Trigger next verification proccess +### Cancel a number ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_numbers import NumberParams -response = client.verify.trigger_next_event(REQUEST_ID) - -if response["status"] == "0": - print("Next verification stage triggered") -else: - print("Error: %s" % response["error_text"]) +status = vonage_client.numbers.cancel_number(NumberParams(country='GB', msisdn='447007000000')) +print(status) ``` -### Send payment authentication code +### Update a Number ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_numbers import UpdateNumberParams -response = client.verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT) +status = vonage_client.numbers.update_number( + UpdateNumberParams( + country='GB', + msisdn='447007000000', + mo_http_url='https://example.com', + mo_smpp_sytem_type='inbound', + voice_callback_type='tel', + voice_callback_value='447008000000', + voice_status_callback='https://example.com', + ) +) -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +print(status) ``` -### Send payment authentication code with workflow +## SMS API + +### Send an SMS + +Create an `SmsMessage` object, then pass into the `Sms.send` method. ```python -client = vonage.Client(key='API_KEY', secret='API_SECRET') +from vonage_sms import SmsMessage, SmsResponse -client.verify.psd2(number=RECIPIENT_NUMBER, payee=PAYEE, amount=AMOUNT, workflow_id: WORKFLOW_ID) +message = SmsMessage(to='1234567890', from_='Acme Inc.', text='Hello, World!') +response: SmsResponse = vonage_client.sms.send(message) -if response["status"] == "0": - print("Started PSD2 verification request_id is %s" % (response["request_id"])) -else: - print("Error: %s" % response["error_text"]) +print(response.model_dump(exclude_unset=True)) ``` -## Video API - -You can make calls to the Vonage Video API from this SDK. See the [Vonage Video API documentation](https://developer.vonage.com/en/video/overview) for detailed information and instructions on how to use the Vonage Python SDK with the Vonage Video API. Have a look at the SDK's [OpenTok to Vonage migration guide](OPENTOK_TO_VONAGE_MIGRATION.md) if you've previously used OpenTok. +## Subaccounts API -## Meetings API +### List Subaccounts -Full docs for the [Meetings API are available here](https://developer.vonage.com/en/meetings/overview). +```python +response = vonage_client.subaccounts.list_subaccounts() +print(response.model_dump) +``` -### Create a meeting room +### Create Subaccount ```python -# Instant room -params = {'display_name': 'my_test_room'} -meeting = client.meetings.create_room(params) +from vonage_subaccounts import SubaccountOptions -# Long term room -params = {'display_name': 'test_long_term_room', 'type': 'long_term', 'expires_at': '2023-01-30T00:47:04+0000'} -meeting = client.meetings.create_room(params) +response = vonage_client.subaccounts.create_subaccount( + SubaccountOptions( + name='test_subaccount', secret='1234asdfA', use_primary_account_balance=False + ) +) +print(response) ``` -### Get all meeting rooms +### Modify a Subaccount ```python -client.meetings.list_rooms() +from vonage_subaccounts import ModifySubaccountOptions + +response = vonage_client.subaccounts.modify_subaccount( + 'test_subaccount', + ModifySubaccountOptions( + suspended=True, + name='modified_test_subaccount', + ), +) +print(response) ``` -### Get a room by id +### List Balance Transfers ```python -client.meetings.get_room('MY_ROOM_ID') +from vonage_subaccounts import ListTransfersFilter + +filter = {'start_date': '2023-08-07T10:50:44Z'} +response = vonage_client.subaccounts.list_balance_transfers(ListTransfersFilter(**filter)) +for item in response: + print(item.model_dump()) ``` -### Update a long term room +### Transfer Balance Between Subaccounts ```python -params = { - 'update_details': { - "available_features": { - "is_recording_available": False, - "is_chat_available": False, - } - } -} -meeting = client.meetings.update_room('MY_ROOM_ID', params) +from vonage_subaccounts import TransferRequest + +request = TransferRequest( + from_='test_api_key', to='test_subaccount', amount=0.02, reference='A reference' +) +response = vonage_client.subaccounts.transfer_balance(request) +print(response) ``` -### Get all recordings for a session +### List Credit Transfers ```python -session = client.meetings.get_session_recordings('MY_SESSION_ID') +from vonage_subaccounts import ListTransfersFilter + +filter = {'start_date': '2023-08-07T10:50:44Z'} +response = vonage_client.subaccounts.list_credit_transfers(ListTransfersFilter(**filter)) +for item in response: + print(item.model_dump()) ``` -### Get a recording by id +### Transfer Credit Between Subaccounts ```python -recording = client.meetings.get_recording('MY_RECORDING_ID') +from vonage_subaccounts import TransferRequest + +request = TransferRequest( + from_='test_api_key', to='test_subaccount', amount=0.02, reference='A reference' +) +response = vonage_client.subaccounts.transfer_balance(request) +print(response) ``` -### Delete a recording +### Transfer a Phone Number Between Subaccounts ```python -client.meetings.delete_recording('MY_RECORDING_ID') +from vonage_subaccounts import TransferNumberRequest + +request = TransferNumberRequest( + from_='test_api_key', to='test_subaccount', number='447700900000', country='GB' +) +response = vonage_client.subaccounts.transfer_number(request) +print(response) ``` -### List dial-in numbers +## Users API + +### List Users + +With no custom options specified, this method will get the last 100 users. It returns a tuple consisting of a list of `UserSummary` objects and a string describing the cursor to the next page of results. ```python -numbers = client.meetings.list_dial_in_numbers() +from vonage_users import ListUsersRequest + +users, _ = vonage_client.users.list_users() + +# With options +params = ListUsersRequest( + page_size=10, + cursor=my_cursor, + order='desc', +) +users, next_cursor = vonage_client.users.list_users(params) ``` -### Create a theme +### Create a New User ```python -params = { - 'theme_name': 'my_theme', - 'main_color': '#12f64e', - 'brand_text': 'My Company', - 'short_company_url': 'my-company', -} -theme = client.meetings.create_theme(params) +from vonage_users import User, Channels, SmsChannel +user_options = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')]), +) +user = vonage_client.users.create_user(user_options) ``` -### Add a theme to a room +### Get a User ```python -meetings.add_theme_to_room('MY_ROOM_ID', 'MY_THEME_ID') +user = client.users.get_user('USR-87e3e6b0-cd7b-45ef-a0a7-bcd5566a672b') +user_as_dict = user.model_dump(exclude_none=True) ``` -### List themes - +### Update a User ```python -themes = client.meetings.list_themes() +from vonage_users import User, Channels, SmsChannel, WhatsappChannel +user_options = User( + name='my_user_name', + display_name='My User Name', + properties={'custom_key': 'custom_value'}, + channels=Channels(sms=[SmsChannel(number='1234567890')], whatsapp=[WhatsappChannel(number='9876543210')]), +) +user = vonage_client.users.update_user(id, user_options) ``` -### Get theme information +### Delete a User ```python -theme = client.meetings.get_theme('MY_THEME_ID') +vonage_client.users.delete_user(id) ``` -### Delete a theme +## Verify v2 API + +### Make a Verify Request ```python -client.meetings.delete_theme('MY_THEME_ID') +from vonage_verify_v2 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_v2.start_verification(verify_request) ``` -### Update a theme +If using silent authentication, the response will include a `check_url` field with a url that should be accessed on the user's device to proceed with silent authentication. If used, silent auth must be the first element in the `workflow` list. ```python +silent_auth_channel = SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890') +sms_channel = SmsChannel(to='1234567890') params = { - 'update_details': { - 'theme_name': 'updated_theme', - 'main_color': '#FF0000', - 'brand_text': 'My Updated Company Name', - 'short_company_url': 'updated_company_url', - } + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], } -theme = client.meetings.update_theme('MY_THEME_ID', params) +verify_request = VerifyRequest(**params) + +response = vonage_client.verify_v2.start_verification(verify_request) ``` -### List all rooms using a specified theme +### Check a Verification Code ```python -rooms = client.meetings.list_rooms_with_theme_id('MY_THEME_ID') +vonage_client.verify_v2.check_code(request_id='my_request_id', code='1234') ``` -### Update the default theme for your application +### Cancel a Verification ```python -response = client.meetings.update_application_theme('MY_THEME_ID') +vonage_client.verify_v2.cancel_verification('my_request_id') ``` -### Upload a logo to a theme +### Trigger the Next Workflow Event ```python -response = client.meetings.upload_logo_to_theme( - theme_id='MY_THEME_ID', - path_to_image='path/to/my/image.png', - logo_type='white', # 'white', 'colored' or 'favicon' - ) +vonage_client.verify_v2.trigger_next_workflow('my_request_id') ``` -## Number Insight API +## Verify v1 API (Legacy) -### Basic Number Insight +### Make a Verify Request ```python -client.number_insight.get_basic_number_insight(number='447700900000') +from vonage_verify import VerifyRequest +params = {'number': '1234567890', 'brand': 'Acme Inc.'} +request = VerifyRequest(**params) +response = vonage_client.verify.start_verification(request) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightBasic](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightBasic) - -### Standard Number Insight +### Make a PSD2 (Payment Services Directive v2) Request ```python -client.number_insight.get_standard_number_insight(number='447700900000') +from vonage_verify import Psd2Request +params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} +request = VerifyRequest(**params) +response = vonage_client.verify.start_verification(request) ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightStandard](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightStandard) - -### Advanced Number Insight +### Check a Verification Code ```python -client.number_insight.get_advanced_number_insight(number='447700900000') +vonage_client.verify.check_code(request_id='my_request_id', code='1234') ``` -Docs: [https://developer.nexmo.com/api/number-insight#getNumberInsightAdvanced](https://developer.nexmo.com/api/number-insight?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#getNumberInsightAdvanced) +### Search Verification Requests -## Proactive Connect API +```python +# Search for single request +response = vonage_client.verify.search('my_request_id') -Full documentation for the [Proactive Connect API](https://developer.vonage.com/en/proactive-connect/overview) is available here. +# Search for multiple requests +response = vonage_client.verify.search(['my_request_id_1', 'my_request_id_2']) +``` -These methods help you manage lists of contacts when using the API: +### Cancel a Verification -### Find all lists ```python -client.proactive_connect.list_all_lists() +response = vonage_client.verify.cancel_verification('my_request_id') ``` -### Create a list -Lists can be created manually or imported from Salesforce. +### Trigger the Next Workflow Event ```python -params = {'name': 'my list', 'description': 'my description', 'tags': ['vip']} -client.proactive_connect.create_list(params) +response = vonage_client.verify.trigger_next_event('my_request_id') ``` -### Get a list -```python -client.proactive_connect.get_list(LIST_ID) -``` +### Request a Network Unblock + +Note: Network Unblock is switched off by default. Contact Sales to enable the Network Unblock API for your account. -### Update a list ```python -params = {'name': 'my list', 'tags': ['sport', 'football']} -client.proactive_connect.update_list(LIST_ID, params) +response = vonage_client.verify.request_network_unblock('23410') ``` -### Delete a list +## Video API + +You will use the custom Pydantic data models to make most of the API calls in this package. They are accessed from the `vonage_video.models` package. + +### Generate a Client Token + ```python -client.proactive_connect.delete_list(LIST_ID) +from vonage_video.models import TokenOptions + +token_options = TokenOptions(session_id='your_session_id', role='publisher') +client_token = vonage_client.video.generate_client_token(token_options) ``` -### Sync a list from an external datasource +### Create a Session + ```python -params = {'name': 'my list', 'tags': ['sport', 'football']} -client.proactive_connect.sync_list_from_datasource(LIST_ID) +from vonage_video.models import SessionOptions + +session_options = SessionOptions(media_mode='routed') +video_session = vonage_client.video.create_session(session_options) ``` -These methods help you work with individual items in a list: -### Find all items in a list +### List Streams + ```python -client.proactive_connect.list_all_items(LIST_ID) +streams = vonage_client.video.list_streams(session_id='your_session_id') ``` -### Create a new list item +### Get a Stream + ```python -data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '123456789101'} -client.proactive_connect.create_item(LIST_ID, data) +stream_info = vonage_client.video.get_stream(session_id='your_session_id', stream_id='your_stream_id') ``` -### Get a list item +### Change Stream Layout + ```python -client.proactive_connect.get_item(LIST_ID, ITEM_ID) +from vonage_video.models import StreamLayoutOptions + +layout_options = StreamLayoutOptions(type='bestFit') +updated_streams = vonage_client.video.change_stream_layout(session_id='your_session_id', stream_layout_options=layout_options) ``` -### Update a list item +### Send a Signal + ```python -data = {'firstName': 'John', 'lastName': 'Doe', 'phone': '447007000000'} -client.proactive_connect.update_item(LIST_ID, ITEM_ID, data) +from vonage_video.models import SignalData + +signal_data = SignalData(type='chat', data='Hello, World!') +vonage_client.video.send_signal(session_id='your_session_id', data=signal_data) ``` -### Delete a list item +### Disconnect a Client + ```python -client.proactive_connect.delete_item(LIST_ID, ITEM_ID) +vonage_client.video.disconnect_client(session_id='your_session_id', connection_id='your_connection_id') ``` -### Download all items in a list as a .csv file +### Mute a Stream + ```python -FILE_PATH = 'path/to/the/downloaded/file/location' -client.proactive_connect.download_list_items(LIST_ID, FILE_PATH) +vonage_client.video.mute_stream(session_id='your_session_id', stream_id='your_stream_id') ``` -### Upload items from a .csv file into a list +### Mute All Streams + ```python -FILE_PATH = 'path/to/the/file/to/upload/location' -client.proactive_connect.upload_list_items(LIST_ID, FILE_PATH) +vonage_client.video.mute_all_streams(session_id='your_session_id', excluded_stream_ids=['stream_id_1', 'stream_id_2']) ``` -This method helps you work with events emitted by the Proactive Connect API when in use: +### Disable Mute All Streams -### List all events ```python -client.proactive_connect.list_events() +vonage_client.video.disable_mute_all_streams(session_id='your_session_id') ``` -## Account API +### Start Captions -### Get your account balance ```python -client.account.get_balance() +from vonage_video.models import CaptionsOptions + +captions_options = CaptionsOptions(language='en-US') +captions_data = vonage_client.video.start_captions(captions_options) ``` -### Top up your account -This feature is only enabled when you enable auto-reload for your account in the dashboard. +### Stop Captions + ```python -# trx is the reference from when auto-reload was enabled and money was added -client.account.topup(trx=transaction_reference) +from vonage_video.models import CaptionsData + +captions_data = CaptionsData(captions_id='your_captions_id') +vonage_client.video.stop_captions(captions_data) ``` -## Subaccounts API +### Start Audio Connector -This API is used to create and configure subaccounts related to your primary account and transfer credit, balances and bought numbers between accounts. +```python +from vonage_video.models import AudioConnectorOptions -The subaccounts API is disabled by default. If you want to use subaccounts, [contact support](https://api.support.vonage.com) to have the API enabled on your account. +audio_connector_options = AudioConnectorOptions(session_id='your_session_id', token='your_token', url='https://example.com') +audio_connector_data = vonage_client.video.start_audio_connector(audio_connector_options) +``` -### Get a list of all subaccounts +### Start Experience Composer ```python -client.subaccounts.list_subaccounts() +from vonage_video.models import ExperienceComposerOptions + +experience_composer_options = ExperienceComposerOptions(session_id='your_session_id', token='your_token', url='https://example.com') +experience_composer = vonage_client.video.start_experience_composer(experience_composer_options) ``` -### Create a subaccount +### List Experience Composers ```python -client.subaccounts.create_subaccount(name='my subaccount') +from vonage_video.models import ListExperienceComposersFilter -# With options -client.subaccounts.create_subaccount( - name='my subaccount', - secret='Password123', - use_primary_account_balance=False, -) +filter = ListExperienceComposersFilter(page_size=10) +experience_composers, count, next_page_offset = vonage_client.video.list_experience_composers(filter) +print(experience_composers) ``` -### Get information about a subaccount +### Get Experience Composer ```python -client.subaccounts.get_subaccount(SUBACCOUNT_API_KEY) +experience_composer = vonage_client.video.get_experience_composer(experience_composer_id='experience_composer_id') ``` -### Modify a subaccount +### Stop Experience Composer ```python -client.subaccounts.modify_subaccount( - SUBACCOUNT_KEY, - suspended=True, - use_primary_account_balance=False, - name='my modified subaccount', -) +vonage_client.video.stop_experience_composer(experience_composer_id='experience_composer_id') ``` -### List credit transfers between accounts - -All fields are optional. If `start_date` or `end_date` are used, the dates must be specified in UTC ISO 8601 format, e.g. `1970-01-01T00:00:00Z`. Don't use milliseconds. +### List Archives ```python -client.subaccounts.list_credit_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key -) -``` +from vonage_video.models import ListArchivesFilter -### Transfer credit between accounts +filter = ListArchivesFilter(offset=2) +archives, count, next_page_offset = vonage_client.video.list_archives(filter) +print(archives) +``` -Transferring credit is only possible for postpaid accounts, i.e. accounts that can have a negative balance. For prepaid and self-serve customers, account balances can be transferred between accounts (see below). +### Start Archive ```python -client.subaccounts.transfer_credit( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - amount=0.50, - reference='test credit transfer', -) -``` +from vonage_video.models import CreateArchiveRequest -### List balance transfers between accounts +archive_options = CreateArchiveRequest(session_id='your_session_id', name='My Archive') +archive = vonage_client.video.start_archive(archive_options) +``` -All fields are optional. If `start_date` or `end_date` are used, the dates must be specified in UTC ISO 8601 format, e.g. `1970-01-01T00:00:00Z`. Don't use milliseconds. +### Get Archive ```python -client.subaccounts.list_balance_transfers( - start_date='2022-03-29T14:16:56Z', - end_date='2023-06-12T17:20:01Z', - subaccount=SUBACCOUNT_API_KEY, # Use to show only the results that contain this key -) +archive = vonage_client.video.get_archive(archive_id='your_archive_id') +print(archive) ``` -### Transfer account balances between accounts +### Delete Archive ```python -client.subaccounts.transfer_balance( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - amount=0.50, - reference='test balance transfer', -) +vonage_client.video.delete_archive(archive_id='your_archive_id') ``` -### Transfer bought phone numbers between accounts +### Add Stream to Archive ```python -client.subaccounts.transfer_balance( - from_=FROM_ACCOUNT, - to=TO_ACCOUNT, - number=NUMBER_TO_TRANSFER, - country='US', -) -``` +from vonage_video.models import AddStreamRequest -## Number Management API +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_archive(archive_id='your_archive_id', params=add_stream_request) +``` -### Get numbers associated with your account +### Remove Stream from Archive ```python -client.numbers.get_account_numbers(size=25) +vonage_client.video.remove_stream_from_archive(archive_id='your_archive_id', stream_id='your_stream_id') ``` -### Get numbers that are available to buy +### Stop Archive ```python -client.numbers.get_available_numbers('CA', size=25) +archive = vonage_client.video.stop_archive(archive_id='your_archive_id') +print(archive) ``` -### Buy an available number +### Change Archive Layout ```python -params = {'country': 'US', 'msisdn': 'number_to_buy'} -client.numbers.buy_number(params) +from vonage_video.models import ComposedLayout -# To buy a number for a subaccount -params = {'country': 'US', 'msisdn': 'number_to_buy', 'target_api_key': SUBACCOUNT_API_KEY} -client.numbers.buy_number(params) +layout = ComposedLayout(type='bestFit') +archive = vonage_client.video.change_archive_layout(archive_id='your_archive_id', layout=layout) +print(archive) ``` -### Cancel your subscription for a specific number +### List Broadcasts ```python -params = {'country': 'US', 'msisdn': 'number_to_cancel'} -client.numbers.cancel_number(params) +from vonage_video.models import ListBroadcastsFilter -# To cancel a number assigned to a subaccount -params = {'country': 'US', 'msisdn': 'number_to_buy', 'target_api_key': SUBACCOUNT_API_KEY} -client.numbers.cancel_number(params) +filter = ListBroadcastsFilter(page_size=10) +broadcasts, count, next_page_offset = vonage_client.video.list_broadcasts(filter) +print(broadcasts) ``` -### Update the behaviour of a number that you own +### Start Broadcast ```python -params = {"country": "US", "msisdn": "number_to_update", "moHttpUrl": "callback_url"} -client.numbers.update_number(params) +from vonage_video.models import CreateBroadcastRequest, BroadcastOutputSettings, BroadcastHls, BroadcastRtmp + +broadcast_options = CreateBroadcastRequest(session_id='your_session_id', outputs=BroadcastOutputSettings( + hls=BroadcastHls(dvr=True, low_latency=False), + rtmp=[ + BroadcastRtmp( + id='test', + server_url='rtmp://a.rtmp.youtube.com/live2', + stream_name='stream-key', + ) + ], +) +) +broadcast = vonage_client.video.start_broadcast(broadcast_options) +print(broadcast) ``` -## Pricing API +### Get Broadcast -### Get pricing for a single country ```python -client.account.get_country_pricing(country_code='GB', type='sms') # Default type is sms +broadcast = vonage_client.video.get_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) ``` -### Get pricing for all countries -```python -client.account.get_all_countries_pricing(type='sms') # Default type is sms, can be voice -``` +### Stop Broadcast -### Get pricing for a specific dialling prefix ```python -client.account.get_prefix_pricing(prefix='44', type='sms') +broadcast = vonage_client.video.stop_broadcast(broadcast_id='your_broadcast_id') +print(broadcast) ``` -## Managing Secrets - -An API is provided to allow you to rotate your API secrets. You can create a new secret (up to a maximum of two secrets) and delete the existing one once all applications have been updated. - -### List Secrets +### Change Broadcast Layout ```python -secrets = client.account.list_secrets(API_KEY) +from vonage_video.models import ComposedLayout + +layout = ComposedLayout(type='bestFit') +broadcast = vonage_client.video.change_broadcast_layout(broadcast_id='your_broadcast_id', layout=layout) +print(broadcast) ``` -### Get information about a specific secret +### Add Stream to Broadcast ```python -secrets = client.account.get_secret(API_KEY, secret_id) -``` +from vonage_video.models import AddStreamRequest -### Create A New Secret +add_stream_request = AddStreamRequest(stream_id='your_stream_id') +vonage_client.video.add_stream_to_broadcast(broadcast_id='your_broadcast_id', params=add_stream_request) +``` -Create a new secret (the created dates will help you know which is which): +### Remove Stream from Broadcast ```python -client.account.create_secret(API_KEY, 'awes0meNewSekret!!;'); +vonage_client.video.remove_stream_from_broadcast(broadcast_id='your_broadcast_id', stream_id='your_stream_id') ``` -### Delete A Secret - -Delete the old secret (any application still using these credentials will stop working): +### Initiate SIP Call ```python -client.account.revoke_secret(API_KEY, 'my-secret-id') -``` +from vonage_video.models import InitiateSipRequest, SipOptions, SipAuth -## Application API +sip_request_params = InitiateSipRequest( + session_id='your_session_id', + token='your_token', + sip=SipOptions( + uri=f'sip:{vonage_number}@sip.nexmo.com;transport=tls', + from_=f'test@vonage.com', + headers={'header_key': 'header_value'}, + auth=SipAuth(username='1485b9e6', password='fL8jvi4W2FmS9som'), + secure=False, + video=False, + observe_force_mute=True, + ), +) +sip_call = vonage_client.video.initiate_sip_call(sip_request_params) +print(sip_call) +``` -### Create an application +### Play DTMF into a call ```python -response = client.application.create_application({name='Example App', type='voice'}) -``` +# Play into all connections +session_id = 'your_session_id' +digits = '1234#*p' -Docs: [https://developer.nexmo.com/api/application.v2#createApplication](https://developer.nexmo.com/api/application.v2#createApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#create-an-application) +vonage_client.video.play_dtmf(session_id=session_id, digits=digits) -### Retrieve a list of applications +# Play into one connection +session_id = 'your_session_id' +digits = '1234#*p' +connection_id = 'your_connection_id' -```python -response = client.application.list_applications() +vonage_client.video.play_dtmf(session_id=session_id, digits=digits, connection_id=connection_id) ``` -Docs: [https://developer.nexmo.com/api/application.v2#listApplication](https://developer.nexmo.com/api/application.v2#listApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-your-applications) +## Voice API + +### Create a Call -### Retrieve a single application +To create a call, you must pass an instance of the `CreateCallRequest` model to the `create_call` method. If supplying an NCCO, import the NCCO actions you want to use and pass them in as a list to the `ncco` model field. ```python -response = client.application.get_application(uuid) -``` +from vonage_voice.models import CreateCallRequest, Talk -Docs: [https://developer.nexmo.com/api/application.v2#getApplication](https://developer.nexmo.com/api/application.v2#getApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#retrieve-an-application) +ncco = [Talk(text='Hello world', loop=3, language='en-GB')] -### Update an application +call = CreateCallRequest( + to=[{'type': 'phone', 'number': '1234567890'}], + ncco=ncco, + random_from_number=True, +) -```python -response = client.application.update_application(uuid, answer_method='POST') +response = vonage_client.voice.create_call(call) +print(response.model_dump()) ``` -Docs: [https://developer.nexmo.com/api/application.v2#updateApplication](https://developer.nexmo.com/api/application.v2#updateApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#update-an-application) - -### Delete an application +### List Calls ```python -response = client.application.delete_application(uuid) -``` +# Gets the first 100 results and the record_index of the +# next page if there's more than 100 +calls, next_record_index = vonage_client.voice.list_calls() -Docs: [https://developer.nexmo.com/api/application.v2#deleteApplication](https://developer.nexmo.com/api/application.v2#deleteApplication?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library#destroy-an-application) +# Specify filtering options +from vonage_voice.models import ListCallsFilter +call_filter = ListCallsFilter( + status='completed', + date_start='2024-03-14T07:45:14Z', + date_end='2024-04-19T08:45:14Z', + page_size=10, + record_index=0, + order='asc', + conversation_uuid='CON-2be039b2-d0a4-4274-afc8-d7b241c7c044', +) -## Users API - -These API methods are part of the [Application (v2) API](https://developer.vonage.com/en/application/overview) but are a in separate module in the SDK. [See the API reference for more details](https://developer.vonage.com/en/api/application.v2#User). +calls, next_record_index = vonage_client.voice.list_calls(call_filter) +``` -### List all Users +### Get Information About a Specific Call ```python -client.users.list_users() +call = vonage_client.voice.get_call('CALL_ID') ``` -### Create a new user +### Transfer a Call to a New NCCO ```python -client.users.create_user() # Default values generated -client.users.create_user(params={...}) # Specify custom values +ncco = [Talk(text='Hello world')] +vonage_client.voice.transfer_call_ncco('UUID', ncco) ``` -### Get detailed information about a user +### Transfer a Call to a New Answer URL ```python -client.users.get_user('USER_ID') +vonage_client.voice.transfer_call_answer_url('UUID', 'ANSWER_URL') ``` -### Update user details +### Hang Up a Call + +End the call for a specified UUID, removing them from it. ```python -client.users.update_user('USER_ID', params={...}) +vonage_client.voice.hangup('UUID') ``` -### Delete a user +### Mute/Unmute a Participant ```python -client.users.delete_user('USER_ID') +vonage_client.voice.mute('UUID') +vonage_client.voice.unmute('UUID') ``` -## Validate webhook signatures +### Earmuff/Unearmuff a UUID -```python -client = vonage.Client(signature_secret='secret') +Prevent/allow a specified UUID participant to be able to hear audio. -if client.check_signature(request.query): - # valid signature -else: - # invalid signature +```python +vonage_client.voice.earmuff('UUID') +vonage_client.voice.unearmuff('UUID') ``` -Docs: [https://developer.nexmo.com/concepts/guides/signing-messages](https://developer.nexmo.com/concepts/guides/signing-messages?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library) +### Play Audio Into a Call -Note: you'll need to contact support@nexmo.com to enable message signing on -your account before you can validate webhook signatures. - -## JWT parameters +```python +from vonage_voice.models import AudioStreamOptions -By default, the library generates tokens for JWT authentication that have an expiry time of 15 minutes. You should set the expiry time (`exp`) to an appropriate value for your organisation's own policies and/or your use case. +# Only the `stream_url` option is required +options = AudioStreamOptions( + stream_url=['https://example.com/audio'], loop=2, level=0.5 +) +response = vonage_client.voice.play_audio_into_call('UUID', options) +``` -Use the `auth` method of the client class to specify custom parameters: +### Stop Playing Audio Into a Call ```python -client.auth(nbf=nbf, exp=exp, jti=jti) -# OR -client.auth({'nbf': nbf, 'exp': exp, 'jti': jti}) +vonage_client.voice.stop_audio_stream('UUID') ``` -## Overriding API Attributes +### Play TTS Into a Call -In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client to use with all API classes. +```python +from vonage_voice.models import TtsStreamOptions -An example using setters/getters with `Object references`: +# Only the `text` field is required +options = TtsStreamOptions( + text='Hello world', language='en-ZA', style=1, premium=False, loop=2, level=0.5 +) +response = voice.play_tts_into_call('UUID', options) +``` + +### Stop Playing TTS Into a Call ```python -from vonage import Client +vonage_client.voice.stop_tts('UUID') +``` -# Define the client -client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # using getter for host -- value returned: rest.nexmo.com +### Play DTMF Tones Into a Call -# Change the value in client -client.host('mio.nexmo.com') # Change host to mio.nexmo.com - this change will be available for sms -client.sms.send_message(params) # Sends an SMS to the host above +```python +response = voice.play_dtmf_into_call('UUID', '1234*#') ``` -### Overriding API Host / Host Attributes - -These attributes are private in the client class and the only way to access them is using the getters/setters we provide. +## Vonage Utils Package ```python -from vonage import Client +from utils import format_phone_number, remove_none_values + +# Use format_phone_number +try: + formatted_number = format_phone_number('123-456-7890') + print(formatted_number) +except (InvalidPhoneNumberError, InvalidPhoneNumberTypeError) as e: + print(e) + +# 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 -client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET') -print(client.host()) # return rest.nexmo.com -client.host('newhost.vonage.com') # rewrites the host value to newhost.vonage.com -print(client.api_host()) # returns api.vonage.com -client.api_host('myapi.vonage.com') # rewrite the value of api_host to myapi.vonage.com +vonage_api_response = vonage.api.method() +cleaned_dict = asdict(my_dataclass, dict_factory=remove_none_values) +print(cleaned_dict) ``` ## Frequently Asked Questions @@ -1214,18 +1309,17 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Dispatch API | Beta | ❌ | | External Accounts API | Beta | ❌ | | Media API | Beta | ❌ | -| Meetings API | General Availability | ✅ | | Messages API | General Availability | ✅ | | Number Insight API | General Availability | ✅ | | Number Management API | General Availability | ✅ | | Pricing API | General Availability | ✅ | -| Proactive Connect API | General Availability | ✅ (partially supported) | | Redact API | Developer Preview | ❌ | | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | | Subaccounts API | General Availability | ✅ | | Verify API v2 | General Availability | ✅ | | Verify API v1 (Legacy)| General Availability | ✅ | +| Video API | General Availability | ✅ | | Voice API | General Availability | ✅ | ### asyncio Support diff --git a/http_client/README.md b/http_client/README.md index 9ed14438..3f436da9 100644 --- a/http_client/README.md +++ b/http_client/README.md @@ -42,7 +42,7 @@ response = client.get(host='api.nexmo.com', request_path='/v1/messages') 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 +### 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. diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 35918eb1..f31e00ec 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -3,23 +3,22 @@ name = "vonage" dynamic = ["version"] description = "Python Server SDK for using Vonage APIs" readme = "README.md" -authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.1.2", - "vonage-http-client>=1.4.0", - "vonage-account>=1.0.0", - "vonage-application>=1.0.0", - "vonage-messages>=1.2.0", - "vonage-number-insight>=1.0.0", - "vonage-numbers>=1.0.0", - "vonage-sms>=1.1.1", - "vonage-subaccounts>=1.0.1", - "vonage-users>=1.1.2", - "vonage-verify>=1.1.1", - "vonage-verify-v2>=1.1.2", + "vonage-utils>=1.1.3", + "vonage-http-client>=1.4.1", + "vonage-account>=1.0.1", + "vonage-application>=1.0.2", + "vonage-messages>=1.2.1", + "vonage-number-insight>=1.0.1", + "vonage-numbers>=1.0.1", + "vonage-sms>=1.1.2", + "vonage-subaccounts>=1.0.2", + "vonage-users>=1.1.3", + "vonage-verify>=1.1.2", + "vonage-verify-v2>=1.1.3", "vonage-video>=1.0.0", - "vonage-voice>=1.0.3", + "vonage-voice>=1.0.4", ] classifiers = [ "Programming Language :: Python", @@ -31,13 +30,16 @@ classifiers = [ "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._version.__version__" } +[[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__" From 05b2b94e1591e5103e79aa86555e011498e17fb8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Sat, 12 Oct 2024 16:30:24 +0100 Subject: [PATCH 81/98] prepare for alpha release --- pants.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pants.toml b/pants.toml index e0fba13a..8ec456b9 100644 --- a/pants.toml +++ b/pants.toml @@ -12,7 +12,7 @@ backend_packages = [ "pants.backend.experimental.python", ] -pants_ignore.add = ['!_test_scripts/'] +pants_ignore.add = ['!_test_scripts/', '!_dev_scripts/'] [anonymous-telemetry] enabled = false From 766964d3ab52c42e030b94e6f3479e0c0136f791 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 15 Oct 2024 18:55:53 +0100 Subject: [PATCH 82/98] support Python 3.9+ and use inbuilt types in type hints --- .github/workflows/build.yml | 2 +- account/src/vonage_account/account.py | 5 +- .../src/vonage_application/application.py | 6 +-- .../src/vonage_application/responses.py | 6 +-- .../src/vonage_http_client/http_client.py | 3 +- .../src/vonage_messages/models/whatsapp.py | 6 +-- network_sim_swap/pyproject.toml | 6 +-- .../number_insight_v2.py | 4 +- .../src/vonage_numbers/number_management.py | 14 +++--- .../src/vonage_numbers/responses.py | 10 ++-- pants.toml | 3 +- requirements.txt | 1 + sms/src/vonage_sms/responses.py | 6 +-- .../src/vonage_subaccounts/responses.py | 6 +-- .../src/vonage_subaccounts/subaccounts.py | 12 ++--- users/src/vonage_users/common.py | 38 +++++++-------- users/src/vonage_users/responses.py | 6 +-- users/src/vonage_users/users.py | 6 +-- verify/src/vonage_verify/responses.py | 10 ++-- verify/src/vonage_verify/verify.py | 8 ++-- verify_v2/src/vonage_verify_v2/requests.py | 6 +-- video/src/vonage_video/models/archive.py | 6 +-- .../vonage_video/models/audio_connector.py | 6 +-- video/src/vonage_video/models/broadcast.py | 14 +++--- video/src/vonage_video/models/stream.py | 13 +++--- video/src/vonage_video/models/token.py | 6 +-- video/src/vonage_video/video.py | 38 +++++++-------- voice/src/vonage_voice/models/input_types.py | 10 ++-- voice/src/vonage_voice/models/ncco.py | 46 +++++++++---------- voice/src/vonage_voice/models/requests.py | 22 ++++----- voice/src/vonage_voice/models/responses.py | 4 +- voice/src/vonage_voice/voice.py | 10 ++-- vonage_utils/pyproject.toml | 2 +- vonage_utils/src/vonage_utils/types.py | 2 +- 34 files changed, 171 insertions(+), 172 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83e09410..8fe82a53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] os: ["ubuntu-latest"] steps: - name: Clone repo diff --git a/account/src/vonage_account/account.py b/account/src/vonage_account/account.py index 80ce66d0..fec0fccc 100644 --- a/account/src/vonage_account/account.py +++ b/account/src/vonage_account/account.py @@ -1,5 +1,4 @@ import re -from typing import List from pydantic import validate_call from vonage_account.errors import InvalidSecretError @@ -91,11 +90,11 @@ def update_default_sms_webhook( ) return SettingsResponse(**response) - def list_secrets(self) -> List[VonageApiSecret]: + def list_secrets(self) -> list[VonageApiSecret]: """List all secrets associated with the account. Returns: - List[VonageApiSecret]: List of VonageApiSecret objects. + list[VonageApiSecret]: List of VonageApiSecret objects. """ response = self._http_client.get( self._http_client.api_host, diff --git a/application/src/vonage_application/application.py b/application/src/vonage_application/application.py index 6e022f48..edf4cb81 100644 --- a/application/src/vonage_application/application.py +++ b/application/src/vonage_application/application.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import Optional from pydantic import validate_call from vonage_http_client.http_client import HttpClient @@ -26,7 +26,7 @@ def http_client(self) -> HttpClient: @validate_call def list_applications( self, filter: ListApplicationsFilter = ListApplicationsFilter() - ) -> Tuple[List[ApplicationData], Optional[str]]: + ) -> tuple[list[ApplicationData], Optional[str]]: """List applications. By default, returns the first 100 applications and the page index of @@ -36,7 +36,7 @@ def list_applications( filter (ListApplicationsFilter): The filter object. Returns: - Tuple[List[ApplicationData], Optional[str]]: A tuple containing a + tuple[list[ApplicationData], Optional[str]]: A tuple containing a list of applications and the next page index. """ response = self._http_client.get( diff --git a/application/src/vonage_application/responses.py b/application/src/vonage_application/responses.py index 2dc56c85..5fbb9728 100644 --- a/application/src/vonage_application/responses.py +++ b/application/src/vonage_application/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field, model_validator from vonage_utils.models import HalLinks, ResourceLink @@ -37,10 +37,10 @@ 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]): A list of application data objects. """ - applications: List[ApplicationData] = [] + applications: list[ApplicationData] = [] class ListApplicationsResponse(BaseModel): diff --git a/http_client/src/vonage_http_client/http_client.py b/http_client/src/vonage_http_client/http_client.py index b0a702f9..539b1e99 100644 --- a/http_client/src/vonage_http_client/http_client.py +++ b/http_client/src/vonage_http_client/http_client.py @@ -1,13 +1,12 @@ from json import JSONDecodeError from logging import getLogger from platform import python_version -from typing import Literal, Optional, Union +from typing import Annotated, Literal, Optional, Union from pydantic import BaseModel, Field, ValidationError, validate_call from requests import PreparedRequest, Response from requests.adapters import HTTPAdapter from requests.sessions import Session -from typing_extensions import Annotated from vonage_http_client.auth import Auth from vonage_http_client.errors import ( AuthenticationError, diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index 9ec3a661..d4f4582e 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Union +from typing import Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field from vonage_utils.types import PhoneNumber @@ -236,14 +236,14 @@ class WhatsappTemplateResource(BaseModel): 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. + 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 + parameters: Optional[list[str]] = None model_config = ConfigDict(extra='allow') diff --git a/network_sim_swap/pyproject.toml b/network_sim_swap/pyproject.toml index 30f8009c..74a981fe 100644 --- a/network_sim_swap/pyproject.toml +++ b/network_sim_swap/pyproject.toml @@ -6,9 +6,9 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-network-auth>=0.1.0b0", - "vonage-utils>=1.1.2", + "vonage-http-client>=1.4.1", + "vonage-network-auth>=0.1.1b0", + "vonage-utils>=1.1.3", ] classifiers = [ "Programming Language :: Python", diff --git a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py index 6f6721b2..ad44ce10 100644 --- a/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py +++ b/number_insight_v2/src/vonage_number_insight_v2/number_insight_v2.py @@ -1,6 +1,6 @@ from copy import deepcopy from dataclasses import dataclass -from typing import List, Literal, Optional, Union +from typing import Literal, Optional, Union from pydantic import BaseModel, field_validator, validate_call from vonage_http_client.http_client import HttpClient @@ -11,7 +11,7 @@ class FraudCheckRequest(BaseModel): phone: Union[str, int] insights: Union[ - Literal['fraud_score', 'sim_swap'], List[Literal['fraud_score', 'sim_swap']] + Literal['fraud_score', 'sim_swap'], list[Literal['fraud_score', 'sim_swap']] ] = ['fraud_score', 'sim_swap'] type: Literal['phone'] = 'phone' diff --git a/number_management/src/vonage_numbers/number_management.py b/number_management/src/vonage_numbers/number_management.py index 2a3c2fee..7343d07a 100644 --- a/number_management/src/vonage_numbers/number_management.py +++ b/number_management/src/vonage_numbers/number_management.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import Optional from pydantic import validate_call from vonage_http_client.http_client import HttpClient @@ -33,7 +33,7 @@ def http_client(self) -> HttpClient: @validate_call def list_owned_numbers( self, filter: ListOwnedNumbersFilter = ListOwnedNumbersFilter() - ) -> Tuple[List[OwnedNumber], int, Optional[int]]: + ) -> tuple[list[OwnedNumber], int, Optional[int]]: """List numbers you own. By default, returns the first 100 numbers and the page index of @@ -43,11 +43,11 @@ def list_owned_numbers( filter (ListOwnedNumbersFilter): The filter object. Returns: - Tuple[List[OwnedNumber], int, Optional[int]]: A tuple containing a + 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]) + number_list: list[OwnedNumber], count: int, next_page_index: Optional[int]) """ response = self._http_client.get( self._http_client.rest_host, @@ -74,7 +74,7 @@ def list_owned_numbers( @validate_call def search_available_numbers( self, filter: SearchAvailableNumbersFilter - ) -> Tuple[List[AvailableNumber], int, Optional[int]]: + ) -> tuple[list[AvailableNumber], int, Optional[int]]: """Search for available numbers to buy. By default, returns the first 100 numbers and the page index of @@ -84,11 +84,11 @@ def search_available_numbers( filter (SearchAvailableNumbersFilter): The filter object. Returns: - Tuple[List[AvailableNumber], int, Optional[int]]: A tuple containing a + 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]) + number_list: list[AvailableNumber], count: int, next_page_index: Optional[int]) """ response = self._http_client.get( self._http_client.rest_host, diff --git a/number_management/src/vonage_numbers/responses.py b/number_management/src/vonage_numbers/responses.py index 9758e0d3..6491b9b1 100644 --- a/number_management/src/vonage_numbers/responses.py +++ b/number_management/src/vonage_numbers/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -12,7 +12,7 @@ class OwnedNumber(BaseModel): 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. + 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. @@ -26,7 +26,7 @@ class OwnedNumber(BaseModel): msisdn: Optional[str] = None mo_http_url: Optional[str] = Field(None, validation_alias='moHttpUrl') type: Optional[str] = None - features: Optional[List[str]] = None + features: Optional[list[str]] = None messages_callback_type: Optional[str] = Field( None, validation_alias='messagesCallbackType' ) @@ -48,14 +48,14 @@ class AvailableNumber(BaseModel): 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. + 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 + features: Optional[list[str]] = None class NumbersStatus(BaseModel): diff --git a/pants.toml b/pants.toml index 8ec456b9..6a61c625 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.23.0a0' +pants_version = '2.23.0rc0' backend_packages = [ 'pants.backend.python', @@ -38,6 +38,7 @@ filter = [ 'jwt/src', 'messages/src', 'network_auth/src', + 'network_number_verification/src', 'network_sim_swap/src', 'number_insight/src', 'number_insight_v2/src', diff --git a/requirements.txt b/requirements.txt index 5b8df2f4..11e66972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ toml>=0.10.2 -e application -e messages -e network_auth +-e network_number_verification -e network_sim_swap -e number_insight -e number_insight_v2 diff --git a/sms/src/vonage_sms/responses.py b/sms/src/vonage_sms/responses.py index 4b594190..04282796 100644 --- a/sms/src/vonage_sms/responses.py +++ b/sms/src/vonage_sms/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -35,9 +35,9 @@ class SmsResponse(BaseModel): Args: message_count (str): The number of messages sent. - messages (List[MessageResponse]): A list of individual message responses. See + 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] + messages: list[MessageResponse] diff --git a/subaccounts/src/vonage_subaccounts/responses.py b/subaccounts/src/vonage_subaccounts/responses.py index 230197a9..5e3cad54 100644 --- a/subaccounts/src/vonage_subaccounts/responses.py +++ b/subaccounts/src/vonage_subaccounts/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Optional, Union from pydantic import BaseModel, Field @@ -62,13 +62,13 @@ class ListSubaccountsResponse(BaseModel): Args: primary_account (PrimaryAccount): The primary account. See `PrimaryAccount`. - subaccounts (List[Subaccount]): The subaccounts. See `Subaccount`. + 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] + subaccounts: list[Subaccount] total_balance: float total_credit_limit: Union[int, float] diff --git a/subaccounts/src/vonage_subaccounts/subaccounts.py b/subaccounts/src/vonage_subaccounts/subaccounts.py index 02f6bd01..a9e3bf73 100644 --- a/subaccounts/src/vonage_subaccounts/subaccounts.py +++ b/subaccounts/src/vonage_subaccounts/subaccounts.py @@ -1,5 +1,3 @@ -from typing import List - from pydantic import validate_call from vonage_http_client.http_client import HttpClient from vonage_subaccounts.requests import ( @@ -46,7 +44,7 @@ def list_subaccounts(self) -> ListSubaccountsResponse: 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. + - 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. """ @@ -155,7 +153,7 @@ def modify_subaccount( return Subaccount(**response) - def list_balance_transfers(self, filter: ListTransfersFilter) -> List[Transfer]: + def list_balance_transfers(self, filter: ListTransfersFilter) -> list[Transfer]: """List all balance transfers. Args: @@ -165,7 +163,7 @@ def list_balance_transfers(self, filter: ListTransfersFilter) -> List[Transfer]: - subaccount (str): Show balance transfers relating to this subaccount. Returns: - List[Transfer]: A list of balance transfers. Each balance transfer contains the following attributes: + list[Transfer]: A list of balance transfers. Each balance transfer contains the following attributes: - id (str) - amount (float) - from_ (str) @@ -213,7 +211,7 @@ def transfer_balance(self, params: TransferRequest) -> Transfer: return Transfer(**response) - def list_credit_transfers(self, filter: ListTransfersFilter) -> List[Transfer]: + def list_credit_transfers(self, filter: ListTransfersFilter) -> list[Transfer]: """List all credit transfers. Args: @@ -223,7 +221,7 @@ def list_credit_transfers(self, filter: ListTransfersFilter) -> List[Transfer]: - subaccount (str): Show credit transfers relating to this subaccount. Returns: - List[Transfer]: A list of credit transfers. Each credit transfer contains the following attributes: + list[Transfer]: A list of credit transfers. Each credit transfer contains the following attributes: - id (str) - amount (float) - from_ (str) diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index a1ab9a82..0fb785a8 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field, model_validator from vonage_utils.models import ResourceLink @@ -109,26 +109,26 @@ 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 (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 + 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): diff --git a/users/src/vonage_users/responses.py b/users/src/vonage_users/responses.py index 68938256..6e21051a 100644 --- a/users/src/vonage_users/responses.py +++ b/users/src/vonage_users/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field, model_validator from vonage_utils.models import Link, ResourceLink @@ -48,10 +48,10 @@ class Embedded(BaseModel): """Model for embedded resources. Args: - users (List[UserSummary]): A list of user summaries. + users (list[UserSummary]): A list of user summaries. """ - users: List[UserSummary] = [] + users: list[UserSummary] = [] class ListUsersResponse(BaseModel): diff --git a/users/src/vonage_users/users.py b/users/src/vonage_users/users.py index 3e4a77d0..bb39727a 100644 --- a/users/src/vonage_users/users.py +++ b/users/src/vonage_users/users.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import Optional from urllib.parse import parse_qs, urlparse from pydantic import validate_call @@ -32,7 +32,7 @@ def http_client(self) -> HttpClient: @validate_call def list_users( self, filter: ListUsersFilter = ListUsersFilter() - ) -> Tuple[List[UserSummary], Optional[str]]: + ) -> tuple[list[UserSummary], Optional[str]]: """List all users. Retrieves a list of all users. Gets 100 users by default. @@ -44,7 +44,7 @@ def list_users( 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` + 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`. """ diff --git a/verify/src/vonage_verify/responses.py b/verify/src/vonage_verify/responses.py index 65e9b06c..84d6f11f 100644 --- a/verify/src/vonage_verify/responses.py +++ b/verify/src/vonage_verify/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel @@ -88,9 +88,9 @@ class VerifyStatus(BaseModel): 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 + 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 + 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. @@ -107,8 +107,8 @@ class VerifyStatus(BaseModel): 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 + checks: Optional[list[Check]] = None + events: Optional[list[Event]] = None estimated_price_messages_sent: Optional[str] = None diff --git a/verify/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index 56279b47..bc623a91 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Optional, Union from pydantic import Field, validate_call from vonage_http_client.http_client import HttpClient @@ -92,15 +92,15 @@ def check_code(self, request_id: str, code: str) -> CheckCodeResponse: @validate_call def search( - self, request: Union[str, List[str]] - ) -> Union[VerifyStatus, List[VerifyStatus]]: + 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 + Union[VerifyStatus, list[VerifyStatus]]: Either the response object containing the verification result, or a list of response objects. """ params = {} diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index 523a676e..43d00c6b 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -1,5 +1,5 @@ from re import search -from typing import List, Optional, Union +from typing import Optional, Union from pydantic import BaseModel, Field, field_validator, model_validator from vonage_utils.types import PhoneNumber @@ -139,7 +139,7 @@ class VerifyRequest(BaseModel): 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]]): + 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. @@ -158,7 +158,7 @@ class VerifyRequest(BaseModel): """ brand: str = Field(..., min_length=1, max_length=16) - workflow: List[ + workflow: list[ Union[ SilentAuthChannel, SmsChannel, diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index d1a87489..37c39c35 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field, model_validator from vonage_video.errors import IndividualArchivePropertyError, NoAudioOrVideoError @@ -65,7 +65,7 @@ class Archive(BaseModel): 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. + 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. @@ -91,7 +91,7 @@ class Archive(BaseModel): multi_archive_tag: Optional[str] = Field(None, validation_alias='multiArchiveTag') event: Optional[str] = None resolution: Optional[VideoResolution] = None - streams: Optional[List[VideoStream]] = None + streams: Optional[list[VideoStream]] = None url: Optional[str] = None transcription: Optional[Transcription] = None diff --git a/video/src/vonage_video/models/audio_connector.py b/video/src/vonage_video/models/audio_connector.py index c19dcbec..1c490930 100644 --- a/video/src/vonage_video/models/audio_connector.py +++ b/video/src/vonage_video/models/audio_connector.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field from vonage_video.models.enums import AudioSampleRate @@ -9,13 +9,13 @@ class AudioConnectorWebSocket(BaseModel): Args: uri (str): The URI. - streams (List[str]): Stream IDs to include. If not provided, all streams are included. + streams (list[str]): Stream IDs to include. If not provided, all streams are included. headers (dict): The headers to send to your WebSocket server. audio_rate (AudioSampleRate): The audio sample rate in Hertz. """ uri: str - streams: Optional[List[str]] = None + streams: Optional[list[str]] = None headers: Optional[dict] = None audio_rate: Optional[AudioSampleRate] = Field(None, serialization_alias='audioRate') diff --git a/video/src/vonage_video/models/broadcast.py b/video/src/vonage_video/models/broadcast.py index 991a93d9..e6153739 100644 --- a/video/src/vonage_video/models/broadcast.py +++ b/video/src/vonage_video/models/broadcast.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field, model_validator from vonage_video.errors import InvalidHlsOptionsError, InvalidOutputOptionsError @@ -77,12 +77,12 @@ class BroadcastUrls(BaseModel): 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. + 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 + rtmp: Optional[list[RtmpStream]] = None class HlsSettings(BaseModel): @@ -131,7 +131,7 @@ class Broadcast(BaseModel): 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 (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". """ @@ -151,7 +151,7 @@ class Broadcast(BaseModel): has_video: Optional[bool] = Field(None, alias='hasVideo') stream_mode: Optional[StreamMode] = Field(None, alias='streamMode') status: Optional[str] = None - streams: Optional[List[VideoStream]] = None + streams: Optional[list[VideoStream]] = None class BroadcastOutputSettings(BaseModel): @@ -160,14 +160,14 @@ class BroadcastOutputSettings(BaseModel): Args: hls (BroadcastHls, Optional): HLS output settings. - rtmp (List[BroadcastRtmp], Optional): RTMP 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 + rtmp: Optional[list[BroadcastRtmp]] = None @model_validator(mode='after') def validate_outputs(self): diff --git a/video/src/vonage_video/models/stream.py b/video/src/vonage_video/models/stream.py index 680a7796..dfe6a666 100644 --- a/video/src/vonage_video/models/stream.py +++ b/video/src/vonage_video/models/stream.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -13,12 +13,13 @@ class StreamInfo(BaseModel): 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( + layout_class_list: Optional[list[str]] = Field( None, validation_alias='layoutClassList' ) @@ -28,19 +29,19 @@ class StreamLayout(BaseModel): Args: id (str): The stream ID. - layout_class_list (list): An array of the layout classes for the stream. + 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') + layout_class_list: list[str] = Field(..., serialization_alias='layoutClassList') class StreamLayoutOptions(BaseModel): """The options for the stream layout. Args: - items (list): An array of the stream layout items. Each item is a StreamLayout + items (list[[StreamLayout]]): An array of the stream layout items. Each item is a StreamLayout object. See StreamLayout. """ - items: List[StreamLayout] + items: list[StreamLayout] diff --git a/video/src/vonage_video/models/token.py b/video/src/vonage_video/models/token.py index 6f5f2740..350611ee 100644 --- a/video/src/vonage_video/models/token.py +++ b/video/src/vonage_video/models/token.py @@ -1,5 +1,5 @@ from time import time -from typing import List, Literal, Optional +from typing import Literal, Optional from uuid import uuid4 from pydantic import BaseModel, Field, field_validator, model_validator @@ -15,7 +15,7 @@ class TokenOptions(BaseModel): 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. + 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. @@ -30,7 +30,7 @@ class TokenOptions(BaseModel): session_id: str role: Optional[TokenRole] = TokenRole.PUBLISHER connection_data: Optional[str] = None - initial_layout_class_list: Optional[List[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())) diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index bda3237d..59a42a50 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Type, Union +from typing import Optional, Type, Union from pydantic import validate_call from vonage_http_client.errors import HttpRequestError @@ -91,14 +91,14 @@ def create_session(self, options: SessionOptions = None) -> VideoSession: return VideoSession(**session_response) @validate_call - def list_streams(self, session_id: str) -> List[StreamInfo]: + 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. + list[StreamInfo]: Information about the video streams. """ response = self._http_client.get( @@ -130,7 +130,7 @@ def get_stream(self, session_id: str, stream_id: str) -> StreamInfo: @validate_call def change_stream_layout( self, session_id: str, stream_layout_options: StreamLayoutOptions - ) -> List[StreamInfo]: + ) -> list[StreamInfo]: """Changes the layout of a stream in a session in the Vonage Video API. Args: @@ -138,7 +138,7 @@ def change_stream_layout( stream_layout_options (StreamLayoutOptions): The options for the stream layout. Returns: - List[StreamInfo]: Information about the video streams. + list[StreamInfo]: Information about the video streams. """ response = self._http_client.put( @@ -199,13 +199,13 @@ def mute_stream(self, session_id: str, stream_id: str) -> None: @validate_call def mute_all_streams( - self, session_id: str, excluded_stream_ids: List[str] = None + 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. + 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) @@ -305,18 +305,18 @@ def start_experience_composer( @validate_call def list_experience_composers( self, filter: ListExperienceComposersFilter = ListExperienceComposersFilter() - ) -> Tuple[List[ExperienceComposer], int, Optional[int]]: + ) -> tuple[list[ExperienceComposer], int, Optional[int]]: """Lists Experience Composers associated with your Vonage application. Args: filter (ListExperienceComposersFilter): Filter for the Experience Composers. Returns: - Tuple[List[ExperienceComposer], int, Optional[int]]: A tuple containing a list of experience + 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] + experience_composers: list[ExperienceComposer], count: int, next_page_offset: Optional[int] """ response = self._http_client.get( self._http_client.video_host, @@ -358,17 +358,17 @@ def stop_experience_composer(self, experience_composer_id: str) -> None: @validate_call def list_archives( self, filter: ListArchivesFilter - ) -> Tuple[List[Archive], int, Optional[int]]: + ) -> tuple[list[Archive], int, Optional[int]]: """Lists archives associated with a Vonage Application. Args: filter (ListArchivesFilter): The filters for the archives. Returns: - Tuple[List[Archive], int, Optional[int]]: A tuple containing a list of archive objects, + 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] + archives: list[Archive], count: int, next_page_offset: Optional[int] """ response = self._http_client.get( self._http_client.video_host, @@ -513,17 +513,17 @@ def change_archive_layout(self, archive_id: str, layout: ComposedLayout) -> Arch @validate_call def list_broadcasts( self, filter: ListBroadcastsFilter - ) -> Tuple[List[Broadcast], int, Optional[int]]: + ) -> tuple[list[Broadcast], int, Optional[int]]: """Lists broadcasts associated with a Vonage Application. Args: filter (ListBroadcastsFilter): The filters for the broadcasts. Returns: - Tuple[List[Broadcast], int, Optional[int]]: A tuple containing a list of broadcast objects, + 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] + broadcasts: list[Broadcast], count: int, next_page_offset: Optional[int] # """ response = self._http_client.get( @@ -707,7 +707,7 @@ def _list_video_objects( ], response: dict, model: Union[Type[Archive], Type[Broadcast], Type[ExperienceComposer]], - ) -> Tuple[List[object], int, Optional[int]]: + ) -> tuple[list[object], int, Optional[int]]: """List objects of a specific model from a response. Args: @@ -718,10 +718,10 @@ def _list_video_objects( model to populate the response into. Returns: - Tuple[List[object], int, Optional[int]]: A tuple containing a list of objects, + 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] + objects: list[object], count: int, next_page_offset: Optional[int] """ index = request_filter.offset + 1 or 1 page_size = request_filter.page_size diff --git a/voice/src/vonage_voice/models/input_types.py b/voice/src/vonage_voice/models/input_types.py index 3ffd6316..a7e1b35b 100644 --- a/voice/src/vonage_voice/models/input_types.py +++ b/voice/src/vonage_voice/models/input_types.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -24,12 +24,12 @@ 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. + 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 + 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. @@ -42,10 +42,10 @@ class Speech(BaseModel): maximum sensitivity. """ - uuid: Optional[List[str]] = None + 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 + 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 diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py index 06007306..b985e3fd 100644 --- a/voice/src/vonage_voice/models/ncco.py +++ b/voice/src/vonage_voice/models/ncco.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Union +from typing import Literal, Optional, Union from pydantic import BaseModel, Field, model_validator from vonage_utils.types import PhoneNumber @@ -40,7 +40,7 @@ class Record(NccoAction): 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 + 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. @@ -55,7 +55,7 @@ class Record(NccoAction): 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 + eventUrl: Optional[list[str]] = None eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.RECORD @@ -74,17 +74,17 @@ class Conversation(NccoAction): Args: name (str): The name of the conversation room. - musicOnHoldUrl (Optional[List[str]]): The URL to the music that is played to + 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 + 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 + 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. @@ -94,12 +94,12 @@ class Conversation(NccoAction): """ name: str - musicOnHoldUrl: Optional[List[str]] = None + 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 + canSpeak: Optional[list[str]] = None + canHear: Optional[list[str]] = None mute: Optional[bool] = None action: NccoActionType = NccoActionType.CONVERSATION @@ -117,7 +117,7 @@ class Connect(NccoAction): or a VBC extension. Args: - endpoint (List[Union[PhoneEndpoint, AppEndpoint, WebsocketEndpoint, SipEndpoint, VbcEndpoint]]): + 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. @@ -133,11 +133,11 @@ class Connect(NccoAction): 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 + 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 + 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 @@ -148,7 +148,7 @@ class Connect(NccoAction): NccoActionError: If both `from_` and `randomFromNumber` are set. """ - endpoint: List[ + endpoint: list[ Union[PhoneEndpoint, AppEndpoint, WebsocketEndpoint, SipEndpoint, VbcEndpoint] ] from_: Optional[PhoneNumber] = Field(None, serialization_alias='from') @@ -158,9 +158,9 @@ class Connect(NccoAction): limit: Optional[int] = Field(None, le=7200) machineDetection: Optional[Literal['continue', 'hangup']] = None advancedMachineDetection: Optional[AdvancedMachineDetection] = None - eventUrl: Optional[List[str]] = None + eventUrl: Optional[list[str]] = None eventMethod: Optional[str] = None - ringbackTone: Optional[List[str]] = None + ringbackTone: Optional[list[str]] = None action: NccoActionType = NccoActionType.CONNECT @model_validator(mode='after') @@ -207,7 +207,7 @@ 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) + 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. @@ -217,7 +217,7 @@ class Stream(NccoAction): is closed. The default is `1`, `0` loops indefinitely. """ - streamUrl: List[str] + streamUrl: list[str] level: Optional[float] = Field(None, ge=-1, le=1) bargeIn: Optional[bool] = None loop: Optional[int] = Field(None, ge=0) @@ -228,10 +228,10 @@ 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. + 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 + 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. @@ -239,10 +239,10 @@ class Input(NccoAction): `eventUrl`. """ - type: List[Union[Literal['dtmf'], Literal['speech']]] + type: list[Union[Literal['dtmf'], Literal['speech']]] dtmf: Optional[Dtmf] = None speech: Optional[Speech] = None - eventUrl: Optional[List[str]] = None + eventUrl: Optional[list[str]] = None eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.INPUT @@ -254,12 +254,12 @@ class Notify(NccoAction): 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 + 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] + eventUrl: list[str] eventMethod: Optional[str] = None action: NccoActionType = NccoActionType.NOTIFY diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py index c4bd9620..7d2b1116 100644 --- a/voice/src/vonage_voice/models/requests.py +++ b/voice/src/vonage_voice/models/requests.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Union +from typing import Literal, Optional, Union from pydantic import BaseModel, Field, model_validator from vonage_utils.types import Dtmf @@ -24,18 +24,18 @@ 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]]]): + 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_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. + to (list[Union[ToPhone, Sip, Websocket, Vbc]]): The type of connection to call. from_ (Optional[Phone]): The phone number to use when calling. Mutually exclusive with the `random_from_number` property. random_from_number (Optional[bool]): Whether to use a random number as the caller's phone number. The number will be selected from the list of the numbers assigned to the current application. Mutually exclusive with the `from_` property. - event_url (Optional[List[str]]): The webhook endpoint where call progress events + 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`. @@ -56,14 +56,14 @@ class CreateCallRequest(BaseModel): 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 + 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]] + to: list[Union[ToPhone, Sip, Websocket, Vbc]] from_: Optional[Phone] = Field(None, serialization_alias='from') random_from_number: Optional[bool] = None - event_url: Optional[List[str]] = None + event_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 @@ -115,14 +115,14 @@ class AudioStreamOptions(BaseModel): """Options for streaming audio to a call. Args: - stream_url (List[str]): The URL to stream audio from. + 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] + stream_url: list[str] 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 index 0b5e3822..0bc36a36 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -86,10 +86,10 @@ class Embedded(BaseModel): """Model for calls embedded in a response. Args: - calls (List[CallInfo]): The calls in this response. + calls (list[CallInfo]): The calls in this response. """ - calls: List[CallInfo] + calls: list[CallInfo] class CallList(BaseModel): diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py index ace4cbf7..5282b1db 100644 --- a/voice/src/vonage_voice/voice.py +++ b/voice/src/vonage_voice/voice.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import Optional from pydantic import validate_call from vonage_http_client.http_client import HttpClient @@ -50,14 +50,14 @@ def create_call(self, params: CreateCallRequest) -> CreateCallResponse: @validate_call def list_calls( self, filter: ListCallsFilter = ListCallsFilter() - ) -> Tuple[List[CallInfo], Optional[int]]: + ) -> 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 + 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`. """ @@ -90,12 +90,12 @@ def get_call(self, call_id: str) -> CallInfo: return CallInfo(**response) @validate_call - def transfer_call_ncco(self, uuid: str, ncco: List[NccoAction]) -> None: + 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. + 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 diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index 54425918..a0aad98c 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] description = 'Utils package containing objects for use with Vonage APIs' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -dependencies = ["typing_extensions>=4.9.0", "pydantic>=2.7.1"] +dependencies = ["pydantic>=2.7.1"] requires-python = ">=3.8" classifiers = [ "Programming Language :: Python", diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py index c19a567b..71137755 100644 --- a/vonage_utils/src/vonage_utils/types.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -1,5 +1,5 @@ from pydantic import Field -from typing_extensions import Annotated +from typing import Annotated 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 From 2e925b0e20a24a99512ce1bdfd85608a1c336487 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 15 Oct 2024 19:39:16 +0100 Subject: [PATCH 83/98] start adding number verification, remove network api beta, add to main package --- network_number_verification/BUILD | 16 +++ network_number_verification/CHANGES.md | 2 + network_number_verification/README.md | 46 ++++++++ .../pyproject.toml | 15 ++- .../vonage_network_number_verification/BUILD | 1 + .../__init__.py | 3 + .../_version.py | 1 + .../number_verification.py | 103 ++++++++++++++++++ .../requests.py | 33 ++++++ .../responses.py | 12 ++ network_number_verification/tests/BUILD | 1 + .../tests/test_number_verification.py | 49 +++++++++ network_sim_swap/README.md | 6 +- pants.toml | 1 - requirements.txt | 1 - voice/src/vonage_voice/models/responses.py | 2 +- vonage/src/vonage/__init__.py | 10 +- vonage/src/vonage/vonage.py | 4 + vonage_network/BUILD | 11 -- vonage_network/CHANGES.md | 5 - vonage_network/README.md | 47 -------- vonage_network/src/vonage_network/BUILD | 1 - vonage_network/src/vonage_network/__init__.py | 18 --- vonage_network/src/vonage_network/_version.py | 1 - .../src/vonage_network/vonage_network.py | 36 ------ vonage_network/tests/BUILD | 1 - vonage_network/tests/test_vonage_network.py | 16 --- vonage_utils/src/vonage_utils/types.py | 3 +- 28 files changed, 291 insertions(+), 154 deletions(-) create mode 100644 network_number_verification/BUILD create mode 100644 network_number_verification/CHANGES.md create mode 100644 network_number_verification/README.md rename {vonage_network => network_number_verification}/pyproject.toml (65%) create mode 100644 network_number_verification/src/vonage_network_number_verification/BUILD create mode 100644 network_number_verification/src/vonage_network_number_verification/__init__.py create mode 100644 network_number_verification/src/vonage_network_number_verification/_version.py create mode 100644 network_number_verification/src/vonage_network_number_verification/number_verification.py create mode 100644 network_number_verification/src/vonage_network_number_verification/requests.py create mode 100644 network_number_verification/src/vonage_network_number_verification/responses.py create mode 100644 network_number_verification/tests/BUILD create mode 100644 network_number_verification/tests/test_number_verification.py delete mode 100644 vonage_network/BUILD delete mode 100644 vonage_network/CHANGES.md delete mode 100644 vonage_network/README.md delete mode 100644 vonage_network/src/vonage_network/BUILD delete mode 100644 vonage_network/src/vonage_network/__init__.py delete mode 100644 vonage_network/src/vonage_network/_version.py delete mode 100644 vonage_network/src/vonage_network/vonage_network.py delete mode 100644 vonage_network/tests/BUILD delete mode 100644 vonage_network/tests/test_vonage_network.py 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..4df12901 --- /dev/null +++ b/network_number_verification/CHANGES.md @@ -0,0 +1,2 @@ +# 0.1.0b0 +- 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..1ec0c53f --- /dev/null +++ b/network_number_verification/README.md @@ -0,0 +1,46 @@ +# 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). + +Please note this package is in beta. + +## 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-verifcation +``` + +## 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. Create an Access Token from an Authentication Code +3. Make a Number Verification Request + +### Get an OIDC URL + +```python +``` + +### Create an Access Token + +```python +``` + +### Make a Number Verification Request + +```python +``` \ No newline at end of file diff --git a/vonage_network/pyproject.toml b/network_number_verification/pyproject.toml similarity index 65% rename from vonage_network/pyproject.toml rename to network_number_verification/pyproject.toml index 4fbf966f..eb02d738 100644 --- a/vonage_network/pyproject.toml +++ b/network_number_verification/pyproject.toml @@ -1,15 +1,14 @@ [project] -name = "vonage-network" +name = "vonage-network-number-verification" dynamic = ["version"] -description = "Python Server SDK for using Vonage Network APIs" +description = "Package for working with the Vonage Number Verification Network API." readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.8" dependencies = [ - "vonage-utils>=1.1.2", - "vonage-http-client>=1.4.0", - "vonage-network-auth>=0.1.0b0", - "vonage-network-sim-swap>=0.1.0b0", + "vonage-http-client>=1.4.1", + "vonage-network-auth>=0.1.1b0", + "vonage-utils>=1.1.3", ] classifiers = [ "Programming Language :: Python", @@ -23,10 +22,10 @@ classifiers = [ ] [project.urls] -homepage = "https://github.com/Vonage/vonage-python-sdk" +Homepage = "https://github.com/Vonage/vonage-python-sdk" [tool.setuptools.dynamic] -version = { attr = "vonage_network._version.__version__" } +version = { attr = "vonage_network_number_verification._version.__version__" } [build-system] requires = ["setuptools>=61.0", "wheel"] 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..ed6dd2ee --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/__init__.py @@ -0,0 +1,3 @@ +from .number_verification import NetworkNumberVerification + +__all__ = ['NetworkNumberVerification'] 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..db4e81a7 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.0b0' 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..4761d290 --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/number_verification.py @@ -0,0 +1,103 @@ +from urllib.parse import urlencode, urlunparse + +from pydantic import validate_call +from vonage_http_client import HttpClient +from vonage_network_auth import NetworkAuth +from vonage_network_number_verification.requests import CreateOidcUrl +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. + """ + 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, + } + if url_settings.state is not None: + params['state'] = url_settings.state + if url_settings.login_hint is not None: + if url_settings.login_hint.startswith('+'): + params['login_hint'] = url_settings.login_hint + else: + params['login_hint'] = f'+{url_settings.login_hint}' + + full_url = urlunparse(('', '', base_url, '', urlencode(params), '')) + return full_url + + @validate_call + def get_oidc_token( + self, oidc_response: dict, grant_type: str = 'urn:openid:params:grant-type:ciba' + ): + """Request a Camara token using an authentication request ID given as a response + to the OIDC request.""" + params = { + 'grant_type': grant_type, + 'auth_req_id': oidc_response['auth_req_id'], + } + return self._request_camara_token(params) + + @validate_call + def verify( + self, access_token: str, phone_number: str = None, hashed_phone_number: str = None + ) -> NumberVerificationResponse: + """Verify if the specified phone number matches the one that the user is currently + using. + + Note: To use this method, the user must be connected to mobile data rather than + Wi-Fi. + + Args: + access_token (str): The access token to use for the request. + 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: Class containing the Number Verification response + containing the device verification information. + """ + return self._http_client.post( + self._host, + '/camara/number-verification/v031/verify', + params={ + 'phoneNumber': phone_number, + 'hashedPhoneNumber': hashed_phone_number, + }, + auth_type=self._auth_type, + token=access_token, + ) 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..2131d51c --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/requests.py @@ -0,0 +1,33 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class CreateOidcUrl(BaseModel): + """Model to craft a URL for OIDC authentication. + + Args: + 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. + """ + + redirect_uri: str + state: Optional[str] = None + login_hint: Optional[str] = None + scope: Optional[ + str + ] = 'openid dpv:FraudPreventionAndDetection#number-verification-verify-read' + + +class NumberVerificationRequest(BaseModel): + """Model for the request to verify a phone number. + + Args: + 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. + """ + + phone_number: str = Field(..., alias='phoneNumber') + hashed_phone_number: str = Field(..., alias='hashedPhoneNumber') 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..1761e95a --- /dev/null +++ b/network_number_verification/src/vonage_network_number_verification/responses.py @@ -0,0 +1,12 @@ +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(..., 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/test_number_verification.py b/network_number_verification/tests/test_number_verification.py new file mode 100644 index 00000000..09927a7b --- /dev/null +++ b/network_number_verification/tests/test_number_verification.py @@ -0,0 +1,49 @@ +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 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_oauth2_user_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('447700900000', max_age=24) + + assert response['swapped'] == True + + +@patch('vonage_network_auth.NetworkAuth.get_oauth2_user_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/network_sim_swap/README.md b/network_sim_swap/README.md index 7a2d70b4..ec752187 100644 --- a/network_sim_swap/README.md +++ b/network_sim_swap/README.md @@ -22,13 +22,13 @@ pip install vonage-network-sim-swap ## Usage -It is recommended to use this as part of the `vonage-network` package. The examples below assume you've created an instance of the `vonage_network.VonageNetwork` class called `network_client`. +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_network.sim_swap.check(phone_number='MY_NUMBER') +swap_status: SwapStatus = vonage_client.sim_swap.check(phone_number='MY_NUMBER') print(swap_status.swapped) ``` @@ -36,6 +36,6 @@ print(swap_status.swapped) ```python from vonage_network_sim_swap import LastSwapDate -swap_date: LastSwapDate = vonage_network.sim_swap.get_last_swap_date +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/pants.toml b/pants.toml index 6a61c625..3bd4419e 100644 --- a/pants.toml +++ b/pants.toml @@ -31,7 +31,6 @@ interpreter_constraints = ['>=3.8'] report = ['html', 'console'] filter = [ 'vonage/src', - 'vonage_network/src', 'http_client/src', 'account/src', 'application/src', diff --git a/requirements.txt b/requirements.txt index 11e66972..faac8fb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,5 +25,4 @@ toml>=0.10.2 -e video -e voice -e vonage_utils --e vonage_network -e vonage diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py index 0bc36a36..10d1497b 100644 --- a/voice/src/vonage_voice/models/responses.py +++ b/voice/src/vonage_voice/models/responses.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Optional, Union from pydantic import BaseModel, Field, model_validator from vonage_utils.models import HalLinks diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 3345cbbc..e0c6dca5 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -6,6 +6,8 @@ Auth, HttpClientOptions, Messages, + NetworkNumberVerification, + NetworkSimSwap, NumberInsight, Numbers, Sms, @@ -19,12 +21,13 @@ ) __all__ = [ - 'Vonage', - 'Auth', - 'HttpClientOptions', 'Account', 'Application', + 'Auth', + 'HttpClientOptions', 'Messages', + 'NetworkSimSwap', + 'NetworkNumberVerification', 'NumberInsight', 'Numbers', 'Sms', @@ -34,5 +37,6 @@ 'VerifyV2', 'Video', 'Voice', + 'Vonage', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index c7faf0a0..5fcc6a48 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -4,6 +4,8 @@ from vonage_application.application import Application from vonage_http_client import Auth, HttpClient, HttpClientOptions from vonage_messages import Messages +from vonage_network_number_verification import NetworkNumberVerification +from vonage_network_sim_swap import NetworkSimSwap from vonage_number_insight import NumberInsight from vonage_numbers import Numbers from vonage_sms import Sms @@ -38,6 +40,8 @@ def __init__( 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) diff --git a/vonage_network/BUILD b/vonage_network/BUILD deleted file mode 100644 index 6f2c0062..00000000 --- a/vonage_network/BUILD +++ /dev/null @@ -1,11 +0,0 @@ -resource(name='pyproject', source='pyproject.toml') - -file(name='readme', source='README.md') - -python_distribution( - name='vonage_network', - dependencies=[':pyproject', ':readme', 'vonage_network/src/vonage_network'], - provides=python_artifact(), - generate_setup=False, - repositories=['@pypi'], -) diff --git a/vonage_network/CHANGES.md b/vonage_network/CHANGES.md deleted file mode 100644 index 80dc8e71..00000000 --- a/vonage_network/CHANGES.md +++ /dev/null @@ -1,5 +0,0 @@ -# 0.1.1b0 -- Update project metadata - -# 0.1.0b0 -- Initial beta release \ No newline at end of file diff --git a/vonage_network/README.md b/vonage_network/README.md deleted file mode 100644 index 9265cf4a..00000000 --- a/vonage_network/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Vonage Network API Python SDK - -The Vonage Network API Python SDK Package `vonage-network` provides a streamlined interface for using Vonage APIs in Python projects. This package includes the `VonageNetwork` class, which simplifies API interactions. - -The `VonageNetwork` class in this package serves as an entry point for using [Vonage Network APIs](https://developer.vonage.com/en/getting-started-network/what-are-network-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). - -Please note this package is in beta and could be subject to change or removal. - -## Installation - -Install the package using pip: - -```bash -pip install vonage-network -``` - -## Usage - -```python -from vonage_network import VonageNetwork, 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 network client instance -vonage_network = VonageNetwork(auth=auth, http_client_options=options) -``` - -The `VonageNetwork` class provides access to various Vonage Network APIs through its properties. For example, to call the Network Sim Swap API: - -```python -from vonage_network_sim_swap import SwapStatus -swap_status: SwapStatus = vonage_network.sim_swap.check(phone_number='MY_NUMBER') -print(swap_status.swapped) -``` - -You can also access the underlying `HttpClient` instance through the `http_client` property: - -```python -user_agent = vonage_network.http_client.user_agent -``` \ No newline at end of file diff --git a/vonage_network/src/vonage_network/BUILD b/vonage_network/src/vonage_network/BUILD deleted file mode 100644 index 79353bfe..00000000 --- a/vonage_network/src/vonage_network/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_sources(name='vonage_network') diff --git a/vonage_network/src/vonage_network/__init__.py b/vonage_network/src/vonage_network/__init__.py deleted file mode 100644 index 2ae6c587..00000000 --- a/vonage_network/src/vonage_network/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from vonage_utils import VonageError - -from .vonage_network import ( - Auth, - HttpClient, - HttpClientOptions, - NetworkSimSwap, - VonageNetwork, -) - -__all__ = [ - 'VonageError', - 'VonageNetwork', - 'NetworkSimSwap', - 'Auth', - 'HttpClient', - 'HttpClientOptions', -] diff --git a/vonage_network/src/vonage_network/_version.py b/vonage_network/src/vonage_network/_version.py deleted file mode 100644 index b7a18f07..00000000 --- a/vonage_network/src/vonage_network/_version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.1.1b0' diff --git a/vonage_network/src/vonage_network/vonage_network.py b/vonage_network/src/vonage_network/vonage_network.py deleted file mode 100644 index a056ee06..00000000 --- a/vonage_network/src/vonage_network/vonage_network.py +++ /dev/null @@ -1,36 +0,0 @@ -from platform import python_version -from typing import Optional - -from vonage_http_client import Auth, HttpClient, HttpClientOptions -from vonage_network_sim_swap import NetworkSimSwap - -from ._version import __version__ - - -class VonageNetwork: - """Main Server SDK class for using Vonage Network APIs. - - When creating an instance, it will create the authentication objects and - an HTTP Client needed for using Vonage Network APIs. - - Use an instance of this class to access the Vonage Network APIs, e.g. to access - methods associated with the Vonage Sim Swap API, call `vonage_network.sim_swap.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._http_client._user_agent = ( - f'vonage-network-python-sdk/{__version__} python/{python_version()}' - ) - - self.sim_swap = NetworkSimSwap(self._http_client) - - @property - def http_client(self): - return self._http_client diff --git a/vonage_network/tests/BUILD b/vonage_network/tests/BUILD deleted file mode 100644 index dabf212d..00000000 --- a/vonage_network/tests/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_tests() diff --git a/vonage_network/tests/test_vonage_network.py b/vonage_network/tests/test_vonage_network.py deleted file mode 100644 index 7bcf7ce8..00000000 --- a/vonage_network/tests/test_vonage_network.py +++ /dev/null @@ -1,16 +0,0 @@ -from vonage_http_client.http_client import HttpClient -from vonage_network._version import __version__ - -from vonage_network import Auth, VonageNetwork - - -def test_create_vonage_class_instance(): - vonage = VonageNetwork(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-network-python-sdk/{__version__}' in vonage.http_client.user_agent diff --git a/vonage_utils/src/vonage_utils/types.py b/vonage_utils/src/vonage_utils/types.py index 71137755..e601c034 100644 --- a/vonage_utils/src/vonage_utils/types.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -1,6 +1,7 @@ -from pydantic import Field 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 From 315b6dc2683e847f6aac11d2b8e828566fbbc408 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 16 Oct 2024 22:01:27 +0100 Subject: [PATCH 84/98] add number verification to network auth --- .../src/vonage_network_auth/_version.py | 2 +- .../src/vonage_network_auth/network_auth.py | 83 +++++++++++++++-- .../src/vonage_network_auth/requests.py | 20 +++++ network_auth/tests/test_network_auth.py | 42 +++++++-- .../__init__.py | 13 ++- .../errors.py | 5 ++ .../number_verification.py | 64 ++++++------- .../requests.py | 45 +++++----- .../responses.py | 4 +- .../tests/data/token_request.json | 5 ++ .../tests/data/verify_number.json | 3 + .../tests/test_number_verification.py | 90 +++++++++++++++---- .../src/vonage_network_sim_swap/sim_swap.py | 4 +- network_sim_swap/tests/test_sim_swap.py | 4 +- video/src/vonage_video/models/broadcast.py | 24 ++--- 15 files changed, 298 insertions(+), 110 deletions(-) create mode 100644 network_auth/src/vonage_network_auth/requests.py create mode 100644 network_number_verification/src/vonage_network_number_verification/errors.py create mode 100644 network_number_verification/tests/data/token_request.json create mode 100644 network_number_verification/tests/data/verify_number.json diff --git a/network_auth/src/vonage_network_auth/_version.py b/network_auth/src/vonage_network_auth/_version.py index b7a18f07..1f356cc5 100644 --- a/network_auth/src/vonage_network_auth/_version.py +++ b/network_auth/src/vonage_network_auth/_version.py @@ -1 +1 @@ -__version__ = '0.1.1b0' +__version__ = '1.0.0' diff --git a/network_auth/src/vonage_network_auth/network_auth.py b/network_auth/src/vonage_network_auth/network_auth.py index 9af1d8a7..066cc82d 100644 --- a/network_auth/src/vonage_network_auth/network_auth.py +++ b/network_auth/src/vonage_network_auth/network_auth.py @@ -1,5 +1,8 @@ +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 @@ -24,8 +27,54 @@ def http_client(self) -> HttpClient: return self._http_client @validate_call - def get_oauth2_user_token(self, number: str, scope: str) -> str: - """Get an OAuth2 user token for a given number and scope. + 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. @@ -34,13 +83,15 @@ def get_oauth2_user_token(self, number: str, scope: str) -> str: Returns: str: The OAuth2 user token. """ - oidc_response = self.make_oidc_request(number, scope) - token_response = self.request_access_token(oidc_response.auth_req_id) + 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_request(self, number: str, scope: str) -> OidcResponse: - """Make an OIDC request to authenticate a user. + 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. @@ -62,11 +113,11 @@ def make_oidc_request(self, number: str, scope: str) -> OidcResponse: return OidcResponse(**response) @validate_call - def request_access_token( + 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 using an authentication request ID given as a - response to an OIDC request. + """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. @@ -77,6 +128,20 @@ def request_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', diff --git a/network_auth/src/vonage_network_auth/requests.py b/network_auth/src/vonage_network_auth/requests.py new file mode 100644 index 00000000..e7452a11 --- /dev/null +++ b/network_auth/src/vonage_network_auth/requests.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import BaseModel + + +class CreateOidcUrl(BaseModel): + """Model to craft a URL for OIDC authentication. + + Args: + redirect_uri (str): The URI to redirect to after authentication. + state (str): A unique identifier for the request. Can be any string. + login_hint (str): The phone number to use for the request. + """ + + redirect_uri: str + state: str + login_hint: str + scope: Optional[ + str + ] = 'openid dpv:FraudPreventionAndDetection#number-verification-verify-read' diff --git a/network_auth/tests/test_network_auth.py b/network_auth/tests/test_network_auth.py index 4bdbf90d..c25e6cb8 100644 --- a/network_auth/tests/test_network_auth.py +++ b/network_auth/tests/test_network_auth.py @@ -29,7 +29,7 @@ def test_oidc_request(): 'oidc_request.json', ) - response = network_auth.make_oidc_request( + response = network_auth.make_oidc_auth_id_request( number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap', ) @@ -40,7 +40,7 @@ def test_oidc_request(): @responses.activate -def test_request_access_token(): +def test_sim_swap_token(): build_response( path, 'POST', @@ -54,7 +54,7 @@ def test_request_access_token(): 'interval': '2', } oidc_response = OidcResponse(**oidc_response_dict) - response = network_auth.request_access_token(oidc_response.auth_req_id) + response = network_auth.request_sim_swap_access_token(oidc_response.auth_req_id) assert ( response.access_token @@ -79,7 +79,7 @@ def test_whole_oauth2_flow(): 'token_request.json', ) - access_token = network_auth.get_oauth2_user_token( + access_token = network_auth.get_sim_swap_camara_token( number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap' ) assert ( @@ -104,8 +104,40 @@ def test_oidc_request_permissions_error(): ) with raises(HttpRequestError) as err: - response = network_auth.make_oidc_request( + 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/src/vonage_network_number_verification/__init__.py b/network_number_verification/src/vonage_network_number_verification/__init__.py index ed6dd2ee..ec46a53c 100644 --- a/network_number_verification/src/vonage_network_number_verification/__init__.py +++ b/network_number_verification/src/vonage_network_number_verification/__init__.py @@ -1,3 +1,12 @@ -from .number_verification import NetworkNumberVerification +from .errors import NetworkNumberVerificationError +from .number_verification import CreateOidcUrl, NetworkNumberVerification +from .requests import NumberVerificationRequest +from .responses import NumberVerificationResponse -__all__ = ['NetworkNumberVerification'] +__all__ = [ + 'NetworkNumberVerification', + 'CreateOidcUrl', + 'NumberVerificationRequest', + 'NumberVerificationResponse', + 'NetworkNumberVerificationError', +] 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 index 4761d290..8b6c1f15 100644 --- a/network_number_verification/src/vonage_network_number_verification/number_verification.py +++ b/network_number_verification/src/vonage_network_number_verification/number_verification.py @@ -1,9 +1,8 @@ -from urllib.parse import urlencode, urlunparse - from pydantic import validate_call from vonage_http_client import HttpClient from vonage_network_auth import NetworkAuth -from vonage_network_number_verification.requests import CreateOidcUrl +from vonage_network_auth.requests import CreateOidcUrl +from vonage_network_number_verification.requests import NumberVerificationRequest from vonage_network_number_verification.responses import NumberVerificationResponse @@ -40,40 +39,24 @@ def get_oidc_url(self, url_settings: CreateOidcUrl) -> str: 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, - } - if url_settings.state is not None: - params['state'] = url_settings.state - if url_settings.login_hint is not None: - if url_settings.login_hint.startswith('+'): - params['login_hint'] = url_settings.login_hint - else: - params['login_hint'] = f'+{url_settings.login_hint}' - - full_url = urlunparse(('', '', base_url, '', urlencode(params), '')) - return full_url + return self._network_auth.get_oidc_url(url_settings) @validate_call - def get_oidc_token( - self, oidc_response: dict, grant_type: str = 'urn:openid:params:grant-type:ciba' - ): - """Request a Camara token using an authentication request ID given as a response - to the OIDC request.""" - params = { - 'grant_type': grant_type, - 'auth_req_id': oidc_response['auth_req_id'], - } - return self._request_camara_token(params) + def exchange_code_for_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. + """ + return self._network_auth.get_number_verification_camara_token(code, redirect_uri) @validate_call def verify( - self, access_token: str, phone_number: str = None, hashed_phone_number: str = None + self, number_verification_params: NumberVerificationRequest ) -> NumberVerificationResponse: """Verify if the specified phone number matches the one that the user is currently using. @@ -91,13 +74,18 @@ def verify( NumberVerificationResponse: Class containing the Number Verification response containing the device verification information. """ - return self._http_client.post( + 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={ - 'phoneNumber': phone_number, - 'hashedPhoneNumber': hashed_phone_number, - }, + params=params, auth_type=self._auth_type, - token=access_token, + token=number_verification_params.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 index 2131d51c..a5856a77 100644 --- a/network_number_verification/src/vonage_network_number_verification/requests.py +++ b/network_number_verification/src/vonage_network_number_verification/requests.py @@ -1,33 +1,34 @@ -from typing import Optional - -from pydantic import BaseModel, Field - - -class CreateOidcUrl(BaseModel): - """Model to craft a URL for OIDC authentication. - - Args: - 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. - """ - - redirect_uri: str - state: Optional[str] = None - login_hint: Optional[str] = None - scope: Optional[ - str - ] = 'openid dpv:FraudPreventionAndDetection#number-verification-verify-read' +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: + access_token (str): The access token for the request obtained from the + three-legged OAuth2 flow. 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. """ - phone_number: str = Field(..., alias='phoneNumber') - hashed_phone_number: str = Field(..., alias='hashedPhoneNumber') + access_token: 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 index 1761e95a..de1e9722 100644 --- a/network_number_verification/src/vonage_network_number_verification/responses.py +++ b/network_number_verification/src/vonage_network_number_verification/responses.py @@ -9,4 +9,6 @@ class NumberVerificationResponse(BaseModel): successfully verified. """ - device_phone_number_verified: bool = Field(..., alias='devicePhoneNumberVerified') + device_phone_number_verified: bool = Field( + ..., validation_alias='devicePhoneNumberVerified' + ) 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 index 09927a7b..8cfade0a 100644 --- a/network_number_verification/tests/test_number_verification.py +++ b/network_number_verification/tests/test_number_verification.py @@ -2,48 +2,104 @@ from unittest.mock import MagicMock, patch import responses +from pytest import raises from vonage_http_client.http_client import HttpClient -from vonage_network_sim_swap import NetworkSimSwap +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__) -sim_swap = NetworkSimSwap(HttpClient(get_mock_jwt_auth())) +number_verification = NetworkNumberVerification(HttpClient(get_mock_jwt_auth())) def test_http_client_property(): - http_client = sim_swap.http_client + http_client = number_verification.http_client assert isinstance(http_client, HttpClient) -@patch('vonage_network_auth.NetworkAuth.get_oauth2_user_token') +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_check_sim_swap(mock_get_oauth2_user_token: MagicMock): +def test_exchange_code_for_token(mock_get_number_verification_camara_token: MagicMock): build_response( path, 'POST', - 'https://api-eu.vonage.com/camara/sim-swap/v040/check', - 'check_sim_swap.json', + 'https://api-eu.vonage.com/oauth2/token', + 'token_request.json', ) - mock_get_oauth2_user_token.return_value = 'token' - response = sim_swap.check('447700900000', max_age=24) + mock_get_number_verification_camara_token.return_value = 'token' - assert response['swapped'] == True + response = number_verification.exchange_code_for_token( + 'code', 'https://example.com/callback' + ) + + assert response == 'token' -@patch('vonage_network_auth.NetworkAuth.get_oauth2_user_token') @responses.activate -def test_get_last_swap_date(mock_get_oauth2_user_token: MagicMock): +def test_verify_number(): build_response( path, 'POST', - 'https://api-eu.vonage.com/camara/sim-swap/v040/retrieve-date', - 'get_swap_date.json', + 'https://api-eu.vonage.com/camara/number-verification/v031/verify', + 'verify_number.json', + ) + + number_verification_params = NumberVerificationRequest( + access_token='token', phone_number='447700900000' ) - mock_get_oauth2_user_token.return_value = 'token' + response = number_verification.verify(number_verification_params) + + assert response.device_phone_number_verified == True + + +@responses.activate +def test_verify_hashed_number(): + build_response( + path, + 'POST', + 'https://api-eu.vonage.com/camara/number-verification/v031/verify', + 'verify_number.json', + ) + + number_verification_params = NumberVerificationRequest( + access_token='token', + hashed_phone_number='d867b6540ac8db72d860d67d3d612a1621adcf3277573e9299be1153b6d0de15', + ) + response = number_verification.verify(number_verification_params) + + assert response.device_phone_number_verified == True + - response = sim_swap.get_last_swap_date('447700900000') +def test_verify_number_model_errors(): + with raises(NetworkNumberVerificationError): + number_verification.verify(NumberVerificationRequest(access_token='token')) - assert response['latestSimChange'] == '2023-12-22T04:00:44.000Z' + with raises(NetworkNumberVerificationError): + number_verification.verify( + NumberVerificationRequest( + access_token='token', + phone_number='447700900000', + hashed_phone_number='hash', + ) + ) 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 index 34f29e10..c52a5ac0 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py +++ b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py @@ -36,7 +36,7 @@ def check(self, phone_number: str, max_age: int = None) -> SwapStatus: Returns: SwapStatus: Class containing the Swap Status response. """ - token = self._network_auth.get_oauth2_user_token( + token = self._network_auth.get_sim_swap_camara_token( number=phone_number, scope='dpv:FraudPreventionAndDetection#check-sim-swap' ) @@ -63,7 +63,7 @@ def get_last_swap_date(self, phone_number: str) -> LastSwapDate: Returns: LastSwapDate: Class containing the Last Swap Date response. """ - token = self._network_auth.get_oauth2_user_token( + token = self._network_auth.get_sim_swap_camara_token( number=phone_number, scope='dpv:FraudPreventionAndDetection#retrieve-sim-swap-date', ) diff --git a/network_sim_swap/tests/test_sim_swap.py b/network_sim_swap/tests/test_sim_swap.py index 09927a7b..baca3f40 100644 --- a/network_sim_swap/tests/test_sim_swap.py +++ b/network_sim_swap/tests/test_sim_swap.py @@ -17,7 +17,7 @@ def test_http_client_property(): assert isinstance(http_client, HttpClient) -@patch('vonage_network_auth.NetworkAuth.get_oauth2_user_token') +@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( @@ -33,7 +33,7 @@ def test_check_sim_swap(mock_get_oauth2_user_token: MagicMock): assert response['swapped'] == True -@patch('vonage_network_auth.NetworkAuth.get_oauth2_user_token') +@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( diff --git a/video/src/vonage_video/models/broadcast.py b/video/src/vonage_video/models/broadcast.py index e6153739..dcedbfe7 100644 --- a/video/src/vonage_video/models/broadcast.py +++ b/video/src/vonage_video/models/broadcast.py @@ -137,19 +137,21 @@ class Broadcast(BaseModel): """ id: Optional[str] = None - session_id: Optional[str] = Field(None, alias='sessionId') - multi_broadcast_tag: Optional[str] = Field(None, alias='multiBroadcastTag') - application_id: Optional[str] = Field(None, alias='applicationId') - created_at: Optional[int] = Field(None, alias='createdAt') - updated_at: Optional[int] = Field(None, alias='updatedAt') - max_duration: Optional[int] = Field(None, alias='maxDuration') - max_bitrate: Optional[int] = Field(None, alias='maxBitrate') - broadcast_urls: Optional[BroadcastUrls] = Field(None, alias='broadcastUrls') + 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, alias='hasAudio') - has_video: Optional[bool] = Field(None, alias='hasVideo') - stream_mode: Optional[StreamMode] = Field(None, alias='streamMode') + 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 From cde28c21b52f39e840797b8454bd05bb896da854 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 17 Oct 2024 17:04:39 +0100 Subject: [PATCH 85/98] prepare for beta release --- OPENTOK_TO_VONAGE_MIGRATION.md | 92 ------------------- account/CHANGES.md | 3 + account/pyproject.toml | 10 +- account/src/vonage_account/_version.py | 2 +- application/CHANGES.md | 3 + application/pyproject.toml | 10 +- .../src/vonage_application/_version.py | 2 +- http_client/CHANGES.md | 3 + http_client/pyproject.toml | 10 +- .../src/vonage_http_client/_version.py | 2 +- jwt/CHANGES.md | 3 + jwt/pyproject.toml | 6 +- jwt/src/vonage_jwt/_version.py | 2 +- messages/CHANGES.md | 3 + messages/pyproject.toml | 8 +- messages/src/vonage_messages/_version.py | 2 +- network_auth/CHANGES.md | 4 + network_auth/README.md | 13 +-- network_auth/pyproject.toml | 10 +- .../src/vonage_network_auth/__init__.py | 8 +- .../src/vonage_network_auth/network_auth.py | 2 +- network_number_verification/CHANGES.md | 2 +- network_number_verification/README.md | 33 +++++-- network_number_verification/pyproject.toml | 11 ++- .../_version.py | 2 +- .../number_verification.py | 38 +++----- .../requests.py | 7 +- .../tests/test_number_verification.py | 39 +++++--- network_sim_swap/CHANGES.md | 3 + network_sim_swap/README.md | 2 - network_sim_swap/pyproject.toml | 11 ++- .../src/vonage_network_sim_swap/_version.py | 2 +- number_insight/CHANGES.md | 3 + number_insight/pyproject.toml | 10 +- .../src/vonage_number_insight/_version.py | 2 +- number_management/CHANGES.md | 3 + number_management/pyproject.toml | 10 +- .../src/vonage_numbers/_version.py | 2 +- sms/CHANGES.md | 3 + sms/pyproject.toml | 10 +- sms/src/vonage_sms/_version.py | 2 +- subaccounts/CHANGES.md | 3 + subaccounts/pyproject.toml | 10 +- .../src/vonage_subaccounts/_version.py | 2 +- users/CHANGES.md | 3 + users/pyproject.toml | 10 +- users/src/vonage_users/_version.py | 2 +- verify/CHANGES.md | 3 + verify/pyproject.toml | 10 +- verify/src/vonage_verify/_version.py | 2 +- verify_v2/CHANGES.md | 3 + verify_v2/pyproject.toml | 10 +- verify_v2/src/vonage_verify_v2/_version.py | 2 +- video/CHANGES.md | 3 + video/pyproject.toml | 10 +- video/src/vonage_video/_version.py | 2 +- voice/CHANGES.md | 3 + voice/pyproject.toml | 10 +- voice/src/vonage_voice/_version.py | 2 +- vonage/CHANGES.md | 12 +++ vonage/pyproject.toml | 35 +++---- vonage/src/vonage/_version.py | 2 +- vonage_utils/CHANGES.md | 3 + vonage_utils/pyproject.toml | 6 +- vonage_utils/src/vonage_utils/_version.py | 2 +- 65 files changed, 269 insertions(+), 274 deletions(-) delete mode 100644 OPENTOK_TO_VONAGE_MIGRATION.md diff --git a/OPENTOK_TO_VONAGE_MIGRATION.md b/OPENTOK_TO_VONAGE_MIGRATION.md deleted file mode 100644 index 87dd1938..00000000 --- a/OPENTOK_TO_VONAGE_MIGRATION.md +++ /dev/null @@ -1,92 +0,0 @@ -# Migration guide from OpenTok Python SDK to Vonage Python SDK - -## Installation - -You can now interact with Vonage's Video API using the `vonage` PyPI package rather than the `opentok` PyPI package. To do this, create a virtual environment and install the `vonage` package in your virtual environment using this command: - -```bash -python3 -m venv venv-vonage-video -. ./venv-vonage-video/bin/activate -pip install vonage -``` - -Note: not all the Video API features are yet supported in the `vonage` package. There is a full list of [Supported Features](#supported-features) later in this document. - -## Setup - -Whereas the `opentok` package used an `api_key` and `api_secret` for Authorization, the Video API implementation in the `vonage` package uses a JWT. The SDK handles JWT generation in the background for you, but will require an `application_id` and `private_key` as credentials in order to generate the token. You can obtain these by setting up a Vonage Application, which you can create via the [Developer Dashboard](https://dashboard.nexmo.com/applications). (The Vonage Application is also where you can set other settings such as callback URLs, storage preferences, etc). - -These credentials are then passed in when instantiating a `Client` object (the example below assumes you have these set as environment variables): - -```python -import vonage - -client = vonage.Client( - application_id='VONAGE_APPLICATION_ID', - private_key='VONAGE_PRIVATE_KEY_PATH', -) -``` - -You can access the Video API via the `Video` class stored at `Client.video`. To call methods related to the Video API, use this syntax: - -```python -client.video.video_api_method... -``` - -You can interact with the Vonage Video API via various methods, for example: - -- Create a Session - -```python -# Pass options for the session as a Python dictionary in SESSION_OPTIONS -session = client.video.create_session(SESSION_OPTIONS) -``` - -- Retrieve a List of Archive Recordings - -```python -archive_list = client.video.list_archives(FILTER_OPTIONS) -``` - -## Changed Methods - -There are some changes to methods between the `opentok` SDK and the Video API implementation in the `vonage` SDK. - -- Any positional parameters in method signatures have been replaced with keyword parameters in the `vonage` package. -- Methods now return the response as a Python dictionary. -- Some methods have been renamed, for clarity and/or to better reflect what the method does. These are listed below: - -| OpenTok Method Name | Vonage Video Method Name | -|---|---| -| `opentok.generate_token` | `video.generate_client_token` | -| `opentok.start_archive` | `video.create_archive` | -| `opentok.add_archive_stream` | `video.add_stream_to_archive` | -| `opentok.remove_archive_stream` | `video.remove_stream_from_archive` | -| `opentok.set_archive_layout` | `video.change_archive_layout` | -| `opentok.add_broadcast_stream` | `video.add_stream_to_broadcast` | -| `opentok.remove_broadcast_stream` | `video.remove_stream_from_broadcast` | -| `opentok.set_broadcast_layout` | `video.change_broadcast_layout` | -| `opentok.set_stream_class_lists` | `video.set_stream_layout` | -| `opentok.force_disconnect` | `video.disconnect_client` | -| `opentok.mute_all` | `video.mute_all_streams` | -| `opentok.disable_force_mute` | `video.disable_mute_all_streams`| -| `opentok.dial` | `video.create_sip_call`| - -## Supported Features - -The following is a list of Vonage Video APIs and whether the SDK provides support for them: - -| API | Supported? -|----------|:-------------:| -| Session Creation | ✅ | -| Stream Management | ✅ | -| Signaling | ✅ | -| Moderation | ✅ | -| Archiving | ✅ | -| Live Streaming Broadcasts | ✅ | -| SIP Interconnect | ✅ | -| Account Management | ❌ | -| Experience Composer | ❌ | -| Audio Connector | ❌ | -| Live Captions | ❌ | -| Custom S3/Azure buckets | ❌ | \ No newline at end of file diff --git a/account/CHANGES.md b/account/CHANGES.md index a6b2ad78..1164e2aa 100644 --- a/account/CHANGES.md +++ b/account/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Support for Python 3.13, drop support for 3.8 + # 1.0.1 - Add docstrings to data models diff --git a/account/pyproject.toml b/account/pyproject.toml index c971f197..fadef3a2 100644 --- a/account/pyproject.toml +++ b/account/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage Account API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-utils>=1.1.3", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/account/src/vonage_account/_version.py b/account/src/vonage_account/_version.py index cd7ca498..a6221b3d 100644 --- a/account/src/vonage_account/_version.py +++ b/account/src/vonage_account/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/application/CHANGES.md b/application/CHANGES.md index 540d358a..d3c171a6 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Support for Python 3.13, drop support for 3.8 + # 1.0.2 - Add docstrings to data models diff --git a/application/pyproject.toml b/application/pyproject.toml index dee25b9f..0aa39214 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage Application API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-utils>=1.1.3", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py index a6221b3d..3f6fab60 100644 --- a/application/src/vonage_application/_version.py +++ b/application/src/vonage_application/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 4bfb7e29..5c249dda 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.4.2 +- Support for Python 3.13, drop support for 3.8 + # 1.4.1 - Add docstrings to data models diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index e59db2ea..5a8ec7ce 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -4,22 +4,22 @@ 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.8" +requires-python = ">=3.9" dependencies = [ - "vonage-utils>=1.1.3", - "vonage-jwt>=1.1.2", + "vonage-utils>=1.1.4", + "vonage-jwt>=1.1.3", "requests>=2.27.0", "typing-extensions>=4.9.0", - "pydantic>=2.7.1", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/http_client/src/vonage_http_client/_version.py b/http_client/src/vonage_http_client/_version.py index 8e3c933c..98d186be 100644 --- a/http_client/src/vonage_http_client/_version.py +++ b/http_client/src/vonage_http_client/_version.py @@ -1 +1 @@ -__version__ = '1.4.1' +__version__ = '1.4.2' diff --git a/jwt/CHANGES.md b/jwt/CHANGES.md index ca3bab5e..45624c05 100644 --- a/jwt/CHANGES.md +++ b/jwt/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.3 +- Support for Python 3.13, drop support for 3.8 + # 1.1.2 - Dynamically specify package version diff --git a/jwt/pyproject.toml b/jwt/pyproject.toml index ca10165b..d5f4ee23 100644 --- a/jwt/pyproject.toml +++ b/jwt/pyproject.toml @@ -4,16 +4,16 @@ 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.8" -dependencies = ["vonage-utils>=1.1.3", "pyjwt[crypto]>=1.6.4"] +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.8", "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", ] diff --git a/jwt/src/vonage_jwt/_version.py b/jwt/src/vonage_jwt/_version.py index 7b344eca..7bb021e2 100644 --- a/jwt/src/vonage_jwt/_version.py +++ b/jwt/src/vonage_jwt/_version.py @@ -1 +1 @@ -__version__ = '1.1.2' +__version__ = '1.1.3' diff --git a/messages/CHANGES.md b/messages/CHANGES.md index b2b6912c..5f14ea7d 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.2 +- Support for Python 3.13, drop support for 3.8 + # 1.2.1 - Add docstrings to data models diff --git a/messages/pyproject.toml b/messages/pyproject.toml index fdd111b1..3143c943 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -4,11 +4,11 @@ dynamic = ["version"] description = 'Vonage messages package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-utils>=1.1.3", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", ] classifiers = [ "Programming Language :: Python", diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 3f262a63..923b9879 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.2.1' +__version__ = '1.2.2' diff --git a/network_auth/CHANGES.md b/network_auth/CHANGES.md index 01a634f4..477b10fb 100644 --- a/network_auth/CHANGES.md +++ b/network_auth/CHANGES.md @@ -1,3 +1,7 @@ +# 1.0.0 +- Add methods to work with the Vonage Number Verification API +- Internal refactoring + # 0.1.1b0 - Add docstrings to data models diff --git a/network_auth/README.md b/network_auth/README.md index ba06fc90..71bfbdcd 100644 --- a/network_auth/README.md +++ b/network_auth/README.md @@ -1,13 +1,11 @@ # Vonage Network API Authentication Client -This package (`vonage-network-auth`) provides a client for authenticating Network APIs that require Oauth2 authentcation. Using it, it is possible to generate authenticated JWTs for use with GNP APIs, e.g. Sim Swap, Number Verification. +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 an SDK, 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. +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). -Please note this package is in beta. - ## Installation Install from the Python Package Index with pip: @@ -27,10 +25,3 @@ from vonage_http_client import HttpClient, Auth network_auth = NetworkAuth(HttpClient(Auth(application_id='application-id', private_key='private-key'))) ``` -### Generate an Authenticated Access Token - -```python -token = network_auth.get_oauth2_user_token( - number='447700900000', scope='dpv:FraudPreventionAndDetection#check-sim-swap' -) -``` diff --git a/network_auth/pyproject.toml b/network_auth/pyproject.toml index 3dfbea88..7ca35f8b 100644 --- a/network_auth/pyproject.toml +++ b/network_auth/pyproject.toml @@ -4,16 +4,20 @@ 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.8" -dependencies = ["vonage-http-client>=1.4.0", "vonage-utils>=1.1.2"] +requires-python = ">=3.9" +dependencies = [ + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/network_auth/src/vonage_network_auth/__init__.py b/network_auth/src/vonage_network_auth/__init__.py index e321e403..ca379c9e 100644 --- a/network_auth/src/vonage_network_auth/__init__.py +++ b/network_auth/src/vonage_network_auth/__init__.py @@ -1,4 +1,10 @@ from .network_auth import NetworkAuth +from .requests import CreateOidcUrl from .responses import OidcResponse, TokenResponse -__all__ = ['NetworkAuth', 'OidcResponse', 'TokenResponse'] +__all__ = [ + 'NetworkAuth', + 'CreateOidcUrl', + 'OidcResponse', + 'TokenResponse', +] diff --git a/network_auth/src/vonage_network_auth/network_auth.py b/network_auth/src/vonage_network_auth/network_auth.py index 066cc82d..62e5440e 100644 --- a/network_auth/src/vonage_network_auth/network_auth.py +++ b/network_auth/src/vonage_network_auth/network_auth.py @@ -8,7 +8,7 @@ class NetworkAuth: - """Class containing methods for authenticating Network APIs following Camara + """Class containing methods for authenticating Network APIs following CAMARA standards.""" def __init__(self, http_client: HttpClient): diff --git a/network_number_verification/CHANGES.md b/network_number_verification/CHANGES.md index 4df12901..a376cb52 100644 --- a/network_number_verification/CHANGES.md +++ b/network_number_verification/CHANGES.md @@ -1,2 +1,2 @@ -# 0.1.0b0 +# 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 index 1ec0c53f..d170d32e 100644 --- a/network_number_verification/README.md +++ b/network_number_verification/README.md @@ -6,8 +6,6 @@ This package is not intended to be used directly, instead being accessed from an For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). -Please note this package is in beta. - ## 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. @@ -17,7 +15,7 @@ To use this API, you must first create and register a business profile with the Install from the Python Package Index with pip: ```bash -pip install vonage-network-number-verifcation +pip install vonage-network-number-verification ``` ## Usage @@ -27,20 +25,39 @@ It is recommended to use this as part of the `vonage` package. The examples belo 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. Create an Access Token from an Authentication Code -3. Make a Number Verification Request +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 -### Create an Access Token +url_options = CreateOidcUrl( + redirect_uri='https://example.com/redirect', + state='c9896ee6-4ff8-464c-b393-d56d6e638f88', + login_hint='+990123456', +) -```python +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 index eb02d738..50350896 100644 --- a/network_number_verification/pyproject.toml +++ b/network_number_verification/pyproject.toml @@ -4,20 +4,21 @@ 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.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-network-auth>=0.1.1b0", - "vonage-utils>=1.1.3", + "vonage-http-client>=1.4.2", + "vonage-network-auth>=1.0.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", ] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "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", ] diff --git a/network_number_verification/src/vonage_network_number_verification/_version.py b/network_number_verification/src/vonage_network_number_verification/_version.py index db4e81a7..1f356cc5 100644 --- a/network_number_verification/src/vonage_network_number_verification/_version.py +++ b/network_number_verification/src/vonage_network_number_verification/_version.py @@ -1 +1 @@ -__version__ = '0.1.0b0' +__version__ = '1.0.0' 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 index 8b6c1f15..67820e04 100644 --- a/network_number_verification/src/vonage_network_number_verification/number_verification.py +++ b/network_number_verification/src/vonage_network_number_verification/number_verification.py @@ -41,19 +41,6 @@ def get_oidc_url(self, url_settings: CreateOidcUrl) -> str: """ return self._network_auth.get_oidc_url(url_settings) - @validate_call - def exchange_code_for_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. - """ - return self._network_auth.get_number_verification_camara_token(code, redirect_uri) - @validate_call def verify( self, number_verification_params: NumberVerificationRequest @@ -61,19 +48,24 @@ def verify( """Verify if the specified phone number matches the one that the user is currently using. - Note: To use this method, the user must be connected to mobile data rather than - Wi-Fi. - Args: - access_token (str): The access token to use for the request. - 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. + 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: Class containing the Number Verification response - containing the device verification information. + 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} @@ -85,7 +77,7 @@ def verify( '/camara/number-verification/v031/verify', params=params, auth_type=self._auth_type, - token=number_verification_params.access_token, + 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 index a5856a77..a5a2cde2 100644 --- a/network_number_verification/src/vonage_network_number_verification/requests.py +++ b/network_number_verification/src/vonage_network_number_verification/requests.py @@ -6,14 +6,15 @@ class NumberVerificationRequest(BaseModel): """Model for the request to verify a phone number. Args: - access_token (str): The access token for the request obtained from the - three-legged OAuth2 flow. + 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. """ - access_token: str + code: str + redirect_uri: str phone_number: str = Field(None, serialization_alias='phoneNumber') hashed_phone_number: str = Field(None, serialization_alias='hashedPhoneNumber') diff --git a/network_number_verification/tests/test_number_verification.py b/network_number_verification/tests/test_number_verification.py index 8cfade0a..e70a408c 100644 --- a/network_number_verification/tests/test_number_verification.py +++ b/network_number_verification/tests/test_number_verification.py @@ -39,7 +39,7 @@ def test_get_oidc_url(): @patch('vonage_network_auth.NetworkAuth.get_number_verification_camara_token') @responses.activate -def test_exchange_code_for_token(mock_get_number_verification_camara_token: MagicMock): +def test_verify_number(mock_get_number_verification_camara_token: MagicMock): build_response( path, 'POST', @@ -49,15 +49,6 @@ def test_exchange_code_for_token(mock_get_number_verification_camara_token: Magi mock_get_number_verification_camara_token.return_value = 'token' - response = number_verification.exchange_code_for_token( - 'code', 'https://example.com/callback' - ) - - assert response == 'token' - - -@responses.activate -def test_verify_number(): build_response( path, 'POST', @@ -66,15 +57,27 @@ def test_verify_number(): ) number_verification_params = NumberVerificationRequest( - access_token='token', phone_number='447700900000' + 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(): +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', @@ -83,7 +86,8 @@ def test_verify_hashed_number(): ) number_verification_params = NumberVerificationRequest( - access_token='token', + code='token', + redirect_uri='https://example.com/callback', hashed_phone_number='d867b6540ac8db72d860d67d3d612a1621adcf3277573e9299be1153b6d0de15', ) response = number_verification.verify(number_verification_params) @@ -93,12 +97,17 @@ def test_verify_hashed_number(): def test_verify_number_model_errors(): with raises(NetworkNumberVerificationError): - number_verification.verify(NumberVerificationRequest(access_token='token')) + number_verification.verify( + NumberVerificationRequest( + code='code', redirect_uri='https://example.com/callback' + ) + ) with raises(NetworkNumberVerificationError): number_verification.verify( NumberVerificationRequest( - access_token='token', + code='code', + redirect_uri='https://example.com/callback', phone_number='447700900000', hashed_phone_number='hash', ) diff --git a/network_sim_swap/CHANGES.md b/network_sim_swap/CHANGES.md index 01a634f4..da547411 100644 --- a/network_sim_swap/CHANGES.md +++ b/network_sim_swap/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.0 +- Support for Python 3.13, drop support for 3.8 + # 0.1.1b0 - Add docstrings to data models diff --git a/network_sim_swap/README.md b/network_sim_swap/README.md index ec752187..2f764842 100644 --- a/network_sim_swap/README.md +++ b/network_sim_swap/README.md @@ -6,8 +6,6 @@ This package is not intended to be used directly, instead being accessed from an For full API documentation, refer to the [Vonage developer documentation](https://developer.vonage.com). -Please note this package is in beta. - ## 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. diff --git a/network_sim_swap/pyproject.toml b/network_sim_swap/pyproject.toml index 74a981fe..c4f0fbff 100644 --- a/network_sim_swap/pyproject.toml +++ b/network_sim_swap/pyproject.toml @@ -4,20 +4,21 @@ 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.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-network-auth>=0.1.1b0", - "vonage-utils>=1.1.3", + "vonage-http-client>=1.4.2", + "vonage-network-auth>=1.0.0", + "vonage-utils>=1.1.4", + "pydantic>=2.9.2", ] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "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", ] diff --git a/network_sim_swap/src/vonage_network_sim_swap/_version.py b/network_sim_swap/src/vonage_network_sim_swap/_version.py index b7a18f07..1f356cc5 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/_version.py +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -1 +1 @@ -__version__ = '0.1.1b0' +__version__ = '1.0.0' diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index a6b2ad78..1164e2aa 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Support for Python 3.13, drop support for 3.8 + # 1.0.1 - Add docstrings to data models diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml index 6a5454c0..5583448b 100644 --- a/number_insight/pyproject.toml +++ b/number_insight/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage Number Insight package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-utils>=1.1.3", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py index cd7ca498..a6221b3d 100644 --- a/number_insight/src/vonage_number_insight/_version.py +++ b/number_insight/src/vonage_number_insight/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/number_management/CHANGES.md b/number_management/CHANGES.md index a2d234fe..09634739 100644 --- a/number_management/CHANGES.md +++ b/number_management/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Support for Python 3.13, drop support for 3.8 + # 1.0.1 - Add docstrings for data models diff --git a/number_management/pyproject.toml b/number_management/pyproject.toml index cd4de353..724fa07b 100644 --- a/number_management/pyproject.toml +++ b/number_management/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage Numbers package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.1", - "vonage-utils>=1.1.3", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/number_management/src/vonage_numbers/_version.py b/number_management/src/vonage_numbers/_version.py index cd7ca498..a6221b3d 100644 --- a/number_management/src/vonage_numbers/_version.py +++ b/number_management/src/vonage_numbers/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/sms/CHANGES.md b/sms/CHANGES.md index 1c7af6dc..890fa7c9 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.3 +- Support for Python 3.13, drop support for 3.8 + # 1.1.2 - Add docstrings to data models diff --git a/sms/pyproject.toml b/sms/pyproject.toml index 1ea50176..b876150c 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage SMS package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/sms/src/vonage_sms/_version.py b/sms/src/vonage_sms/_version.py index 7b344eca..7bb021e2 100644 --- a/sms/src/vonage_sms/_version.py +++ b/sms/src/vonage_sms/_version.py @@ -1 +1 @@ -__version__ = '1.1.2' +__version__ = '1.1.3' diff --git a/subaccounts/CHANGES.md b/subaccounts/CHANGES.md index b133ad93..c0dc3855 100644 --- a/subaccounts/CHANGES.md +++ b/subaccounts/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Support for Python 3.13, drop support for 3.8 + # 1.0.2 - Add docstrings to data models diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml index 904a3701..2aefdbfb 100644 --- a/subaccounts/pyproject.toml +++ b/subaccounts/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage Subaccounts API package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-utils>=1.1.2", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/subaccounts/src/vonage_subaccounts/_version.py b/subaccounts/src/vonage_subaccounts/_version.py index a6221b3d..3f6fab60 100644 --- a/subaccounts/src/vonage_subaccounts/_version.py +++ b/subaccounts/src/vonage_subaccounts/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/users/CHANGES.md b/users/CHANGES.md index 7d363b60..77324631 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.4 +- Support for Python 3.13, drop support for 3.8 + # 1.1.3 - Add docstrings to data models diff --git a/users/pyproject.toml b/users/pyproject.toml index 8a8e2910..941f55ca 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage Users package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.2", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/users/src/vonage_users/_version.py b/users/src/vonage_users/_version.py index 7bb021e2..bc50bee6 100644 --- a/users/src/vonage_users/_version.py +++ b/users/src/vonage_users/_version.py @@ -1 +1 @@ -__version__ = '1.1.3' +__version__ = '1.1.4' diff --git a/verify/CHANGES.md b/verify/CHANGES.md index fda2114e..d9f52f50 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.3 +- Support for Python 3.13, drop support for 3.8 + # 1.1.2 - Add docstrings to data models diff --git a/verify/pyproject.toml b/verify/pyproject.toml index 32913259..effc9349 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage verify package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.1", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/verify/src/vonage_verify/_version.py b/verify/src/vonage_verify/_version.py index 7b344eca..7bb021e2 100644 --- a/verify/src/vonage_verify/_version.py +++ b/verify/src/vonage_verify/_version.py @@ -1 +1 @@ -__version__ = '1.1.2' +__version__ = '1.1.3' diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md index 012a09e5..031bbf6d 100644 --- a/verify_v2/CHANGES.md +++ b/verify_v2/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.4 +- Support for Python 3.13, drop support for 3.8 + # 1.1.3 - Add docstrings for data models diff --git a/verify_v2/pyproject.toml b/verify_v2/pyproject.toml index 14e7945e..85d78ac5 100644 --- a/verify_v2/pyproject.toml +++ b/verify_v2/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage verify v2 package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-utils>=1.1.2", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/verify_v2/src/vonage_verify_v2/_version.py b/verify_v2/src/vonage_verify_v2/_version.py index 7bb021e2..bc50bee6 100644 --- a/verify_v2/src/vonage_verify_v2/_version.py +++ b/verify_v2/src/vonage_verify_v2/_version.py @@ -1 +1 @@ -__version__ = '1.1.3' +__version__ = '1.1.4' diff --git a/video/CHANGES.md b/video/CHANGES.md index be516a55..6340e72a 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Support for Python 3.13, drop support for 3.8 + # 1.0.0 - Initial upload diff --git a/video/pyproject.toml b/video/pyproject.toml index f98e6276..9e3b3bf1 100644 --- a/video/pyproject.toml +++ b/video/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage video package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.0", - "vonage-utils>=1.1.2", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 1f356cc5..cd7ca498 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.0.0' +__version__ = '1.0.1' diff --git a/voice/CHANGES.md b/voice/CHANGES.md index 7b359abe..f1eb406d 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.5 +- Support for Python 3.13, drop support for 3.8 + # 1.0.4 - Add docstrings to data models diff --git a/voice/pyproject.toml b/voice/pyproject.toml index 1327e188..c0d471f3 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -4,20 +4,20 @@ dynamic = ["version"] description = 'Vonage voice package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.3.1", - "vonage-utils>=1.1.2", - "pydantic>=2.7.1", + "vonage-http-client>=1.4.2", + "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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 8a81504c..858de170 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.0.4' +__version__ = '1.0.5' diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 1ffce63c..25e1656b 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,15 @@ +# 4.0.0b0 +A complete, ground-up rewrite of the SDK. +Key changes: +- Monorepo structure, with each API under separate packages +- Usage of data models throughout +- Use of Pydantic to enforce correct typing +- Increased use of docstrings to improve developer experience +- Add support for all features in the [Vonage Video API](https://developer.vonage.com/en/video/overview) +- 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) +- Targeting Python 3.9+ +- With more to come in v4! + # 3.99.5a0 - Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) - Add docstrings for data models across the SDK to increase quality-of-life developer experience diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index f31e00ec..7b8384f7 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -3,31 +3,34 @@ name = "vonage" dynamic = ["version"] description = "Python Server SDK for using Vonage APIs" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "vonage-utils>=1.1.3", - "vonage-http-client>=1.4.1", - "vonage-account>=1.0.1", - "vonage-application>=1.0.2", - "vonage-messages>=1.2.1", - "vonage-number-insight>=1.0.1", - "vonage-numbers>=1.0.1", - "vonage-sms>=1.1.2", - "vonage-subaccounts>=1.0.2", - "vonage-users>=1.1.3", - "vonage-verify>=1.1.2", - "vonage-verify-v2>=1.1.3", - "vonage-video>=1.0.0", - "vonage-voice>=1.0.4", + "vonage-utils>=1.1.4", + "vonage-http-client>=1.4.2", + "vonage-account>=1.0.2", + "vonage-application>=1.0.3", + "vonage-messages>=1.2.2", + "vonage-network-auth>=1.0.0", + "vonage-network-sim-swap>=1.0.0", + "vonage-network-number-verification>=1.0.0", + "vonage-number-insight>=1.0.2", + "vonage-numbers>=1.0.2", + "vonage-sms>=1.1.3", + "vonage-subaccounts>=1.0.3", + "vonage-users>=1.1.4", + "vonage-verify>=1.1.3", + "vonage-verify-v2>=1.1.4", + "vonage-video>=1.0.1", + "vonage-voice>=1.0.5", ] 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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] [[project.authors]] diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index a8447645..1e4bf4fe 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '3.99.5a0' +__version__ = '4.0.0b0' diff --git a/vonage_utils/CHANGES.md b/vonage_utils/CHANGES.md index 8d235239..3bb536d8 100644 --- a/vonage_utils/CHANGES.md +++ b/vonage_utils/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.4 +- Support for Python 3.13, drop support for 3.8 + # 1.1.3 - Add docstrings to data models diff --git a/vonage_utils/pyproject.toml b/vonage_utils/pyproject.toml index a0aad98c..451d5e18 100644 --- a/vonage_utils/pyproject.toml +++ b/vonage_utils/pyproject.toml @@ -4,16 +4,16 @@ 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.7.1"] -requires-python = ">=3.8" +dependencies = ["pydantic>=2.9.2"] +requires-python = ">=3.9" 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", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: Apache Software License", ] diff --git a/vonage_utils/src/vonage_utils/_version.py b/vonage_utils/src/vonage_utils/_version.py index 7bb021e2..bc50bee6 100644 --- a/vonage_utils/src/vonage_utils/_version.py +++ b/vonage_utils/src/vonage_utils/_version.py @@ -1 +1 @@ -__version__ = '1.1.3' +__version__ = '1.1.4' From 329d775280f232bc1c3f7b37bb82611a642ff8a8 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 18 Oct 2024 18:12:32 +0100 Subject: [PATCH 86/98] writing migration guides/readmes --- README.md | 89 +++++++++++- V3_TO_V4_SDK_MIGRATION_GUIDE.md | 200 +++++++++++++++++++++++++++ messages/README.md | 2 +- video/OPENTOK_TO_VONAGE_MIGRATION.md | 112 +++++++++++++++ video/src/vonage_video/video.py | 3 +- vonage/CHANGES.md | 98 +++++-------- 6 files changed, 433 insertions(+), 71 deletions(-) create mode 100644 V3_TO_V4_SDK_MIGRATION_GUIDE.md create mode 100644 video/OPENTOK_TO_VONAGE_MIGRATION.md diff --git a/README.md b/README.md index 75b26d97..45372913 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This is the Python server SDK for Vonage's API. To use it you'll need a Vonage account. Sign up [for free at vonage.com][signup]. - [Installation](#installation) +- [Migration Guides](#migration-guides) - [Calling Vonage APIs](#calling-vonage-apis) - [Usage](#usage) - [Account API](#account-api) @@ -19,6 +20,8 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [HTTP Client](#http-client) - [JWT Client](#jwt-client) - [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) @@ -35,20 +38,36 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. ## Installation -To install the Python client library using pip: +To install the Python SDK package using pip: - pip install vonage +```bash +pip install vonage +``` To upgrade your installed client library using pip: - pip install vonage --upgrade +```bash +pip install vonage --upgrade +``` Alternatively, you can clone the repository via the command line: - git clone git@github.com:Vonage/vonage-python-sdk.git +```bash +git clone git@github.com:Vonage/vonage-python-sdk.git +``` 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. + +### OpenTok to Vonage Video API + +This SDK includes support for the [Vonage Video API](https://developer.vonage.com/en/video/overview). If you have an application that uses OpenTok for video and want to migrate (which is highly recommended!) then [A migration guide is available here](video/OPENTOK_TO_VONAGE_MIGRATION.md) which will help you to migrate your applications to use Vonage Video. + ## Calling Vonage APIs The Vonage Python SDK is a monorepo, with separate packages for each API. When you install the Python SDK, you'll see there's a top-level package, `vonage`, and then specialised packages for every API class. @@ -365,7 +384,7 @@ Some message types have submodels with additional fields. In this case, import t e.g. ```python -from vonage_messages import MessengerImage, MessengerOptions, MessengerResource +from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource messenger = MessengerImage( to='1234567890', @@ -439,6 +458,66 @@ MessengerText, MessengerImage, MessengerAudio, MessengerVideo, MessengerFile ViberText, ViberImage, ViberVideo, ViberFile ``` +## 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 ### Make a Basic Number Insight Request diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md new file mode 100644 index 00000000..8f7305e0 --- /dev/null +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -0,0 +1,200 @@ +# 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 its 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 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) +- [API Changes](#API-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 that's used (that is in the `vonage-http-client` package). 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 this all 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_v2 import VerifyRequest, SmsChannel + +sms_channel = SmsChannel(to='1234567890') +verify_request = VerifyRequest(brand='Vonage', workflow=[sms_channel]) + +response = vonage_client.verify_v2.start_verification(verify_request) +print(response) +``` + +However, some APIs with a lot of models have them located under the `vonage_api_package.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. 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()) +``` + +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. + +## 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. + +Rather than just returning a `ClientError` when an HTTP Client error is thrown, we now throw more specific errors with more information. + +### Specific API Changes + +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 v2 functionality is accessed from `verify2` in v3 and `verify_v2` in v4. + +SMS message signing/verifying signatures code in `vonage.Client` 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.psd2` | `verify.start_psd2_verification` | +| `verify.check` | `verify.check_code` | +| `verify.check` | `verify.check_code` | +| `verify2.new_request` | `verify_v2.start_verification` | +| `video.set_stream_layout` | `video.change_stream_layout` | + +## Additional Resources + diff --git a/messages/README.md b/messages/README.md index cdbfd415..37f7f957 100644 --- a/messages/README.md +++ b/messages/README.md @@ -35,7 +35,7 @@ Some message types have submodels with additional fields. In this case, import t e.g. ```python -from vonage_messages import MessengerImage, MessengerOptions, MessengerResource +from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource messenger = MessengerImage( to='1234567890', diff --git a/video/OPENTOK_TO_VONAGE_MIGRATION.md b/video/OPENTOK_TO_VONAGE_MIGRATION.md new file mode 100644 index 00000000..efb7dd7d --- /dev/null +++ b/video/OPENTOK_TO_VONAGE_MIGRATION.md @@ -0,0 +1,112 @@ +# Migration guide from OpenTok Python SDK to Vonage Python SDK + +This is a guide to help you migrate from using the OpenTok Python SDK to the Vonage Python SDK to access Video API functionality. You can interact with the Vonage Video API via the Vonage Python SDK to use all the same features available in the `opentok` package. + +The OpenTok package includes methods to manage a video application in Python. It includes features like archiving, broadcasting, live captioning and more. All of these features are now available in the Vonage Python SDK, which is the recommended way to access them. + +## Contents + +- [Improvements](#improvements) +- [Installation](#installation) +- [Configuration](#configuration) +- [Accessing Video API Methods](#accessing-video-api-methods) +- [Accessing Video API Data Models](#accessing-video-api-data-models) +- [New Methods](#new-methods) +- [Changed Methods](#changed-methods) +- [Additional Resources](#additional-resources) + +## Improvements + +Vonage Video adds data models to help you construct video objects. There's also finer-grained and more descriptive errors. We now authenticate with JWTs, improving security. + +You can now manage all your Vonage usage from the Developer Dashboard, including setting callbacks for different video functions as well as things like application configuration and billing. + +## Installation + +You can now interact with Vonage's Video API using the `vonage-video` PyPI package rather than the `opentok` PyPI package. You shouldn't use this directly for most use cases, it's easier to use the global `vonage` SDK package which includes video functionality. To do this, create a virtual environment and install the `vonage` package in your virtual environment using this command: + +```bash +python3 -m venv venv +. ./venv/bin/activate +pip install vonage +``` + +`vonage-video` will be installed as a dependency so there's no need to install directly. + +## Configuration + +Whereas the `opentok` package used an `api_key` and `api_secret` for authorization, the Vonage Video API uses JWTs. The SDK handles JWT generation in the background for you, but will require an `application_id` and `private_key` as credentials in order to generate the token. You can obtain these by setting up a Vonage Application, which you can create via the [Developer Dashboard](https://dashboard.nexmo.com/applications). (The Vonage Application is also where you can set other settings such as callback URLs, storage preferences, etc). + +These credentials are then passed in when instantiating a `vonage.Vonage` object: + +```python +from vonage import Vonage, Auth + +vonage_client = Vonage( + Auth( + application_id='VONAGE_APPLICATION_ID', + private_key='VONAGE_PRIVATE_KEY_PATH', + ) +) +``` + +## Accessing Video API Methods + +You can access the Video API via the `Video` class stored at `Vonage.video`. To call methods related to the Video API, use this syntax: + +```python +vonage_client.video.video_api_method... +``` + +## Accessing Video API Data Models + +You can access data models for the Video API, e.g. as arguments to video methods, by importing them from the `vonage_video.models` package, e.g. + +```python +from vonage_video.models import SessionOptions + +session_options = SessionOptions(...) + +vonage_client.video.create_session(session_options) +``` + +## New Methods + +`video.list_broadcasts` + +## Changed Methods + +There are some changes to methods between the `opentok` SDK and the Video API implementation in the `vonage-video` SDK. + +- Any positional parameters in method signatures have been replaced with data models in the `vonage-video` package, stored at `vonage_video.models`. +- Methods now return responses as Pydantic data models. +- Some methods have been renamed, for clarity and/or to better reflect what the method does. These are listed below: + +| OpenTok Method Name | Vonage Video Method Name | +|---|---| +| `opentok.generate_token` | `video.generate_client_token` | +| `opentok.add_archive_stream` | `video.add_stream_to_archive` | +| `opentok.remove_archive_stream` | `video.remove_stream_from_archive` | +| `opentok.set_archive_layout` | `video.change_archive_layout` | +| `opentok.add_broadcast_stream` | `video.add_stream_to_broadcast` | +| `opentok.remove_broadcast_stream` | `video.remove_stream_from_broadcast` | +| `opentok.set_broadcast_layout` | `video.change_broadcast_layout` | +| `opentok.set_stream_class_lists` | `video.change_stream_layout` | +| `opentok.force_disconnect` | `video.disconnect_client` | +| `opentok.mute_all` | `video.mute_all_streams` | +| `opentok.disable_force_mute` | `video.disable_mute_all_streams`| +| `opentok.dial` | `video.initiate_sip_call`| +| `opentok.start_render` | `video.start_experience_composer`| +| `opentok.list_renders` | `video.list_experience_composers`| +| `opentok.get_render` | `video.get_experience_composer`| +| `opentok.stop_render` | `video.stop_experience_composer`| +| `opentok.connect_audio_to_websocket` | `video.start_audio_connector`| +| `opentok.connect_audio_to_websocket` | `video.start_audio_connector`| + +## Additional Resources + +- [Vonage Video API Developer Documentation](https://developer.vonage.com/en/video/overview) +- [Vonage Video API Specification](https://developer.vonage.com/en/api/video) +- [Link to the Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) +- [Join the Vonage Developer Community Slack](https://developer.vonage.com/en/community/slack) +- [Submit a Vonage Video API Support Request](https://api.support.vonage.com/hc/en-us) \ No newline at end of file diff --git a/video/src/vonage_video/video.py b/video/src/vonage_video/video.py index 59a42a50..2bb3ca14 100644 --- a/video/src/vonage_video/video.py +++ b/video/src/vonage_video/video.py @@ -265,7 +265,8 @@ def stop_captions(self, captions: CaptionsData) -> None: @validate_call def start_audio_connector(self, options: AudioConnectorOptions) -> AudioConnectorData: - """Starts an audio connector in a session using the Vonage Video API. + """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. diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index 25e1656b..d4f7ab6a 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -2,81 +2,51 @@ A complete, ground-up rewrite of the SDK. Key changes: - Monorepo structure, with each API under separate packages -- Usage of data models throughout -- Use of Pydantic to enforce correct typing -- Increased use of docstrings to improve developer experience -- Add support for all features in the [Vonage Video API](https://developer.vonage.com/en/video/overview) -- 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) - Targeting Python 3.9+ -- With more to come in v4! - -# 3.99.5a0 -- Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) -- Add docstrings for data models across the SDK to increase quality-of-life developer experience -- Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 - -# 3.99.4a0 -- Add support for the [Vonage Numbers API](https://developer.vonage.com/en/numbers/overview) - -# 3.99.3a1 -- Updated `vonage_subaccounts.ListSubaccountsResponse` for compatibility with Python 3.8 - -# 3.99.3a0 -- Add support for the [Vonage Subaccounts API](https://developer.vonage.com/en/use-cases/using-subaccounts) - -# 3.99.2a0 -- Add support for the [RCS messaging channel](https://developer.vonage.com/en/messages/concepts/rcs) of the Vonage Messages API -- Add method to revoke an RCS message -- Add method to mark a WhatsApp message as read - -# 3.99.0a12 -- Add support for the [Vonage Account API](https://developer.vonage.com/en/account/overview). -- Update package metadata for the `vonage-application` package. - -# 3.99.0a11 -- Remove the Number Insight v2 beta which was not in use and is going to be deprecated -- Lower the VerifyV2 minimum channel timeout to 15s - -# 3.99.1a10 -- Migrate the Vonage JWT package -- Internal refactoring - -# 3.99.0a10 -- Add support for the [Vonage Application API](https://developer.vonage.com/en/application/overview). +- 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 +- With even more enhancements to come! -# 3.99.0a9 -- Internal refactoring +# 3.17.1 +- Add "mark WhatsApp message as read" option for Messages API -# 3.99.0a8 -- Add support for the [Vonage Number Insight API](https://developer.vonage.com/en/number-insight/overview). -- Update minimum dependency version +# 3.17.0 +- Add RCS message type option for Messages API +- Add "revoke RCS message" option -# 3.99.0a7 -- Add support for the [Vonage Voice API](https://developer.vonage.com/en/voice/voice-api/overview). -- Add `http_client` property to each module that has an HTTP Client, e.g. Voice, Sms, Verify. +# 3.16.1 +- Fix video client token option +- Fix typos in README +- Bump minimum versions for dependencies with fixed vulnerabilities -# 3.99.0a6 -- Add support for the [Vonage Messages API](https://developer.vonage.com/en/messages/overview). +# 3.16.0 +- Add support for the [Vonage Number Verification API](https://developer.vonage.com/number-verification/overview) -# 3.99.0a5 -- Add support for the [Vonage Verify V2 API](https://developer.vonage.com/en/verify/overview). -- Expose error classes at the top level of the `vonage-http-client` package. +# 3.15.0 +- Add support for the [Vonage Sim Swap API](https://developer.vonage.com/en/sim-swap/overview) -# 3.99.0a4 -- Add support for the [Vonage Verify API](https://developer.vonage.com/en/api/verify). -- Add `last_request` and `last_response` properties to the HTTP Client. +# 3.14.0 +- Add publisher-only as a valid Video API client token role -# 3.99.0a3 -- Add support for the [Vonage Users API](https://developer.vonage.com/en/api/application.v2#User). +# 3.13.1 +- Fix content-type incorrect serialization -# 3.99.0a2 -- Internal refactoring +# 3.13.0 +- Migrating to use Pydantic v2 as a dependency -# 3.99.0a1 -- Add support for the [Vonage SMS API](https://developer.vonage.com/en/messaging/sms/overview). +# 3.12.0 +- Add support for the [Vonage Video API](https://developer.vonage.com/en/video/overview) -# 3.99.0a0 -Created new monorepo structure - this package `vonage` is now a way to depend on the functionality of all Vonage APIs, which has been moved into separate packages. Additionally, there are many breaking changes. +# 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` From a3ba43cac6ec03558d7762bd08f5b33281341493 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 21 Oct 2024 13:19:08 +0100 Subject: [PATCH 87/98] prepare for v4 beta release --- README.md | 26 +++++++------ V3_TO_V4_SDK_MIGRATION_GUIDE.md | 68 +++++++++++++++++++++++++++------ vonage/CHANGES.md | 2 +- vonage/src/vonage/_version.py | 2 +- 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 45372913..9219803e 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) ![Total lines](https://sloc.xyz/github/vonage/vonage-python-sdk) -This is the Python server SDK for Vonage's API. To use it you'll -need a Vonage account. Sign up [for free at vonage.com][signup]. +This is the Python server SDK to help you use Vonage APIs in your Python application. To use it you'll need a Vonage account. [Sign up for free on the Vonage site](https://ui.idp.vonage.com/ui/auth/registration). + +### Contents: - [Installation](#installation) - [Migration Guides](#migration-guides) @@ -35,6 +36,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup]. - [Frequently Asked Questions](#frequently-asked-questions) - [Contributing](#contributing) - [License](#license) +- [Additional Resources](#additional-resources) ## Installation @@ -1381,7 +1383,6 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | API | API Release Status | Supported? | | --------------------- | :------------------: | :--------: | | Account API | General Availability | ✅ | -| Alerts API | General Availability | ✅ | | Application API | General Availability | ✅ | | Audit API | Beta | ❌ | | Conversation API | Beta | ❌ | @@ -1405,7 +1406,7 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo [asyncio](https://docs.python.org/3/library/asyncio.html) is a library to write **concurrent** code using the **async/await** syntax. -We don't currently support asyncio in the Python SDK but we are planning to do so in upcoming releases. +We don't currently support asyncio in the Python SDK. ## Contributing @@ -1414,13 +1415,13 @@ We :heart: contributions! But if you plan to work on something big or controvers We recommend working on `vonage-python-sdk` with a [virtualenv][virtualenv]. The following command will install all the Python dependencies you need to run the tests: ```bash -make install +pip install -r requirements.txt ``` The tests are all written with pytest. You run them with: ```bash -make test +pytest -v ``` We use [Black](https://black.readthedocs.io/en/stable/index.html) for code formatting, with our config in the `pyproject.toml` file. To ensure a PR follows the right format, you can set up and use our pre-commit settings with @@ -1433,10 +1434,11 @@ Then when you commit code, if it's not in the right format, it will be automatic ## License -This library is released under the [Apache License][license]. +This library is released under the [Apache License](license). + +## Additional Resources -[virtualenv]: https://virtualenv.pypa.io/en/stable/ -[report-a-bug]: https://github.com/Vonage/vonage-python-sdk/issues/new -[pull-request]: https://github.com/Vonage/vonage-python-sdk/pulls -[signup]: https://dashboard.nexmo.com/sign-up?utm_source=DEV_REL&utm_medium=github&utm_campaign=python-client-library -[license]: LICENSE.txt +- [Vonage Video API Developer Documentation](https://developer.vonage.com/en/video/overview) +- [Link to the Vonage Python SDK](https://github.com/Vonage/vonage-python-sdk) +- [Join the Vonage Developer Community Slack](https://developer.vonage.com/en/community/slack) +- [Submit a Vonage Video API Support Request](https://api.support.vonage.com/hc/en-us) diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md index 8f7305e0..55c15f61 100644 --- a/V3_TO_V4_SDK_MIGRATION_GUIDE.md +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -1,8 +1,8 @@ # 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 its release. +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 network APIs announced by Vonage. +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 @@ -12,7 +12,10 @@ The Vonage Python SDK (`vonage`) contains methods and data models to help you us - [Accessing API Methods](#accessing-api-methods) - [Accessing API Data Models](#accessing-api-data-models) - [Response Objects](#response-objects) -- [API Changes](#API-changes) +- [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 @@ -46,7 +49,7 @@ 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 that's used (that is in the `vonage-http-client` package). This section will break all of this down then provide an example. +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 @@ -120,7 +123,7 @@ response = vonage_client.verify_v2.start_verification(verify_request) print(response) ``` -However, some APIs with a lot of models have them located under the `vonage_api_package.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: +However, some APIs with a lot of models have them located under the `vonage_api_package.models` package, e.g. `vonage-messages`, `vonage-voice` and `vonage-video`. To access these, simply import from `vonage_api_package.models`, e.g. to send an image via Facebook Messenger do this: ```python from vonage_messages.models import MessengerImage, MessengerOptions, MessengerResource @@ -154,27 +157,57 @@ print(settings.model_dump()) 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. -## API Changes +## Error Handling + +In v3 of the SDK, most HTTP client errors gave a general `HttpClientError`. In v4 these are finer-grained. 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. -Rather than just returning a `ClientError` when an HTTP Client error is thrown, we now throw more specific errors with more information. +## 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. -### Specific API Changes +### Voice API + +Methods have been added to help you moderate a voice call: + +- `voice.hangup` +- `voice.mute` +- `voice.unmute` +- `voice.earmuff` +- `voice.unearmuff` + +See the [Voice API samples](voice/README.md) for more information. + +### Network Number Verification API The process for verifying a number using the Network Number Verification API has been simplified. In v3 it was required to exchange a code for a token then use this token in the verify request. In v4, these steps are combined so both functions are taken care of in the `NetworkNumberVerification.verify` method. -Verify v2 functionality is accessed from `verify2` in v3 and `verify_v2` in v4. +### Verify V2 API + +Verify v2 functionality is accessed from `vonage_client.verify2` in v3 and `vonage_client.verify_v2` in v4. + +### SMS API -SMS message signing/verifying signatures code in `vonage.Client` 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. +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 +## 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: @@ -195,6 +228,19 @@ Some methods from v3 have had their names changed in v4. Assuming you access all | `verify.check` | `verify.check_code` | | `verify2.new_request` | `verify_v2.start_verification` | | `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 +- [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) \ No newline at end of file diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index d4f7ab6a..f951e4d8 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,4 +1,4 @@ -# 4.0.0b0 +# 4.0.0b1 A complete, ground-up rewrite of the SDK. Key changes: - Monorepo structure, with each API under separate packages diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 1e4bf4fe..42ac78b7 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.0.0b0' +__version__ = '4.0.0b1' From 62ef878d2923e256969f20a983ebf738df3f29d7 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Mon, 21 Oct 2024 18:37:53 +0100 Subject: [PATCH 88/98] rename number insight methods --- README.md | 10 ++++++++++ number_insight/CHANGES.md | 3 +++ number_insight/src/vonage_number_insight/_version.py | 2 +- .../src/vonage_number_insight/number_insight.py | 6 +++--- number_insight/tests/test_number_insight.py | 12 ++++++------ vonage/pyproject.toml | 2 +- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9219803e..0ac0cf37 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ This is the Python server SDK to help you use Vonage APIs in your Python applica ## Installation +It's recommended to create a new virtual environment to install the SDK. You can do this with + +```bash +# Create the virtual environment +python3 -m venv venv + +# Activate the virtual environment +. ./venv/bin/activate +``` + To install the Python SDK package using pip: ```bash diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index 1164e2aa..3fe586ce 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,3 +1,6 @@ +# 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 diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py index a6221b3d..3f6fab60 100644 --- a/number_insight/src/vonage_number_insight/_version.py +++ b/number_insight/src/vonage_number_insight/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py index fef34c47..da24e0dc 100644 --- a/number_insight/src/vonage_number_insight/number_insight.py +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -37,7 +37,7 @@ def http_client(self) -> HttpClient: return self._http_client @validate_call - def basic_number_insight(self, options: BasicInsightRequest) -> BasicInsightResponse: + def get_basic_info(self, options: BasicInsightRequest) -> BasicInsightResponse: """Get basic number insight information about a phone number. Args: @@ -83,7 +83,7 @@ def standard_number_insight( return StandardInsightResponse(**response) @validate_call - def advanced_async_number_insight( + def get_advanced_info_async( self, options: AdvancedAsyncInsightRequest ) -> AdvancedAsyncInsightResponse: """Get advanced number insight information about a phone number asynchronously. @@ -108,7 +108,7 @@ def advanced_async_number_insight( return AdvancedAsyncInsightResponse(**response) @validate_call - def advanced_sync_number_insight( + def get_advanced_info_sync( self, options: AdvancedSyncInsightRequest ) -> AdvancedSyncInsightResponse: """Get advanced number insight information about a phone number synchronously. diff --git a/number_insight/tests/test_number_insight.py b/number_insight/tests/test_number_insight.py index a9494954..02b7ca63 100644 --- a/number_insight/tests/test_number_insight.py +++ b/number_insight/tests/test_number_insight.py @@ -34,7 +34,7 @@ def test_basic_insight(): 'basic_insight.json', ) options = BasicInsightRequest(number='12345678900', country_code='US') - response = number_insight.basic_number_insight(options) + response = number_insight.get_basic_info(options) assert response.status == 0 assert response.status_message == 'Success' @@ -50,7 +50,7 @@ def test_basic_insight_error(): with raises(NumberInsightError) as e: options = BasicInsightRequest(number='1234567890', country_code='US') - number_insight.basic_number_insight(options) + number_insight.get_basic_info(options) assert e.match('Invalid request :: Not valid number format detected') @@ -85,7 +85,7 @@ def test_advanced_async_insight(): country_code='GB', cnam=True, ) - response = number_insight.advanced_async_number_insight(options) + 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' @@ -108,7 +108,7 @@ def test_advanced_async_insight_error(): cnam=True, ) with raises(NumberInsightError) as e: - number_insight.advanced_async_number_insight(options) + number_insight.get_advanced_info_async(options) assert e.match('Invalid credentials') @@ -127,7 +127,7 @@ def test_advanced_async_insight_partial_error(caplog): country_code='GB', cnam=True, ) - response = number_insight.advanced_async_number_insight(options) + response = number_insight.get_advanced_info_async(options) assert 'Not all parameters are available' in caplog.text assert response.status == 43 @@ -143,7 +143,7 @@ def test_advanced_sync_insight(caplog): options = AdvancedSyncInsightRequest( number='12345678900', country_code='US', cnam=True ) - response = number_insight.advanced_sync_number_insight(options) + response = number_insight.get_advanced_info_sync(options) assert 'Not all parameters are available' in caplog.text assert response.status == 44 diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 7b8384f7..56def366 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "vonage-network-auth>=1.0.0", "vonage-network-sim-swap>=1.0.0", "vonage-network-number-verification>=1.0.0", - "vonage-number-insight>=1.0.2", + "vonage-number-insight>=1.0.3", "vonage-numbers>=1.0.2", "vonage-sms>=1.1.3", "vonage-subaccounts>=1.0.3", From 67d53daea290ecd50c25bbd7f0ecad21753363d2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 22 Oct 2024 14:30:49 +0100 Subject: [PATCH 89/98] rename verify -> verify_legacy and verify_v2 -> verify --- README.md | 44 +-- V3_TO_V4_SDK_MIGRATION_GUIDE.md | 25 +- .../vonage_number_insight/number_insight.py | 4 +- pants.toml | 2 +- requirements.txt | 2 +- verify/CHANGES.md | 13 +- verify/README.md | 57 ++- verify/src/vonage_verify/__init__.py | 32 +- verify/src/vonage_verify/_version.py | 2 +- .../src/vonage_verify}/enums.py | 0 verify/src/vonage_verify/requests.py | 250 ++++++++----- verify/src/vonage_verify/responses.py | 125 +------ verify/src/vonage_verify/verify.py | 198 ++--------- verify/tests/data/check_code.json | 8 +- .../tests/data/check_code_400.json | 0 .../tests/data/check_code_410.json | 0 .../data/trigger_next_workflow_error.json | 0 verify/tests/data/verify_request.json | 4 +- verify/tests/data/verify_request_error.json | 7 +- {verify_v2 => verify}/tests/test_models.py | 6 +- verify/tests/test_verify.py | 327 ++++++------------ {verify_v2 => verify_legacy}/BUILD | 4 +- verify_legacy/CHANGES.md | 20 ++ verify_legacy/README.md | 63 ++++ {verify_v2 => verify_legacy}/pyproject.toml | 6 +- .../src/vonage_verify_legacy}/BUILD | 0 .../src/vonage_verify_legacy/__init__.py | 25 ++ .../src/vonage_verify_legacy/_version.py | 1 + .../src/vonage_verify_legacy}/errors.py | 2 +- .../vonage_verify_legacy}/language_codes.py | 0 .../src/vonage_verify_legacy/requests.py | 120 +++++++ .../src/vonage_verify_legacy/responses.py | 137 ++++++++ .../src/vonage_verify_legacy/verify_legacy.py | 239 +++++++++++++ verify_legacy/tests/BUILD | 1 + .../tests/data/cancel_verification.json | 0 .../tests/data/cancel_verification_error.json | 0 verify_legacy/tests/data/check_code.json | 8 + .../tests/data/check_code_error.json | 0 .../tests/data/network_unblock.json | 0 .../tests/data/network_unblock_error.json | 0 .../tests/data/search_request.json | 0 .../tests/data/search_request_error.json | 0 .../tests/data/search_request_list.json | 0 .../tests/data/trigger_next_event.json | 0 .../tests/data/trigger_next_event_error.json | 0 verify_legacy/tests/data/verify_request.json | 4 + .../tests/data/verify_request_error.json | 5 + .../verify_request_error_with_network.json | 0 verify_legacy/tests/test_verify_legacy.py | 304 ++++++++++++++++ verify_v2/CHANGES.md | 17 - verify_v2/README.md | 54 --- verify_v2/src/vonage_verify_v2/__init__.py | 27 -- verify_v2/src/vonage_verify_v2/_version.py | 1 - verify_v2/src/vonage_verify_v2/requests.py | 184 ---------- verify_v2/src/vonage_verify_v2/responses.py | 28 -- verify_v2/src/vonage_verify_v2/verify_v2.py | 80 ----- verify_v2/tests/BUILD | 1 - verify_v2/tests/data/check_code.json | 4 - verify_v2/tests/data/verify_request.json | 4 - .../tests/data/verify_request_error.json | 6 - verify_v2/tests/test_verify_v2.py | 189 ---------- vonage/CHANGES.md | 3 +- vonage/pyproject.toml | 4 +- vonage/src/vonage/__init__.py | 4 +- vonage/src/vonage/vonage.py | 4 +- 65 files changed, 1336 insertions(+), 1319 deletions(-) rename {verify_v2/src/vonage_verify_v2 => verify/src/vonage_verify}/enums.py (100%) rename {verify_v2 => verify}/tests/data/check_code_400.json (100%) rename {verify_v2 => verify}/tests/data/check_code_410.json (100%) rename {verify_v2 => verify}/tests/data/trigger_next_workflow_error.json (100%) rename {verify_v2 => verify}/tests/test_models.py (96%) rename {verify_v2 => verify_legacy}/BUILD (79%) create mode 100644 verify_legacy/CHANGES.md create mode 100644 verify_legacy/README.md rename {verify_v2 => verify_legacy}/pyproject.toml (85%) rename {verify_v2/src/vonage_verify_v2 => verify_legacy/src/vonage_verify_legacy}/BUILD (100%) create mode 100644 verify_legacy/src/vonage_verify_legacy/__init__.py create mode 100644 verify_legacy/src/vonage_verify_legacy/_version.py rename {verify_v2/src/vonage_verify_v2 => verify_legacy/src/vonage_verify_legacy}/errors.py (52%) rename {verify/src/vonage_verify => verify_legacy/src/vonage_verify_legacy}/language_codes.py (100%) create mode 100644 verify_legacy/src/vonage_verify_legacy/requests.py create mode 100644 verify_legacy/src/vonage_verify_legacy/responses.py create mode 100644 verify_legacy/src/vonage_verify_legacy/verify_legacy.py create mode 100644 verify_legacy/tests/BUILD rename {verify => verify_legacy}/tests/data/cancel_verification.json (100%) rename {verify => verify_legacy}/tests/data/cancel_verification_error.json (100%) create mode 100644 verify_legacy/tests/data/check_code.json rename {verify => verify_legacy}/tests/data/check_code_error.json (100%) rename {verify => verify_legacy}/tests/data/network_unblock.json (100%) rename {verify => verify_legacy}/tests/data/network_unblock_error.json (100%) rename {verify => verify_legacy}/tests/data/search_request.json (100%) rename {verify => verify_legacy}/tests/data/search_request_error.json (100%) rename {verify => verify_legacy}/tests/data/search_request_list.json (100%) rename {verify => verify_legacy}/tests/data/trigger_next_event.json (100%) rename {verify => verify_legacy}/tests/data/trigger_next_event_error.json (100%) create mode 100644 verify_legacy/tests/data/verify_request.json create mode 100644 verify_legacy/tests/data/verify_request_error.json rename {verify => verify_legacy}/tests/data/verify_request_error_with_network.json (100%) create mode 100644 verify_legacy/tests/test_verify_legacy.py delete mode 100644 verify_v2/CHANGES.md delete mode 100644 verify_v2/README.md delete mode 100644 verify_v2/src/vonage_verify_v2/__init__.py delete mode 100644 verify_v2/src/vonage_verify_v2/_version.py delete mode 100644 verify_v2/src/vonage_verify_v2/requests.py delete mode 100644 verify_v2/src/vonage_verify_v2/responses.py delete mode 100644 verify_v2/src/vonage_verify_v2/verify_v2.py delete mode 100644 verify_v2/tests/BUILD delete mode 100644 verify_v2/tests/data/check_code.json delete mode 100644 verify_v2/tests/data/verify_request.json delete mode 100644 verify_v2/tests/data/verify_request_error.json delete mode 100644 verify_v2/tests/test_verify_v2.py diff --git a/README.md b/README.md index 0ac0cf37..562b1a25 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ This is the Python server SDK to help you use Vonage APIs in your Python applica - [SMS API](#sms-api) - [Subaccounts API](#subaccounts-api) - [Users API](#users-api) -- [Verify v2 API](#verify-v2-api) -- [Verify v1 API (Legacy)](#verify-v1-api-legacy) +- [Verify API](#verify-api) +- [Verify API (Legacy)](#verify-api-legacy) - [Video API](#video-api) - [Voice API](#voice-api) - [Vonage Utils Package](#vonage-utils-package) @@ -824,12 +824,12 @@ user = vonage_client.users.update_user(id, user_options) vonage_client.users.delete_user(id) ``` -## Verify v2 API +## Verify API ### Make a Verify Request ```python -from vonage_verify_v2 import VerifyRequest, SmsChannel +from vonage_verify import VerifyRequest, SmsChannel # All channels have associated models sms_channel = SmsChannel(to='1234567890') params = { @@ -838,7 +838,7 @@ params = { } verify_request = VerifyRequest(**params) -response = vonage_client.verify_v2.start_verification(verify_request) +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. @@ -852,73 +852,73 @@ params = { } verify_request = VerifyRequest(**params) -response = vonage_client.verify_v2.start_verification(verify_request) +response = vonage_client.verify.start_verification(verify_request) ``` ### Check a Verification Code ```python -vonage_client.verify_v2.check_code(request_id='my_request_id', code='1234') +vonage_client.verify.check_code(request_id='my_request_id', code='1234') ``` ### Cancel a Verification ```python -vonage_client.verify_v2.cancel_verification('my_request_id') +vonage_client.verify.cancel_verification('my_request_id') ``` ### Trigger the Next Workflow Event ```python -vonage_client.verify_v2.trigger_next_workflow('my_request_id') +vonage_client.verify.trigger_next_workflow('my_request_id') ``` -## Verify v1 API (Legacy) +## Verify API (Legacy) ### Make a Verify Request ```python -from vonage_verify import VerifyRequest +from vonage_verify_legacy import VerifyRequest params = {'number': '1234567890', 'brand': 'Acme Inc.'} request = VerifyRequest(**params) -response = vonage_client.verify.start_verification(request) +response = vonage_client.verify_legacy.start_verification(request) ``` ### Make a PSD2 (Payment Services Directive v2) Request ```python -from vonage_verify import Psd2Request +from vonage_verify_legacy import Psd2Request params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} request = VerifyRequest(**params) -response = vonage_client.verify.start_verification(request) +response = vonage_client.verify_legacy.start_verification(request) ``` ### Check a Verification Code ```python -vonage_client.verify.check_code(request_id='my_request_id', code='1234') +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.search('my_request_id') +response = vonage_client.verify_legacy.search('my_request_id') # Search for multiple requests -response = vonage_client.verify.search(['my_request_id_1', 'my_request_id_2']) +response = vonage_client.verify_legacy.search(['my_request_id_1', 'my_request_id_2']) ``` ### Cancel a Verification ```python -response = vonage_client.verify.cancel_verification('my_request_id') +response = vonage_client.verify_legacy.cancel_verification('my_request_id') ``` ### Trigger the Next Workflow Event ```python -response = vonage_client.verify.trigger_next_event('my_request_id') +response = vonage_client.verify_legacy.trigger_next_event('my_request_id') ``` ### Request a Network Unblock @@ -926,7 +926,7 @@ response = vonage_client.verify.trigger_next_event('my_request_id') Note: Network Unblock is switched off by default. Contact Sales to enable the Network Unblock API for your account. ```python -response = vonage_client.verify.request_network_unblock('23410') +response = vonage_client.verify_legacy.request_network_unblock('23410') ``` ## Video API @@ -1407,8 +1407,8 @@ The following is a list of Vonage APIs and whether the Python SDK provides suppo | Reports API | Beta | ❌ | | SMS API | General Availability | ✅ | | Subaccounts API | General Availability | ✅ | -| Verify API v2 | General Availability | ✅ | -| Verify API v1 (Legacy)| General Availability | ✅ | +| Verify API | General Availability | ✅ | +| Verify API (Legacy) | General Availability | ✅ | | Video API | General Availability | ✅ | | Voice API | General Availability | ✅ | diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md index 55c15f61..48684e7f 100644 --- a/V3_TO_V4_SDK_MIGRATION_GUIDE.md +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -114,12 +114,12 @@ Unlike the methods to call each Vonage API, the data models and errors specific 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_v2 import VerifyRequest, SmsChannel +from vonage_verify import VerifyRequest, SmsChannel sms_channel = SmsChannel(to='1234567890') verify_request = VerifyRequest(brand='Vonage', workflow=[sms_channel]) -response = vonage_client.verify_v2.start_verification(verify_request) +response = vonage_client.verify.start_verification(verify_request) print(response) ``` @@ -195,9 +195,11 @@ See the [Voice API samples](voice/README.md) for more information. 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 V2 API +### Verify API Name Changes -Verify v2 functionality is accessed from `vonage_client.verify2` in v3 and `vonage_client.verify_v2` in v4. +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 @@ -223,10 +225,17 @@ Some methods from v3 have had their names changed in v4. Assuming you access all | `numbers.get_account_numbers` | `numbers.list_owned_numbers` | | `numbers.get_available_numbers` | `numbers.search_available_numbers` | | `sms.send_message` | `sms.send` | -| `verify.psd2` | `verify.start_psd2_verification` | -| `verify.check` | `verify.check_code` | -| `verify.check` | `verify.check_code` | -| `verify2.new_request` | `verify_v2.start_verification` | +| `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` | diff --git a/number_insight/src/vonage_number_insight/number_insight.py b/number_insight/src/vonage_number_insight/number_insight.py index da24e0dc..d71790b1 100644 --- a/number_insight/src/vonage_number_insight/number_insight.py +++ b/number_insight/src/vonage_number_insight/number_insight.py @@ -29,10 +29,10 @@ def __init__(self, http_client: HttpClient) -> None: @property def http_client(self) -> HttpClient: - """The HTTP client used to make requests to the Verify V2 API. + """The HTTP client used to make requests to the Vonage Number Insight API. Returns: - HttpClient: The HTTP client used to make requests to the Verify V2 API. + HttpClient: The HTTP client used to make requests to the Number Insight API. """ return self._http_client diff --git a/pants.toml b/pants.toml index 3bd4419e..10fcc4da 100644 --- a/pants.toml +++ b/pants.toml @@ -48,7 +48,7 @@ filter = [ 'utils/src', 'testutils', 'verify/src', - 'verify_v2/src', + 'verify_legacy/src', 'video/src', 'voice/src', 'vonage_utils/src', diff --git a/requirements.txt b/requirements.txt index faac8fb7..06803d26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ toml>=0.10.2 -e subaccounts -e users -e verify --e verify_v2 +-e verify_legacy -e video -e voice -e vonage_utils diff --git a/verify/CHANGES.md b/verify/CHANGES.md index d9f52f50..d31f8c51 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,8 +1,14 @@ -# 1.1.3 +# 2.0.0 +- Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. + +# 1.1.4 - Support for Python 3.13, drop support for 3.8 +# 1.1.3 +- Add docstrings for data models + # 1.1.2 -- Add docstrings to data models +- Allow minimum `channel_timeout` value to be 15 seconds # 1.1.1 - Update minimum dependency version @@ -10,8 +16,5 @@ # 1.1.0 - Add `http_client` property -# 1.0.1 -- Internal refactoring - # 1.0.0 - Initial upload diff --git a/verify/README.md b/verify/README.md index bd033887..24d8097f 100644 --- a/verify/README.md +++ b/verify/README.md @@ -1,8 +1,6 @@ # Vonage Verify Package -This package contains the code to use Vonage's 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 v2 API](https://developer.vonage.com/en/verify/overview) which is recommended for most use cases. The v2 API lets you send messages via multiple channels, including Email, SMS, MMS, WhatsApp, Messenger and others. You can also make Silent Authentication requests with Verify v2 to give an end user a more seamless experience. +This package 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 @@ -11,19 +9,30 @@ It is recommended to use this as part of the main `vonage` package. The examples ### Make a Verify Request ```python -from vonage_verify import VerifyRequest -params = {'number': '1234567890', 'brand': 'Acme Inc.'} -request = VerifyRequest(**params) -response = vonage_client.verify.start_verification(request) +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) ``` -### Make a PSD2 (Payment Services Directive v2) 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 -from vonage_verify import Psd2Request -params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} -request = VerifyRequest(**params) -response = vonage_client.verify.start_verification(request) +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 @@ -32,32 +41,14 @@ response = vonage_client.verify.start_verification(request) vonage_client.verify.check_code(request_id='my_request_id', code='1234') ``` -### Search Verification Requests - -```python -# Search for single request -response = vonage_client.verify.search('my_request_id') - -# Search for multiple requests -response = vonage_client.verify.search(['my_request_id_1', 'my_request_id_2']) -``` - ### Cancel a Verification ```python -response = vonage_client.verify.cancel_verification('my_request_id') +vonage_client.verify.cancel_verification('my_request_id') ``` ### Trigger the Next Workflow Event ```python -response = vonage_client.verify.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.request_network_unblock('23410') -``` +vonage_client.verify.trigger_next_workflow('my_request_id') +``` \ No newline at end of file diff --git a/verify/src/vonage_verify/__init__.py b/verify/src/vonage_verify/__init__.py index 65a043bb..90218321 100644 --- a/verify/src/vonage_verify/__init__.py +++ b/verify/src/vonage_verify/__init__.py @@ -1,25 +1,27 @@ +from .enums import ChannelType, Locale from .errors import VerifyError -from .language_codes import LanguageCode, Psd2LanguageCode -from .requests import Psd2Request, VerifyRequest -from .responses import ( - CheckCodeResponse, - NetworkUnblockStatus, - StartVerificationResponse, - VerifyControlStatus, - VerifyStatus, +from .requests import ( + EmailChannel, + SilentAuthChannel, + SmsChannel, + VerifyRequest, + VoiceChannel, + WhatsappChannel, ) +from .responses import CheckCodeResponse, StartVerificationResponse from .verify import Verify __all__ = [ 'Verify', 'VerifyError', - 'LanguageCode', - 'Psd2LanguageCode', - 'Psd2Request', - 'VerifyRequest', + 'ChannelType', 'CheckCodeResponse', - 'NetworkUnblockStatus', + 'Locale', + 'VerifyRequest', + 'SilentAuthChannel', + 'SmsChannel', + 'WhatsappChannel', + 'VoiceChannel', + 'EmailChannel', 'StartVerificationResponse', - 'VerifyControlStatus', - 'VerifyStatus', ] diff --git a/verify/src/vonage_verify/_version.py b/verify/src/vonage_verify/_version.py index 7bb021e2..afced147 100644 --- a/verify/src/vonage_verify/_version.py +++ b/verify/src/vonage_verify/_version.py @@ -1 +1 @@ -__version__ = '1.1.3' +__version__ = '2.0.0' diff --git a/verify_v2/src/vonage_verify_v2/enums.py b/verify/src/vonage_verify/enums.py similarity index 100% rename from verify_v2/src/vonage_verify_v2/enums.py rename to verify/src/vonage_verify/enums.py diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py index bc191d75..43d00c6b 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -1,120 +1,184 @@ -from logging import getLogger -from typing import Literal, Optional +from re import search +from typing import Optional, Union -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from vonage_utils.types import PhoneNumber -from .language_codes import LanguageCode, Psd2LanguageCode +from .enums import ChannelType, Locale +from .errors import VerifyError -logger = getLogger('vonage_verify') +class Channel(BaseModel): + """Base model for a channel to use in a verification request. -class BaseVerifyRequest(BaseModel): - """Base request object containing the data and options for 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: - 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. + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + redirect_url (str, Optional): Optional final redirect added at the end of the + check_url request/response lifecycle. Will contain the `request_id` and + `code` as a url fragment after the URL. + sandbox (bool, Optional): Whether you are using the sandbox to test Silent + Authentication integrations. """ - 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) + redirect_url: Optional[str] = None + sandbox: Optional[bool] = None + channel: ChannelType = ChannelType.SILENT_AUTH - @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.' + +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}".' ) - self.pin_expiry = self.next_event_wait - return self + return v -class VerifyRequest(BaseVerifyRequest): - """Request object for a verification request. +class WhatsappChannel(Channel): + """Model for a WhatsApp channel. + + Args: + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. + from_ (Union[PhoneNumber, str]): A WhatsApp Business Account (WABA)-connected + sender number, in the E.164 format. Don't use a leading + or 00 when entering + a phone number. + + Raises: + VerifyError: If the `from_` field is not a valid phone number or string of 3-11 + alphanumeric characters. + """ + + from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') + channel: ChannelType = ChannelType.WHATSAPP + + @field_validator('from_') + @classmethod + def check_valid_sender(cls, v): + if type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{3,11}$', v): + raise VerifyError( + f'You must specify a valid "from_" value. ' + 'It must be a valid phone number without the leading +, or a string of 3-11 alphanumeric characters. ' + f'You set "from_": "{v}".' + ) + return v - You must set the `number` and `brand` fields. + +class VoiceChannel(Channel): + """Model for a Voice channel. 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. + to (PhoneNumber): The phone number to send the verification code to, in the + E.164 format without a leading `+` or `00`. """ - 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) + channel: ChannelType = ChannelType.VOICE -class Psd2Request(BaseVerifyRequest): - """Request object for a PSD2 verification request. +class EmailChannel(Channel): + """Model for an Email channel. - You must set the `number`, `payee` and `amount` fields. + 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: - 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 + 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. - 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. + 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. """ - payee: str = Field(..., max_length=18) - amount: float - lg: Optional[Psd2LanguageCode] = None + 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 index 84d6f11f..f7acb4e0 100644 --- a/verify/src/vonage_verify/responses.py +++ b/verify/src/vonage_verify/responses.py @@ -4,134 +4,25 @@ class StartVerificationResponse(BaseModel): - """Response object for starting a verification process. + """Model for the response of a start verification request. 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): The request ID. + check_url (str, Optional): URL for Silent Authentication Verify workflow + completion (only shows if using Silent Auth). """ request_id: str - status: str + check_url: Optional[str] = None class CheckCodeResponse(BaseModel): - """Response object for checking a verification code. + """Model for the response of a check code request. 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): The request ID. + status (str): The status of the verification request. """ 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/src/vonage_verify/verify.py b/verify/src/vonage_verify/verify.py index bc623a91..20eabc22 100644 --- a/verify/src/vonage_verify/verify.py +++ b/verify/src/vonage_verify/verify.py @@ -1,36 +1,15 @@ -from typing import Optional, Union - -from pydantic import Field, validate_call +from pydantic import 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, -) +from .requests import VerifyRequest +from .responses import CheckCodeResponse, StartVerificationResponse class Verify: - """Calls Vonage's Verify API. - - This class provides methods to interact with Vonage's Verify API for starting - verification processes. - - 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. - """ + """Calls Vonage's Verify API.""" def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client - self._sent_data_type = 'form' - self._auth_type = 'body' @property def http_client(self) -> HttpClient: @@ -51,23 +30,16 @@ def start_verification( verify_request (VerifyRequest): The verification request object. Returns: - StartVerificationResponse: The response object containing the verification result. + StartVerificationResponse: The response object containing the `request_id`. + If requesting Silent Authentication, it will also contain a `check_url` field. """ - 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. + response = self._http_client.post( + self._http_client.api_host, + '/v2/verify', + verify_request.model_dump(by_alias=True, exclude_none=True), + ) - Returns: - StartVerificationResponse: The response object containing the verification result. - """ - return self._make_verify_request(verify_request) + return StartVerificationResponse(**response) @validate_call def check_code(self, request_id: str, code: str) -> CheckCodeResponse: @@ -81,158 +53,28 @@ def check_code(self, request_id: str, code: str) -> CheckCodeResponse: CheckCodeResponse: The response object containing the verification result. """ response = self._http_client.post( - self._http_client.api_host, - '/verify/check/json', - {'request_id': request_id, 'code': code}, - self._auth_type, - self._sent_data_type, + self._http_client.api_host, f'/v2/verify/{request_id}', {'code': code} ) - 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: + def cancel_verification(self, request_id: str) -> None: """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) + self._http_client.delete(self._http_client.api_host, f'/v2/verify/{request_id}') @validate_call - def trigger_next_event(self, request_id: str) -> VerifyControlStatus: - """Trigger the next event in the verification process. + 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. - - Returns: - VerifyControlStatus: The response object containing details of the submitted - verification control. """ - response = self._http_client.post( + 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, + f'/v2/verify/{request_id}/next_workflow', ) - 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/tests/data/check_code.json b/verify/tests/data/check_code.json index 37267b48..2fbe4b8e 100644 --- a/verify/tests/data/check_code.json +++ b/verify/tests/data/check_code.json @@ -1,8 +1,4 @@ { - "request_id": "c5037cb8b47449158ed6611afde58990", - "status": "0", - "event_id": "390f7296-aeff-45ba-8931-84a13f3f76d7", - "price": "0.05000000", - "currency": "EUR", - "estimated_price_messages_sent": "0.04675" + "request_id": "36e7060d-2b23-4257-bad0-773ab47f85ef", + "status": "completed" } \ No newline at end of file diff --git a/verify_v2/tests/data/check_code_400.json b/verify/tests/data/check_code_400.json similarity index 100% rename from verify_v2/tests/data/check_code_400.json rename to verify/tests/data/check_code_400.json diff --git a/verify_v2/tests/data/check_code_410.json b/verify/tests/data/check_code_410.json similarity index 100% rename from verify_v2/tests/data/check_code_410.json rename to verify/tests/data/check_code_410.json diff --git a/verify_v2/tests/data/trigger_next_workflow_error.json b/verify/tests/data/trigger_next_workflow_error.json similarity index 100% rename from verify_v2/tests/data/trigger_next_workflow_error.json rename to verify/tests/data/trigger_next_workflow_error.json diff --git a/verify/tests/data/verify_request.json b/verify/tests/data/verify_request.json index 74136a5b..719396cc 100644 --- a/verify/tests/data/verify_request.json +++ b/verify/tests/data/verify_request.json @@ -1,4 +1,4 @@ { - "request_id": "abcdef0123456789abcdef0123456789", - "status": "0" + "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 index 934d0fad..f40b1b12 100644 --- a/verify/tests/data/verify_request_error.json +++ b/verify/tests/data/verify_request_error.json @@ -1,5 +1,6 @@ { - "request_id": "b6fc2b91d23c43f9b8ea05f9be64415c", - "status": "10", - "error_text": "Concurrent verifications to the same number are not allowed" + "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_v2/tests/test_models.py b/verify/tests/test_models.py similarity index 96% rename from verify_v2/tests/test_models.py rename to verify/tests/test_models.py index b1a3eb81..88c075a5 100644 --- a/verify_v2/tests/test_models.py +++ b/verify/tests/test_models.py @@ -1,7 +1,7 @@ from pytest import raises -from vonage_verify_v2.enums import ChannelType, Locale -from vonage_verify_v2.errors import VerifyError -from vonage_verify_v2.requests import * +from vonage_verify.enums import ChannelType, Locale +from vonage_verify.errors import VerifyError +from vonage_verify.requests import * def test_create_silent_auth_channel(): diff --git a/verify/tests/test_verify.py b/verify/tests/test_verify.py index 80a58ace..40251c6b 100644 --- a/verify/tests/test_verify.py +++ b/verify/tests/test_verify.py @@ -2,303 +2,188 @@ import responses from pytest import raises -from vonage_http_client.errors import NotFoundError +from vonage_http_client.errors import HttpRequestError from vonage_http_client.http_client import HttpClient -from vonage_verify.errors import VerifyError -from vonage_verify.language_codes import LanguageCode, Psd2LanguageCode -from vonage_verify.requests import Psd2Request, VerifyRequest -from vonage_verify.responses import NetworkUnblockStatus, VerifyControlStatus +from vonage_verify.requests import * from vonage_verify.verify import Verify -from testutils import build_response, get_mock_api_key_auth +from testutils import build_response, get_mock_jwt_auth path = abspath(__file__) -verify = Verify(HttpClient(get_mock_api_key_auth())) - -data = { - 'number': '1234567890', - 'country': 'US', - 'code_length': 6, - 'pin_expiry': 600, - 'next_event_wait': 150, - 'workflow_id': 2, -} - - -def test_create_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 +verify = Verify(HttpClient(get_mock_jwt_auth())) @responses.activate def test_make_verify_request(): build_response( - path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request.json' + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' ) - params = {'number': '1234567890', 'brand': 'Acme Inc.'} + 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 == 'abcdef0123456789abcdef0123456789' - assert response.status == '0' - - -@responses.activate -def test_make_psd2_request(): - build_response( - path, 'POST', 'https://api.nexmo.com/verify/psd2/json', 'verify_request.json' + 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' ) - 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' + assert verify._http_client.last_response.status_code == 202 @responses.activate -def test_verify_request_error(): +def test_make_verify_request_full(): build_response( - path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request_error.json' - ) - params = {'number': '1234567890', 'brand': 'Acme Inc.'} + 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) - with raises(VerifyError) as e: - verify.start_verification(request) - - assert e.match( - "'error_text': 'Concurrent verifications to the same number are not allowed'" - ) + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' @responses.activate -def test_verify_request_error_with_network(): +def test_verify_request_concurrent_verifications_error(): build_response( path, 'POST', - 'https://api.nexmo.com/verify/json', - 'verify_request_error_with_network.json', - ) - params = {'number': '1234567890', 'brand': 'Acme Inc.'} + '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(VerifyError) as e: + with raises(HttpRequestError) as e: verify.start_verification(request) - assert e.match("'network': '244523'") - - -@responses.activate -def test_check_code(): - build_response( - path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code.json' - ) - response = verify.check_code( - request_id='c5037cb8b47449158ed6611afde58990', code='1234' - ) - assert response.request_id == 'c5037cb8b47449158ed6611afde58990' - assert response.status == '0' - assert response.event_id == '390f7296-aeff-45ba-8931-84a13f3f76d7' - assert response.price == '0.05000000' - assert response.currency == 'EUR' - assert response.estimated_price_messages_sent == '0.04675' - - -@responses.activate -def test_check_code_error(): - build_response( - path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code_error.json' - ) - - with raises(VerifyError) as e: - verify.check_code(request_id='c5037cb8b47449158ed6611afde58990', code='1234') - - assert e.match( - "'status': '16', 'error_text': 'The code provided does not match the expected value'" + 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_search(): - build_response( - path, 'GET', 'https://api.nexmo.com/verify/search/json', 'search_request.json' - ) - response = verify.search('c5037cb8b47449158ed6611afde58990') - - assert response.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' - assert response.account_id == 'abcdef01' - assert response.status == 'EXPIRED' - assert response.number == '1234567890' - assert response.price == '0' - assert response.currency == 'EUR' - assert response.sender_id == 'Acme Inc.' - assert response.date_submitted == '2024-04-03 02:22:37' - assert response.date_finalized == '2024-04-03 02:27:38' - assert response.first_event_date == '2024-04-03 02:22:37' - assert response.last_event_date == '2024-04-03 02:24:38' - assert response.estimated_price_messages_sent == '0.09350' - assert response.checks[0].date_received == '2024-04-03 02:23:04' - assert response.checks[0].code == '1234' - assert response.checks[0].status == 'INVALID' - assert response.checks[0].ip_address == '' - assert response.events[0].type == 'sms' - assert response.events[0].id == '23f3a13d-6d03-4262-8f4d-67f12a56e1c8' - - -@responses.activate -def test_search_list_of_ids(): +def test_check_code(): build_response( path, - 'GET', - 'https://api.nexmo.com/verify/search/json', - 'search_request_list.json', - ) - response0, response1 = verify.search( - ['cc121958d8fb4368aa3bb762bb9a0f75', 'c5037cb8b47449158ed6611afde58990'] + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', ) - assert response0.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' - assert response1.request_id == 'c5037cb8b47449158ed6611afde58990' - assert response1.status == 'SUCCESS' - assert response1.checks[0].status == 'VALID' - - -@responses.activate -def test_search_error(): - build_response( - path, - 'GET', - 'https://api.nexmo.com/verify/search/json', - 'search_request_error.json', + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' ) - - with raises(VerifyError) as e: - verify.search('c5037cb8b47449158ed6611afde58990') - - assert e.match("{'status': '101', 'error_text': 'No response found'}") + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' @responses.activate -def test_cancel_verification(): +def test_check_code_invalid_code_error(): build_response( path, 'POST', - 'https://api.nexmo.com/verify/control/json', - 'cancel_verification.json', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_400.json', + 400, ) - response = verify.cancel_verification('c5037cb8b47449158ed6611afde58990') - assert type(response) == VerifyControlStatus - assert response.status == '0' - assert response.command == 'cancel' + 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_cancel_verification_error(): +def test_check_code_too_many_attempts(): build_response( path, 'POST', - 'https://api.nexmo.com/verify/control/json', - 'cancel_verification_error.json', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_410.json', + 410, ) - with raises(VerifyError) as e: - verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') - assert e.match( - "The requestId 'cc121958d8fb4368aa3bb762bb9a0f75' does not exist or its no longer active." - ) + assert e.value.response.status_code == 410 + assert e.value.response.json()['title'] == 'Invalid Code' @responses.activate -def test_trigger_next_event(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/verify/control/json', - 'trigger_next_event.json', +def test_cancel_verification(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, ) - response = verify.trigger_next_event('c5037cb8b47449158ed6611afde58990') - - assert type(response) == VerifyControlStatus - assert response.status == '0' - assert response.command == 'trigger_next_event' + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 @responses.activate -def test_trigger_next_event_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/verify/control/json', - 'trigger_next_event_error.json', +def test_trigger_next_workflow(): + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, ) - - 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') + 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_request_network_unblock(): +def test_trigger_next_event_error(): build_response( path, 'POST', - 'https://api.nexmo.com/verify/network-unblock', - 'network_unblock.json', - 202, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + 'trigger_next_workflow_error.json', + status_code=409, ) - 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' + with raises(HttpRequestError) as e: + verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') - -@responses.activate -def test_request_network_unblock_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/verify/network-unblock', - 'network_unblock_error.json', - 404, + 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.' ) - 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' - def test_http_client_property(): - verify = Verify(HttpClient(get_mock_api_key_auth())) - assert isinstance(verify.http_client, HttpClient) + http_client = verify.http_client + assert isinstance(http_client, HttpClient) diff --git a/verify_v2/BUILD b/verify_legacy/BUILD similarity index 79% rename from verify_v2/BUILD rename to verify_legacy/BUILD index a4665a48..5459756b 100644 --- a/verify_v2/BUILD +++ b/verify_legacy/BUILD @@ -4,11 +4,11 @@ file(name='readme', source='README.md') files(sources=['tests/data/*']) python_distribution( - name='vonage-verify-v2', + name='vonage-verify', dependencies=[ ':pyproject', ':readme', - 'verify_v2/src/vonage_verify_v2', + 'verify_legacy/src/vonage_verify_legacy', ], provides=python_artifact(), generate_setup=False, diff --git a/verify_legacy/CHANGES.md b/verify_legacy/CHANGES.md new file mode 100644 index 00000000..89a19a39 --- /dev/null +++ b/verify_legacy/CHANGES.md @@ -0,0 +1,20 @@ +# 2.0.0 +- Rename to "Verify Legacy", rename `vonage-verify` -> `vonage-verify-legacy`, `Verify` -> `VerifyLegacy`, etc. + +# 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.1 +- Internal refactoring + +# 1.0.0 +- Initial upload 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_v2/pyproject.toml b/verify_legacy/pyproject.toml similarity index 85% rename from verify_v2/pyproject.toml rename to verify_legacy/pyproject.toml index 85d78ac5..cf201385 100644 --- a/verify_v2/pyproject.toml +++ b/verify_legacy/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = 'vonage-verify-v2' +name = 'vonage-verify-legacy' dynamic = ["version"] -description = 'Vonage verify v2 package' +description = 'Vonage legacy verify package' readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" @@ -25,7 +25,7 @@ classifiers = [ homepage = "https://github.com/Vonage/vonage-python-sdk" [tool.setuptools.dynamic] -version = { attr = "vonage_verify_v2._version.__version__" } +version = { attr = "vonage_verify_legacy._version.__version__" } [build-system] requires = ["setuptools>=61.0", "wheel"] diff --git a/verify_v2/src/vonage_verify_v2/BUILD b/verify_legacy/src/vonage_verify_legacy/BUILD similarity index 100% rename from verify_v2/src/vonage_verify_v2/BUILD rename to verify_legacy/src/vonage_verify_legacy/BUILD 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..afced147 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/_version.py @@ -0,0 +1 @@ +__version__ = '2.0.0' diff --git a/verify_v2/src/vonage_verify_v2/errors.py b/verify_legacy/src/vonage_verify_legacy/errors.py similarity index 52% rename from verify_v2/src/vonage_verify_v2/errors.py rename to verify_legacy/src/vonage_verify_legacy/errors.py index b2832d2f..6d2358a8 100644 --- a/verify_v2/src/vonage_verify_v2/errors.py +++ b/verify_legacy/src/vonage_verify_legacy/errors.py @@ -2,4 +2,4 @@ class VerifyError(VonageError): - """Indicates an error when using the Vonage Verify API.""" + """Indicates an error when using the legacy Vonage Verify API.""" diff --git a/verify/src/vonage_verify/language_codes.py b/verify_legacy/src/vonage_verify_legacy/language_codes.py similarity index 100% rename from verify/src/vonage_verify/language_codes.py rename to verify_legacy/src/vonage_verify_legacy/language_codes.py diff --git a/verify_legacy/src/vonage_verify_legacy/requests.py b/verify_legacy/src/vonage_verify_legacy/requests.py new file mode 100644 index 00000000..bc191d75 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/requests.py @@ -0,0 +1,120 @@ +from logging import getLogger +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator +from vonage_utils.types import PhoneNumber + +from .language_codes import LanguageCode, Psd2LanguageCode + +logger = getLogger('vonage_verify') + + +class BaseVerifyRequest(BaseModel): + """Base request object containing the data and options for a verification request. + + Args: + number (PhoneNumber): The phone number to verify. Unless you are setting country + explicitly, this number must be in E.164 format. + country (str, Optional): If you do not provide `number` in international format + or you are not sure if `number` is correctly formatted, specify the + two-character country code in country. Verify will then format the number for + you. + code_length (int, Optional): The length of the verification code to generate. + pin_expiry (int, Optional): How long the generated verification code is valid + for, in seconds. When you specify both `pin_expiry` and `next_event_wait` + then `pin_expiry` must be an integer multiple of `next_event_wait` otherwise + `pin_expiry` is defaulted to equal `next_event_wait`. + next_event_wait (int, Optional): The wait time in seconds between attempts to + deliver the verification code. + workflow_id (int, Optional): Selects the predefined sequence of SMS and TTS (Text + To Speech) actions to use in order to convey the PIN to your user. + """ + + number: PhoneNumber + country: Optional[str] = Field(None, max_length=2) + code_length: Optional[Literal[4, 6]] = None + pin_expiry: Optional[int] = Field(None, ge=60, le=3600) + next_event_wait: Optional[int] = Field(None, ge=60, le=900) + workflow_id: Optional[int] = Field(None, ge=1, le=7) + + @model_validator(mode='after') + def check_expiry_and_next_event_timing(self): + if self.pin_expiry is None or self.next_event_wait is None: + return self + if self.pin_expiry % self.next_event_wait != 0: + logger.warning( + f'The pin_expiry should be a multiple of next_event_wait.' + f'\nThe current values are: pin_expiry={self.pin_expiry}, next_event_wait={self.next_event_wait}.' + f'\nThe value of pin_expiry will be set to next_event_wait.' + ) + self.pin_expiry = self.next_event_wait + return self + + +class VerifyRequest(BaseVerifyRequest): + """Request object for a verification request. + + You must set the `number` and `brand` fields. + + Args: + number (PhoneNumber): The phone number to verify. Unless you are setting country + explicitly, this number must be in E.164 format. + country (str, Optional): If you do not provide `number` in international format + or you are not sure if `number` is correctly formatted, specify the + two-character country code in country. Verify will then format the number for + you. + brand (str): The name of the company or service that is sending the verification + request. This will appear in the body of the SMS or TTS message. + code_length (int, Optional): The length of the verification code to generate. + pin_expiry (int, Optional): How long the generated verification code is valid + for, in seconds. When you specify both `pin_expiry` and `next_event_wait` + then `pin_expiry` must be an integer multiple of `next_event_wait` otherwise + `pin_expiry` is defaulted to equal `next_event_wait`. + next_event_wait (int, Optional): The wait time in seconds between attempts to + deliver the verification code. + workflow_id (int, Optional): Selects the predefined sequence of SMS and TTS (Text + To Speech) actions to use in order to convey the PIN to your user. + sender_id (str, Optional): An 11-character alphanumeric string that represents the + sender of the verification request. Depending on the location of the phone + number, restrictions may apply. + lg (LanguageCode, Optional): The language to use for the verification message. + pin_code (str, Optional): The verification code to send to the user. If you do not + provide this, Vonage will generate a code for you. + """ + + brand: str = Field(..., max_length=18) + sender_id: Optional[str] = Field(None, max_length=11) + lg: Optional[LanguageCode] = None + pin_code: Optional[str] = Field(None, min_length=4, max_length=10) + + +class Psd2Request(BaseVerifyRequest): + """Request object for a PSD2 verification request. + + You must set the `number`, `payee` and `amount` fields. + + Args: + number (PhoneNumber): The phone number to verify. Unless you are setting country + explicitly, this number must be in E.164 format. + payee (str): An alphanumeric string to indicate to the user the name of the + recipient that they are confirming a payment to. + amount (float): The decimal amount of the payment to be confirmed, in Euros. + country (str, Optional): If you do not provide `number` in international + format or you are not sure if `number` is correctly formatted, specify the + two-character country code in `country`. Verify will then format the number for + you. + lg (Psd2LanguageCode, Optional): The language to use for the verification message. + code_length (int, Optional): The length of the verification code to generate. + pin_expiry (int, Optional): How long the generated verification code is valid + for, in seconds. When you specify both `pin_expiry` and `next_event_wait` + then `pin_expiry` must be an integer multiple of `next_event_wait` otherwise + `pin_expiry` is defaulted to equal `next_event_wait`. + next_event_wait (int, Optional): The wait time in seconds between attempts to + deliver the verification code. + workflow_id (int, Optional): Selects the predefined sequence of SMS and TTS (Text + To Speech) actions to use in order to convey the PIN to your user. + """ + + payee: str = Field(..., max_length=18) + amount: float + lg: Optional[Psd2LanguageCode] = None diff --git a/verify_legacy/src/vonage_verify_legacy/responses.py b/verify_legacy/src/vonage_verify_legacy/responses.py new file mode 100644 index 00000000..84d6f11f --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/responses.py @@ -0,0 +1,137 @@ +from typing import Optional + +from pydantic import BaseModel + + +class StartVerificationResponse(BaseModel): + """Response object for starting a verification process. + + Args: + request_id (str): The unique ID of the Verify request. You need this `request_id` + for the Verify check. + status (str): Indicates the outcome of the request; zero is success. + """ + + request_id: str + status: str + + +class CheckCodeResponse(BaseModel): + """Response object for checking a verification code. + + Args: + request_id (str): The unique ID of the Verify request to check. + status (str): Indicates the outcome of the request; zero is success. + event_id (str): The ID of the verification event, such as an SMS or TTS call. + price (str): The cost incurred for this request. + currency (str): The currency code. + estimated_price_messages_sent (str, Optional): Cost (in EUR) of the calls made + and messages sent for the verification process. + """ + + request_id: str + status: str + event_id: str + price: str + currency: str + estimated_price_messages_sent: Optional[str] = None + + +class Check(BaseModel): + """The list of checks made for a specific verification and their outcomes. + + Args: + date_received (str, Optional): The date and time this check was received (in the + format YYYY-MM-DD HH:MM:SS) + code (str, Optional): The code supplied with this check request. + status (str, Optional): The status of the check. + ip_address (str, Optional): The IP address of the check. This field is no longer + used. + """ + + date_received: Optional[str] = None + code: Optional[str] = None + status: Optional[str] = None + ip_address: Optional[str] = None + + +class Event(BaseModel): + """The events that have taken place to verify this number, and their unique + identifiers. + + Args: + type (str, Optional): The type of event. + id (str, Optional): The ID of the event. + """ + + type: Optional[str] = None + id: Optional[str] = None + + +class VerifyStatus(BaseModel): + """The status of a verification request. + + Args: + request_id (str, Optional): The `request_id` that you received in the response to + the Verify request and used in the Verify search request. + account_id (str, Optional): The Vonage account ID the request was for. + status (str, Optional): The status of the verification request. + number (str, Optional): The phone number used in the request. + price (str, Optional): The cost of this verification. + currency (str, Optional): The currency code. + sender_id (str, Optional): The sender ID provided in the Verify request. + date_submitted (str, Optional): The date and time this verification request was + submitted (in the format YYYY-MM-DD HH:MM:SS). + date_finalized (str, Optional): The date and time this verification request was + completed (in the format YYYY-MM-DD HH:MM:SS). + first_event_date (str, Optional): The date and time of the first verification + attempt (in the format YYYY-MM-DD HH:MM:SS). + last_event_date (str, Optional): The date and time of the last verification + attempt (in the format YYYY-MM-DD HH:MM:SS). + checks (list[Check], Optional): The list of checks made for this verification and + their outcomes. + events (list[Event], Optional): The events that have taken place to verify this + number, and their unique identifiers. + estimated_price_messages_sent (str, Optional): Cost (in EUR) of the calls made + and messages sent for the verification process. + """ + + request_id: Optional[str] = None + account_id: Optional[str] = None + status: Optional[str] = None + number: Optional[str] = None + price: Optional[str] = None + currency: Optional[str] = None + sender_id: Optional[str] = None + date_submitted: Optional[str] = None + date_finalized: Optional[str] = None + first_event_date: Optional[str] = None + last_event_date: Optional[str] = None + checks: Optional[list[Check]] = None + events: Optional[list[Event]] = None + estimated_price_messages_sent: Optional[str] = None + + +class VerifyControlStatus(BaseModel): + """The status of a verification control request. + + Args: + status (str): The status of the control request. + command (str): The command that was requested when cancelling a verify request + or triggering the next workflow in a request. + """ + + status: str + command: str + + +class NetworkUnblockStatus(BaseModel): + """The status of a network unblock request. + + Args: + network (str): The unique network ID of the network that was unblocked. + unblocked_until (str): The date and time until which the network is unblocked. + """ + + network: str + unblocked_until: str diff --git a/verify_legacy/src/vonage_verify_legacy/verify_legacy.py b/verify_legacy/src/vonage_verify_legacy/verify_legacy.py new file mode 100644 index 00000000..4ee46860 --- /dev/null +++ b/verify_legacy/src/vonage_verify_legacy/verify_legacy.py @@ -0,0 +1,239 @@ +from typing import Optional, Union + +from pydantic import Field, validate_call +from vonage_http_client.http_client import HttpClient + +from .errors import VerifyError +from .requests import BaseVerifyRequest, Psd2Request, VerifyRequest +from .responses import ( + CheckCodeResponse, + NetworkUnblockStatus, + StartVerificationResponse, + VerifyControlStatus, + VerifyStatus, +) + + +class VerifyLegacy: + """Calls Vonage's Legacy Verify API. If you are just starting to use the Verify API, + please use the `Verify` class instead. + + This class provides methods to interact with Vonage's Legacy Verify API for verifying + users. + + Args: + http_client (HttpClient): The HTTP client used to make requests to the Verify API. + + Raises: + VerifyError: If an error is found in the response. + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + self._sent_data_type = 'form' + self._auth_type = 'body' + + @property + def http_client(self) -> HttpClient: + """The HTTP client used to make requests to the Verify API. + + Returns: + HttpClient: The HTTP client used to make requests to the Verify API. + """ + return self._http_client + + @validate_call + def start_verification( + self, verify_request: VerifyRequest + ) -> StartVerificationResponse: + """Start a verification process. + + Args: + verify_request (VerifyRequest): The verification request object. + + Returns: + StartVerificationResponse: The response object containing the verification result. + """ + return self._make_verify_request(verify_request) + + @validate_call + def start_psd2_verification( + self, verify_request: Psd2Request + ) -> StartVerificationResponse: + """Start a PSD2 verification process. + + Args: + verify_request (Psd2Request): The PSD2 verification request object. + + Returns: + StartVerificationResponse: The response object containing the verification result. + """ + return self._make_verify_request(verify_request) + + @validate_call + def check_code(self, request_id: str, code: str) -> CheckCodeResponse: + """Check a verification code. + + Args: + request_id (str): The request ID. + code (str): The verification code. + + Returns: + CheckCodeResponse: The response object containing the verification result. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/check/json', + {'request_id': request_id, 'code': code}, + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + return CheckCodeResponse(**response) + + @validate_call + def search( + self, request: Union[str, list[str]] + ) -> Union[VerifyStatus, list[VerifyStatus]]: + """Search for past or current verification requests. + + Args: + request (str | list[str]): The request ID, or a list of request IDs. + + Returns: + Union[VerifyStatus, list[VerifyStatus]]: Either the response object + containing the verification result, or a list of response objects. + """ + params = {} + if type(request) == str: + params['request_id'] = request + elif type(request) == list: + params['request_ids'] = request + + response = self._http_client.get( + self._http_client.api_host, '/verify/search/json', params, self._auth_type + ) + + if 'verification_requests' in response: + parsed_response = [] + for verification_request in response['verification_requests']: + parsed_response.append(VerifyStatus(**verification_request)) + return parsed_response + elif 'error_text' in response: + error_message = f'Error with the following details: {response}' + raise VerifyError(error_message) + else: + parsed_response = VerifyStatus(**response) + return parsed_response + + @validate_call + def cancel_verification(self, request_id: str) -> VerifyControlStatus: + """Cancel a verification request. + + Args: + request_id (str): The request ID. + + Returns: + VerifyControlStatus: The response object containing details of the submitted + verification control. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/control/json', + {'request_id': request_id, 'cmd': 'cancel'}, + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + + return VerifyControlStatus(**response) + + @validate_call + def trigger_next_event(self, request_id: str) -> VerifyControlStatus: + """Trigger the next event in the verification process. + + Args: + request_id (str): The request ID. + + Returns: + VerifyControlStatus: The response object containing details of the submitted + verification control. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/control/json', + {'request_id': request_id, 'cmd': 'trigger_next_event'}, + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + + return VerifyControlStatus(**response) + + @validate_call + def request_network_unblock( + self, network: str, unblock_duration: Optional[int] = Field(None, ge=0, le=86400) + ) -> NetworkUnblockStatus: + """Request to unblock a network that has been blocked due to potential fraud + detection. + + Note: The network unblock feature is switched off by default. + Please contact Sales to enable the Network Unblock API for your account. + + Args: + network (str): The network code of the network to unblock. + unblock_duration (int, optional): How long (in seconds) to unblock the network for. + """ + response = self._http_client.post( + self._http_client.api_host, + '/verify/network-unblock', + {'network': network, 'duration': unblock_duration}, + self._auth_type, + ) + + return NetworkUnblockStatus(**response) + + def _make_verify_request( + self, verify_request: BaseVerifyRequest + ) -> StartVerificationResponse: + """Make a verify request. + + This method makes a verify request to the Vonage Verify API. + + Args: + verify_request (BaseVerifyRequest): The verify request object. + + Returns: + VerifyResponse: The response object containing the verification result. + """ + if type(verify_request) == VerifyRequest: + request_path = '/verify/json' + elif type(verify_request) == Psd2Request: + request_path = '/verify/psd2/json' + + response = self._http_client.post( + self._http_client.api_host, + request_path, + verify_request.model_dump(by_alias=True, exclude_none=True), + self._auth_type, + self._sent_data_type, + ) + self._check_for_error(response) + + return StartVerificationResponse(**response) + + def _check_for_error(self, response: dict) -> None: + """Check for error in the response. + + This method checks if the response contains a non-zero status code + and raises a VerifyError if this is found. + + Args: + response (dict): The response object. + + Raises: + VerifyError: If an error is found in the response. + """ + if int(response['status']) != 0: + error_message = f'Error with the following details: {response}' + raise VerifyError(error_message) diff --git a/verify_legacy/tests/BUILD b/verify_legacy/tests/BUILD new file mode 100644 index 00000000..cb195efa --- /dev/null +++ b/verify_legacy/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['verify_legacy', 'testutils']) diff --git a/verify/tests/data/cancel_verification.json b/verify_legacy/tests/data/cancel_verification.json similarity index 100% rename from verify/tests/data/cancel_verification.json rename to verify_legacy/tests/data/cancel_verification.json diff --git a/verify/tests/data/cancel_verification_error.json b/verify_legacy/tests/data/cancel_verification_error.json similarity index 100% rename from verify/tests/data/cancel_verification_error.json rename to verify_legacy/tests/data/cancel_verification_error.json 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/tests/data/check_code_error.json b/verify_legacy/tests/data/check_code_error.json similarity index 100% rename from verify/tests/data/check_code_error.json rename to verify_legacy/tests/data/check_code_error.json diff --git a/verify/tests/data/network_unblock.json b/verify_legacy/tests/data/network_unblock.json similarity index 100% rename from verify/tests/data/network_unblock.json rename to verify_legacy/tests/data/network_unblock.json diff --git a/verify/tests/data/network_unblock_error.json b/verify_legacy/tests/data/network_unblock_error.json similarity index 100% rename from verify/tests/data/network_unblock_error.json rename to verify_legacy/tests/data/network_unblock_error.json diff --git a/verify/tests/data/search_request.json b/verify_legacy/tests/data/search_request.json similarity index 100% rename from verify/tests/data/search_request.json rename to verify_legacy/tests/data/search_request.json diff --git a/verify/tests/data/search_request_error.json b/verify_legacy/tests/data/search_request_error.json similarity index 100% rename from verify/tests/data/search_request_error.json rename to verify_legacy/tests/data/search_request_error.json diff --git a/verify/tests/data/search_request_list.json b/verify_legacy/tests/data/search_request_list.json similarity index 100% rename from verify/tests/data/search_request_list.json rename to verify_legacy/tests/data/search_request_list.json diff --git a/verify/tests/data/trigger_next_event.json b/verify_legacy/tests/data/trigger_next_event.json similarity index 100% rename from verify/tests/data/trigger_next_event.json rename to verify_legacy/tests/data/trigger_next_event.json diff --git a/verify/tests/data/trigger_next_event_error.json b/verify_legacy/tests/data/trigger_next_event_error.json similarity index 100% rename from verify/tests/data/trigger_next_event_error.json rename to verify_legacy/tests/data/trigger_next_event_error.json 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/tests/data/verify_request_error_with_network.json b/verify_legacy/tests/data/verify_request_error_with_network.json similarity index 100% rename from verify/tests/data/verify_request_error_with_network.json rename to verify_legacy/tests/data/verify_request_error_with_network.json diff --git a/verify_legacy/tests/test_verify_legacy.py b/verify_legacy/tests/test_verify_legacy.py new file mode 100644 index 00000000..21482b2f --- /dev/null +++ b/verify_legacy/tests/test_verify_legacy.py @@ -0,0 +1,304 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import NotFoundError +from vonage_http_client.http_client import HttpClient +from vonage_verify_legacy.errors import VerifyError +from vonage_verify_legacy.language_codes import LanguageCode, Psd2LanguageCode +from vonage_verify_legacy.requests import Psd2Request, VerifyRequest +from vonage_verify_legacy.responses import NetworkUnblockStatus, VerifyControlStatus +from vonage_verify_legacy.verify_legacy import VerifyLegacy + +from testutils import build_response, get_mock_api_key_auth + +path = abspath(__file__) + + +verify = VerifyLegacy(HttpClient(get_mock_api_key_auth())) + +data = { + 'number': '1234567890', + 'country': 'US', + 'code_length': 6, + 'pin_expiry': 600, + 'next_event_wait': 150, + 'workflow_id': 2, +} + + +def test_http_client_property(): + verify = VerifyLegacy(HttpClient(get_mock_api_key_auth())) + assert isinstance(verify.http_client, HttpClient) + + +def test_create_verify_request_model(): + params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', 'lg': LanguageCode.en_us, **data} + request = VerifyRequest(**params) + + assert request.model_dump(exclude_none=True) == params + + +def test_create_psd2_request_model(): + params = {'payee': 'Acme Inc.', 'amount': 99.99, 'lg': Psd2LanguageCode.en_gb, **data} + request = Psd2Request(**params) + + assert request.model_dump(exclude_none=True) == params + + +def test_create_verify_request_model_invalid_pin_expiry(caplog): + data['pin_expiry'] = 301 + data['next_event_wait'] = 150 + params = {'brand': 'Acme Inc.', 'sender_id': 'Acme', **data} + VerifyRequest(**params) + + assert 'The current values are: pin_expiry=301, next_event_wait=150.' in caplog.text + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request.json' + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' + + +@responses.activate +def test_make_psd2_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/psd2/json', 'verify_request.json' + ) + params = {'number': '1234567890', 'payee': 'Acme Inc.', 'amount': 99.99} + request = Psd2Request(**params) + + response = verify.start_psd2_verification(request) + assert response.request_id == 'abcdef0123456789abcdef0123456789' + assert response.status == '0' + + +@responses.activate +def test_verify_request_error(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/json', 'verify_request_error.json' + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + with raises(VerifyError) as e: + verify.start_verification(request) + + assert e.match( + "'error_text': 'Concurrent verifications to the same number are not allowed'" + ) + + +@responses.activate +def test_verify_request_error_with_network(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/json', + 'verify_request_error_with_network.json', + ) + params = {'number': '1234567890', 'brand': 'Acme Inc.'} + request = VerifyRequest(**params) + + with raises(VerifyError) as e: + verify.start_verification(request) + + assert e.match("'network': '244523'") + + +@responses.activate +def test_check_code(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code.json' + ) + response = verify.check_code( + request_id='c5037cb8b47449158ed6611afde58990', code='1234' + ) + assert response.request_id == 'c5037cb8b47449158ed6611afde58990' + assert response.status == '0' + assert response.event_id == '390f7296-aeff-45ba-8931-84a13f3f76d7' + assert response.price == '0.05000000' + assert response.currency == 'EUR' + assert response.estimated_price_messages_sent == '0.04675' + + +@responses.activate +def test_check_code_error(): + build_response( + path, 'POST', 'https://api.nexmo.com/verify/check/json', 'check_code_error.json' + ) + + with raises(VerifyError) as e: + verify.check_code(request_id='c5037cb8b47449158ed6611afde58990', code='1234') + + assert e.match( + "'status': '16', 'error_text': 'The code provided does not match the expected value'" + ) + + +@responses.activate +def test_search(): + build_response( + path, 'GET', 'https://api.nexmo.com/verify/search/json', 'search_request.json' + ) + response = verify.search('c5037cb8b47449158ed6611afde58990') + + assert response.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' + assert response.account_id == 'abcdef01' + assert response.status == 'EXPIRED' + assert response.number == '1234567890' + assert response.price == '0' + assert response.currency == 'EUR' + assert response.sender_id == 'Acme Inc.' + assert response.date_submitted == '2024-04-03 02:22:37' + assert response.date_finalized == '2024-04-03 02:27:38' + assert response.first_event_date == '2024-04-03 02:22:37' + assert response.last_event_date == '2024-04-03 02:24:38' + assert response.estimated_price_messages_sent == '0.09350' + assert response.checks[0].date_received == '2024-04-03 02:23:04' + assert response.checks[0].code == '1234' + assert response.checks[0].status == 'INVALID' + assert response.checks[0].ip_address == '' + assert response.events[0].type == 'sms' + assert response.events[0].id == '23f3a13d-6d03-4262-8f4d-67f12a56e1c8' + + +@responses.activate +def test_search_list_of_ids(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/verify/search/json', + 'search_request_list.json', + ) + response0, response1 = verify.search( + ['cc121958d8fb4368aa3bb762bb9a0f75', 'c5037cb8b47449158ed6611afde58990'] + ) + assert response0.request_id == 'cc121958d8fb4368aa3bb762bb9a0f74' + assert response1.request_id == 'c5037cb8b47449158ed6611afde58990' + assert response1.status == 'SUCCESS' + assert response1.checks[0].status == 'VALID' + + +@responses.activate +def test_search_error(): + build_response( + path, + 'GET', + 'https://api.nexmo.com/verify/search/json', + 'search_request_error.json', + ) + + with raises(VerifyError) as e: + verify.search('c5037cb8b47449158ed6611afde58990') + + assert e.match("{'status': '101', 'error_text': 'No response found'}") + + +@responses.activate +def test_cancel_verification(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'cancel_verification.json', + ) + response = verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + + assert type(response) == VerifyControlStatus + assert response.status == '0' + assert response.command == 'cancel' + + +@responses.activate +def test_cancel_verification_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'cancel_verification_error.json', + ) + + with raises(VerifyError) as e: + verify.cancel_verification('c5037cb8b47449158ed6611afde58990') + + assert e.match( + "The requestId 'cc121958d8fb4368aa3bb762bb9a0f75' does not exist or its no longer active." + ) + + +@responses.activate +def test_trigger_next_event(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'trigger_next_event.json', + ) + response = verify.trigger_next_event('c5037cb8b47449158ed6611afde58990') + + assert type(response) == VerifyControlStatus + assert response.status == '0' + assert response.command == 'trigger_next_event' + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/control/json', + 'trigger_next_event_error.json', + ) + + with raises(VerifyError) as e: + verify.trigger_next_event('2c021d25cf2e47a9b277a996f4325b81') + + assert e.match("'status': '19") + assert e.match('No more events are left to execute for the request') + + +@responses.activate +def test_request_network_unblock(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/network-unblock', + 'network_unblock.json', + 202, + ) + + response = verify.request_network_unblock('23410') + + assert verify._http_client.last_response.status_code == 202 + assert type(response) == NetworkUnblockStatus + assert response.network == '23410' + assert response.unblocked_until == '2024-04-22T08:34:58Z' + + +@responses.activate +def test_request_network_unblock_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/verify/network-unblock', + 'network_unblock_error.json', + 404, + ) + + try: + verify.request_network_unblock('23410') + except NotFoundError as e: + assert ( + e.response.json()['detail'] + == 'The network you provided does not have an active block.' + ) + assert e.response.json()['title'] == 'Not Found' diff --git a/verify_v2/CHANGES.md b/verify_v2/CHANGES.md deleted file mode 100644 index 031bbf6d..00000000 --- a/verify_v2/CHANGES.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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_v2/README.md b/verify_v2/README.md deleted file mode 100644 index 79a4f7f4..00000000 --- a/verify_v2/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Vonage Verify V2 Package - -This package contains the code to use [Vonage's Verify v2 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 v2 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_v2 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_v2.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_v2.start_verification(verify_request) -``` - -### Check a Verification Code - -```python -vonage_client.verify_v2.check_code(request_id='my_request_id', code='1234') -``` - -### Cancel a Verification - -```python -vonage_client.verify_v2.cancel_verification('my_request_id') -``` - -### Trigger the Next Workflow Event - -```python -vonage_client.verify_v2.trigger_next_workflow('my_request_id') -``` \ No newline at end of file diff --git a/verify_v2/src/vonage_verify_v2/__init__.py b/verify_v2/src/vonage_verify_v2/__init__.py deleted file mode 100644 index f0b3b432..00000000 --- a/verify_v2/src/vonage_verify_v2/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from .enums import ChannelType, Locale -from .errors import VerifyError -from .requests import ( - EmailChannel, - SilentAuthChannel, - SmsChannel, - VerifyRequest, - VoiceChannel, - WhatsappChannel, -) -from .responses import CheckCodeResponse, StartVerificationResponse -from .verify_v2 import VerifyV2 - -__all__ = [ - 'VerifyV2', - 'VerifyError', - 'ChannelType', - 'CheckCodeResponse', - 'Locale', - 'VerifyRequest', - 'SilentAuthChannel', - 'SmsChannel', - 'WhatsappChannel', - 'VoiceChannel', - 'EmailChannel', - 'StartVerificationResponse', -] diff --git a/verify_v2/src/vonage_verify_v2/_version.py b/verify_v2/src/vonage_verify_v2/_version.py deleted file mode 100644 index bc50bee6..00000000 --- a/verify_v2/src/vonage_verify_v2/_version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '1.1.4' diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py deleted file mode 100644 index 43d00c6b..00000000 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ /dev/null @@ -1,184 +0,0 @@ -from re import search -from typing import Optional, Union - -from pydantic import BaseModel, Field, field_validator, model_validator -from vonage_utils.types import PhoneNumber - -from .enums import ChannelType, Locale -from .errors import VerifyError - - -class Channel(BaseModel): - """Base model for a channel to use in a verification request. - - Args: - to (PhoneNumber): The phone number to send the verification code to, in the - E.164 format without a leading `+` or `00`. - """ - - to: PhoneNumber - - -class SilentAuthChannel(Channel): - """Model for a Silent Authentication channel. - - Args: - to (PhoneNumber): The phone number to send the verification code to, in the - E.164 format without a leading `+` or `00`. - redirect_url (str, Optional): Optional final redirect added at the end of the - check_url request/response lifecycle. Will contain the `request_id` and - `code` as a url fragment after the URL. - sandbox (bool, Optional): Whether you are using the sandbox to test Silent - Authentication integrations. - """ - - redirect_url: Optional[str] = None - sandbox: Optional[bool] = None - channel: ChannelType = ChannelType.SILENT_AUTH - - -class SmsChannel(Channel): - """Model for an SMS channel. - - Args: - to (PhoneNumber): The phone number to send the verification code to, in the - E.164 format without a leading `+` or `00`. - from_ (Union[PhoneNumber, str], Optional): The sender of the SMS. This can be - a phone number in E.164 format without a leading `+` or `00`, or a string - of 3-11 alphanumeric characters. - app_hash (str, Optional): Optional Android Application Hash Key for automatic - code detection on a user's device. - entity_id (str, Optional): Optional PEID required for SMS delivery using Indian - carriers. - content_id (str, Optional): Optional PEID required for SMS delivery using Indian - carriers. - - Raises: - VerifyError: If the `from_` field is not a valid phone number. - """ - - from_: Optional[Union[PhoneNumber, str]] = Field(None, serialization_alias='from') - app_hash: Optional[str] = Field(None, min_length=11, max_length=11) - entity_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') - content_id: Optional[str] = Field(None, pattern=r'^[0-9]{1,20}$') - channel: ChannelType = ChannelType.SMS - - @field_validator('from_') - @classmethod - def check_valid_from_field(cls, v): - if ( - v is not None - and type(v) is not PhoneNumber - and not search(r'^[a-zA-Z0-9]{3,11}$', v) - ): - raise VerifyError( - 'You must specify a valid "from_" value if included. ' - 'It must be a valid phone number without the leading +, or a string of 3-11 alphanumeric characters. ' - f'You set "from_": "{v}".' - ) - return v - - -class WhatsappChannel(Channel): - """Model for a WhatsApp channel. - - Args: - to (PhoneNumber): The phone number to send the verification code to, in the - E.164 format without a leading `+` or `00`. - from_ (Union[PhoneNumber, str]): A WhatsApp Business Account (WABA)-connected - sender number, in the E.164 format. Don't use a leading + or 00 when entering - a phone number. - - Raises: - VerifyError: If the `from_` field is not a valid phone number or string of 3-11 - alphanumeric characters. - """ - - from_: Union[PhoneNumber, str] = Field(..., serialization_alias='from') - channel: ChannelType = ChannelType.WHATSAPP - - @field_validator('from_') - @classmethod - def check_valid_sender(cls, v): - if type(v) is not PhoneNumber and not search(r'^[a-zA-Z0-9]{3,11}$', v): - raise VerifyError( - f'You must specify a valid "from_" value. ' - 'It must be a valid phone number without the leading +, or a string of 3-11 alphanumeric characters. ' - f'You set "from_": "{v}".' - ) - return v - - -class VoiceChannel(Channel): - """Model for a Voice channel. - - Args: - to (PhoneNumber): The phone number to send the verification code to, in the - E.164 format without a leading `+` or `00`. - """ - - channel: ChannelType = ChannelType.VOICE - - -class EmailChannel(Channel): - """Model for an Email channel. - - Args: - to (str): The email address to send the verification code to. - from_ (str, Optional): The email address of the sender. - """ - - to: str - from_: Optional[str] = Field(None, serialization_alias='from') - channel: ChannelType = ChannelType.EMAIL - - -class VerifyRequest(BaseModel): - """Request object for a verification request. - - Args: - brand (str): The name of the company or service that is sending the verification - request. This will appear in the body of the SMS or TTS message. - workflow (list[Union[SilentAuthChannel, SmsChannel, WhatsappChannel, VoiceChannel, EmailChannel]]): - The list of channels to use in the verification workflow. They will be used - in the order they are listed. - locale (Locale, Optional): The locale to use for the verification message. - channel_timeout (int, Optional): The time in seconds to wait between attempts to - deliver the verification code. - client_ref (str, Optional): A unique identifier for the verification request. If - the client_ref is set when the request is sent, it will be included in the - callbacks. - code_length (int, Optional): The length of the verification code to generate. - code (str, Optional): An optional alphanumeric custom code to use, if you don't - want Vonage to generate the code. - - Raises: - VerifyError: If the `workflow` list contains a Silent Authentication channel that - is not the first channel in the list. - """ - - brand: str = Field(..., min_length=1, max_length=16) - workflow: list[ - Union[ - SilentAuthChannel, - SmsChannel, - WhatsappChannel, - VoiceChannel, - EmailChannel, - ] - ] - locale: Optional[Locale] = None - channel_timeout: Optional[int] = Field(None, ge=15, le=900) - client_ref: Optional[str] = Field(None, min_length=1, max_length=16) - code_length: Optional[int] = Field(None, ge=4, le=10) - code: Optional[str] = Field(None, pattern=r'^[a-zA-Z0-9]{4,10}$') - - @model_validator(mode='after') - def check_silent_auth_first_if_present(self): - if len(self.workflow) > 1: - for i in range(1, len(self.workflow)): - if isinstance(self.workflow[i], SilentAuthChannel): - raise VerifyError( - 'If using Silent Authentication, it must be the first channel in the "workflow" list.' - ) - return self diff --git a/verify_v2/src/vonage_verify_v2/responses.py b/verify_v2/src/vonage_verify_v2/responses.py deleted file mode 100644 index f7845087..00000000 --- a/verify_v2/src/vonage_verify_v2/responses.py +++ /dev/null @@ -1,28 +0,0 @@ -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_v2/src/vonage_verify_v2/verify_v2.py b/verify_v2/src/vonage_verify_v2/verify_v2.py deleted file mode 100644 index d405b6f9..00000000 --- a/verify_v2/src/vonage_verify_v2/verify_v2.py +++ /dev/null @@ -1,80 +0,0 @@ -from pydantic import validate_call -from vonage_http_client.http_client import HttpClient - -from .requests import VerifyRequest -from .responses import CheckCodeResponse, StartVerificationResponse - - -class VerifyV2: - """Calls Vonage's Verify V2 API.""" - - def __init__(self, http_client: HttpClient) -> None: - self._http_client = http_client - - @property - def http_client(self) -> HttpClient: - """The HTTP client used to make requests to the Verify V2 API. - - Returns: - HttpClient: The HTTP client used to make requests to the Verify V2 API. - """ - return self._http_client - - @validate_call - def start_verification( - self, verify_request: VerifyRequest - ) -> StartVerificationResponse: - """Start a verification process. - - Args: - verify_request (VerifyRequest): The verification request object. - - Returns: - StartVerificationResponse: The response object containing the `request_id`. - If requesting Silent Authentication, it will also contain a `check_url` field. - """ - response = self._http_client.post( - self._http_client.api_host, - '/v2/verify', - verify_request.model_dump(by_alias=True, exclude_none=True), - ) - - return StartVerificationResponse(**response) - - @validate_call - def check_code(self, request_id: str, code: str) -> CheckCodeResponse: - """Check a verification code. - - Args: - request_id (str): The request ID. - code (str): The verification code. - - Returns: - CheckCodeResponse: The response object containing the verification result. - """ - response = self._http_client.post( - self._http_client.api_host, f'/v2/verify/{request_id}', {'code': code} - ) - return CheckCodeResponse(**response) - - @validate_call - def cancel_verification(self, request_id: str) -> None: - """Cancel a verification request. - - Args: - request_id (str): The request ID. - """ - self._http_client.delete(self._http_client.api_host, f'/v2/verify/{request_id}') - - @validate_call - def trigger_next_workflow(self, request_id: str) -> None: - """Trigger the next workflow event in the list of workflows passed in when making - the request. - - Args: - request_id (str): The request ID. - """ - self._http_client.post( - self._http_client.api_host, - f'/v2/verify/{request_id}/next_workflow', - ) diff --git a/verify_v2/tests/BUILD b/verify_v2/tests/BUILD deleted file mode 100644 index 7453da10..00000000 --- a/verify_v2/tests/BUILD +++ /dev/null @@ -1 +0,0 @@ -python_tests(dependencies=['verify_v2', 'testutils']) diff --git a/verify_v2/tests/data/check_code.json b/verify_v2/tests/data/check_code.json deleted file mode 100644 index 2fbe4b8e..00000000 --- a/verify_v2/tests/data/check_code.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "request_id": "36e7060d-2b23-4257-bad0-773ab47f85ef", - "status": "completed" -} \ No newline at end of file diff --git a/verify_v2/tests/data/verify_request.json b/verify_v2/tests/data/verify_request.json deleted file mode 100644 index 719396cc..00000000 --- a/verify_v2/tests/data/verify_request.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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_v2/tests/data/verify_request_error.json b/verify_v2/tests/data/verify_request_error.json deleted file mode 100644 index f40b1b12..00000000 --- a/verify_v2/tests/data/verify_request_error.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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_v2/tests/test_verify_v2.py b/verify_v2/tests/test_verify_v2.py deleted file mode 100644 index a17a3832..00000000 --- a/verify_v2/tests/test_verify_v2.py +++ /dev/null @@ -1,189 +0,0 @@ -from os.path import abspath - -import responses -from pytest import raises -from vonage_http_client.errors import HttpRequestError -from vonage_http_client.http_client import HttpClient -from vonage_verify_v2.requests import * -from vonage_verify_v2.verify_v2 import VerifyV2 - -from testutils import build_response, get_mock_jwt_auth - -path = abspath(__file__) - - -verify = VerifyV2(HttpClient(get_mock_jwt_auth())) - - -@responses.activate -def test_make_verify_request(): - build_response( - path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 - ) - silent_auth_channel = SilentAuthChannel( - channel=ChannelType.SILENT_AUTH, to='1234567890' - ) - sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') - params = { - 'brand': 'Vonage', - 'workflow': [silent_auth_channel, sms_channel], - } - request = VerifyRequest(**params) - - response = verify.start_verification(request) - assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' - assert ( - response.check_url - == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' - ) - assert verify._http_client.last_response.status_code == 202 - - -@responses.activate -def test_make_verify_request_full(): - build_response( - path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 - ) - workflow = [ - SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), - SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), - WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), - VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), - EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), - ] - params = { - 'brand': 'Vonage', - 'workflow': workflow, - 'locale': 'en-gb', - 'channel_timeout': 60, - 'client_ref': 'my-client-ref', - 'code_length': 6, - 'code': '123456', - } - request = VerifyRequest(**params) - - response = verify.start_verification(request) - assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' - - -@responses.activate -def test_verify_request_concurrent_verifications_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify', - 'verify_request_error.json', - 409, - ) - sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') - params = { - 'brand': 'Vonage', - 'workflow': [sms_channel], - } - request = VerifyRequest(**params) - - with raises(HttpRequestError) as e: - verify.start_verification(request) - - assert e.value.response.status_code == 409 - assert e.value.response.json()['title'] == 'Conflict' - assert ( - e.value.response.json()['detail'] - == 'Concurrent verifications to the same number are not allowed' - ) - - -@responses.activate -def test_check_code(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - 'check_code.json', - ) - response = verify.check_code( - request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' - ) - assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' - assert response.status == 'completed' - - -@responses.activate -def test_check_code_invalid_code_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - 'check_code_400.json', - 400, - ) - - with raises(HttpRequestError) as e: - verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') - - assert e.value.response.status_code == 400 - assert e.value.response.json()['title'] == 'Invalid Code' - - -@responses.activate -def test_check_code_too_many_attempts(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - 'check_code_410.json', - 410, - ) - - with raises(HttpRequestError) as e: - verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') - - assert e.value.response.status_code == 410 - assert e.value.response.json()['title'] == 'Invalid Code' - - -@responses.activate -def test_cancel_verification(): - responses.add( - responses.DELETE, - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', - status=204, - ) - assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None - assert verify._http_client.last_response.status_code == 204 - - -@responses.activate -def test_trigger_next_workflow(): - responses.add( - responses.POST, - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', - status=200, - ) - assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None - assert verify._http_client.last_response.status_code == 200 - - -@responses.activate -def test_trigger_next_event_error(): - build_response( - path, - 'POST', - 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', - 'trigger_next_workflow_error.json', - status_code=409, - ) - - with raises(HttpRequestError) as e: - verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') - - assert e.value.response.status_code == 409 - assert e.value.response.json()['title'] == 'Conflict' - assert ( - e.value.response.json()['detail'] == 'There are no more events left to trigger.' - ) - - -def test_http_client_property(): - http_client = verify.http_client - assert isinstance(http_client, HttpClient) diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index f951e4d8..2a7abed6 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,4 +1,4 @@ -# 4.0.0b1 +# 4.0.0b2 A complete, ground-up rewrite of the SDK. Key changes: - Monorepo structure, with each API under separate packages @@ -13,6 +13,7 @@ Key changes: - Add `http_client` property to each module that has an HTTP Client, e.g. Voice, Sms, Verify - Add `last_request` and `last_response` properties to the HTTP Client for easier debugging - Migrated the Vonage JWT package into the monorepo +- Rename `Verify` -> `VerifyLegacy` and `VerifyV2` -> `Verify` - With even more enhancements to come! # 3.17.1 diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 56def366..c1934fd2 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -18,8 +18,8 @@ dependencies = [ "vonage-sms>=1.1.3", "vonage-subaccounts>=1.0.3", "vonage-users>=1.1.4", - "vonage-verify>=1.1.3", - "vonage-verify-v2>=1.1.4", + "vonage-verify>=2.0.0", + "vonage-verify-legacy>=2.0.0", "vonage-video>=1.0.1", "vonage-voice>=1.0.5", ] diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index e0c6dca5..aa2133db 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -14,7 +14,7 @@ Subaccounts, Users, Verify, - VerifyV2, + VerifyLegacy, Video, Voice, Vonage, @@ -34,7 +34,7 @@ 'Subaccounts', 'Users', 'Verify', - 'VerifyV2', + 'VerifyLegacy', 'Video', 'Voice', 'Vonage', diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index 5fcc6a48..317613f5 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -12,7 +12,7 @@ from vonage_subaccounts import Subaccounts from vonage_users import Users from vonage_verify import Verify -from vonage_verify_v2 import VerifyV2 +from vonage_verify_legacy import VerifyLegacy from vonage_video import Video from vonage_voice import Voice @@ -48,7 +48,7 @@ def __init__( self.subaccounts = Subaccounts(self._http_client) self.users = Users(self._http_client) self.verify = Verify(self._http_client) - self.verify_v2 = VerifyV2(self._http_client) + self.verify_legacy = VerifyLegacy(self._http_client) self.video = Video(self._http_client) self.voice = Voice(self._http_client) From 7653fb7b5f87c44db2ee459ed84cf823781e1fde Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 22 Oct 2024 14:52:43 +0100 Subject: [PATCH 90/98] add SimSwapCheckRequest for SimSwap.check method --- network_sim_swap/CHANGES.md | 3 +++ .../src/vonage_network_sim_swap/__init__.py | 3 ++- .../src/vonage_network_sim_swap/_version.py | 2 +- .../src/vonage_network_sim_swap/requests.py | 17 +++++++++++++++++ .../src/vonage_network_sim_swap/sim_swap.py | 17 +++++++---------- network_sim_swap/tests/test_sim_swap.py | 5 ++++- vonage/pyproject.toml | 2 +- 7 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 network_sim_swap/src/vonage_network_sim_swap/requests.py diff --git a/network_sim_swap/CHANGES.md b/network_sim_swap/CHANGES.md index da547411..89595372 100644 --- a/network_sim_swap/CHANGES.md +++ b/network_sim_swap/CHANGES.md @@ -1,3 +1,6 @@ +# 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 diff --git a/network_sim_swap/src/vonage_network_sim_swap/__init__.py b/network_sim_swap/src/vonage_network_sim_swap/__init__.py index 41b72410..2e6bd8cb 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/__init__.py +++ b/network_sim_swap/src/vonage_network_sim_swap/__init__.py @@ -1,4 +1,5 @@ +from .requests import SimSwapCheckRequest from .responses import LastSwapDate, SwapStatus from .sim_swap import NetworkSimSwap -__all__ = ['NetworkSimSwap', 'LastSwapDate', 'SwapStatus'] +__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 index 1f356cc5..1a72d32e 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/_version.py +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -1 +1 @@ -__version__ = '1.0.0' +__version__ = '1.1.0' 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/sim_swap.py b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py index c52a5ac0..c9c5cf5b 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py +++ b/network_sim_swap/src/vonage_network_sim_swap/sim_swap.py @@ -1,6 +1,7 @@ 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 @@ -25,29 +26,25 @@ def http_client(self) -> HttpClient: return self._http_client @validate_call - def check(self, phone_number: str, max_age: int = None) -> SwapStatus: + def check(self, sim_swap_request: SimSwapCheckRequest) -> SwapStatus: """Check if a SIM swap has been performed in a given time frame. 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. + 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=phone_number, scope='dpv:FraudPreventionAndDetection#check-sim-swap' + number=sim_swap_request.phone_number, + scope='dpv:FraudPreventionAndDetection#check-sim-swap', ) - params = {'phoneNumber': phone_number} - if max_age: - params['maxAge'] = max_age - return self._http_client.post( self._host, '/camara/sim-swap/v040/check', - params, + params=sim_swap_request.model_dump(by_alias=True, exclude_none=True), auth_type=self._auth_type, token=token, ) diff --git a/network_sim_swap/tests/test_sim_swap.py b/network_sim_swap/tests/test_sim_swap.py index baca3f40..e4ab50eb 100644 --- a/network_sim_swap/tests/test_sim_swap.py +++ b/network_sim_swap/tests/test_sim_swap.py @@ -4,6 +4,7 @@ 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 @@ -28,7 +29,9 @@ def test_check_sim_swap(mock_get_oauth2_user_token: MagicMock): ) mock_get_oauth2_user_token.return_value = 'token' - response = sim_swap.check('447700900000', max_age=24) + response = sim_swap.check( + SimSwapCheckRequest(phone_number='447700900000', max_age=24) + ) assert response['swapped'] == True diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index c1934fd2..2d609241 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "vonage-application>=1.0.3", "vonage-messages>=1.2.2", "vonage-network-auth>=1.0.0", - "vonage-network-sim-swap>=1.0.0", + "vonage-network-sim-swap>=1.1.0", "vonage-network-number-verification>=1.0.0", "vonage-number-insight>=1.0.3", "vonage-numbers>=1.0.2", From 4616cb69da73cc9e768f6d2c25683559cd38861c Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 23 Oct 2024 19:35:12 +0100 Subject: [PATCH 91/98] update migration guide --- V3_TO_V4_SDK_MIGRATION_GUIDE.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md index 48684e7f..aa2de146 100644 --- a/V3_TO_V4_SDK_MIGRATION_GUIDE.md +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -78,7 +78,7 @@ options = HttpClientOptions(api_host='new-api-host.example.com', timeout=100) ### Example -Putting this all together, to set up an instance of the `vonage.Vonage` class to call Vonage APIs, do this: +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 @@ -123,7 +123,7 @@ response = vonage_client.verify.start_verification(verify_request) print(response) ``` -However, some APIs with a lot of models have them located under the `vonage_api_package.models` package, e.g. `vonage-messages`, `vonage-voice` and `vonage-video`. To access these, simply import from `vonage_api_package.models`, e.g. to send an image via Facebook Messenger do this: +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 @@ -159,7 +159,7 @@ Response fields are also converted into snake_case where applicable, so as to be ## Error Handling -In v3 of the SDK, most HTTP client errors gave a general `HttpClientError`. In v4 these are finer-grained. 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. +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. @@ -249,7 +249,6 @@ Some methods from v3 have had their names changed in v4. Assuming you access all ## Additional Resources -- [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) \ No newline at end of file +- [Submit a Vonage API Support Request](https://api.support.vonage.com/hc/en-us) \ No newline at end of file From 06ddaa6bbb050f6e06430e02f8bb88f1b62c02c2 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Fri, 25 Oct 2024 18:26:11 +0100 Subject: [PATCH 92/98] update application parameter name and make users models more easily accessible --- application/CHANGES.md | 3 ++ .../src/vonage_application/_version.py | 2 +- .../src/vonage_application/application.py | 16 +++++------ users/CHANGES.md | 3 ++ users/src/vonage_users/__init__.py | 28 +++++++++++++++++-- users/src/vonage_users/_version.py | 2 +- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/application/CHANGES.md b/application/CHANGES.md index d3c171a6..462553e8 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,3 +1,6 @@ +# 2.0.0 +- Rename `params` -> `config` in method arguments + # 1.0.3 - Support for Python 3.13, drop support for 3.8 diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py index 3f6fab60..afced147 100644 --- a/application/src/vonage_application/_version.py +++ b/application/src/vonage_application/_version.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '2.0.0' diff --git a/application/src/vonage_application/application.py b/application/src/vonage_application/application.py index edf4cb81..0f41b579 100644 --- a/application/src/vonage_application/application.py +++ b/application/src/vonage_application/application.py @@ -56,13 +56,13 @@ def list_applications( @validate_call def create_application( - self, params: Optional[ApplicationConfig] = None + self, config: Optional[ApplicationConfig] = None ) -> ApplicationData: """Create a new application. Args: - params (Optional[ApplicationConfig]): Parameters describing the - application options to set. + config (Optional[ApplicationConfig]): Configuration options describing the + application to create. Returns: ApplicationData: The created application object. @@ -70,7 +70,7 @@ def create_application( response = self._http_client.post( self._http_client.api_host, '/v2/applications', - params.model_dump(exclude_none=True) if params is not None else None, + config.model_dump(exclude_none=True) if config is not None else None, self._auth_type, ) return ApplicationData(**response) @@ -91,13 +91,13 @@ def get_application(self, id: str) -> ApplicationData: return ApplicationData(**response) @validate_call - def update_application(self, id: str, params: ApplicationConfig) -> ApplicationData: + def update_application(self, id: str, config: ApplicationConfig) -> ApplicationData: """Update an application. Args: id (str): The ID of the application to update. - params (ApplicationConfig): Parameters describing the - application options to update. + config (ApplicationConfig): Configuration options describing the application + to update. Returns: ApplicationData: The updated application object. @@ -105,7 +105,7 @@ def update_application(self, id: str, params: ApplicationConfig) -> ApplicationD response = self._http_client.put( self._http_client.api_host, f'/v2/applications/{id}', - params.model_dump(exclude_none=True), + config.model_dump(exclude_none=True), self._auth_type, ) return ApplicationData(**response) diff --git a/users/CHANGES.md b/users/CHANGES.md index 77324631..53ca01d2 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.0 +- Expose more properties in the top-level `vonage_users` scope + # 1.1.4 - Support for Python 3.13, drop support for 3.8 diff --git a/users/src/vonage_users/__init__.py b/users/src/vonage_users/__init__.py index 159bf010..de647262 100644 --- a/users/src/vonage_users/__init__.py +++ b/users/src/vonage_users/__init__.py @@ -1,11 +1,35 @@ -from .common import User +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__ = [ - 'Users', '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 index bc50bee6..58d478ab 100644 --- a/users/src/vonage_users/_version.py +++ b/users/src/vonage_users/_version.py @@ -1 +1 @@ -__version__ = '1.1.4' +__version__ = '1.2.0' From 5631faf11489e16b3780a10d15dbf5f566ab1074 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 30 Oct 2024 11:01:29 +0000 Subject: [PATCH 93/98] update pants version --- pants.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pants.toml b/pants.toml index 10fcc4da..16761ca5 100644 --- a/pants.toml +++ b/pants.toml @@ -1,5 +1,5 @@ [GLOBAL] -pants_version = '2.23.0rc0' +pants_version = '2.23.0rc1' backend_packages = [ 'pants.backend.python', From 61e8abd379b4745531ac740c026d5a62f9e269f9 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 30 Oct 2024 13:34:07 +0000 Subject: [PATCH 94/98] update jwt method, update dependency --- account/pyproject.toml | 2 +- application/pyproject.toml | 2 +- http_client/CHANGES.md | 3 +++ http_client/pyproject.toml | 2 +- http_client/src/vonage_http_client/_version.py | 2 +- jwt/CHANGES.md | 3 +++ jwt/src/vonage_jwt/_version.py | 2 +- jwt/src/vonage_jwt/jwt.py | 3 ++- messages/pyproject.toml | 2 +- network_auth/pyproject.toml | 2 +- network_number_verification/pyproject.toml | 2 +- network_sim_swap/pyproject.toml | 2 +- number_insight/pyproject.toml | 2 +- number_management/pyproject.toml | 2 +- sms/pyproject.toml | 2 +- subaccounts/pyproject.toml | 2 +- users/pyproject.toml | 2 +- verify/pyproject.toml | 2 +- verify_legacy/pyproject.toml | 2 +- video/pyproject.toml | 2 +- voice/pyproject.toml | 2 +- vonage/pyproject.toml | 2 +- 22 files changed, 27 insertions(+), 20 deletions(-) diff --git a/account/pyproject.toml b/account/pyproject.toml index fadef3a2..dc3767b7 100644 --- a/account/pyproject.toml +++ b/account/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/application/pyproject.toml b/application/pyproject.toml index 0aa39214..5ce02df5 100644 --- a/application/pyproject.toml +++ b/application/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/http_client/CHANGES.md b/http_client/CHANGES.md index 5c249dda..b01231cc 100644 --- a/http_client/CHANGES.md +++ b/http_client/CHANGES.md @@ -1,3 +1,6 @@ +# 1.4.3 +- Update JWT dependency version + # 1.4.2 - Support for Python 3.13, drop support for 3.8 diff --git a/http_client/pyproject.toml b/http_client/pyproject.toml index 5a8ec7ce..e068f809 100644 --- a/http_client/pyproject.toml +++ b/http_client/pyproject.toml @@ -7,7 +7,7 @@ authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", - "vonage-jwt>=1.1.3", + "vonage-jwt>=1.1.4", "requests>=2.27.0", "typing-extensions>=4.9.0", "pydantic>=2.9.2", diff --git a/http_client/src/vonage_http_client/_version.py b/http_client/src/vonage_http_client/_version.py index 98d186be..4e7c72a5 100644 --- a/http_client/src/vonage_http_client/_version.py +++ b/http_client/src/vonage_http_client/_version.py @@ -1 +1 @@ -__version__ = '1.4.2' +__version__ = '1.4.3' diff --git a/jwt/CHANGES.md b/jwt/CHANGES.md index 45624c05..f4d4b74e 100644 --- a/jwt/CHANGES.md +++ b/jwt/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.4 +- Fix a bug with generating non-default JWTs + # 1.1.3 - Support for Python 3.13, drop support for 3.8 diff --git a/jwt/src/vonage_jwt/_version.py b/jwt/src/vonage_jwt/_version.py index 7bb021e2..bc50bee6 100644 --- a/jwt/src/vonage_jwt/_version.py +++ b/jwt/src/vonage_jwt/_version.py @@ -1 +1 @@ -__version__ = '1.1.3' +__version__ = '1.1.4' diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py index f0f2c547..90b394b3 100644 --- a/jwt/src/vonage_jwt/jwt.py +++ b/jwt/src/vonage_jwt/jwt.py @@ -1,3 +1,4 @@ +from copy import deepcopy import re from time import time from typing import Union @@ -42,7 +43,7 @@ def generate_application_jwt(self, jwt_options: dict = None) -> bytes: iat = int(time()) - payload = jwt_options + payload = deepcopy(jwt_options) payload["application_id"] = self._application_id payload['iat'] = payload.get("iat", iat) payload["jti"] = payload.get("jti", str(uuid4())) diff --git a/messages/pyproject.toml b/messages/pyproject.toml index 3143c943..1f0bb113 100644 --- a/messages/pyproject.toml +++ b/messages/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/network_auth/pyproject.toml b/network_auth/pyproject.toml index 7ca35f8b..c983dd96 100644 --- a/network_auth/pyproject.toml +++ b/network_auth/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/network_number_verification/pyproject.toml b/network_number_verification/pyproject.toml index 50350896..f270235f 100644 --- a/network_number_verification/pyproject.toml +++ b/network_number_verification/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-network-auth>=1.0.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", diff --git a/network_sim_swap/pyproject.toml b/network_sim_swap/pyproject.toml index c4f0fbff..d14bd321 100644 --- a/network_sim_swap/pyproject.toml +++ b/network_sim_swap/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-network-auth>=1.0.0", "vonage-utils>=1.1.4", "pydantic>=2.9.2", diff --git a/number_insight/pyproject.toml b/number_insight/pyproject.toml index 5583448b..8dbd7558 100644 --- a/number_insight/pyproject.toml +++ b/number_insight/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/number_management/pyproject.toml b/number_management/pyproject.toml index 724fa07b..848bf636 100644 --- a/number_management/pyproject.toml +++ b/number_management/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/sms/pyproject.toml b/sms/pyproject.toml index b876150c..1c21cec2 100644 --- a/sms/pyproject.toml +++ b/sms/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/subaccounts/pyproject.toml b/subaccounts/pyproject.toml index 2aefdbfb..367e77da 100644 --- a/subaccounts/pyproject.toml +++ b/subaccounts/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/users/pyproject.toml b/users/pyproject.toml index 941f55ca..4aa2f5d4 100644 --- a/users/pyproject.toml +++ b/users/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/verify/pyproject.toml b/verify/pyproject.toml index effc9349..6fa5e68e 100644 --- a/verify/pyproject.toml +++ b/verify/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/verify_legacy/pyproject.toml b/verify_legacy/pyproject.toml index cf201385..f2811534 100644 --- a/verify_legacy/pyproject.toml +++ b/verify_legacy/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/video/pyproject.toml b/video/pyproject.toml index 9e3b3bf1..5259588c 100644 --- a/video/pyproject.toml +++ b/video/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/voice/pyproject.toml b/voice/pyproject.toml index c0d471f3..06ea83b3 100644 --- a/voice/pyproject.toml +++ b/voice/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Vonage", email = "devrel@vonage.com" }] requires-python = ">=3.9" dependencies = [ - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-utils>=1.1.4", "pydantic>=2.9.2", ] diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 2d609241..ef063b1f 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", - "vonage-http-client>=1.4.2", + "vonage-http-client>=1.4.3", "vonage-account>=1.0.2", "vonage-application>=1.0.3", "vonage-messages>=1.2.2", From c34b9cb82d18021a52f4325934f61fcc830085cc Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 30 Oct 2024 14:22:12 +0000 Subject: [PATCH 95/98] update package versions --- account/CHANGES.md | 3 +++ account/src/vonage_account/_version.py | 2 +- application/CHANGES.md | 3 +++ .../src/vonage_application/_version.py | 2 +- jwt/src/vonage_jwt/jwt.py | 2 +- messages/CHANGES.md | 3 +++ messages/src/vonage_messages/_version.py | 2 +- network_auth/CHANGES.md | 3 +++ .../src/vonage_network_auth/_version.py | 2 +- network_number_verification/CHANGES.md | 3 +++ .../_version.py | 2 +- network_sim_swap/CHANGES.md | 3 +++ .../src/vonage_network_sim_swap/_version.py | 2 +- number_insight/CHANGES.md | 3 +++ .../src/vonage_number_insight/_version.py | 2 +- number_management/CHANGES.md | 3 +++ .../src/vonage_numbers/_version.py | 2 +- sms/CHANGES.md | 3 +++ sms/src/vonage_sms/_version.py | 2 +- subaccounts/CHANGES.md | 3 +++ .../src/vonage_subaccounts/_version.py | 2 +- users/CHANGES.md | 1 + verify/CHANGES.md | 1 + verify_legacy/CHANGES.md | 1 + video/CHANGES.md | 3 +++ video/src/vonage_video/_version.py | 2 +- voice/CHANGES.md | 3 +++ voice/src/vonage_voice/_version.py | 2 +- vonage/pyproject.toml | 26 +++++++++---------- vonage/src/vonage/_version.py | 2 +- 30 files changed, 66 insertions(+), 27 deletions(-) diff --git a/account/CHANGES.md b/account/CHANGES.md index 1164e2aa..e42088a7 100644 --- a/account/CHANGES.md +++ b/account/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Update dependency versions + # 1.0.2 - Support for Python 3.13, drop support for 3.8 diff --git a/account/src/vonage_account/_version.py b/account/src/vonage_account/_version.py index a6221b3d..3f6fab60 100644 --- a/account/src/vonage_account/_version.py +++ b/account/src/vonage_account/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/application/CHANGES.md b/application/CHANGES.md index 462553e8..c6b3e0f6 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,3 +1,6 @@ +# 2.0.1 +- Update dependency versions + # 2.0.0 - Rename `params` -> `config` in method arguments diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py index afced147..3f390799 100644 --- a/application/src/vonage_application/_version.py +++ b/application/src/vonage_application/_version.py @@ -1 +1 @@ -__version__ = '2.0.0' +__version__ = '2.0.1' diff --git a/jwt/src/vonage_jwt/jwt.py b/jwt/src/vonage_jwt/jwt.py index 90b394b3..3ea36937 100644 --- a/jwt/src/vonage_jwt/jwt.py +++ b/jwt/src/vonage_jwt/jwt.py @@ -1,5 +1,5 @@ -from copy import deepcopy import re +from copy import deepcopy from time import time from typing import Union from uuid import uuid4 diff --git a/messages/CHANGES.md b/messages/CHANGES.md index 5f14ea7d..2019adce 100644 --- a/messages/CHANGES.md +++ b/messages/CHANGES.md @@ -1,3 +1,6 @@ +# 1.2.3 +- Update dependency versions + # 1.2.2 - Support for Python 3.13, drop support for 3.8 diff --git a/messages/src/vonage_messages/_version.py b/messages/src/vonage_messages/_version.py index 923b9879..5a5df3be 100644 --- a/messages/src/vonage_messages/_version.py +++ b/messages/src/vonage_messages/_version.py @@ -1 +1 @@ -__version__ = '1.2.2' +__version__ = '1.2.3' diff --git a/network_auth/CHANGES.md b/network_auth/CHANGES.md index 477b10fb..72a7fc85 100644 --- a/network_auth/CHANGES.md +++ b/network_auth/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.1 +- Update dependency versions + # 1.0.0 - Add methods to work with the Vonage Number Verification API - Internal refactoring diff --git a/network_auth/src/vonage_network_auth/_version.py b/network_auth/src/vonage_network_auth/_version.py index 1f356cc5..cd7ca498 100644 --- a/network_auth/src/vonage_network_auth/_version.py +++ b/network_auth/src/vonage_network_auth/_version.py @@ -1 +1 @@ -__version__ = '1.0.0' +__version__ = '1.0.1' diff --git a/network_number_verification/CHANGES.md b/network_number_verification/CHANGES.md index a376cb52..38ac7bab 100644 --- a/network_number_verification/CHANGES.md +++ b/network_number_verification/CHANGES.md @@ -1,2 +1,5 @@ +# 1.0.1 +- Update dependency versions + # 1.0.0 - Initial upload \ No newline at end of file diff --git a/network_number_verification/src/vonage_network_number_verification/_version.py b/network_number_verification/src/vonage_network_number_verification/_version.py index 1f356cc5..cd7ca498 100644 --- a/network_number_verification/src/vonage_network_number_verification/_version.py +++ b/network_number_verification/src/vonage_network_number_verification/_version.py @@ -1 +1 @@ -__version__ = '1.0.0' +__version__ = '1.0.1' diff --git a/network_sim_swap/CHANGES.md b/network_sim_swap/CHANGES.md index 89595372..8d7cd951 100644 --- a/network_sim_swap/CHANGES.md +++ b/network_sim_swap/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.1 +- Update dependency versions + # 1.1.0 - Add new model `SimSwapCheckRequest` to replace arguments in the `SimSwap.check` method diff --git a/network_sim_swap/src/vonage_network_sim_swap/_version.py b/network_sim_swap/src/vonage_network_sim_swap/_version.py index 1a72d32e..b3ddbc41 100644 --- a/network_sim_swap/src/vonage_network_sim_swap/_version.py +++ b/network_sim_swap/src/vonage_network_sim_swap/_version.py @@ -1 +1 @@ -__version__ = '1.1.0' +__version__ = '1.1.1' diff --git a/number_insight/CHANGES.md b/number_insight/CHANGES.md index 3fe586ce..c1abf577 100644 --- a/number_insight/CHANGES.md +++ b/number_insight/CHANGES.md @@ -1,3 +1,6 @@ +# 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` diff --git a/number_insight/src/vonage_number_insight/_version.py b/number_insight/src/vonage_number_insight/_version.py index 3f6fab60..8a81504c 100644 --- a/number_insight/src/vonage_number_insight/_version.py +++ b/number_insight/src/vonage_number_insight/_version.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '1.0.4' diff --git a/number_management/CHANGES.md b/number_management/CHANGES.md index 09634739..724aa699 100644 --- a/number_management/CHANGES.md +++ b/number_management/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Update dependency versions + # 1.0.2 - Support for Python 3.13, drop support for 3.8 diff --git a/number_management/src/vonage_numbers/_version.py b/number_management/src/vonage_numbers/_version.py index a6221b3d..3f6fab60 100644 --- a/number_management/src/vonage_numbers/_version.py +++ b/number_management/src/vonage_numbers/_version.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/sms/CHANGES.md b/sms/CHANGES.md index 890fa7c9..0937faee 100644 --- a/sms/CHANGES.md +++ b/sms/CHANGES.md @@ -1,3 +1,6 @@ +# 1.1.4 +- Update dependency versions + # 1.1.3 - Support for Python 3.13, drop support for 3.8 diff --git a/sms/src/vonage_sms/_version.py b/sms/src/vonage_sms/_version.py index 7bb021e2..bc50bee6 100644 --- a/sms/src/vonage_sms/_version.py +++ b/sms/src/vonage_sms/_version.py @@ -1 +1 @@ -__version__ = '1.1.3' +__version__ = '1.1.4' diff --git a/subaccounts/CHANGES.md b/subaccounts/CHANGES.md index c0dc3855..a9efcbae 100644 --- a/subaccounts/CHANGES.md +++ b/subaccounts/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.3 +- Update dependency versions + # 1.0.3 - Support for Python 3.13, drop support for 3.8 diff --git a/subaccounts/src/vonage_subaccounts/_version.py b/subaccounts/src/vonage_subaccounts/_version.py index 3f6fab60..8a81504c 100644 --- a/subaccounts/src/vonage_subaccounts/_version.py +++ b/subaccounts/src/vonage_subaccounts/_version.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '1.0.4' diff --git a/users/CHANGES.md b/users/CHANGES.md index 53ca01d2..579ff93d 100644 --- a/users/CHANGES.md +++ b/users/CHANGES.md @@ -1,5 +1,6 @@ # 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 diff --git a/verify/CHANGES.md b/verify/CHANGES.md index d31f8c51..f8bf361b 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,5 +1,6 @@ # 2.0.0 - Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. +- Update dependency versions # 1.1.4 - Support for Python 3.13, drop support for 3.8 diff --git a/verify_legacy/CHANGES.md b/verify_legacy/CHANGES.md index 89a19a39..c6e06449 100644 --- a/verify_legacy/CHANGES.md +++ b/verify_legacy/CHANGES.md @@ -1,5 +1,6 @@ # 2.0.0 - Rename to "Verify Legacy", rename `vonage-verify` -> `vonage-verify-legacy`, `Verify` -> `VerifyLegacy`, etc. +- Update dependency versions # 1.1.3 - Support for Python 3.13, drop support for 3.8 diff --git a/video/CHANGES.md b/video/CHANGES.md index 6340e72a..ecaf37cf 100644 --- a/video/CHANGES.md +++ b/video/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.2 +- Update dependency versions + # 1.0.1 - Support for Python 3.13, drop support for 3.8 diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index cd7ca498..a6221b3d 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' diff --git a/voice/CHANGES.md b/voice/CHANGES.md index f1eb406d..743d3661 100644 --- a/voice/CHANGES.md +++ b/voice/CHANGES.md @@ -1,3 +1,6 @@ +# 1.0.6 +- Update dependency versions + # 1.0.5 - Support for Python 3.13, drop support for 3.8 diff --git a/voice/src/vonage_voice/_version.py b/voice/src/vonage_voice/_version.py index 858de170..da2182f1 100644 --- a/voice/src/vonage_voice/_version.py +++ b/voice/src/vonage_voice/_version.py @@ -1 +1 @@ -__version__ = '1.0.5' +__version__ = '1.0.6' diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index ef063b1f..8d0d4e0d 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -7,21 +7,21 @@ requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", "vonage-http-client>=1.4.3", - "vonage-account>=1.0.2", - "vonage-application>=1.0.3", - "vonage-messages>=1.2.2", - "vonage-network-auth>=1.0.0", - "vonage-network-sim-swap>=1.1.0", - "vonage-network-number-verification>=1.0.0", - "vonage-number-insight>=1.0.3", - "vonage-numbers>=1.0.2", - "vonage-sms>=1.1.3", - "vonage-subaccounts>=1.0.3", - "vonage-users>=1.1.4", + "vonage-account>=1.0.3", + "vonage-application>=2.0.1", + "vonage-messages>=1.2.3", + "vonage-network-auth>=1.0.1", + "vonage-network-sim-swap>=1.1.1", + "vonage-network-number-verification>=1.0.1", + "vonage-number-insight>=1.0.4", + "vonage-numbers>=1.0.3", + "vonage-sms>=1.1.4", + "vonage-subaccounts>=1.0.4", + "vonage-users>=1.2.0", "vonage-verify>=2.0.0", "vonage-verify-legacy>=2.0.0", - "vonage-video>=1.0.1", - "vonage-voice>=1.0.5", + "vonage-video>=1.0.2", + "vonage-voice>=1.0.6", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 42ac78b7..8303ea95 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.0.0b1' +__version__ = '4.0.0b2' From 4717e7d53e0697c5fb83ea621cd506bd920c2e2d Mon Sep 17 00:00:00 2001 From: maxkahan Date: Tue, 5 Nov 2024 18:44:23 +0000 Subject: [PATCH 96/98] add pricing methods --- account/README.md | 32 +++++++- account/src/vonage_account/__init__.py | 19 ++++- account/src/vonage_account/account.py | 77 ++++++++++++++++++- account/src/vonage_account/requests.py | 43 +++++++++++ account/src/vonage_account/responses.py | 60 +++++++++++++++ application/CHANGES.md | 4 +- .../src/vonage_application/_version.py | 2 +- sms/src/vonage_sms/sms.py | 2 +- vonage/pyproject.toml | 2 +- 9 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 account/src/vonage_account/requests.py diff --git a/account/README.md b/account/README.md index 2974f04f..c3dcde0d 100644 --- a/account/README.md +++ b/account/README.md @@ -2,13 +2,12 @@ This package contains the code to use Vonage's Account API in Python. -It includes methods for managing Vonage accounts. +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 @@ -23,6 +22,35 @@ 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. diff --git a/account/src/vonage_account/__init__.py b/account/src/vonage_account/__init__.py index 84b76801..fb3b3b93 100644 --- a/account/src/vonage_account/__init__.py +++ b/account/src/vonage_account/__init__.py @@ -1,11 +1,26 @@ from .account import Account from .errors import InvalidSecretError -from .responses import Balance, SettingsResponse, TopUpResponse, VonageApiSecret +from .requests import GetCountryPricingRequest, GetPrefixPricingRequest, ServiceType +from .responses import ( + Balance, + GetPricingResponse, + GetMultiplePricingResponse, + NetworkPricing, + SettingsResponse, + TopUpResponse, + VonageApiSecret, +) __all__ = [ 'Account', - 'Balance', 'InvalidSecretError', + 'GetCountryPricingRequest', + 'GetPrefixPricingRequest', + 'ServiceType', + 'Balance', + 'GetPricingResponse', + 'GetMultiplePricingResponse', + 'NetworkPricing', 'SettingsResponse', 'TopUpResponse', 'VonageApiSecret', diff --git a/account/src/vonage_account/account.py b/account/src/vonage_account/account.py index fec0fccc..6a29df7b 100644 --- a/account/src/vonage_account/account.py +++ b/account/src/vonage_account/account.py @@ -2,9 +2,21 @@ 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, SettingsResponse, TopUpResponse, VonageApiSecret +from .responses import ( + Balance, + GetMultiplePricingResponse, + GetPricingResponse, + SettingsResponse, + TopUpResponse, + VonageApiSecret, +) class Account: @@ -90,6 +102,69 @@ def update_default_sms_webhook( ) 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. diff --git a/account/src/vonage_account/requests.py b/account/src/vonage_account/requests.py new file mode 100644 index 00000000..3a95d035 --- /dev/null +++ b/account/src/vonage_account/requests.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel +from enum import Enum + + +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 index 9e980e95..c094557d 100644 --- a/account/src/vonage_account/responses.py +++ b/account/src/vonage_account/responses.py @@ -53,6 +53,66 @@ class SettingsResponse(BaseModel): ) +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. + """ + + 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 + + +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. diff --git a/application/CHANGES.md b/application/CHANGES.md index c6b3e0f6..37a93036 100644 --- a/application/CHANGES.md +++ b/application/CHANGES.md @@ -1,8 +1,6 @@ -# 2.0.1 -- Update 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 diff --git a/application/src/vonage_application/_version.py b/application/src/vonage_application/_version.py index 3f390799..afced147 100644 --- a/application/src/vonage_application/_version.py +++ b/application/src/vonage_application/_version.py @@ -1 +1 @@ -__version__ = '2.0.1' +__version__ = '2.0.0' diff --git a/sms/src/vonage_sms/sms.py b/sms/src/vonage_sms/sms.py index 5e78eeb8..b9e4b6ba 100644 --- a/sms/src/vonage_sms/sms.py +++ b/sms/src/vonage_sms/sms.py @@ -22,7 +22,7 @@ class Sms: def __init__(self, http_client: HttpClient) -> None: self._http_client = http_client self._sent_data_type = 'form' - if self._http_client._auth._signature_secret: + if self._http_client.auth._signature_secret: self._auth_type = 'signature' else: self._auth_type = 'basic' diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 8d0d4e0d..bbabbf14 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "vonage-utils>=1.1.4", "vonage-http-client>=1.4.3", "vonage-account>=1.0.3", - "vonage-application>=2.0.1", + "vonage-application>=2.0.0", "vonage-messages>=1.2.3", "vonage-network-auth>=1.0.1", "vonage-network-sim-swap>=1.1.1", From 4d53afa266450dcb3dc68f782f3b30559060bd2b Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 6 Nov 2024 11:14:22 +0000 Subject: [PATCH 97/98] add pricing api testing --- README.md | 29 +++++++ account/CHANGES.md | 3 +- account/src/vonage_account/__init__.py | 2 +- account/src/vonage_account/_version.py | 2 +- account/src/vonage_account/requests.py | 3 +- account/src/vonage_account/responses.py | 2 + account/tests/data/get_country_pricing.json | 79 +++++++++++++++++++ .../data/get_multiple_countries_pricing.json | 47 +++++++++++ account/tests/test_account.py | 65 +++++++++++++++ 9 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 account/tests/data/get_country_pricing.json create mode 100644 account/tests/data/get_multiple_countries_pricing.json diff --git a/README.md b/README.md index 562b1a25..5a1022bf 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,35 @@ settings: SettingsResponse = vonage_client.account.update_default_sms_webhook( print(settings) ``` +### 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) +``` + ### List Secrets Associated with the Account ```python diff --git a/account/CHANGES.md b/account/CHANGES.md index e42088a7..032649f7 100644 --- a/account/CHANGES.md +++ b/account/CHANGES.md @@ -1,4 +1,5 @@ -# 1.0.3 +# 1.1.0 +- Add support for the [Vonage Pricing API](https://developer.vonage.com/en/api/pricing) - Update dependency versions # 1.0.2 diff --git a/account/src/vonage_account/__init__.py b/account/src/vonage_account/__init__.py index fb3b3b93..c5384afd 100644 --- a/account/src/vonage_account/__init__.py +++ b/account/src/vonage_account/__init__.py @@ -3,8 +3,8 @@ from .requests import GetCountryPricingRequest, GetPrefixPricingRequest, ServiceType from .responses import ( Balance, - GetPricingResponse, GetMultiplePricingResponse, + GetPricingResponse, NetworkPricing, SettingsResponse, TopUpResponse, diff --git a/account/src/vonage_account/_version.py b/account/src/vonage_account/_version.py index 3f6fab60..1a72d32e 100644 --- a/account/src/vonage_account/_version.py +++ b/account/src/vonage_account/_version.py @@ -1 +1 @@ -__version__ = '1.0.3' +__version__ = '1.1.0' diff --git a/account/src/vonage_account/requests.py b/account/src/vonage_account/requests.py index 3a95d035..073abe5e 100644 --- a/account/src/vonage_account/requests.py +++ b/account/src/vonage_account/requests.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel from enum import Enum +from pydantic import BaseModel + class ServiceType(str, Enum): """The service you wish to retrieve outbound pricing data about. diff --git a/account/src/vonage_account/responses.py b/account/src/vonage_account/responses.py index c094557d..b49115e1 100644 --- a/account/src/vonage_account/responses.py +++ b/account/src/vonage_account/responses.py @@ -65,6 +65,7 @@ class NetworkPricing(BaseModel): 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 @@ -75,6 +76,7 @@ class NetworkPricing(BaseModel): 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): 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/test_account.py b/account/tests/test_account.py index b085d2a1..318d72ff 100644 --- a/account/tests/test_account.py +++ b/account/tests/test_account.py @@ -4,6 +4,11 @@ from pytest import raises from vonage_account.account import Account from vonage_account.errors import InvalidSecretError +from vonage_account.requests import ( + GetCountryPricingRequest, + GetPrefixPricingRequest, + ServiceType, +) from vonage_http_client.errors import ForbiddenError from vonage_http_client.http_client import HttpClient @@ -70,6 +75,66 @@ def test_update_default_sms_webhook(): 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( From 52ae54fb9b021f9579927d339a7072dce196e827 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 6 Nov 2024 17:40:51 +0000 Subject: [PATCH 98/98] update readmes and versioning --- README.md | 26 +++++++++++++------ V3_TO_V4_SDK_MIGRATION_GUIDE.md | 3 ++- verify/CHANGES.md | 2 +- verify_legacy/CHANGES.md | 21 +-------------- .../src/vonage_verify_legacy/_version.py | 2 +- vonage/pyproject.toml | 4 +-- 6 files changed, 25 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5a1022bf..be28ad61 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,11 @@ It's recommended to create a new virtual environment to install the SDK. You can # Create the virtual environment python3 -m venv venv -# Activate the virtual environment +# Activate the virtual environment in Mac/Linux . ./venv/bin/activate + +# Or on Windows Command Prompt +venv\Scripts\activate ``` To install the Python SDK package using pip: @@ -62,13 +65,7 @@ To upgrade your installed client library using pip: pip install vonage --upgrade ``` -Alternatively, you can clone the repository via the command line: - -```bash -git clone git@github.com:Vonage/vonage-python-sdk.git -``` - -or by opening it on GitHub desktop. +Alternatively, you can clone the repository via the command line, or by opening it on GitHub desktop. ## Migration Guides @@ -99,6 +96,8 @@ print(response.model_dump(exclude_unset=True)) ## 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 from vonage import Vonage, Auth, HttpClientOptions @@ -129,6 +128,17 @@ You can also access the underlying `HttpClient` instance through the `http_clien user_agent = vonage.http_client.user_agent ``` +### 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` + +```python +response = vonage.api_package.api_call(...) + +response_dict = response.model_dump() +response_json = response.model_dump_json() +``` + ## Account API ### Get Account Balance diff --git a/V3_TO_V4_SDK_MIGRATION_GUIDE.md b/V3_TO_V4_SDK_MIGRATION_GUIDE.md index aa2de146..639d3743 100644 --- a/V3_TO_V4_SDK_MIGRATION_GUIDE.md +++ b/V3_TO_V4_SDK_MIGRATION_GUIDE.md @@ -142,7 +142,7 @@ vonage_client.messages.send(message) 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. For example: +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 @@ -153,6 +153,7 @@ settings: SettingsResponse = vonage_client.account.update_default_sms_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. diff --git a/verify/CHANGES.md b/verify/CHANGES.md index f8bf361b..769d8e2b 100644 --- a/verify/CHANGES.md +++ b/verify/CHANGES.md @@ -1,5 +1,5 @@ # 2.0.0 -- Rename `vonage-verify-v2` package -> `vonage-verify`, `VerifyV2` -> `Verify`, etc. +- 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 diff --git a/verify_legacy/CHANGES.md b/verify_legacy/CHANGES.md index c6e06449..9a43ef2c 100644 --- a/verify_legacy/CHANGES.md +++ b/verify_legacy/CHANGES.md @@ -1,21 +1,2 @@ -# 2.0.0 -- Rename to "Verify Legacy", rename `vonage-verify` -> `vonage-verify-legacy`, `Verify` -> `VerifyLegacy`, etc. -- 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.1 -- Internal refactoring - # 1.0.0 -- Initial upload +- Initial upload as `legacy` package diff --git a/verify_legacy/src/vonage_verify_legacy/_version.py b/verify_legacy/src/vonage_verify_legacy/_version.py index afced147..1f356cc5 100644 --- a/verify_legacy/src/vonage_verify_legacy/_version.py +++ b/verify_legacy/src/vonage_verify_legacy/_version.py @@ -1 +1 @@ -__version__ = '2.0.0' +__version__ = '1.0.0' diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index bbabbf14..5ed9810c 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.9" dependencies = [ "vonage-utils>=1.1.4", "vonage-http-client>=1.4.3", - "vonage-account>=1.0.3", + "vonage-account>=1.1.0", "vonage-application>=2.0.0", "vonage-messages>=1.2.3", "vonage-network-auth>=1.0.1", @@ -19,7 +19,7 @@ dependencies = [ "vonage-subaccounts>=1.0.4", "vonage-users>=1.2.0", "vonage-verify>=2.0.0", - "vonage-verify-legacy>=2.0.0", + "vonage-verify-legacy>=1.0.0", "vonage-video>=1.0.2", "vonage-voice>=1.0.6", ]