Skip to content

Commit 3450237

Browse files
committedMar 21, 2025
rest-api: Add support and a test scenario for REST over Websockets
* Added support in lib/python/asterisk/ari.py for sending requests and receiving responses over the websocket. * Added the tests/rest-api/websocket_requests test that runs tests of GET, PUT, POST and DELETE requests over ther websocket.
1 parent a5d156d commit 3450237

File tree

6 files changed

+318
-8
lines changed

6 files changed

+318
-8
lines changed
 

‎lib/python/asterisk/ari.py

+34
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import re
1313
import requests
1414
import traceback
15+
import uuid
1516
try:
1617
from urllib.parse import urlencode
1718
except:
@@ -419,6 +420,39 @@ def onMessage(self, msg, binary):
419420
msg = json.loads(msg)
420421
self.receiver.on_ws_event(msg)
421422

423+
def sendHandshake(self):
424+
"""Send ASTSwaggerSocket Handshake.
425+
"""
426+
msg = u'{ "type": "RESTHandshakeRequest", "protocol_version": "1.0", "protocol_name": "ASTSwaggerSocket" }'.encode('utf-8')
427+
LOGGER.info("Sending handshake: %s", msg)
428+
self.sendMessage(msg)
429+
430+
def sendRequest(self, identity, method, path, **kwargs):
431+
"""Send a REST Request over Websocket.
432+
433+
:param identity: Identity returned from handshake.
434+
:param method: Method.
435+
:param path: Resource URI without query string.
436+
:param kwargs: Additional request parameters
437+
:returns: Request UUID
438+
"""
439+
uuidstr = kwargs.pop("uuid", str(uuid.uuid4()))
440+
req = {}
441+
req["type"] = "RESTRequest"
442+
req["identity"] = identity
443+
req["requests"] = [ {
444+
"uuid": uuidstr,
445+
"method": method,
446+
"path": path
447+
}]
448+
449+
for k,v in kwargs.items():
450+
req["requests"][0][k] = v
451+
452+
msg = json.dumps(req)
453+
LOGGER.info("Sending request message: %s", msg)
454+
self.sendMessage(msg.encode('utf-8'))
455+
return uuidstr
422456

423457
class ARI(object):
424458
"""Bare bones object for an ARI interface."""

‎test-config.yaml

+8-8
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ global-settings:
77
# The active test configuration. The value must match a subsequent key
88
# in this file, which defines the global settings to apply to the test execution
99
# run.
10-
test-configuration: config-standard
10+
test-configuration: config-remote
1111

1212
# The following sequence defines for any test configuration the available pre-
1313
# and post-test conditions. The 'name' field specifies how the test configurations
@@ -125,19 +125,19 @@ config-remote:
125125
-
126126
# The host to connect to. This will be used for the SSH
127127
# connection, as well as for the various API connections
128-
host: '192.168.0.102'
128+
host: '192.168.147.245'
129129
# Path to the SSH private key
130-
identity: '~/.ssh/id_psa'
130+
identity: '~/.ssh/id_ed25519'
131131
# Passphrase used for encrypted private keys
132-
passphrase: 'imalittleteapot'
132+
#passphrase: 'imalittleteapot'
133133
# SSH username
134-
username: 'user'
134+
username: 'root'
135135
# SSH password
136-
passsword: 'supersecret'
136+
#passsword: 'supersecret'
137137
# AMI credentials.
138138
ami:
139-
username: 'asterisk'
140-
secret: 'asterisk'
139+
username: 'test'
140+
secret: 'test'
141141

142142
# This test enables the pre- and post-test condition checking on all tests
143143
# that support it. Individual tests can override the behavior of a pre-

‎tests/rest_api/tests.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Enter tests here in the order they should be considered for execution:
22
tests:
3+
- test: 'websocket_requests'
34
- test: 'continue'
45
- test: 'authentication'
56
- test: 'CORS'
@@ -18,3 +19,4 @@ tests:
1819
- dir: 'message'
1920
- dir: 'external_interaction'
2021
- test: 'move'
22+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[general]
2+
enabled=yes
3+
bindaddr=127.0.0.1
4+
bindport=8088
5+
prefix=
+250
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env python
2+
3+
"""A test that runs GET, PUT, POST and DELETE requests over the websocket
4+
5+
Copyright (C) 2025, Sangoma Technologies Corporation
6+
George Joseph <gjoseph@sangoma.com>
7+
8+
This program is free software, distributed under the terms of
9+
the GNU General Public License Version 2.
10+
"""
11+
12+
import sys
13+
import uuid
14+
import logging
15+
import json
16+
import os
17+
18+
from twisted.internet import reactor
19+
20+
sys.path.append("lib/python")
21+
22+
from asterisk.test_case import TestCase
23+
from asterisk.ari import AriClientFactory
24+
25+
LOGGER = logging.getLogger(__name__)
26+
27+
USERPASS = ('testsuite', 'testsuite')
28+
29+
class RESToverWebsocketTest(TestCase):
30+
"""A class that manages this test"""
31+
32+
def __init__(self):
33+
"""Constructor"""
34+
super(RESToverWebsocketTest, self).__init__()
35+
self.create_asterisk(count=1)
36+
self.protocol = None
37+
self.requester = None
38+
self.identity = None
39+
self.last_request_uuid = None
40+
# This class is really a state machine so we
41+
# need to store the next expected state and status code.
42+
self.expect = { 'status_code': 0, 'state': None }
43+
44+
self.factory = AriClientFactory(receiver=self,
45+
host=self.ast[0].host,
46+
port=8088,
47+
apps='testsuite',
48+
userpass=USERPASS)
49+
self.factory.connect()
50+
51+
def run(self):
52+
"""Entry point for the twisted reactor"""
53+
super(RESToverWebsocketTest, self).run()
54+
55+
def on_ws_event(self, message):
56+
"""Handler for WebSocket events
57+
58+
Keyword Arguments:
59+
message -- The WS event payload
60+
"""
61+
msg_type = message.get('type')
62+
LOGGER.info("Received event reponse: %s" % message)
63+
callback = getattr(self, 'handle_%s' % msg_type.lower(), None)
64+
if callback:
65+
callback(message)
66+
67+
def on_ws_open(self, protocol):
68+
"""Handler for WebSocket Client Protocol opened
69+
70+
Keyword Arguments:
71+
protocol -- The WS Client protocol object
72+
"""
73+
LOGGER.info("WebSocket connection made: %s" % protocol)
74+
self.protocol = protocol
75+
76+
# Start the test by sending an ASTSwaggerSocket handshake.
77+
protocol.sendHandshake()
78+
79+
def on_ws_closed(self, protocol):
80+
"""Handler for WebSocket Client Protocol closed
81+
82+
:param protocol The WS Client protocol object
83+
"""
84+
LOGGER.info("WebSocket connection closed: %s" % protocol)
85+
self.stop_reactor()
86+
87+
def handle_reststatusresponse(self, message):
88+
"""RESTStatusResponse handler
89+
90+
Keyword Arguments:
91+
message -- the JSON message
92+
"""
93+
rc = message['status']['status_code']
94+
if rc != 200:
95+
LOGGER.error("Received handshake status reponse %d" % rc)
96+
self.passed = False
97+
self.protocol.transport.loseConnection()
98+
return
99+
100+
# Save the identity returned in the handshake status
101+
# response to use in subsequent requests.
102+
self.identity = message['identity']
103+
104+
LOGGER.info("Handshake completed. Sending first request")
105+
# The status code for this request must be 200.
106+
self.expect['status_code'] = 200
107+
# The supplied uuid will be returned by sendRequest
108+
# and in the corresponding response. It'll be used
109+
# by handle_restresponsemsg() to determine the next
110+
# state.
111+
self.expect['state'] = self.protocol.sendRequest(
112+
self.identity, "GET", "asterisk/info", uuid="get-info")
113+
114+
def handle_restresponsemsg(self, message):
115+
"""RESTResponseMsg handler
116+
117+
Keyword Arguments:
118+
message -- the JSON message
119+
"""
120+
response = message['responses'][0]
121+
state = response['uuid']
122+
rc = response['status_code']
123+
124+
# If the state in the response doesn't match what's
125+
# expected, just bail now.
126+
if state != self.expect['state']:
127+
LOGGER.error("State mismatch. Expected %s, received %s"
128+
% (self.expect['state'], state))
129+
self.passed = False
130+
self.protocol.transport.loseConnection()
131+
return
132+
133+
# If the status_code in the response doesn't match what's
134+
# expected, just bail now.
135+
if rc != self.expect['status_code']:
136+
LOGGER.error("RC mismatch. Expected %d, received %d"
137+
% (self.expect['status_code'], rc))
138+
self.passed = False
139+
self.protocol.transport.loseConnection()
140+
return
141+
142+
# "state" is the last successful state. The case statements
143+
# say what to do next.
144+
match state:
145+
case "get-info":
146+
# asterisk/ino doesn't exist so a 404 should be returned.
147+
self.expect['status_code'] = 404
148+
self.expect['state'] = self.protocol.sendRequest(
149+
self.identity, "GET", "asterisk/ino", uuid="get-fail")
150+
case "get-fail":
151+
# chan_pjsip can't be reloaded so 409 should be returned.
152+
self.expect['status_code'] = 409
153+
self.expect['state'] = self.protocol.sendRequest(
154+
self.identity, "PUT", "asterisk/modules/chan_pjsip.so", uuid="reload-fail")
155+
case "reload-fail":
156+
# res_pjsip CAN be reloaded so 204 should be returned.
157+
self.expect['status_code'] = 204
158+
self.expect['state'] = self.protocol.sendRequest(
159+
self.identity, "PUT", "asterisk/modules/res_pjsip.so", uuid="reload-success")
160+
case "reload-success":
161+
# Create a new log channel using query_strings.
162+
self.expect['status_code'] = 204
163+
self.expect['state'] = self.protocol.sendRequest(
164+
self.identity, "POST", "asterisk/logging/testlog",
165+
uuid="create-log-channel",
166+
query_strings=[ { 'name': 'configuration', 'value': 'verbose' } ])
167+
case "create-log-channel":
168+
# Now let's get the list of log channels.
169+
self.expect['status_code'] = 200
170+
self.expect['state'] = self.protocol.sendRequest(
171+
self.identity, "GET", "asterisk/logging",
172+
uuid="list-log-channels")
173+
case "list-log-channels":
174+
# We should see the log channel we just created.
175+
msg_body = json.loads(response['message_body'])
176+
found = False
177+
for l in msg_body:
178+
if os.path.basename(l['channel']) == "testlog":
179+
if l['configuration'].strip() == "VERBOSE":
180+
found = True
181+
else:
182+
self.passed = False
183+
self.protocol.transport.loseConnection()
184+
if not found:
185+
LOGGER.error("Logger channel config mismatch")
186+
self.passed = False
187+
self.protocol.transport.loseConnection()
188+
return
189+
# So now lets delete it.
190+
self.expect['status_code'] = 204
191+
self.expect['state'] = self.protocol.sendRequest(
192+
self.identity, "DELETE", "asterisk/logging/testlog",
193+
uuid="delete-log-channel")
194+
case "delete-log-channel":
195+
# Lets create it again but this time put the configuration
196+
# in the message body as json.
197+
self.expect['status_code'] = 204
198+
self.expect['state'] = self.protocol.sendRequest(
199+
self.identity, "POST", "asterisk/logging/testlog",
200+
uuid="create-log-channel2",
201+
content_type="application/json",
202+
message_body='{ "configuration": "verbose" }')
203+
case "create-log-channel2":
204+
# So now lets delete it again.
205+
self.expect['status_code'] = 204
206+
self.expect['state'] = self.protocol.sendRequest(
207+
self.identity, "DELETE", "asterisk/logging/testlog",
208+
uuid="delete-log-channel2")
209+
case "delete-log-channel2":
210+
# Lets create it again but this time put the configuration
211+
# in the message body as form-url-encoded.
212+
self.expect['status_code'] = 204
213+
self.expect['state'] = self.protocol.sendRequest(
214+
self.identity, "POST", "asterisk/logging/testlog",
215+
uuid="create-log-channel3",
216+
content_type="application/x-www-form-urlencoded",
217+
message_body='configuration=verbose')
218+
case "create-log-channel3":
219+
# So now lets delete it again.
220+
self.expect['status_code'] = 204
221+
self.expect['state'] = self.protocol.sendRequest(
222+
self.identity, "DELETE", "asterisk/logging/testlog",
223+
uuid="delete-log-channel3")
224+
case "delete-log-channel3":
225+
# We're done.
226+
self.passed = True
227+
self.protocol.transport.loseConnection()
228+
case _:
229+
LOGGER.error("Unknown state: %s" % state)
230+
self.passed = False
231+
self.protocol.transport.loseConnection()
232+
233+
def main():
234+
"""Main entry point for the test.
235+
236+
Returns:
237+
0 on test pass
238+
1 on test failure
239+
"""
240+
241+
test = RESToverWebsocketTest()
242+
reactor.run()
243+
244+
if not test.passed:
245+
return 1
246+
247+
return 0
248+
249+
if __name__ == "__main__":
250+
sys.exit(main() or 0)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
testinfo:
2+
summary: Test REST over Websocket
3+
description: |
4+
This test simply runs a few "asterisk" resource scenarios to
5+
ensure that requests and responses are working over the
6+
websocket.
7+
8+
properties:
9+
dependencies:
10+
- python : autobahn.websocket
11+
- python : requests
12+
- python : twisted
13+
- asterisk : res_ari
14+
- asterisk : res_http_websocket
15+
- asterisk : res_ari_events
16+
- asterisk : res_ari_asterisk
17+
- asterisk : chan_pjsip
18+
tags:
19+
- ARI

0 commit comments

Comments
 (0)