volga-sdk 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- volga_sdk-1.0.0/.gitignore +7 -0
- volga_sdk-1.0.0/PKG-INFO +144 -0
- volga_sdk-1.0.0/README.md +127 -0
- volga_sdk-1.0.0/pyproject.toml +34 -0
- volga_sdk-1.0.0/tests/conftest.py +61 -0
- volga_sdk-1.0.0/tests/test_client.py +113 -0
- volga_sdk-1.0.0/tests/test_http.py +144 -0
- volga_sdk-1.0.0/tests/test_operations.py +63 -0
- volga_sdk-1.0.0/tests/test_pagination.py +37 -0
- volga_sdk-1.0.0/tests/test_webhooks.py +73 -0
- volga_sdk-1.0.0/volga/__init__.py +60 -0
- volga_sdk-1.0.0/volga/_http.py +198 -0
- volga_sdk-1.0.0/volga/client.py +217 -0
- volga_sdk-1.0.0/volga/errors.py +170 -0
- volga_sdk-1.0.0/volga/operations.py +29 -0
- volga_sdk-1.0.0/volga/pagination.py +25 -0
- volga_sdk-1.0.0/volga/webhooks.py +93 -0
volga_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: volga-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the Volga Public API (conversations, messages, outbound webhooks).
|
|
5
|
+
Project-URL: Homepage, https://hooks.volga-ai.com/v1/docs
|
|
6
|
+
Project-URL: Source, https://github.com/volga-ai/volga-core-lite
|
|
7
|
+
Author: Volga
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: api,instagram,sdk,volga,webhooks,whatsapp
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# volga
|
|
19
|
+
|
|
20
|
+
Official Python SDK for the [Volga Public API](https://hooks.volga-ai.com/v1/docs) — conversations, messages, and outbound webhooks.
|
|
21
|
+
|
|
22
|
+
- Zero dependencies (standard library only)
|
|
23
|
+
- Automatic retries (429 + 5xx + network) with `Retry-After` support
|
|
24
|
+
- Cursor auto-pagination via generators
|
|
25
|
+
- Built-in webhook signature verification
|
|
26
|
+
- Python 3.8+
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install volga-sdk
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
(The distribution is `volga-sdk`; you still `import volga`.)
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from volga import VolgaClient
|
|
41
|
+
|
|
42
|
+
volga = VolgaClient(api_key=os.environ["VOLGA_API_KEY"])
|
|
43
|
+
|
|
44
|
+
# List conversations
|
|
45
|
+
page = volga.conversations.list(channel="whatsapp", limit=25)
|
|
46
|
+
|
|
47
|
+
# Send a reply (idempotent retries)
|
|
48
|
+
import uuid
|
|
49
|
+
volga.messages.send(
|
|
50
|
+
conversation_id=page["data"][0]["id"],
|
|
51
|
+
text="Thanks for reaching out!",
|
|
52
|
+
idempotency_key=str(uuid.uuid4()),
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Pagination
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# One page at a time
|
|
60
|
+
page = volga.messages.list(conversation_id="c_123")
|
|
61
|
+
print(page["data"], page["has_more"], page["next_cursor"])
|
|
62
|
+
|
|
63
|
+
# Or auto-paginate every item
|
|
64
|
+
for message in volga.messages.iterate(conversation_id="c_123"):
|
|
65
|
+
print(message["id"], message["text"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Webhooks
|
|
69
|
+
|
|
70
|
+
Register an endpoint (the signing `secret` is returned **once**):
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
endpoint = volga.webhook_endpoints.create(
|
|
74
|
+
url="https://example.com/volga/webhooks",
|
|
75
|
+
event_types=["message.received", "conversation.created"],
|
|
76
|
+
)
|
|
77
|
+
# store endpoint["secret"] securely
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Verify and parse incoming deliveries (pass the **raw** request body):
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from volga import construct_event, VolgaSignatureVerificationError
|
|
84
|
+
|
|
85
|
+
@app.post("/volga/webhooks")
|
|
86
|
+
def handle(request):
|
|
87
|
+
try:
|
|
88
|
+
event = construct_event(
|
|
89
|
+
secret=os.environ["VOLGA_WEBHOOK_SECRET"],
|
|
90
|
+
payload=request.get_data(), # raw bytes, not parsed JSON
|
|
91
|
+
signature_header=request.headers.get("Volga-Signature"),
|
|
92
|
+
)
|
|
93
|
+
except VolgaSignatureVerificationError:
|
|
94
|
+
return "", 400
|
|
95
|
+
# handle event["type"] / event["data"] ...
|
|
96
|
+
return "", 200
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Deliveries are **at-least-once** — deduplicate on the event `id` (or the
|
|
100
|
+
`Volga-Delivery-Id` header).
|
|
101
|
+
|
|
102
|
+
## Errors
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from volga import VolgaRateLimitError, VolgaPermissionError, VolgaApiError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
volga.messages.send(conversation_id=cid, text=text)
|
|
109
|
+
except VolgaRateLimitError as e:
|
|
110
|
+
time.sleep(e.retry_after_sec or 1)
|
|
111
|
+
except VolgaPermissionError:
|
|
112
|
+
... # the key is missing a scope
|
|
113
|
+
except VolgaApiError as e:
|
|
114
|
+
print(e.status, e.code, e.message, e.trace_id)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Class | Status |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `VolgaInvalidRequestError` | 400 / 422 |
|
|
120
|
+
| `VolgaAuthenticationError` | 401 |
|
|
121
|
+
| `VolgaPaymentRequiredError` | 402 |
|
|
122
|
+
| `VolgaPermissionError` | 403 |
|
|
123
|
+
| `VolgaNotFoundError` | 404 |
|
|
124
|
+
| `VolgaConflictError` | 409 |
|
|
125
|
+
| `VolgaRateLimitError` | 429 |
|
|
126
|
+
| `VolgaServerError` | 5xx |
|
|
127
|
+
| `VolgaConnectionError` / `VolgaTimeoutError` | no response |
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
VolgaClient(
|
|
133
|
+
api_key="vk_live_…",
|
|
134
|
+
base_url="https://hooks.volga-ai.com/v1", # default
|
|
135
|
+
timeout=30.0, # default
|
|
136
|
+
max_retries=2, # default
|
|
137
|
+
retry_base_delay=0.5,
|
|
138
|
+
retry_max_delay=8.0,
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# volga
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Volga Public API](https://hooks.volga-ai.com/v1/docs) — conversations, messages, and outbound webhooks.
|
|
4
|
+
|
|
5
|
+
- Zero dependencies (standard library only)
|
|
6
|
+
- Automatic retries (429 + 5xx + network) with `Retry-After` support
|
|
7
|
+
- Cursor auto-pagination via generators
|
|
8
|
+
- Built-in webhook signature verification
|
|
9
|
+
- Python 3.8+
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install volga-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
(The distribution is `volga-sdk`; you still `import volga`.)
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
from volga import VolgaClient
|
|
24
|
+
|
|
25
|
+
volga = VolgaClient(api_key=os.environ["VOLGA_API_KEY"])
|
|
26
|
+
|
|
27
|
+
# List conversations
|
|
28
|
+
page = volga.conversations.list(channel="whatsapp", limit=25)
|
|
29
|
+
|
|
30
|
+
# Send a reply (idempotent retries)
|
|
31
|
+
import uuid
|
|
32
|
+
volga.messages.send(
|
|
33
|
+
conversation_id=page["data"][0]["id"],
|
|
34
|
+
text="Thanks for reaching out!",
|
|
35
|
+
idempotency_key=str(uuid.uuid4()),
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Pagination
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# One page at a time
|
|
43
|
+
page = volga.messages.list(conversation_id="c_123")
|
|
44
|
+
print(page["data"], page["has_more"], page["next_cursor"])
|
|
45
|
+
|
|
46
|
+
# Or auto-paginate every item
|
|
47
|
+
for message in volga.messages.iterate(conversation_id="c_123"):
|
|
48
|
+
print(message["id"], message["text"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Webhooks
|
|
52
|
+
|
|
53
|
+
Register an endpoint (the signing `secret` is returned **once**):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
endpoint = volga.webhook_endpoints.create(
|
|
57
|
+
url="https://example.com/volga/webhooks",
|
|
58
|
+
event_types=["message.received", "conversation.created"],
|
|
59
|
+
)
|
|
60
|
+
# store endpoint["secret"] securely
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Verify and parse incoming deliveries (pass the **raw** request body):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from volga import construct_event, VolgaSignatureVerificationError
|
|
67
|
+
|
|
68
|
+
@app.post("/volga/webhooks")
|
|
69
|
+
def handle(request):
|
|
70
|
+
try:
|
|
71
|
+
event = construct_event(
|
|
72
|
+
secret=os.environ["VOLGA_WEBHOOK_SECRET"],
|
|
73
|
+
payload=request.get_data(), # raw bytes, not parsed JSON
|
|
74
|
+
signature_header=request.headers.get("Volga-Signature"),
|
|
75
|
+
)
|
|
76
|
+
except VolgaSignatureVerificationError:
|
|
77
|
+
return "", 400
|
|
78
|
+
# handle event["type"] / event["data"] ...
|
|
79
|
+
return "", 200
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Deliveries are **at-least-once** — deduplicate on the event `id` (or the
|
|
83
|
+
`Volga-Delivery-Id` header).
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from volga import VolgaRateLimitError, VolgaPermissionError, VolgaApiError
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
volga.messages.send(conversation_id=cid, text=text)
|
|
92
|
+
except VolgaRateLimitError as e:
|
|
93
|
+
time.sleep(e.retry_after_sec or 1)
|
|
94
|
+
except VolgaPermissionError:
|
|
95
|
+
... # the key is missing a scope
|
|
96
|
+
except VolgaApiError as e:
|
|
97
|
+
print(e.status, e.code, e.message, e.trace_id)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Class | Status |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `VolgaInvalidRequestError` | 400 / 422 |
|
|
103
|
+
| `VolgaAuthenticationError` | 401 |
|
|
104
|
+
| `VolgaPaymentRequiredError` | 402 |
|
|
105
|
+
| `VolgaPermissionError` | 403 |
|
|
106
|
+
| `VolgaNotFoundError` | 404 |
|
|
107
|
+
| `VolgaConflictError` | 409 |
|
|
108
|
+
| `VolgaRateLimitError` | 429 |
|
|
109
|
+
| `VolgaServerError` | 5xx |
|
|
110
|
+
| `VolgaConnectionError` / `VolgaTimeoutError` | no response |
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
VolgaClient(
|
|
116
|
+
api_key="vk_live_…",
|
|
117
|
+
base_url="https://hooks.volga-ai.com/v1", # default
|
|
118
|
+
timeout=30.0, # default
|
|
119
|
+
max_retries=2, # default
|
|
120
|
+
retry_base_delay=0.5,
|
|
121
|
+
retry_max_delay=8.0,
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution name on PyPI (the bare `volga` is taken by an unrelated project).
|
|
7
|
+
# The import name stays `volga` (see [tool.hatch.build.targets.wheel]).
|
|
8
|
+
name = "volga-sdk"
|
|
9
|
+
version = "1.0.0"
|
|
10
|
+
description = "Official Python SDK for the Volga Public API (conversations, messages, outbound webhooks)."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
keywords = ["volga", "whatsapp", "instagram", "webhooks", "api", "sdk"]
|
|
15
|
+
authors = [{ name = "Volga" }]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://hooks.volga-ai.com/v1/docs"
|
|
28
|
+
Source = "https://github.com/volga-ai/volga-core-lite"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["volga"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from volga import VolgaClient
|
|
7
|
+
from volga._http import HttpResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeHttp:
|
|
11
|
+
"""Replays a queue of canned responses and records every call."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, responses: List[Dict[str, Any]]) -> None:
|
|
14
|
+
self.responses = responses
|
|
15
|
+
self.calls: List[Dict[str, Any]] = []
|
|
16
|
+
|
|
17
|
+
def __call__(self, method, url, headers, body, timeout): # noqa: ANN001
|
|
18
|
+
self.calls.append(
|
|
19
|
+
{"method": method, "url": url, "headers": headers, "body": body}
|
|
20
|
+
)
|
|
21
|
+
idx = min(len(self.calls) - 1, len(self.responses) - 1)
|
|
22
|
+
spec = self.responses[idx] if self.responses else {}
|
|
23
|
+
if "raise" in spec:
|
|
24
|
+
raise spec["raise"]
|
|
25
|
+
status = spec.get("status", 200)
|
|
26
|
+
raw = spec.get("body")
|
|
27
|
+
if raw is None:
|
|
28
|
+
text = ""
|
|
29
|
+
elif isinstance(raw, str):
|
|
30
|
+
text = raw
|
|
31
|
+
else:
|
|
32
|
+
text = json.dumps(raw)
|
|
33
|
+
return HttpResponse(status, spec.get("headers", {}), text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_client(responses: List[Dict[str, Any]], **overrides):
|
|
37
|
+
http = FakeHttp(responses)
|
|
38
|
+
client = VolgaClient(
|
|
39
|
+
api_key="vk_test_abc",
|
|
40
|
+
base_url="https://api.test/v1",
|
|
41
|
+
http=http,
|
|
42
|
+
retry_base_delay=0,
|
|
43
|
+
retry_max_delay=0,
|
|
44
|
+
sleep=lambda _s: None,
|
|
45
|
+
**overrides,
|
|
46
|
+
)
|
|
47
|
+
return client, http
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def client_factory():
|
|
52
|
+
return make_client
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def page(data: List[Any], next_cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"object": "list",
|
|
58
|
+
"data": data,
|
|
59
|
+
"has_more": next_cursor is not None,
|
|
60
|
+
"next_cursor": next_cursor,
|
|
61
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from volga import VolgaClient
|
|
6
|
+
from tests.conftest import make_client, page
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_requires_api_key():
|
|
10
|
+
with pytest.raises(Exception) as exc:
|
|
11
|
+
VolgaClient(api_key="")
|
|
12
|
+
assert "api key is required" in str(exc.value).lower()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_sends_bearer_and_accept():
|
|
16
|
+
client, http = make_client([{"body": page([])}])
|
|
17
|
+
client.conversations.list()
|
|
18
|
+
headers = http.calls[0]["headers"]
|
|
19
|
+
assert headers["Authorization"] == "Bearer vk_test_abc"
|
|
20
|
+
assert headers["Accept"] == "application/json"
|
|
21
|
+
assert headers["User-Agent"].startswith("volga-sdk-python/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_conversations_list_builds_query_and_omits_none():
|
|
25
|
+
client, http = make_client([{"body": page([{"id": "c1"}])}])
|
|
26
|
+
res = client.conversations.list(channel="instagram", limit=50)
|
|
27
|
+
assert res["data"][0]["id"] == "c1"
|
|
28
|
+
assert http.calls[0]["url"] == "https://api.test/v1/conversations?limit=50&channel=instagram"
|
|
29
|
+
assert "cursor" not in http.calls[0]["url"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_retrieve_url_encodes_id():
|
|
33
|
+
client, http = make_client([{"body": {"id": "a/b"}}])
|
|
34
|
+
client.conversations.retrieve("a/b")
|
|
35
|
+
assert http.calls[0]["url"] == "https://api.test/v1/conversations/a%2Fb"
|
|
36
|
+
assert http.calls[0]["method"] == "GET"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_messages_list_requires_conversation_id():
|
|
40
|
+
client, http = make_client([{"body": page([{"id": "m1"}])}])
|
|
41
|
+
client.messages.list("c1", limit=10)
|
|
42
|
+
assert http.calls[0]["url"] == "https://api.test/v1/messages?conversationId=c1&limit=10"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_messages_send_posts_body_and_idempotency_key():
|
|
46
|
+
client, http = make_client([{"status": 201, "body": {"id": "m9"}}])
|
|
47
|
+
sent = client.messages.send(conversation_id="c1", text="hello", idempotency_key="idem-123")
|
|
48
|
+
assert sent["id"] == "m9"
|
|
49
|
+
call = http.calls[0]
|
|
50
|
+
assert call["method"] == "POST"
|
|
51
|
+
assert json.loads(call["body"].decode()) == {"conversationId": "c1", "text": "hello"}
|
|
52
|
+
assert call["headers"]["Idempotency-Key"] == "idem-123"
|
|
53
|
+
assert call["headers"]["Content-Type"] == "application/json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_messages_send_without_idempotency_key():
|
|
57
|
+
client, http = make_client([{"status": 201, "body": {"id": "m1"}}])
|
|
58
|
+
client.messages.send(conversation_id="c1", text="hi")
|
|
59
|
+
assert "Idempotency-Key" not in http.calls[0]["headers"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_webhook_endpoint_create_returns_secret():
|
|
63
|
+
client, http = make_client(
|
|
64
|
+
[{"status": 201, "body": {"id": "we_1", "secret": "whsec_xyz"}}]
|
|
65
|
+
)
|
|
66
|
+
ep = client.webhook_endpoints.create(
|
|
67
|
+
url="https://x.test/hook", event_types=["message.sent"]
|
|
68
|
+
)
|
|
69
|
+
assert ep["secret"] == "whsec_xyz"
|
|
70
|
+
assert http.calls[0]["method"] == "POST"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_webhook_endpoint_update_rotate_secret_is_query_not_body():
|
|
74
|
+
client, http = make_client([{"body": {"id": "we_1", "secret": "whsec_new"}}])
|
|
75
|
+
client.webhook_endpoints.update("we_1", is_active=False, rotate_secret=True)
|
|
76
|
+
call = http.calls[0]
|
|
77
|
+
assert call["method"] == "PATCH"
|
|
78
|
+
assert call["url"] == "https://api.test/v1/webhook-endpoints/we_1?rotateSecret=true"
|
|
79
|
+
assert json.loads(call["body"].decode()) == {"isActive": False}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_webhook_endpoint_update_can_clear_description():
|
|
83
|
+
client, http = make_client([{"body": {"id": "we_1"}}])
|
|
84
|
+
client.webhook_endpoints.update("we_1", description=None)
|
|
85
|
+
assert json.loads(http.calls[0]["body"].decode()) == {"description": None}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_webhook_endpoint_update_omits_untouched_description():
|
|
89
|
+
client, http = make_client([{"body": {"id": "we_1"}}])
|
|
90
|
+
client.webhook_endpoints.update("we_1", is_active=True)
|
|
91
|
+
assert json.loads(http.calls[0]["body"].decode()) == {"isActive": True}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_webhook_endpoint_delete():
|
|
95
|
+
client, http = make_client([{"body": {"object": "webhook_endpoint", "id": "we_1", "deleted": True}}])
|
|
96
|
+
res = client.webhook_endpoints.delete("we_1")
|
|
97
|
+
assert res["deleted"] is True
|
|
98
|
+
assert http.calls[0]["method"] == "DELETE"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_webhook_delivery_replay():
|
|
102
|
+
client, http = make_client([{"body": {"id": "wd_1", "status": "PENDING"}}])
|
|
103
|
+
res = client.webhook_deliveries.replay("wd_1")
|
|
104
|
+
assert res["status"] == "PENDING"
|
|
105
|
+
assert http.calls[0]["method"] == "POST"
|
|
106
|
+
assert http.calls[0]["url"] == "https://api.test/v1/webhook-deliveries/wd_1/replay"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_get_openapi_spec():
|
|
110
|
+
client, http = make_client([{"body": {"openapi": "3.1.0"}}])
|
|
111
|
+
spec = client.get_openapi_spec()
|
|
112
|
+
assert spec["openapi"] == "3.1.0"
|
|
113
|
+
assert http.calls[0]["url"] == "https://api.test/v1/openapi.json"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from volga import (
|
|
4
|
+
VolgaApiError,
|
|
5
|
+
VolgaAuthenticationError,
|
|
6
|
+
VolgaConflictError,
|
|
7
|
+
VolgaConnectionError,
|
|
8
|
+
VolgaNotFoundError,
|
|
9
|
+
VolgaPermissionError,
|
|
10
|
+
VolgaRateLimitError,
|
|
11
|
+
VolgaServerError,
|
|
12
|
+
VolgaTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
from volga.errors import VolgaInvalidRequestError
|
|
15
|
+
from tests.conftest import make_client, page
|
|
16
|
+
|
|
17
|
+
EMPTY = page([])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def err(code, message):
|
|
21
|
+
return {"error": message, "errorCode": code}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize(
|
|
25
|
+
"status,ctor",
|
|
26
|
+
[
|
|
27
|
+
(400, VolgaInvalidRequestError),
|
|
28
|
+
(401, VolgaAuthenticationError),
|
|
29
|
+
(403, VolgaPermissionError),
|
|
30
|
+
(404, VolgaNotFoundError),
|
|
31
|
+
(409, VolgaConflictError),
|
|
32
|
+
(422, VolgaInvalidRequestError),
|
|
33
|
+
(500, VolgaServerError),
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
def test_error_mapping(status, ctor):
|
|
37
|
+
client, _ = make_client([{"status": status, "body": err("X", "boom")}], max_retries=0)
|
|
38
|
+
with pytest.raises(ctor):
|
|
39
|
+
client.conversations.list()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_reads_message_and_code():
|
|
43
|
+
client, _ = make_client([{"status": 403, "body": err("INSUFFICIENT_SCOPE", "missing scope")}])
|
|
44
|
+
with pytest.raises(VolgaApiError) as exc:
|
|
45
|
+
client.conversations.list()
|
|
46
|
+
assert exc.value.status == 403
|
|
47
|
+
assert exc.value.code == "INSUFFICIENT_SCOPE"
|
|
48
|
+
assert exc.value.message == "missing scope"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_exposes_trace_id_and_details():
|
|
52
|
+
body = {"error": "bad", "errorCode": "INVALID_PAYLOAD", "traceId": "tr_1", "details": {"f": 1}}
|
|
53
|
+
client, _ = make_client([{"status": 400, "body": body}])
|
|
54
|
+
with pytest.raises(VolgaApiError) as exc:
|
|
55
|
+
client.messages.send(conversation_id="c", text="x")
|
|
56
|
+
assert exc.value.trace_id == "tr_1"
|
|
57
|
+
assert exc.value.details == {"f": 1}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_non_json_body_falls_back_to_synthetic_code():
|
|
61
|
+
client, _ = make_client([{"status": 404, "body": "Not Found"}], max_retries=0)
|
|
62
|
+
with pytest.raises(VolgaApiError) as exc:
|
|
63
|
+
client.conversations.retrieve("x")
|
|
64
|
+
assert exc.value.code == "HTTP_404"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_retries_429_then_succeeds():
|
|
68
|
+
client, http = make_client(
|
|
69
|
+
[{"status": 429, "headers": {"Retry-After": "0"}, "body": err("RATE_LIMITED", "slow")},
|
|
70
|
+
{"body": EMPTY}]
|
|
71
|
+
)
|
|
72
|
+
client.conversations.list()
|
|
73
|
+
assert len(http.calls) == 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_surfaces_retry_after_when_exhausted():
|
|
77
|
+
client, _ = make_client(
|
|
78
|
+
[{"status": 429, "headers": {"Retry-After": "7"}, "body": err("RATE_LIMITED", "slow")}],
|
|
79
|
+
max_retries=1,
|
|
80
|
+
)
|
|
81
|
+
with pytest.raises(VolgaRateLimitError) as exc:
|
|
82
|
+
client.conversations.list()
|
|
83
|
+
assert exc.value.retry_after_sec == 7
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_retries_get_on_500():
|
|
87
|
+
client, http = make_client([{"status": 500, "body": err("X", "e")}, {"body": EMPTY}])
|
|
88
|
+
client.conversations.list()
|
|
89
|
+
assert len(http.calls) == 2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_does_not_retry_post_without_idempotency_on_500():
|
|
93
|
+
client, http = make_client([{"status": 500, "body": err("X", "e")}])
|
|
94
|
+
with pytest.raises(VolgaServerError):
|
|
95
|
+
client.messages.send(conversation_id="c", text="x")
|
|
96
|
+
assert len(http.calls) == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_retries_post_with_idempotency_on_500():
|
|
100
|
+
client, http = make_client(
|
|
101
|
+
[{"status": 500, "body": err("X", "e")}, {"status": 201, "body": {"id": "m1"}}]
|
|
102
|
+
)
|
|
103
|
+
client.messages.send(conversation_id="c", text="x", idempotency_key="k1")
|
|
104
|
+
assert len(http.calls) == 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_does_not_retry_4xx_other_than_429():
|
|
108
|
+
client, http = make_client([{"status": 400, "body": err("INVALID", "bad")}])
|
|
109
|
+
with pytest.raises(VolgaApiError):
|
|
110
|
+
client.conversations.list()
|
|
111
|
+
assert len(http.calls) == 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_retries_network_error_on_get():
|
|
115
|
+
client, http = make_client(
|
|
116
|
+
[{"raise": VolgaConnectionError("reset")}, {"body": EMPTY}]
|
|
117
|
+
)
|
|
118
|
+
client.conversations.list()
|
|
119
|
+
assert len(http.calls) == 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_wraps_persistent_network_failure():
|
|
123
|
+
client, _ = make_client([{"raise": VolgaConnectionError("reset")}], max_retries=1)
|
|
124
|
+
with pytest.raises(VolgaConnectionError):
|
|
125
|
+
client.conversations.list()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_timeout_on_get_retries_then_raises():
|
|
129
|
+
client, http = make_client(
|
|
130
|
+
[{"raise": VolgaTimeoutError("timed out")}], max_retries=2
|
|
131
|
+
)
|
|
132
|
+
with pytest.raises(VolgaTimeoutError):
|
|
133
|
+
client.conversations.list()
|
|
134
|
+
# initial attempt + 2 retries (timeout is a network-level failure on a GET)
|
|
135
|
+
assert len(http.calls) == 3
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_timeout_on_post_without_idempotency_is_not_retried():
|
|
139
|
+
client, http = make_client(
|
|
140
|
+
[{"raise": VolgaTimeoutError("timed out")}], max_retries=2
|
|
141
|
+
)
|
|
142
|
+
with pytest.raises(VolgaTimeoutError):
|
|
143
|
+
client.messages.send(conversation_id="c", text="x")
|
|
144
|
+
assert len(http.calls) == 1
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from volga import OPERATIONS, WEBHOOK_EVENT_TYPES, VolgaClient, operation_key
|
|
5
|
+
|
|
6
|
+
REPO_ROOT = os.path.abspath(
|
|
7
|
+
os.path.join(os.path.dirname(__file__), "..", "..", "..")
|
|
8
|
+
)
|
|
9
|
+
OPENAPI_PATH = os.path.join(REPO_ROOT, "openapi", "openapi.json")
|
|
10
|
+
|
|
11
|
+
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _spec_operation_keys():
|
|
15
|
+
with open(OPENAPI_PATH, "r", encoding="utf-8") as fh:
|
|
16
|
+
spec = json.load(fh)
|
|
17
|
+
keys = set()
|
|
18
|
+
for path, item in spec["paths"].items():
|
|
19
|
+
for method in item:
|
|
20
|
+
if method.lower() in HTTP_METHODS:
|
|
21
|
+
keys.add("{} {}".format(method.upper(), path))
|
|
22
|
+
return keys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _spec_webhook_event_types():
|
|
26
|
+
with open(OPENAPI_PATH, "r", encoding="utf-8") as fh:
|
|
27
|
+
spec = json.load(fh)
|
|
28
|
+
return set(spec.get("webhooks", {}).keys())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_sdk_declares_exactly_the_documented_webhook_events():
|
|
32
|
+
spec_events = _spec_webhook_event_types()
|
|
33
|
+
sdk_events = set(WEBHOOK_EVENT_TYPES)
|
|
34
|
+
assert sdk_events == spec_events, (
|
|
35
|
+
"webhook event drift — sdk: {}, spec: {}".format(sdk_events, spec_events)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_operation_keys_are_unique():
|
|
40
|
+
keys = [operation_key(op) for op in OPERATIONS]
|
|
41
|
+
assert len(set(keys)) == len(keys)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_sdk_covers_every_documented_operation():
|
|
45
|
+
spec_keys = _spec_operation_keys()
|
|
46
|
+
sdk_keys = {operation_key(op) for op in OPERATIONS}
|
|
47
|
+
missing = spec_keys - sdk_keys
|
|
48
|
+
phantom = sdk_keys - spec_keys
|
|
49
|
+
assert not missing, "documented operations not implemented in the SDK: {}".format(missing)
|
|
50
|
+
assert not phantom, "SDK operations not in the contract: {}".format(phantom)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_every_operation_resolves_to_a_real_method():
|
|
54
|
+
client = VolgaClient(api_key="vk_test_x")
|
|
55
|
+
for op in OPERATIONS:
|
|
56
|
+
if op["resource"] == "(client)":
|
|
57
|
+
assert callable(getattr(client, op["fn"], None)), op["fn"]
|
|
58
|
+
continue
|
|
59
|
+
resource = getattr(client, op["resource"], None)
|
|
60
|
+
assert resource is not None, "missing resource {}".format(op["resource"])
|
|
61
|
+
assert callable(getattr(resource, op["fn"], None)), "{}.{}".format(
|
|
62
|
+
op["resource"], op["fn"]
|
|
63
|
+
)
|