json-rpc
json-rpc copied to clipboard
Feature proposal: Remote Method Invocation dispatcher
Remote Method Invocation (RMI) is the object oriented's version of RPC, where methods are invoked on remote objects. It would allow people to employ object oriented programming in the architecture of JSON-RPC API's in a transparent manner, i. e. the client does not need to know whether they are calling a function (unbound procedure) or a method (bound procedure).
MVC variants are a common pattern in the architecture of many applications. For web services, generally a Controller object receive requests from clients and call methods on other objects, injected into the Controller through its constructor, to fulfill the request and return a response. In Django, for example, the View receives the request and return a response, its role is somewhat close to the Controller in other MVC variants. Django's views can be either a function or class-based.
Design
The methods available for RMI, the exposed methods, would be described and made available under a namespace, a string defined as a class attribute, by a RMIReceiver
. Also those methods would be explicitly exposed by annotating it's definition with @exposed
decorator.
A reference to a exposed method would be done by referencing its path, which would be a string in format<namespace>.<method-name>
, where <method-name>
is the name of the method annotated by @exposed
and the dot is used to delimit the end of the namespace, which can have any character including dots (e.g. "foo.bar.method").
The RMIDispatcher
would dispatch the calls by taking the method's path and returning the corresponding method's implementation bound to its receiver. The receivers would be registered in the dispatcher by passing its instances to RMIDispatcher.register_receivers(*receivers)
.
iter(RMIDispatcher)
returns an iterator that yield tuples (method-path, method)
for all added receivers, internally it calls RMIReceiver.iter_methods()
for all receivers.
This dispatcher would be read-only and compatible with the __getitem__
and iterator protocols, that is enough for jsonrpc to dispatch calls, right?
Implementation
import typing
class _RMIReceiverConstructor(type):
def __new__(cls, name, bases, clsdict):
if len(bases) > 0:
clsdict['_exposed_'] = {}
return super().__new__(cls, name, bases, clsdict)
class RMIReceiver(metaclass=_RMIReceiverConstructor):
def resolve_method(self, method_name: str):
method = next((method for name, method in self._iter_methods() if name == method_name), None)
if method is not None:
return method
else:
raise NotImplementedError
def iter_methods(self):
for name, method in self._iter_methods():
yield f"{self.namespace}.{name}", method
def _iter_methods(self):
for klass in self.__class__.__mro__:
if hasattr(klass, '_exposed_'):
for name, method in klass._exposed_.items():
yield name, method.__get__(self, klass)
class exposed:
def __init__(self, method):
self.method = method
def __set_name__(self, owner, name: str):
if not issubclass(owner, RMIReceiver):
raise TypeError(
f"The @exposed decorator works only in subclasses of RMIReceiver, but '{owner.__name__}' is not."
)
setattr(owner, name, self.method)
owner._exposed_[name] = self.method
class RMIDispatcher:
def __init__(self):
self._receivers: typing.Dict[str, RMIReceiver] = {}
def register_receivers(self, *receivers: typing.List[RMIReceiver]):
"""NOTE: Receivers in same namespace will override each other"""
for receiver in receivers:
self._receivers[receiver.namespace] = receiver
def __getitem__(self, method_path: str):
parts = method_path.rsplit('.', 1)
if len(parts) != 2:
raise KeyError(
f"Invalid method path '{method_path}'. All method paths must be in format '<namespace>.<method-name>'"
)
try:
receiver = self._receivers[parts[0]]
except KeyError:
raise KeyError(f"There is no receiver for namespace '{parts[0]}'")
try:
return receiver.resolve_method(parts[1])
except NotImplementedError:
raise KeyError(
f"The method '{parts[1]}' from '{method_path}' is not implemented by the receiver '{receiver.__class__.__name__}'"
)
def __iter__(self):
def _iter():
for receiver in self._receivers.values():
yield from receiver.iter_methods()
return _iter()
The implementation could be located in the rmi
module.
Usage
from jsonrpc.rmi import RMIReceiver, RMIDispatcher, exposed
class SuperHelloWorld(RMIReceiver):
namespace = 'hello'
@exposed
def super_hello(self):
print('Super Hello, World')
def non_exposed_hello(self):
print("I can't be called from dispatcher!")
class HelloWorld(SuperHelloWorld):
@exposed
def hello(self):
print('Hello, World')
dispatcher = RMIDispatcher()
dispatcher.register_receivers(HelloWorld())
dispatcher['hello.super_hello']()
dispatcher['hello.hello']()
dispatcher['hello.non_exposed_hello']() # KeyError: "The method 'non_exposed_hello' from 'hello.non_exposed_hello' is not implemented by the receiver 'HelloWorld'"
To define an API, one could use Adapters for each exposed object, thus creating exposed interfaces:
class FooAPI(RMIReceiver, FooInterface):
def __init__(self, implementation: FooInterface):
self._implementation = implementation
@exposed
def my_public_method(self):
return self._implementation.my_public_method()
Implementations can be changed just by changing the injected object, the interface itself, FooAPI
, would remain intact.
Compatibility
RMIDispatcher
once filled with receivers can:
- be converted into a
Dispatcher
:
from jsonrpc import Dispatcher, RMIDispatcher
# fill a rmi_dispatcher with receivers
dispatcher = Dispatcher(dict(iter(rmi_dispatcher)))
- extend an existing
Dispatcher
:
from jsonrpc import dispatcher
# fill a rmi_dispatcher with receivers
dispatcher.add_dict(dict(iter(rmi_dispatcher)))
A single RMIReceiver
can be added to an existing Dispatcher
:
from jsonrpc import dispatcher
# ...
hello_world_receiver = HelloWorld()
dispatcher.add_dict(dict(hello_world_receiver.iter_methods()))
From what I understand in JSONRPCResponseManager, it expects a dispatcher to implement __getitem__
and raise KeyError
if the method does not exist, I believe my implementation fulfills that. The dispatcher interface used by jsonrpc should be explicit.
To do
- [ ] There will be namespace conflicts when two subclasses uses the same namespace declared by their parent, currently the implementation does not try to detect that. Should every class explicitly declare its namespace? Should the namespace reflect the class hierarchy? Should the namespace be the class name?
- [ ] Add to jsonrpc and write tests
- [ ] Open pull request
- [ ] Merge into jsonrpc's master branch
Thank you for the proposal! Let me take a closer look at it.