Skip to content

Commit 553e623

Browse files
committed
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 553e623

File tree

6 files changed

+311
-0
lines changed

6 files changed

+311
-0
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."""

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+
- dir: '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=
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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.identity = None
38+
# This class is really a state machine so we
39+
# need to store the next expected state and status code.
40+
self.expect = { 'status_code': 0, 'state': None }
41+
42+
self.factory = AriClientFactory(receiver=self,
43+
host=self.ast[0].host,
44+
port=8088,
45+
apps='testsuite',
46+
userpass=USERPASS)
47+
self.factory.connect()
48+
49+
def run(self):
50+
"""Entry point for the twisted reactor"""
51+
super(RESToverWebsocketTest, self).run()
52+
53+
def on_ws_event(self, message):
54+
"""Handler for WebSocket events
55+
56+
Keyword Arguments:
57+
message -- The WS event payload
58+
"""
59+
msg_type = message.get('type')
60+
LOGGER.info("Received event reponse: %s" % message)
61+
callback = getattr(self, 'handle_%s' % msg_type.lower(), None)
62+
if callback:
63+
callback(message)
64+
65+
def on_ws_open(self, protocol):
66+
"""Handler for WebSocket Client Protocol opened
67+
68+
Keyword Arguments:
69+
protocol -- The WS Client protocol object
70+
"""
71+
LOGGER.info("WebSocket connection made: %s" % protocol)
72+
self.protocol = protocol
73+
74+
# Start the test by sending an ASTSwaggerSocket handshake.
75+
protocol.sendHandshake()
76+
77+
def on_ws_closed(self, protocol):
78+
"""Handler for WebSocket Client Protocol closed
79+
80+
:param protocol The WS Client protocol object
81+
"""
82+
LOGGER.info("WebSocket connection closed: %s" % protocol)
83+
self.stop_reactor()
84+
85+
def handle_reststatusresponse(self, message):
86+
"""RESTStatusResponse handler
87+
88+
Keyword Arguments:
89+
message -- the JSON message
90+
"""
91+
rc = message['status']['status_code']
92+
if rc != 200:
93+
LOGGER.error("Received handshake status reponse %d" % rc)
94+
self.passed = False
95+
self.protocol.sendClose()
96+
return
97+
98+
# Save the identity returned in the handshake status
99+
# response to use in subsequent requests.
100+
self.identity = message['identity']
101+
102+
LOGGER.info("Handshake completed. Sending first request")
103+
# The status code for this request must be 200.
104+
self.expect['status_code'] = 200
105+
# The supplied uuid will be returned by sendRequest
106+
# and in the corresponding response. It'll be used
107+
# by handle_restresponsemsg() to determine the next
108+
# state.
109+
self.expect['state'] = self.protocol.sendRequest(
110+
self.identity, "GET", "asterisk/info", uuid="get-info")
111+
112+
def handle_restresponsemsg(self, message):
113+
"""RESTResponseMsg handler
114+
115+
Keyword Arguments:
116+
message -- the JSON message
117+
"""
118+
response = message['responses'][0]
119+
state = response['uuid']
120+
rc = response['status_code']
121+
122+
# If the state in the response doesn't match what's
123+
# expected, just bail now.
124+
if state != self.expect['state']:
125+
LOGGER.error("State mismatch. Expected %s, received %s"
126+
% (self.expect['state'], state))
127+
self.passed = False
128+
self.protocol.sendClose()
129+
return
130+
131+
# If the status_code in the response doesn't match what's
132+
# expected, just bail now.
133+
if rc != self.expect['status_code']:
134+
LOGGER.error("RC mismatch. Expected %d, received %d"
135+
% (self.expect['status_code'], rc))
136+
self.passed = False
137+
self.protocol.sendClose()
138+
return
139+
140+
# "state" is the last successful state. The case statements
141+
# say what to do next.
142+
match state:
143+
case "get-info":
144+
# asterisk/ino doesn't exist so a 404 should be returned.
145+
self.expect['status_code'] = 404
146+
self.expect['state'] = self.protocol.sendRequest(
147+
self.identity, "GET", "asterisk/ino", uuid="get-fail")
148+
case "get-fail":
149+
# chan_pjsip can't be reloaded so 409 should be returned.
150+
self.expect['status_code'] = 409
151+
self.expect['state'] = self.protocol.sendRequest(
152+
self.identity, "PUT", "asterisk/modules/chan_pjsip.so", uuid="reload-fail")
153+
case "reload-fail":
154+
# res_pjsip CAN be reloaded so 204 should be returned.
155+
self.expect['status_code'] = 204
156+
self.expect['state'] = self.protocol.sendRequest(
157+
self.identity, "PUT", "asterisk/modules/res_pjsip.so", uuid="reload-success")
158+
case "reload-success":
159+
# Create a new log channel using query_strings.
160+
self.expect['status_code'] = 204
161+
self.expect['state'] = self.protocol.sendRequest(
162+
self.identity, "POST", "asterisk/logging/testlog",
163+
uuid="create-log-channel",
164+
query_strings=[ { 'name': 'configuration', 'value': 'verbose' } ])
165+
case "create-log-channel":
166+
# Now let's get the list of log channels.
167+
self.expect['status_code'] = 200
168+
self.expect['state'] = self.protocol.sendRequest(
169+
self.identity, "GET", "asterisk/logging",
170+
uuid="list-log-channels")
171+
case "list-log-channels":
172+
# We should see the log channel we just created.
173+
msg_body = json.loads(response['message_body'])
174+
found = False
175+
for l in msg_body:
176+
if os.path.basename(l['channel']) == "testlog":
177+
if l['configuration'].strip() == "VERBOSE":
178+
found = True
179+
else:
180+
self.passed = False
181+
self.protocol.sendClose()
182+
if not found:
183+
LOGGER.error("Logger channel config mismatch")
184+
self.passed = False
185+
self.protocol.sendClose()
186+
return
187+
# So now lets delete it.
188+
self.expect['status_code'] = 204
189+
self.expect['state'] = self.protocol.sendRequest(
190+
self.identity, "DELETE", "asterisk/logging/testlog",
191+
uuid="delete-log-channel")
192+
case "delete-log-channel":
193+
# Lets create it again but this time put the configuration
194+
# in the message body as json.
195+
self.expect['status_code'] = 204
196+
self.expect['state'] = self.protocol.sendRequest(
197+
self.identity, "POST", "asterisk/logging/testlog",
198+
uuid="create-log-channel2",
199+
content_type="application/json",
200+
message_body='{ "configuration": "verbose" }')
201+
case "create-log-channel2":
202+
# So now lets delete it again.
203+
self.expect['status_code'] = 204
204+
self.expect['state'] = self.protocol.sendRequest(
205+
self.identity, "DELETE", "asterisk/logging/testlog",
206+
uuid="delete-log-channel2")
207+
case "delete-log-channel2":
208+
# Lets create it again but this time put the configuration
209+
# in the message body as form-url-encoded.
210+
self.expect['status_code'] = 204
211+
self.expect['state'] = self.protocol.sendRequest(
212+
self.identity, "POST", "asterisk/logging/testlog",
213+
uuid="create-log-channel3",
214+
content_type="application/x-www-form-urlencoded",
215+
message_body='configuration=verbose')
216+
case "create-log-channel3":
217+
# So now lets delete it again.
218+
self.expect['status_code'] = 204
219+
self.expect['state'] = self.protocol.sendRequest(
220+
self.identity, "DELETE", "asterisk/logging/testlog",
221+
uuid="delete-log-channel3")
222+
case "delete-log-channel3":
223+
# We're done.
224+
self.passed = True
225+
self.protocol.sendClose()
226+
case _:
227+
LOGGER.error("Unknown state: %s" % state)
228+
self.passed = False
229+
self.protocol.sendClose()
230+
231+
232+
def main():
233+
"""Main entry point for the test.
234+
235+
Returns:
236+
0 on test pass
237+
1 on test failure
238+
"""
239+
240+
test = RESToverWebsocketTest()
241+
reactor.run()
242+
243+
if not test.passed:
244+
return 1
245+
246+
return 0
247+
248+
if __name__ == "__main__":
249+
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
tests:
2+
- test: 'single_requests'

0 commit comments

Comments
 (0)