Skip to content

Commit 6056fc7

Browse files
authored
Add ncco builder (#236)
* initial Ncco class creation * adding optional fields, changing structure for namespacing reasons * added pydantic as dependency * using List type from typing module * adding and testing Notify and Talk actions, refactoring build_ncco method * more endpoints and tests * added connect_endpoints.py, Endpoints types, refactored into separate modules, testing connect action and endpoint models * placeholder validator test * changing NCCO builder test and module structure, add Connect and Stream endpoints, starting Input endpoint * added Input action and submodels, testing Input action, adding pay and submodels * renaming to fix validator conflict * adding pay prompt actions and errors, pay action testing * adding PayPrompts tests, adding type hints * finished Pay action, testing Ncco.build_ncco method * removing custom URL types as they cause problems, testing NCCO builder * adding full ncco builder test * added voice test using ncco builder
1 parent 2c97ff8 commit 6056fc7

File tree

16 files changed

+1145
-68
lines changed

16 files changed

+1145
-68
lines changed

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ need a Vonage account. Sign up [for free at vonage.com][signup].
1616
- [SMS API](#sms-api)
1717
- [Messages API](#messages-api)
1818
- [Voice API](#voice-api)
19+
- [NCCO Builder](#ncco-builder)
1920
- [Verify API](#verify-api)
2021
- [Number Insight API](#number-insight-api)
2122
- [Number Management API](#number-management-api)
@@ -337,6 +338,65 @@ client.voice.send_dtmf(response['uuid'], digits='1234')
337338
response = client.get_recording(RECORDING_URL)
338339
```
339340

341+
## NCCO Builder
342+
343+
The SDK contains a builder to help you create Call Control Objects (NCCOs) for use with the Vonage Voice API.
344+
345+
For more information, [check the full NCCO reference documentation on the Vonage website](https://developer.vonage.com/voice/voice-api/ncco-reference).
346+
347+
An NCCO is a list of "Actions": steps to be followed when a call is initiated or received.
348+
349+
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:
350+
351+
* Record
352+
* Conversation
353+
* Connect
354+
* Talk
355+
* Stream
356+
* Input
357+
* Notify
358+
* Pay
359+
360+
### Construct actions
361+
362+
```python
363+
record = Ncco.Record(eventUrl=['https://example.com'])
364+
talk = Ncco.Talk(text='Hello from Vonage!', bargeIn=True, loop=5, premium=True)
365+
```
366+
367+
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.
368+
369+
This example shows a Connect action created with an endpoint object.
370+
371+
```python
372+
phone = ConnectEndpoints.PhoneEndpoint(
373+
number='447000000000',
374+
dtmfAnswer='1p2p3p#**903#',
375+
)
376+
connect = Ncco.Connect(endpoint=phone, eventUrl=['https://example.com/events'], from_='447000000000')
377+
```
378+
379+
This example shows a different Connect action, created with a dictionary.
380+
381+
```python
382+
connect = Ncco.Connect(endpoint={'type': 'phone', 'number': '447000000000', 'dtmfAnswer': '2p02p'}, randomFromNumber=True)
383+
```
384+
385+
### Build into an NCCO
386+
387+
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.
388+
389+
```python
390+
ncco = Ncco.build_ncco(record, connect, talk)
391+
392+
response = client.voice.create_call({
393+
'to': [{'type': 'phone', 'number': TO_NUMBER}],
394+
'from': {'type': 'phone', 'number': VONAGE_NUMBER},
395+
'ncco': ncco
396+
})
397+
398+
pprint(response)
399+
```
340400

341401
## Verify API
342402

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pytest==7.2.0
33
responses==0.22.0
44
coverage
5+
pydantic
56

67
bump2version
78
build

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"PyJWT[crypto]>=1.6.4",
2828
"pytz>=2018.5",
2929
"Deprecated",
30+
"pydantic>=1.10.2",
3031
],
3132
python_requires=">=3.7",
3233
tests_require=["cryptography>=2.3.1"],

src/vonage/client.py

Lines changed: 33 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .application import ApplicationV2, Application
55
from .errors import *
66
from .messages import Messages
7+
from .ncco_builder.ncco import Ncco, ConnectEndpoints, InputTypes, PayPrompts
78
from .number_insight import NumberInsight
89
from .numbers import Numbers
910
from .redact import Redact
@@ -37,6 +38,7 @@
3738

3839
logger = logging.getLogger("vonage")
3940

41+
4042
class Client:
4143
"""
4244
Create a Client object to start making calls to Vonage/Nexmo APIs.
@@ -78,10 +80,10 @@ def __init__(
7880
private_key=None,
7981
app_name=None,
8082
app_version=None,
81-
timeout=None,
82-
pool_connections=10,
83-
pool_maxsize=10,
84-
max_retries=3
83+
timeout=None,
84+
pool_connections=10,
85+
pool_maxsize=10,
86+
max_retries=3,
8587
):
8688
self.api_key = key or os.environ.get("VONAGE_API_KEY", None)
8789
self.api_secret = secret or os.environ.get("VONAGE_API_SECRET", None)
@@ -126,9 +128,7 @@ def __init__(
126128
self.timeout = timeout
127129
self.session = Session()
128130
self.adapter = HTTPAdapter(
129-
pool_connections=pool_connections,
130-
pool_maxsize=pool_maxsize,
131-
max_retries=max_retries
131+
pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries
132132
)
133133
self.session.mount("https://", self.adapter)
134134

@@ -156,9 +156,7 @@ def check_signature(self, params):
156156

157157
def signature(self, params):
158158
if self.signature_method:
159-
hasher = hmac.new(
160-
self.signature_secret.encode(), digestmod=self.signature_method
161-
)
159+
hasher = hmac.new(self.signature_secret.encode(), digestmod=self.signature_method)
162160
else:
163161
hasher = hashlib.md5()
164162

@@ -186,13 +184,9 @@ def get(self, host, request_uri, params=None, auth_type=None):
186184
if auth_type == 'jwt':
187185
self._request_headers = self._add_jwt_to_request_headers()
188186
elif auth_type == 'params':
189-
params = dict(
190-
params or {}, api_key=self.api_key, api_secret=self.api_secret
191-
)
187+
params = dict(params or {}, api_key=self.api_key, api_secret=self.api_secret)
192188
elif auth_type == 'header':
193-
hash = base64.b64encode(
194-
f"{self.api_key}:{self.api_secret}".encode("utf-8")
195-
).decode("ascii")
189+
hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii")
196190
self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}")
197191
else:
198192
raise InvalidAuthenticationTypeError(
@@ -201,47 +195,45 @@ def get(self, host, request_uri, params=None, auth_type=None):
201195

202196
logger.debug(f"GET to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}")
203197
return self.parse(
204-
host,
205-
self.session.get(uri, params=params, headers=self._request_headers, timeout=self.timeout))
198+
host, self.session.get(uri, params=params, headers=self._request_headers, timeout=self.timeout)
199+
)
206200

207201
def post(self, host, request_uri, params, auth_type=None, body_is_json=True, supports_signature_auth=False):
208202
"""
209203
Low-level method to make a post request to an API server.
210204
This method automatically adds authentication, picking the first applicable authentication method from the following:
211-
- If the supports_signature_auth param is True, and the client was instantiated with a signature_secret,
205+
- If the supports_signature_auth param is True, and the client was instantiated with a signature_secret,
212206
then signature authentication will be used.
213-
:param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided
207+
:param bool supports_signature_auth: Preferentially use signature authentication if a signature_secret was provided
214208
when initializing this client.
215209
"""
216210
uri = f"https://{host}{request_uri}"
217211
self._request_headers = self.headers
218-
212+
219213
if supports_signature_auth and self.signature_secret:
220214
params["api_key"] = self.api_key
221215
params["sig"] = self.signature(params)
222216
elif auth_type == 'jwt':
223217
self._request_headers = self._add_jwt_to_request_headers()
224218
elif auth_type == 'params':
225-
params = dict(
226-
params, api_key=self.api_key, api_secret=self.api_secret
227-
)
219+
params = dict(params, api_key=self.api_key, api_secret=self.api_secret)
228220
elif auth_type == 'header':
229-
hash = base64.b64encode(
230-
f"{self.api_key}:{self.api_secret}".encode("utf-8")
231-
).decode("ascii")
221+
hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii")
232222
self._request_headers = dict(self.headers or {}, Authorization=f"Basic {hash}")
233223
else:
234224
raise InvalidAuthenticationTypeError(
235225
f'Invalid authentication type. Must be one of "jwt", "header" or "params".'
236226
)
237-
227+
238228
logger.debug(f"POST to {repr(uri)} with params {repr(params)}, headers {repr(self._request_headers)}")
239229
if body_is_json:
240230
return self.parse(
241-
host, self.session.post(uri, json=params, headers=self._request_headers, timeout=self.timeout))
231+
host, self.session.post(uri, json=params, headers=self._request_headers, timeout=self.timeout)
232+
)
242233
else:
243234
return self.parse(
244-
host, self.session.post(uri, data=params, headers=self._request_headers, timeout=self.timeout))
235+
host, self.session.post(uri, data=params, headers=self._request_headers, timeout=self.timeout)
236+
)
245237

246238
def put(self, host, request_uri, params, auth_type=None):
247239
uri = f"https://{host}{request_uri}"
@@ -250,9 +242,7 @@ def put(self, host, request_uri, params, auth_type=None):
250242
if auth_type == 'jwt':
251243
self._request_headers = self._add_jwt_to_request_headers()
252244
elif auth_type == 'header':
253-
hash = base64.b64encode(
254-
f"{self.api_key}:{self.api_secret}".encode("utf-8")
255-
).decode("ascii")
245+
hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii")
256246
self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}")
257247
else:
258248
raise InvalidAuthenticationTypeError(
@@ -269,27 +259,21 @@ def delete(self, host, request_uri, auth_type=None):
269259

270260
if auth_type == 'jwt':
271261
self._request_headers = self._add_jwt_to_request_headers()
272-
elif auth_type =='header':
273-
hash = base64.b64encode(
274-
f"{self.api_key}:{self.api_secret}".encode("utf-8")
275-
).decode("ascii")
262+
elif auth_type == 'header':
263+
hash = base64.b64encode(f"{self.api_key}:{self.api_secret}".encode("utf-8")).decode("ascii")
276264
self._request_headers = dict(self._request_headers or {}, Authorization=f"Basic {hash}")
277265
else:
278266
raise InvalidAuthenticationTypeError(
279267
f'Invalid authentication type. Must be one of "jwt", "header" or "params".'
280268
)
281269

282270
logger.debug(f"DELETE to {repr(uri)} with headers {repr(self._request_headers)}")
283-
return self.parse(
284-
host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout)
285-
)
271+
return self.parse(host, self.session.delete(uri, headers=self._request_headers, timeout=self.timeout))
286272

287273
def parse(self, host, response):
288274
logger.debug(f"Response headers {repr(response.headers)}")
289275
if response.status_code == 401:
290-
raise AuthenticationError(
291-
"Authentication failed. Check you're using a valid authentication method."
292-
)
276+
raise AuthenticationError("Authentication failed. Check you're using a valid authentication method.")
293277
elif response.status_code == 204:
294278
return None
295279
elif 200 <= response.status_code < 300:
@@ -301,22 +285,16 @@ def parse(self, host, response):
301285
else:
302286
return response.content
303287
elif 400 <= response.status_code < 500:
304-
logger.warning(
305-
f"Client error: {response.status_code} {repr(response.content)}"
306-
)
288+
logger.warning(f"Client error: {response.status_code} {repr(response.content)}")
307289
message = f"{response.status_code} response from {host}"
308290

309291
# Test for standard error format:
310292
try:
311293
error_data = response.json()
312-
if (
313-
"type" in error_data
314-
and "title" in error_data
315-
and "detail" in error_data
316-
):
317-
title=error_data["title"]
318-
detail=error_data["detail"]
319-
type=error_data["type"]
294+
if "type" in error_data and "title" in error_data and "detail" in error_data:
295+
title = error_data["title"]
296+
detail = error_data["detail"]
297+
type = error_data["type"]
320298
message = f"{title}: {detail} ({type})"
321299

322300
except JSONDecodeError:
@@ -342,7 +320,7 @@ def _generate_application_jwt(self):
342320
token = jwt.encode(payload, self._private_key, algorithm="RS256")
343321

344322
# If token is string transform it to byte type
345-
if(type(token) is str):
323+
if type(token) is str:
346324
token = bytes(token, 'utf-8')
347325

348326
return token
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from pydantic import BaseModel, HttpUrl, AnyUrl, Field, constr
2+
from typing import Optional, Dict
3+
from typing_extensions import Literal
4+
5+
6+
class ConnectEndpoints:
7+
class Endpoint(BaseModel):
8+
type: str = None
9+
10+
class PhoneEndpoint(Endpoint):
11+
type = Field('phone', const=True)
12+
number: constr(regex=r'^[1-9]\d{6,14}$')
13+
dtmfAnswer: Optional[constr(regex='^[0-9*#p]+$')]
14+
onAnswer: Optional[Dict[str, HttpUrl]]
15+
16+
class AppEndpoint(Endpoint):
17+
type = Field('app', const=True)
18+
user: str
19+
20+
class WebsocketEndpoint(Endpoint):
21+
type = Field('websocket', const=True)
22+
uri: AnyUrl
23+
contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000']
24+
headers: Optional[dict]
25+
26+
class SipEndpoint(Endpoint):
27+
type = Field('sip', const=True)
28+
uri: str
29+
headers: Optional[dict]
30+
31+
class VbcEndpoint(Endpoint):
32+
type = Field('vbc', const=True)
33+
extension: str
34+
35+
@classmethod
36+
def create_endpoint_model_from_dict(cls, d) -> Endpoint:
37+
if d['type'] == 'phone':
38+
return cls.PhoneEndpoint.parse_obj(d)
39+
elif d['type'] == 'app':
40+
return cls.AppEndpoint.parse_obj(d)
41+
elif d['type'] == 'websocket':
42+
return cls.WebsocketEndpoint.parse_obj(d)
43+
elif d['type'] == 'sip':
44+
return cls.WebsocketEndpoint.parse_obj(d)
45+
elif d['type'] == 'vbc':
46+
return cls.WebsocketEndpoint.parse_obj(d)
47+
else:
48+
raise ValueError(
49+
'Invalid "type" specified for endpoint object. Cannot create a ConnectEndpoints.Endpoint model.'
50+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pydantic import BaseModel, confloat, conint
2+
from typing import Optional, List
3+
4+
5+
class InputTypes:
6+
class Dtmf(BaseModel):
7+
timeOut: Optional[conint(ge=0, le=10)]
8+
maxDigits: Optional[conint(ge=1, le=20)]
9+
submitOnHash: Optional[bool]
10+
11+
class Speech(BaseModel):
12+
uuid: Optional[str]
13+
endOnSilence: Optional[confloat(ge=0.4, le=10.0)]
14+
language: Optional[str]
15+
context: Optional[List[str]]
16+
startTimeout: Optional[conint(ge=1, le=60)]
17+
maxDuration: Optional[conint(ge=1, le=60)]
18+
saveAudio: Optional[bool]
19+
20+
@classmethod
21+
def create_dtmf_model(cls, dict) -> Dtmf:
22+
return cls.Dtmf.parse_obj(dict)
23+
24+
@classmethod
25+
def create_speech_model(cls, dict) -> Speech:
26+
return cls.Speech.parse_obj(dict)

0 commit comments

Comments
 (0)