JSON-RPC over Websockets implementation.

This commit is contained in:
grossmj 2013-12-21 17:34:51 -07:00
parent 798f0367b9
commit e28079096e
8 changed files with 500 additions and 28 deletions

View File

@ -2,4 +2,4 @@ GNS3-server
===========
GNS3 server manages emulators such as Dynamips, VirtualBox or Qemu/KVM.
Clients like the GNS3 GUI controls the server using a HTTP REST API.
Clients like the GNS3 GUI controls the server using an API over Websockets.

View File

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2013 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
JSON-RPC protocol over Websockets.
"""
import zmq
import uuid
import tornado.websocket
from tornado.escape import json_decode
from ..jsonrpc import JSONRPCParseError, JSONRPCInvalidRequest, JSONRPCMethodNotFound
import logging
log = logging.getLogger(__name__)
class JSONRPCWebSocket(tornado.websocket.WebSocketHandler):
"""
STOMP protocol over Tornado Websockets with message
routing to ZeroMQ dealer clients.
:param application: Tornado Application object
:param request: Tornado Request object
:param zmq_router: ZeroMQ router socket
"""
clients = set()
destinations = {}
version = 2.0 # only JSON-RPC version 2.0 is supported
def __init__(self, application, request, zmq_router):
tornado.websocket.WebSocketHandler.__init__(self, application, request)
self._session_id = str(uuid.uuid4())
self.zmq_router = zmq_router
@property
def session_id(self):
"""
Session ID uniquely representing a Websocket client
:returns: the session id
"""
return self._session_id
@classmethod
def dispatch_message(cls, message):
"""
Sends a message to Websocket client
:param message: message from a module (received via ZeroMQ)
"""
# Module name that is replying
module = message[0].decode("utf-8")
# ZMQ responses are encoded in JSON
# format is a JSON array: [session ID, JSON-RPC response]
json_message = json_decode(message[1])
session_id = json_message[0]
jsonrpc_response = json_message[1]
log.debug("Received message from module {}: {}".format(module, json_message))
for client in cls.clients:
if client.session_id == session_id:
client.write_message(jsonrpc_response)
@classmethod
def register_destination(cls, destination, module):
"""
Registers a destination handled by a module.
Used to route requests to the right module.
:param destination: destination string
:param module: module string
"""
# Make sure the destination is not already registered
# by another module for instance
assert destination not in cls.destinations
log.info("registering {} as a destination for {}".format(destination,
module))
cls.destinations[destination] = module
def open(self):
"""
Invoked when a new WebSocket is opened.
"""
log.info("Websocket client {} connected".format(self.session_id))
self.clients.add(self)
def on_message(self, message):
"""
Handles incoming messages.
:param message: message received over the Websocket
"""
log.debug("Received Websocket message: {}".format(message))
try:
request = json_decode(message)
jsonrpc_version = request["jsonrpc"]
method = request["method"]
# warning: notifications cannot be sent by a client because check for an "id" here
request_id = request["id"]
except:
return self.write_message(JSONRPCParseError()())
if jsonrpc_version != self.version:
return self.write_message(JSONRPCInvalidRequest()())
if method not in self.destinations:
return self.write_message(JSONRPCMethodNotFound(request_id)())
module = self.destinations[method]
# ZMQ requests are encoded in JSON
# format is a JSON array: [session ID, JSON-RPC request]
zmq_request = [self.session_id, request]
# Route to the correct module
self.zmq_router.send_string(module, zmq.SNDMORE)
# Send the encoded JSON request
self.zmq_router.send_json(zmq_request)
def on_close(self):
"""
Invoked when the WebSocket is closed.
"""
log.info("Websocket client {} disconnected".format(self.session_id))
self.clients.remove(self)

182
gns3server/jsonrpc.py Normal file
View File

@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2013 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
JSON-RPC protocol implementation.
http://www.jsonrpc.org/specification
"""
import json
import uuid
class JSONRPCObject(object):
"""
Base object for JSON-RPC requests, responses,
notifications and errors.
"""
def __init__(self):
return JSONRPCEncoder().default(self)
def __str__(self, *args, **kwargs):
return json.dumps(self, cls=JSONRPCEncoder)
def __call__(self):
return JSONRPCEncoder().default(self)
class JSONRPCEncoder(json.JSONEncoder):
"""
Creates the JSON-RPC message.
"""
def default(self, obj):
"""
Returns a Python dictionary corresponding to a JSON-RPC message.
"""
if isinstance(obj, JSONRPCObject):
message = {"jsonrpc": 2.0}
for field in dir(obj):
if not field.startswith('_'):
value = getattr(obj, field)
message[field] = value
return message
return json.JSONEncoder.default(self, obj)
class JSONRPCInvalidRequest(JSONRPCObject):
"""
Error response for an invalid request.
"""
def __init__(self):
JSONRPCObject.__init__(self)
self.id = None
self.error = {"code": -32600, "message": "Invalid Request"}
class JSONRPCMethodNotFound(JSONRPCObject):
"""
Error response for an method not found.
:param request_id: JSON-RPC identifier
"""
def __init__(self, request_id):
JSONRPCObject.__init__(self)
self.id = request_id
self.error = {"code": -32601, "message": "Method not found"}
class JSONRPCInvalidParams(JSONRPCObject):
"""
Error response for invalid parameters.
:param request_id: JSON-RPC identifier
"""
def __init__(self, request_id):
JSONRPCObject.__init__(self)
self.id = request_id
self.error = {"code": -32602, "message": "Invalid params"}
class JSONRPCInternalError(JSONRPCObject):
"""
Error response for an internal error.
:param request_id: JSON-RPC identifier (optional)
"""
def __init__(self, request_id=None):
JSONRPCObject.__init__(self)
self.id = request_id
self.error = {"code": -32603, "message": "Internal error"}
class JSONRPCParseError(JSONRPCObject):
"""
Error response for parsing error.
"""
def __init__(self):
JSONRPCObject.__init__(self)
self.id = None
self.error = {"code": -32700, "message": "Parse error"}
class JSONRPCCustomError(JSONRPCObject):
"""
Error response for an custom error.
:param code: JSON-RPC error code
:param message: JSON-RPC error message
:param request_id: JSON-RPC identifier (optional)
"""
def __init__(self, code, message, request_id=None):
JSONRPCObject.__init__(self)
self.id = request_id
self.error = {"code": code, "message": message}
class JSONRPCResponse(JSONRPCObject):
"""
JSON-RPC successful response.
:param result: JSON-RPC result
:param request_id: JSON-RPC identifier
"""
def __init__(self, result, request_id):
JSONRPCObject.__init__(self)
self.id = request_id
self.result = result
class JSONRPCRequest(JSONRPCObject):
"""
JSON-RPC request.
:param method: JSON-RPC destination method
:param params: JSON-RPC params for the corresponding method (optional)
:param request_id: JSON-RPC identifier (generated by default)
"""
def __init__(self, method, params=None, request_id=str(uuid.uuid1())):
JSONRPCObject.__init__(self)
self.id = request_id
self.method = method
if params:
self.params = params
class JSONRPCNotification(JSONRPCObject):
"""
JSON-RPC notification.
:param method: JSON-RPC destination method
:param params: JSON-RPC params for the corresponding method (optional)
"""
def __init__(self, method, params=None):
JSONRPCObject.__init__(self)
self.method = method
if params:
self.params = params

View File

@ -19,8 +19,8 @@
import datetime
import sys
import logging
import gns3server
import tornado.options
import gns3server
# command line options
from tornado.options import define

View File

@ -19,6 +19,7 @@
Base class (interface) for modules
"""
import gns3server.jsonrpc as jsonrpc
import multiprocessing
import zmq
@ -28,7 +29,11 @@ log = logging.getLogger(__name__)
class IModule(multiprocessing.Process):
"""
Module interface
Module interface.
:param name: module name
:param args: arguments for the module
:param kwargs: named arguments for the module
"""
destination = {}
@ -47,6 +52,7 @@ class IModule(multiprocessing.Process):
self._port = args[1]
self._current_session = None
self._current_destination = None
self._current_call_id = None
def _setup(self):
"""
@ -59,7 +65,9 @@ class IModule(multiprocessing.Process):
def _create_stream(self, host=None, port=0, callback=None):
"""
Creates a new ZMQ stream
Creates a new ZMQ stream.
:returns: ZMQ stream object
"""
socket = self._context.socket(zmq.DEALER)
@ -98,27 +106,66 @@ class IModule(multiprocessing.Process):
def stop(self):
"""
Stops the event loop
Stops the event loop.
"""
#zmq.eventloop.ioloop.IOLoop.instance().stop()
self._ioloop.stop()
def send_response(self, response):
def send_response(self, results):
"""
Sends a response back to the requester
Sends a response back to the requester.
:param response:
:param results: JSON results to the ZeroMQ server
"""
# add session and destination to the response
response = [self._current_session, self._current_destination, response]
jsonrpc_response = jsonrpc.JSONRPCResponse(results, self._current_call_id)()
# add session to the response
response = [self._current_session, jsonrpc_response]
log.debug("ZeroMQ client ({}) sending: {}".format(self.name, response))
self._stream.send_json(response)
def send_param_error(self):
"""
Sends a param error back to the requester.
"""
jsonrpc_response = jsonrpc.JSONRPCInvalidParams(self._current_call_id)()
# add session to the response
response = [self._current_session, jsonrpc_response]
log.info("ZeroMQ client ({}) sending JSON-RPC param error for call id {}".format(self.name, self._current_call_id))
self._stream.send_json(response)
def send_internal_error(self):
"""
Sends a param error back to the requester.
"""
jsonrpc_response = jsonrpc.JSONRPCInternalError()()
# add session to the response
response = [self._current_session, jsonrpc_response]
log.critical("ZeroMQ client ({}) sending JSON-RPC internal error".format(self.name))
self._stream.send_json(response)
def send_custom_error(self, message, code=-3200):
"""
Sends a custom error back to the requester.
"""
jsonrpc_response = jsonrpc.JSONRPCCustomError(code, message, self._current_call_id)()
# add session to the response
response = [self._current_session, jsonrpc_response]
log.info("ZeroMQ client ({}) sending JSON-RPC custom error {} for call id {}".format(self.name,
message,
self._current_call_id))
self._stream.send_json(response)
def _decode_request(self, request):
"""
Decodes the request to JSON
Decodes the request to JSON.
:param request: request from ZeroMQ server
"""
@ -126,18 +173,22 @@ class IModule(multiprocessing.Process):
try:
request = zmq.utils.jsonapi.loads(request[0])
except ValueError:
self.send_response("ValueError") # FIXME: explicit json error
self._current_session = None
self.send_internal_error()
return
log.debug("ZeroMQ client ({}) received: {}".format(self.name, request))
self._current_session = request[0]
self._current_destination = request[1]
self._current_call_id = request[1].get("id")
destination = request[1].get("method")
params = request[1].get("params")
if self._current_destination not in self.destination:
# FIXME: return error if destination not found!
if destination not in self.destination:
self.send_internal_error()
return
log.debug("Routing request to {}: {}".format(self._current_destination, request[2]))
self.destination[self._current_destination](self, request[2])
log.debug("Routing request to {}: {}".format(destination, request[1]))
self.destination[destination](self, params)
def destinations(self):
"""

View File

@ -31,7 +31,7 @@ import socket
import tornado.ioloop
import tornado.web
import tornado.autoreload
from .handlers.stomp_websocket import StompWebSocket
from .handlers.jsonrpc_websocket import JSONRPCWebSocket
from .handlers.version_handler import VersionHandler
from .module_manager import ModuleManager
@ -57,7 +57,7 @@ class Server(object):
def load_modules(self):
"""
Loads the modules
Loads the modules.
"""
cwd = os.path.dirname(os.path.abspath(__file__))
@ -70,17 +70,17 @@ class Server(object):
self._modules.append(instance)
destinations = instance.destinations()
for destination in destinations:
StompWebSocket.register_destination(destination, module.name)
JSONRPCWebSocket.register_destination(destination, module.name)
instance.start() # starts the new process
def run(self):
"""
Starts the Tornado web server and ZeroMQ server
Starts the Tornado web server and ZeroMQ server.
"""
router = self._create_zmq_router()
# Add our Stomp Websocket handler to Tornado
self.handlers.extend([(r"/", StompWebSocket, dict(zmq_router=router))])
# Add our JSON-RPC Websocket handler to Tornado
self.handlers.extend([(r"/", JSONRPCWebSocket, dict(zmq_router=router))])
tornado_app = tornado.web.Application(self.handlers, debug=True) # FIXME: debug mode!
try:
print("Starting server on port {}".format(self._port))
@ -92,7 +92,7 @@ class Server(object):
ioloop = tornado.ioloop.IOLoop.instance()
stream = zmqstream.ZMQStream(router, ioloop)
stream.on_recv(StompWebSocket.dispatch_message)
stream.on_recv(JSONRPCWebSocket.dispatch_message)
tornado.autoreload.add_reload_hook(functools.partial(self._cleanup, stop=False))
def signal_handler(signum=None, frame=None):

91
tests/test_jsonrpc.py Normal file
View File

@ -0,0 +1,91 @@
import uuid
from tornado.testing import AsyncTestCase
from tornado.escape import json_encode, json_decode
from ws4py.client.tornadoclient import TornadoWebSocketClient
import gns3server.jsonrpc as jsonrpc
"""
Tests for JSON-RPC protocol over Websockets
"""
class JSONRPC(AsyncTestCase):
URL = "ws://127.0.0.1:8000/"
def test_request(self):
params = {"echo": "test"}
request = jsonrpc.JSONRPCRequest("dynamips.echo", params)
AsyncWSRequest(self.URL, self.io_loop, self.stop, str(request))
response = self.wait()
json_response = json_decode(response)
assert json_response["jsonrpc"] == 2.0
assert json_response["id"] == request.id
assert json_response["result"] == params
def test_request_with_invalid_method(self):
message = {"echo": "test"}
request = jsonrpc.JSONRPCRequest("dynamips.non_existent", message)
AsyncWSRequest(self.URL, self.io_loop, self.stop, str(request))
response = self.wait()
json_response = json_decode(response)
assert json_response["error"].get("code") == -32601
assert json_response["id"] == request.id
def test_request_with_invalid_version(self):
request = {"jsonrpc": "1.0", "method": "dynamips.echo", "id": 1}
AsyncWSRequest(self.URL, self.io_loop, self.stop, json_encode(request))
response = self.wait()
json_response = json_decode(response)
assert json_response["id"] == None
assert json_response["error"].get("code") == -32600
def test_request_with_invalid_json(self):
request = "my non JSON request"
AsyncWSRequest(self.URL, self.io_loop, self.stop, request)
response = self.wait()
json_response = json_decode(response)
assert json_response["id"] == None
assert json_response["error"].get("code") == -32700
def test_request_with_invalid_jsonrpc_field(self):
request = {"jsonrpc": "2.0", "method_bogus": "dynamips.echo", "id": 1}
AsyncWSRequest(self.URL, self.io_loop, self.stop, json_encode(request))
response = self.wait()
json_response = json_decode(response)
assert json_response["id"] == None
assert json_response["error"].get("code") == -32700
def test_request_with_no_params(self):
request = jsonrpc.JSONRPCRequest("dynamips.echo")
AsyncWSRequest(self.URL, self.io_loop, self.stop, str(request))
response = self.wait()
json_response = json_decode(response)
assert json_response["id"] == request.id
assert json_response["error"].get("code") == -32602
class AsyncWSRequest(TornadoWebSocketClient):
"""
Very basic Websocket client for tests
"""
def __init__(self, url, io_loop, callback, message):
TornadoWebSocketClient.__init__(self, url, io_loop=io_loop)
self._callback = callback
self._message = message
self.connect()
def opened(self):
self.send(self._message, binary=False)
def received_message(self, message):
self.close()
if self._callback:
self._callback(message.data)

View File

@ -6,7 +6,7 @@ from gns3server.stomp import frame as stomp_frame
from gns3server.stomp import protocol as stomp_protocol
"""
Tests STOMP protocol over Websockets
Tests for STOMP protocol over Websockets
"""
@ -64,7 +64,7 @@ class Stomp(AsyncTestCase):
and check for a STOMP MESSAGE with echoed message and destination.
"""
destination = "dynamips/echo"
destination = "dynamips.echo"
message = {"ping": "test"}
request = self.stomp.send(destination, json_encode(message), "application/json")
AsyncWSRequest(self.URL, self.io_loop, self.stop, request)
@ -109,7 +109,7 @@ class Stomp(AsyncTestCase):
class AsyncWSRequest(TornadoWebSocketClient):
"""
Very basic Websocket client for the tests
Very basic Websocket client for tests
"""
def __init__(self, url, io_loop, callback, message):