diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..3227554 --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +#!/usr/bin/env/python +""" +Copyright 2009 Josh Marshall +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import distutils.core + +distutils.core.setup( + name = "tornadorpc", + version = "0.1", + packages = ["tornadorpc"], + author = "Josh Marshall", + author_email = "catchjosh@gmail.com", + url = "http://code.google.com/p/tornadorpc/", + license = "http://www.apache.org/licenses/LICENSE-2.0", + description = "TornadoRPC is a an implementation of both JSON-RPC " + + "and XML-RPC handlers for the Tornado framework.", +) diff --git a/tornadorpc/__init__.py b/tornadorpc/__init__.py new file mode 100644 index 0000000..7fe1aa5 --- /dev/null +++ b/tornadorpc/__init__.py @@ -0,0 +1,16 @@ +""" +Copyright 2009 Josh Marshall +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from base import private, start_server diff --git a/tornadorpc/base.py b/tornadorpc/base.py new file mode 100644 index 0000000..f7125d4 --- /dev/null +++ b/tornadorpc/base.py @@ -0,0 +1,316 @@ +""" +Copyright 2009 Josh Marshall +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +============================ +Base RPC Handler for Tornado +============================ +This is a basic server implementation, designed for use within the +Tornado framework. The classes in this library should not be used +directly, but rather though the XML or JSON RPC implementations. +You can use the utility functions like 'private' and 'start_server'. +""" + +from tornado.web import RequestHandler +import types + +class BaseRPCParser(object): + """ + This class is responsible for managing the request, dispatch, + and response formatting of the system. It is tied into the + _RPC_ attribute of the BaseRPCHandler (or subclasses) and + populated as necessary throughout the request. Use the + .faults attribute to take advantage of the built-in error + codes. + """ + content_type = 'text/plain' + + def __init__(self, library, encode=None, decode=None): + # Attaches the RPC library and encode / decode functions. + self.library = library + if not encode: + encode = getattr(library, 'dumps') + if not decode: + decode = getattr(library, 'loads') + self.encode = encode + self.decode = decode + + @property + def faults(self): + # Grabs the fault tree on request + return Faults(self) + + def run(self, handler, request_body): + """ + This is the main loop -- it passes the request body to + the parse_request method, and then takes the resulting + method(s) and parameters and passes them to the appropriate + method on the parent Handler class, then parses the response + into text and returns it to the parent Handler to send back + to the client. + """ + self.handler = handler + requests = self.parse_request(request_body) + if type(requests) is not types.TupleType: + # SHOULD be the result of a fault call, + # according tothe parse_request spec below. + return requests + responses = [] + for request in requests: + response = self.dispatch(request[0], request[1]) + responses.append(response) + responses = tuple(responses) + response_text = self.parse_responses(responses) + if type(response_text) not in types.StringTypes: + # Likely a fault, or something messed up + response_text = self.encode(response_text) + return response_text + + def dispatch(self, method_name, params): + """ + This method walks the attribute tree in the method + and passes the parameters, either in positional or + keyword form, into the appropriate method on the + Handler class. Currently supports only positional + or keyword arguments, not mixed. + """ + if method_name in dir(RequestHandler): + # Pre-existing, not an implemented attribute + return self.faults.method_not_found() + method = self.handler + method_list = dir(method) + method_list.sort() + attr_tree = method_name.split('.') + try: + for attr_name in attr_tree: + method = self.check_method(attr_name, method) + except AttributeError: + return self.faults.method_not_found() + if not callable(method): + # Not callable, so not a method + return self.faults.method_not_found() + if method_name.startswith('_') or \ + ('private' in dir(method) and method.private is True): + # No, no. That's private. + return self.faults.method_not_found() + if type(params) is types.DictType: + # Keyword arguments + try: + response = method(**params) + except TypeError: + return self.faults.invalid_params() + except: + # We should log here...bare excepts are evil. + return self.faults.internal_error() + return response + elif type(params) in (types.ListType, types.TupleType): + # Positional arguments + try: + response = method(*params) + except TypeError: + return self.faults.invalid_params() + except: + # Once again, we need to log here + return self.faults.internal_error() + return response + else: + # Bad argument formatting? + return self.faults.invalid_params() + + def parse_request(self, request_body): + """ + Extend this on the implementing protocol. If it + should error out, return the output of the + 'self.faults.fault_name' response. Otherwise, + it MUST return a TUPLE of TUPLE. Each entry + tuple must have the following structure: + ('method_name', params) + ...where params is a list or dictionary of + arguments (positional or keyword, respectively.) + So, the result should look something like + the following: + ( ('add', [5,4]), ('add', {'x':5, 'y':4}) ) + """ + return ([], []) + + def parse_responses(self, responses): + """ + Extend this on the implementing protocol. It must + return a response that can be returned as output to + the client. + """ + return self.encode(responses, methodresponse = True) + + def check_method(self, attr_name, obj): + """ + Just checks to see whether an attribute is private + (by the decorator or by a leading underscore) and + returns boolean result. + """ + if attr_name.startswith('_'): + raise AttributeError('Private object or method.') + attr = getattr(obj, attr_name) + if 'private' in dir(attr) and attr.private == True: + raise AttributeError('Private object or method.') + return attr + +class BaseRPCHandler(RequestHandler): + """ + This is the base handler to be subclassed by the actual + implementations and by the end user. The only attribute + this adds to the Tornado request handler is '_RPC_', which + is what holds the RPC Parser subclassed from the + BaseRPCParser above. + """ + _RPC_ = None + + def post(self): + # Very simple -- dispatches request body to the parser + # and returns the output + request_body = self.request.body + response_text = self._RPC_.run(self, request_body) + self.set_header('Content-Type', self._RPC_.content_type) + self.write(response_text) + return + +class FaultMethod(object): + """ + This is the 'dynamic' fault method so that the message can + be changed on request from the parser.faults call. + """ + def __init__(self, fault, code, message): + self.fault = fault + self.code = code + self.message = message + + def __call__(self, message=None): + if message: + self.message = message + return self.fault(self.code, self.message) + +class Faults(object): + """ + This holds the codes and messages for the RPC implementation. + It is attached (dynamically) to the Parser when called via the + parser.faults query, and returns a FaultMethod to be called so + that the message can be changed. If the 'dynamic' attribute is + not a key in the codes list, then it will error. + + USAGE: + parser.fault.parse_error('Error parsing content.') + + If no message is passed in, it will check the messages dictionary + for the same key as the codes dict. Otherwise, it just prettifies + the code 'key' from the codes dict. + + """ + codes = { + 'parse_error': -32700, + 'method_not_found': -32601, + 'invalid_request': -32600, + 'invalid_params': -32602, + 'internal_error': -32603 + } + + messages = {} + + def __init__(self, parser, fault=None): + self.library = parser.library + self.fault = fault + if not self.fault: + self.fault = getattr(self.library, 'Fault') + + def __getattr__(self, attr): + message = 'Error' + if attr in self.messages.keys(): + message = self.messages[attr] + else: + message = ' '.join(map(str.capitalize, attr.split('_'))) + fault = FaultMethod(self.fault, self.codes[attr], message) + return fault + +""" +Utility Functions +""" + +def private(obj): + """ + Use this to make a method private. + It is intended to be used as a decorator. + If you wish to make a method tree private, just + create and set the 'private' variable to True + on the tree object itself. + """ + class PrivateMethod(object): + def __init__(self): + self.private = True + __call__ = obj + return PrivateMethod() + +def start_server(handler, route=r'/', port=8080): + """ + This is just a friendly wrapper around the default + Tornado instantiation calls. It simplifies the imports + and setup calls you'd make otherwise. + USAGE: + start_server(handler_class, route=r'/', port=8181) + """ + import tornado.web + import tornado.ioloop + import tornado.httpserver + + routes = [(route, handler),] + if not route.endswith('/'): + route = r'%s/' % route + routes.append(('%sRPC2' % route, handler)) + + application = tornado.web.Application(routes) + http_server = tornado.httpserver.HTTPServer(application) + http_server.listen(port) + tornado.ioloop.IOLoop.instance().start() + + +""" +The following is a test implementation which should work +for both the XMLRPC and the JSONRPC clients. +""" + +class TestMethodTree(object): + def power(self, x, y=2): + return pow(x, y) + + @private + def private(self): + # Shouldn't be called + return False + +class TestRPCHandler(BaseRPCHandler): + + _RPC_ = None + + def add(self, x, y): + return x+y + + def ping(self, x): + return x + + tree = TestMethodTree() + + def _private(self): + # Shouldn't be called + return False + + @private + def private(self): + # Also shouldn't be called + return False diff --git a/tornadorpc/json.py b/tornadorpc/json.py new file mode 100644 index 0000000..8d0d238 --- /dev/null +++ b/tornadorpc/json.py @@ -0,0 +1,123 @@ +""" +Copyright 2009 Josh Marshall +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +============================ +JSON-RPC Handler for Tornado +============================ +This is a JSON-RPC server implementation, designed for use within the +Tornado framework. Usage is pretty simple: + +>>> from tornadorpc.json import JSONRPCHandler +>>> from tornadorpc import start_server +>>> +>>> class handler(JSONRPCHandler): +>>> ... def add(self, x, y): +>>> ....... return x+y +>>> +>>> start_server(handler, port=8484) + +It requires the jsonrpclib, which you can get from: + + http://code.google.com/p/jsonrpclib/ + +Also, you will need one of the following JSON modules: +* cjson +* simplejson + +From Python 2.6 on, simplejson is included in the standard +distribution as the "json" module. +""" + +from base import BaseRPCParser, BaseRPCHandler +import jsonrpclib +import types + +class JSONRPCParser(BaseRPCParser): + + content_type = 'text/json' + + def parse_request(self, request_body): + try: + request = jsonrpclib.loads(request_body) + except: + # Bad request formatting. Bad. + return self.faults.parse_error() + self._requests = request + self._batch = False + request_list = [] + if jsonrpclib.isbatch(request): + self._batch = True + for req in request: + req_tuple = (req['method'], req['params']) + request_list.append(req_tuple) + else: + self._requests = [request,] + request_list.append((request['method'], request['params'])) + return tuple(request_list) + + def parse_responses(self, responses): + if isinstance(responses, jsonrpclib.Fault): + return jsonrpclib.dumps(responses) + if len(responses) != len(self._requests): + return jsonrpclib.dumps(self.faults.internal_error()) + response_list = [] + for i in range(0, len(responses)): + request = self._requests[i] + response = responses[i] + if jsonrpclib.isnotification(request): + # Even in batches, notifications have no + # response entry + continue + rpcid = request['id'] + version = jsonrpclib._version + if 'jsonrpc' not in request.keys(): + version = 1.0 + try: + response_json = jsonrpclib.dumps(response, version=version, + rpcid=rpcid, methodresponse=True) + except TypeError: + return jsonrpclib.dumps(self.faults.server_error(), + rpcid=rpcid, version=version) + response_list.append(response_json) + if not self._batch: + # Ensure it wasn't a batch to begin with, then + # return 1 or 0 responses depending on if it was + # a notification. + if len(response_list) < 1: + return '' + return response_list[0] + # Batch, return list + return '[ %s ]' % ', '.join(response_list) + +class JSONRPCHandler(BaseRPCHandler): + """ + Subclass this to add methods -- you can treat them + just like normal methods, this handles the JSON formatting. + """ + _RPC_ = JSONRPCParser(jsonrpclib) + +if __name__ == '__main__': + # Example Implementation + import sys + from base import start_server + from base import TestRPCHandler + + class TestJSONRPC(TestRPCHandler): + _RPC_ = JSONRPCParser(jsonrpclib) + + port = 8181 + if len(sys.argv) > 1: + port = int(sys.argv[1]) + + start_server(TestJSONRPC, port=port) diff --git a/tornadorpc/xml.py b/tornadorpc/xml.py new file mode 100644 index 0000000..13a3dc7 --- /dev/null +++ b/tornadorpc/xml.py @@ -0,0 +1,102 @@ +""" +Copyright 2009 Josh Marshall +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=========================== +XML-RPC Handler for Tornado +=========================== +This is a XML-RPC server implementation, designed for use within the +Tornado framework. Usage is pretty simple: + +>>> from tornadorpc.xml import XMLRPCHandler +>>> from tornadorpc import start_server +>>> +>>> class handler(XMLRPCHandler): +>>> ... def add(self, x, y): +>>> ....... return x+y +>>> +>>> start_server(handler, port=8484) + +It requires the xmlrpclib, which is built-in to Python distributions +from version 2.3 on. + +""" + +from base import BaseRPCParser, BaseRPCHandler +import xmlrpclib +import types + +class XMLRPCSystem(object): + # Multicall functions and, eventually, introspection + + def __init__(self, handler): + self._dispatch = handler._RPC_.dispatch + + def multicall(self, calls): + response_list = [] + for call in calls: + method_name = call['methodName'] + params = call['params'] + response = self._dispatch(method_name, params) + response_list.append((response,)) + return response_list + + +class XMLRPCParser(BaseRPCParser): + + content_type = 'text/xml' + + def parse_request(self, request_body): + try: + params, method_name = xmlrpclib.loads(request_body) + except: + # Bad request formatting, bad. + return self.faults.parse_error() + return ((method_name, params),) + + def parse_responses(self, responses): + if isinstance(responses, xmlrpclib.Fault): + return xmlrpclib.dumps(responses) + try: + response_xml = xmlrpclib.dumps(responses, methodresponse=True) + except TypeError: + return self.faults.internal_error() + return response_xml + +class XMLRPCHandler(BaseRPCHandler): + """ + Subclass this to add methods -- you can treat them + just like normal methods, this handles the XML formatting. + """ + _RPC_ = XMLRPCParser(xmlrpclib) + + @property + def system(self): + return XMLRPCSystem(self) + +if __name__ == '__main__': + # Test implementation + from base import TestRPCHandler, start_server + import sys + + port = 8282 + if len(sys.argv) > 1: + port = int(sys.argv[1]) + + class TestXMLRPC(TestRPCHandler): + _RPC_ = XMLRPCParser(xmlrpclib) + @property + def system(self): + return XMLRPCSystem(self) + + start_server(TestJSONRPC, port=port)