1
0
Fork 0

Initial public releases

* `bthlabs-jsonrpc-aiohttp` v1.0.0
* `bthlabs-jsonrpc-core` v1.0.0
* `bthlabs-jsonrpc-django` v1.0.0
master bthlabs-jsonrpc-django_v1.0.0
Tomek Wójcik 2022-06-04 10:41:53 +02:00
commit c75ea4ea9d
111 changed files with 7193 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
*.pyc
*.pyo
*.swp
.envrc
.venv
invoke.json
tasks.py
.mypy_cache/
.pytest_cache/
build/
dist/
ops/

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# bthlabs-jsonrpc
BTHLabs JSONRPC is a set of Python libraries that provide extensible framework
for adding JSONRPC interfaces to existing Python Web applications.
## Author
*bthlabs-jsonrpc* is developed by [Tomek Wójcik](https://www.bthlabs.pl/).
## License
*bthlabs-jsonrpc-core* is licensed under the MIT License.

View File

@ -0,0 +1,19 @@
Copyright (c) 2022-present Tomek Wójcik <contact@bthlabs.pl>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,55 @@
bthlabs-jsonrpc-aiohttp
=======================
BTHLabs JSONRPC - aiohttp integration
`Docs`_ | `Source repository`_
Overview
--------
BTHLabs JSONRPC is a set of Python libraries that provide extensible framework
for adding JSONRPC interfaces to existing Python Web applications.
The *aiohttp* package provides aiohttp integration.
Installation
------------
.. code-block:: shell
$ pip install bthlabs_jsonrpc_aiohttp
Example
-------
.. code-block:: python
# app.py
from aiohttp import web
from bthlabs_jsonrpc_core import register_method
from bthlabs_jsonrpc_aiohttp import JSONRPCView
@register_method('hello')
async def hello(request, who='World'):
return f'Hello, {who}!'
app = web.Application()
app.add_routes([
web.post('/rpc', JSONRPCView()),
])
Author
------
*bthlabs-jsonrpc-aiohttp* is developed by `Tomek Wójcik`_.
License
-------
*bthlabs-jsonrpc-aiohttp* is licensed under the MIT License.
.. _Docs: https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/
.. _Source repository: https://git.bthlabs.pl/tomekwojcik/bthlabs-jsonrpc/
.. _Tomek Wójcik: https://www.bthlabs.pl/

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
from .views import JSONRPCView # noqa
__version__ = '1.0.0'

View File

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
import logging
from bthlabs_jsonrpc_core import Executor, JSONRPCAccessDeniedError
from bthlabs_jsonrpc_core.exceptions import JSONRPCParseError
LOGGER = logging.getLogger('bthlabs_jsonrpc.aiohttp.executor')
class AioHttpExecutor(Executor):
def __init__(self, request, can_call, namespace=None):
super().__init__(namespace=namespace)
self.request = request
self.can_call = can_call
async def list_methods(self, *args, **kwargs):
return super().list_methods()
async def deserialize_data(self, request):
try:
return await request.json()
except Exception as exception:
LOGGER.error('Error deserializing RPC call!', exc_info=exception)
raise JSONRPCParseError()
def enrich_args(self, args):
return [self.request, *super().enrich_args(args)]
async def before_call(self, method, args, kwargs):
can_call = await self.can_call(self.request, method, args, kwargs)
if can_call is False:
raise JSONRPCAccessDeniedError(data='can_call')
async def execute(self):
with self.execute_context() as execute_context:
data = await self.deserialize_data(self.request)
calls = self.get_calls(data)
for call in calls:
with self.call_context(execute_context, call) as call_context:
if call_context.is_valid is True:
await self.before_call(
call_context.method,
call_context.args,
call_context.kwargs,
)
call_context.result = await call_context.handler(
*call_context.args, **call_context.kwargs,
)
return execute_context.serializer

View File

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
import typing
from aiohttp import web
from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor
class JSONRPCView:
"""
The JSONRPC View. This is the main JSONRPC entry point. Use it to register
your JSONRPC endpoints.
Example:
.. code-block:: python
from bthlabs_jsonrpc_aiohttp import JSONRPCView
app.add_routes([
web.post('/rpc', JSONRPCView()),
web.post('/example/rpc', JSONRPCView(namespace='examnple')),
])
"""
# pragma mark - Public interface
def __init__(self, namespace: typing.Optional[str] = None):
self.namespace: typing.Optional[str] = namespace
async def can_call(self,
request: web.Request,
method: str,
args: list,
kwargs: dict) -> bool:
"""
Hook for subclasses to perform additional per-call permissions checks
etc. The default implementation returns ``True``.
"""
return True
async def __call__(self, request: web.Request) -> web.Response:
"""The request handler."""
executor = AioHttpExecutor(
request, self.can_call, namespace=self.namespace,
)
serializer = await executor.execute()
if serializer is None:
return web.Response(body='')
return web.json_response(serializer.data)

View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@ -0,0 +1,13 @@
API Documentation
=================
.. module:: bthlabs_jsonrpc_aiohttp
This section provides the API documentation for BTHLabs JSONRPC - aiohttp.
Views
-----
.. autoclass:: JSONRPCView
:members:
:special-members: __call__

View File

@ -0,0 +1,57 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('../../'))
# -- Project information -----------------------------------------------------
project = 'BTHLabs JSONRPC - aiohttp'
copyright = '2022-present Tomek Wójcik'
author = 'Tomek Wójcik'
version = '1.0.0'
# The full version, including alpha/beta/rc tags
release = '1.0.0'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

View File

@ -0,0 +1,17 @@
BTHLabs JSONRPC - aiohttp
=========================
BTHLabs JSONRPC is a set of Python libraries that provide extensible framework
for adding JSONRPC interfaces to existing Python Web applications.
The *aiohttp* package provides aiohttp integration.
.. toctree::
:maxdepth: 2
overview
.. toctree::
:maxdepth: 2
api

View File

@ -0,0 +1,36 @@
Overview
========
This section provides the general overview of the integration.
Installation
------------
.. code-block:: shell
$ pip install bthlabs_jsonrpc_aiohttp
Usage
-----
First, you'll need to add a JSONRPC view to your project's URLs:
.. code-block:: python
# app.py
app = web.Application()
app.add_routes([
web.post('/rpc', JSONRPCView()),
])
Then, you'll need to implement the RPC method modules:
.. code-block:: python
# your_app/rpc_methods.py
from bthlabs_jsonrpc_core import register_method
@register_method(name='hello')
async def hello(request, who='World'):
return f'Hello, {who}!'

View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
import asyncio
import logging
import os
import sys
from aiohttp import web
from bthlabs_jsonrpc_core import register_method
from bthlabs_jsonrpc_aiohttp import JSONRPCView
logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example')
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter(
'%(asctime)s %(name)s: %(levelname)s: %(message)s',
)
handler.setFormatter(formatter)
logger.addHandler(handler)
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app')
jsonrpc_logger = logger = logging.getLogger('bthlabs_jsonrpc')
jsonrpc_logger.setLevel(logging.DEBUG)
jsonrpc_logger.addHandler(handler)
async def app_on_startup(app):
logger.info('BTHLabs JSONRPC aiohttp integration example')
logger.debug('My PID = {pid}'.format(pid=os.getpid()))
@register_method('hello')
async def hello(request):
return 'Hello, World!'
@register_method('async_test')
async def async_test(request, delay):
await asyncio.sleep(delay)
return 'It works!'
@register_method('hello', namespace='example')
async def hello_example(request):
return 'Hello, Example!'
def create_app(loop=None):
app = web.Application(logger=app_logger, loop=loop)
app.on_startup.append(app_on_startup)
app.add_routes([
web.post('/rpc', JSONRPCView()),
web.post('/example/rpc', JSONRPCView(namespace='example')),
])
return app
app = create_app()

View File

@ -0,0 +1,3 @@
#!/bin/bash
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
exec adev runserver example.py

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
[virtualenvs]
create = true
in-project = true

View File

@ -0,0 +1,32 @@
[tool.poetry]
name = "bthlabs-jsonrpc-aiohttp"
version = "1.0.0"
description = "BTHLabs JSONRPC - aiohttp integration"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
maintainers = ["BTHLabs <contact@bthlabs.pl>"]
license = "MIT License"
readme = "README.rst"
homepage = "https://projects.bthlabs.pl/bthlabs-jsonrpc/"
repository = "https://git.bthlabs.pl/tomekwojcik/bthlabs-jsonrpc/"
documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/"
[tool.poetry.dependencies]
python = "^3.10"
aiohttp = ">=3.6,<4.0"
bthlabs-jsonrpc-core = "1.0.0"
[tool.poetry.dev-dependencies]
bthlabs-jsonrpc-core = { path = "../bthlabs-jsonrpc-core", develop = true }
aiohttp-devtools = "1.0.post0"
flake8 = "4.0.1"
flake8-commas = "2.1.0"
mypy = "0.950"
pytest = "7.1.2"
pytest-aiohttp = "1.0.4"
pytest-asyncio = "0.18.3"
sphinx = "4.5.0"
sphinx-rtd-theme = "1.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View File

@ -0,0 +1,7 @@
[flake8]
exclude = .venv/,.pytest_cache/
ignore = E402
max-line-length = 119
[tool:pytest]
asyncio_mode = auto

View File

@ -0,0 +1,2 @@
export VIRTUAL_ENV="`realpath .venv`"
export PATH="$VIRTUAL_ENV/bin:$PATH"

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from unittest import mock
from aiohttp.web import Request
import pytest
@pytest.fixture
def fake_request():
return mock.Mock(spec=Request)

View File

@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
from unittest import mock
from bthlabs_jsonrpc_core import exceptions, serializer
import pytest
from bthlabs_jsonrpc_aiohttp import executor
@pytest.fixture
def fake_can_call():
result = mock.AsyncMock()
result.return_value = True
return result
def test_init(fake_request, fake_can_call):
# When
result = executor.AioHttpExecutor(fake_request, fake_can_call)
# Then
assert result.request == fake_request
assert result.can_call == fake_can_call
async def test_list_methods(fake_request, fake_can_call):
# Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
# When
result = await the_executor.list_methods()
# Then
assert result == ['system.list_methods']
async def test_deserialize_data(fake_request, fake_can_call):
# Given
fake_request.json.return_value = 'spam'
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
# When
result = await the_executor.deserialize_data(fake_request)
# Then
assert result == 'spam'
async def test_deserialize_data_error(fake_request, fake_can_call):
# Given
fake_request.json.side_effect = RuntimeError('I HAZ FAIL')
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
# When
try:
_ = await the_executor.deserialize_data(fake_request)
except Exception as exception:
assert isinstance(exception, exceptions.JSONRPCParseError)
else:
assert False, 'No exception raised?'
def test_enrich_args(fake_request, fake_can_call):
# Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
# When
result = the_executor.enrich_args(['spam'])
# Then
assert result == [fake_request, 'spam']
async def test_before_call(fake_request, fake_can_call):
# Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
# When
await the_executor.before_call('test', ['spam'], {'spam': True})
# Then
fake_can_call.assert_called_with(
fake_request, 'test', ['spam'], {'spam': True},
)
async def test_before_call_access_denied(fake_request, fake_can_call):
# Given
fake_can_call.return_value = False
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
# When
try:
await the_executor.before_call('test', ['spam'], {'spam': True})
except Exception as exception:
assert isinstance(exception, exceptions.JSONRPCAccessDeniedError)
else:
assert False, 'No exception raised?'
@mock.patch('bthlabs_jsonrpc_core.registry.MethodRegistry.shared_registry')
async def test_execute(mock_shared_registry, fake_request, fake_can_call):
# Given
fake_method_registry = mock.Mock()
fake_method_registry.get_handler.return_value = None
fake_method_registry.get_methods.return_value = []
mock_shared_registry.return_value = fake_method_registry
batch = [
{
'jsonrpc': '2.0',
'id': 'call_1',
'method': 'system.list_methods',
'params': ['spam'],
},
{
'jsonrpc': '2.0',
'id': 'call_2',
'method': 'idontexist',
},
{
'jsonrpc': '2.0',
'method': 'system.list_methods',
'params': {'spam': True},
},
]
fake_request.json.return_value = batch
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
with mock.patch.object(the_executor, 'before_call') as mock_before_call:
# When
result = await the_executor.execute()
# Then
assert isinstance(result, serializer.JSONRPCSerializer)
expected_result_data = [
{
'jsonrpc': '2.0',
'id': 'call_1',
'result': ['system.list_methods'],
},
{
'jsonrpc': '2.0',
'id': 'call_2',
'error': {
'code': exceptions.JSONRPCMethodNotFoundError.ERROR_CODE,
'message': exceptions.JSONRPCMethodNotFoundError.ERROR_MESSAGE,
},
},
]
assert result.data == expected_result_data
fake_method_registry.get_handler.assert_called_with(
'jsonrpc', 'idontexist',
)
assert mock_before_call.call_count == 2
mock_before_call.assert_any_call(
'system.list_methods', [fake_request, 'spam'], {},
)
mock_before_call.assert_any_call(
'system.list_methods', [fake_request], {'spam': True},
)

View File

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
from unittest import mock
from aiohttp import web
from bthlabs_jsonrpc_core import exceptions
from bthlabs_jsonrpc_aiohttp import views
def test_init():
# When
result = views.JSONRPCView()
# Then
assert result.namespace is None
def test_init_with_namespace():
# When
result = views.JSONRPCView(namespace='testing')
# Then
assert result.namespace == 'testing'
async def test_can_call(fake_request):
# Given
view = views.JSONRPCView()
# When
result = await view.can_call(fake_request, 'test', [], {})
# Then
assert result is True
async def test_view(aiohttp_client):
# Given
view = views.JSONRPCView()
app = web.Application()
app.router.add_post('/', view)
client = await aiohttp_client(app)
batch = [
{
'jsonrpc': '2.0',
'id': 'call_1',
'method': 'system.list_methods',
'params': ['spam'],
},
{
'jsonrpc': '2.0',
'id': 'call_2',
'method': 'idontexist',
},
{
'jsonrpc': '2.0',
'method': 'system.list_methods',
'params': {'spam': True},
},
]
# When
response = await client.post('/', json=batch)
# Then
assert response.status == 200
data = await response.json()
expected_result_data = [
{
'jsonrpc': '2.0',
'id': 'call_1',
'result': ['system.list_methods'],
},
{
'jsonrpc': '2.0',
'id': 'call_2',
'error': {
'code': exceptions.JSONRPCMethodNotFoundError.ERROR_CODE,
'message': exceptions.JSONRPCMethodNotFoundError.ERROR_MESSAGE,
},
},
]
assert data == expected_result_data
async def test_view_empty_response(aiohttp_client):
# Given
view = views.JSONRPCView()
app = web.Application()
app.router.add_post('/', view)
client = await aiohttp_client(app)
call = {
'jsonrpc': '2.0',
'method': 'system.list_methods',
}
# When
response = await client.post('/', json=call)
# Then
assert response.status == 200
data = await response.content.read()
assert data == b''
async def test_view_permission_denied(aiohttp_client):
# Given
view = views.JSONRPCView()
app = web.Application()
app.router.add_post('/', view)
client = await aiohttp_client(app)
call = {
'jsonrpc': '2.0',
'id': 'call_1',
'method': 'system.list_methods',
}
with mock.patch.object(view, 'can_call') as mock_can_call:
mock_can_call.return_value = False
# When
response = await client.post('/', json=call)
# Then
assert response.status == 200
data = await response.json()
expected_result_data = {
'jsonrpc': '2.0',
'id': 'call_1',
'error': {
'code': exceptions.JSONRPCAccessDeniedError.ERROR_CODE,
'message': exceptions.JSONRPCAccessDeniedError.ERROR_MESSAGE,
'data': 'can_call',
},
}
assert data == expected_result_data

View File

@ -0,0 +1,19 @@
Copyright (c) 2022-present Tomek Wójcik <contact@bthlabs.pl>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,57 @@
bthlabs-jsonrpc-core
====================
Extensible framework for Python JSONRPC implementations.
`Docs`_ | `Source repository`_
Overview
--------
BTHLabs JSONRPC is a set of Python libraries that provide extensible framework
for adding JSONRPC interfaces to existing Python Web applications.
The *core* package acts as a foundation for framework-specific integrations.
Integrations
------------
BTHLabs JSONRPC provides integration packages for specific Web frameworks.
**Django**
Django integration is provided by ``bthlabs-jsonrpc-django`` package.
+-------------------+-----------------------------------------------------+
| PyPI | https://pypi.org/project/bthlabs-jsonrpc-django/ |
+-------------------+-----------------------------------------------------+
| Docs | https://projects.bthlabs.pl/bthlabs-jsonrpc/django/ |
+-------------------+-----------------------------------------------------+
| Source repository | https://git.bthlabs.pl/tomekwojcik/bthlabs-jsonrpc/ |
+-------------------+-----------------------------------------------------+
**aiohttp**
aiohttp integration is provided by ``bthlabs-jsonrpc-aiohttp`` package.
+-------------------+------------------------------------------------------+
| PyPI | https://pypi.org/project/bthlabs-jsonrpc-aiohttp/ |
+-------------------+------------------------------------------------------+
| Docs | https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/ |
+-------------------+------------------------------------------------------+
| Source repository | https://git.bthlabs.pl/tomekwojcik/bthlabs-jsonrpc/ |
+-------------------+------------------------------------------------------+
Author
------
*bthlabs-jsonrpc-core* is developed by `Tomek Wójcik`_.
License
-------
*bthlabs-jsonrpc-core* is licensed under the MIT License.
.. _Docs: https://projects.bthlabs.pl/bthlabs-jsonrpc/core/
.. _Source repository: https://git.bthlabs.pl/tomekwojcik/bthlabs-jsonrpc/
.. _Tomek Wójcik: https://www.bthlabs.pl/

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from .decorators import register_method # noqa
from .exceptions import ( # noqa
BaseJSONRPCError,
JSONRPCAccessDeniedError,
JSONRPCInternalError,
JSONRPCParseError,
JSONRPCSerializerError,
)
from .executor import Executor # noqa
from .serializer import JSONRPCSerializer # noqa
__version__ = '1.0.0'

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
import typing
from bthlabs_jsonrpc_core.registry import MethodRegistry
def register_method(method: str,
namespace: typing.Optional[str] = None,
) -> typing.Callable:
"""
Registers the decorated function as JSONRPC *method* in *namespace*.
If *namespace* is omitted, the function will be registered in the default
namespace.
Example:
.. code-block:: python
@register_method('example')
def example(a, b):
return a + b
"""
if namespace is None:
namespace = MethodRegistry.DEFAULT_NAMESPACE
def decorator(handler: typing.Callable) -> typing.Callable:
registry = MethodRegistry.shared_registry()
registry.register_method(namespace, method, handler)
handler.jsonrpc_method = method
handler.jsonrpc_namespace = namespace
return handler
return decorator

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
class BaseJSONRPCError(Exception):
"""
Base class for JSONRPC exceptions.
If *data* is provided, it'll be added to the exception's response payload.
"""
#: Error code
ERROR_CODE: int = -32001
#: Error message
ERROR_MESSAGE: str = 'JSONRPC Error'
def __init__(self, data=None):
self.data = data
def to_rpc(self) -> dict:
"""Returns payload for :py:class:`JSONRPCSerializer`."""
result = {
'code': self.ERROR_CODE,
'message': self.ERROR_MESSAGE,
}
if self.data:
result['data'] = self.data
return result
class JSONRPCParseError(BaseJSONRPCError):
"""Parse error"""
#: Error code
ERROR_CODE = -32700
#: Error message
ERROR_MESSAGE = 'Parse error'
class JSONRPCInvalidRequestError(BaseJSONRPCError):
"""Invalid request error"""
#: Error code
ERROR_CODE = -32600
#: Error message
ERROR_MESSAGE = 'Invalid Request'
class JSONRPCMethodNotFoundError(BaseJSONRPCError):
"""Method not found error"""
#: Error code
ERROR_CODE = -32601
#: Error message
ERROR_MESSAGE = 'Method not found'
class JSONRPCInvalidParamsError(BaseJSONRPCError):
"""Invalid params error"""
#: Error code
ERROR_CODE = -32602
#: Error message
ERROR_MESSAGE = 'Invalid params'
class JSONRPCInternalError(BaseJSONRPCError):
"""Internal error"""
#: Error code
ERROR_CODE = -32603
#: Error message
ERROR_MESSAGE = 'Internal error'
class JSONRPCSerializerError(BaseJSONRPCError):
"""Serializer error"""
#: Error code
ERROR_CODE = -32002
#: Error message
ERROR_MESSAGE = 'JSONRPCSerializer error'
class JSONRPCAccessDeniedError(BaseJSONRPCError):
"""Access denied error"""
#: Error code
ERROR_CODE = -32003
#: Error message
ERROR_MESSAGE = 'Access denied'

View File

@ -0,0 +1,397 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from contextlib import contextmanager
from dataclasses import dataclass
import json
import logging
import typing
from bthlabs_jsonrpc_core.exceptions import (
BaseJSONRPCError,
JSONRPCInternalError,
JSONRPCInvalidParamsError,
JSONRPCInvalidRequestError,
JSONRPCMethodNotFoundError,
JSONRPCParseError,
)
from bthlabs_jsonrpc_core.registry import MethodRegistry
from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer
LOGGER = logging.getLogger('bthlabs_jsonrpc.core.executor')
class Executor:
"""
*Executor* is the main interface for the integrations. It processes the
JSONRPC request, executes the calls and returns the responses.
*namespace* will be used to look up called methods in the registry. If
omitted, it'll fall back to the default namespace.
Example:
.. code-block:: python
def rpc_handler(request):
executor = Executor()
serializer = executor.execute(request.body)
return JSONResponse(serializer.data)
"""
# pragma mark - Private class attributes
# Supported internal methods.
# These methods will be resolved and handled internally.
INTERNAL_METHODS = ('system.list_methods',)
# The method registry class to use for handler lookups.
registry = MethodRegistry
# The serializer registry class to use for response serialization.
serializer = JSONRPCSerializer
@dataclass
class CallContext:
"""
The context of a single call.
:meta private:
"""
#: Method
method: str
#: Handler
handler: typing.Callable
#: Call args
args: list[typing.Any]
#: Call kwargs
kwargs: dict
#: Call result
result: typing.Optional[typing.Any] = None
@classmethod
def invalid_context(cls):
return cls(None, None, None, None)
@property
def is_valid(self) -> bool:
"""Returns ``True`` if the context is valid."""
return all((
self.method is not None,
self.handler is not None,
self.args is not None,
self.kwargs is not None,
))
@dataclass
class ExecuteContext:
"""
The context of an execute call.
:meta private:
"""
#: List of call results.
results: list
#: The serializer instance.
serializer: typing.Optional[JSONRPCSerializer] = None
# pragma mark - Private interface
def __init__(self, namespace=None):
self.namespace = namespace or MethodRegistry.DEFAULT_NAMESPACE
def get_internal_handler(self, method: str) -> typing.Callable:
"""
Returns the internal handler for *method* or raises
``JSONRPCMethodNotFoundError``.
:meta private:
"""
match method:
case 'system.list_methods':
return self.list_methods
case _:
raise JSONRPCMethodNotFoundError()
def get_calls(self, data: typing.Union[dict, list]) -> list:
"""
Returns the list of calls.
If *data* is a list, it's returned verbatim. If it's a dict, it's
wrapped in a list.
Raises ``JSONRPCInvalidRequestError`` if the effective list of calls
is empty:
:meta private:
"""
result = list()
if isinstance(data, list):
result = data
else:
result.append(data)
if len(result) == 0:
raise JSONRPCInvalidRequestError()
return result
def get_call_spec(self,
call: typing.Any,
) -> tuple[str, typing.Callable, list, dict]:
"""
Validates and pre-processes the *call*.
Returns tuple of *method*, *handler*, *args*, *kwargs*.
:meta private:
"""
method = None
handler = None
args = []
kwargs = {}
try:
assert isinstance(call, dict), JSONRPCInvalidRequestError
assert call.get('jsonrpc', None) == '2.0', JSONRPCInvalidRequestError
method = call.get('method', None)
assert method is not None, JSONRPCInvalidRequestError
if method in self.INTERNAL_METHODS:
handler = self.get_internal_handler(method)
else:
handler = self.registry.shared_registry().get_handler(
self.namespace, method,
)
assert handler is not None, JSONRPCMethodNotFoundError
except AssertionError as exception:
klass = exception.args[0]
raise klass()
call_params = call.get('params', None)
if call_params is not None:
if isinstance(call_params, list):
args = call_params
elif isinstance(call_params, dict):
kwargs = call_params
else:
raise JSONRPCInvalidParamsError()
args = self.enrich_args(args)
kwargs = self.enrich_kwargs(kwargs)
return method, handler, args, kwargs
def process_results(self,
results: list,
) -> typing.Optional[typing.Union[list, dict]]:
"""
Post-processes the *results* and returns responses.
If *results* is a single-element list, the result is a single
response object. Otherwise, it's a list of response objects.
If the effective response is empty (e.g. all the calls were
notifications), returns ``None``.
:meta private:
"""
responses = []
for result in results:
call, call_result = result
response: dict[str, typing.Any] = {
'jsonrpc': '2.0',
}
if call is None:
response['id'] = None
response['error'] = call_result
elif call.get('id', None) is not None:
response['id'] = call['id']
if isinstance(call_result, BaseJSONRPCError):
response['error'] = call_result
else:
response['result'] = call_result
else:
continue
responses.append(response)
if len(responses) == 0:
return None
elif len(responses) == 1:
return responses[0]
return responses
@contextmanager
def call_context(self, execute_context: ExecuteContext, call: dict):
"""
The call context manager. Yields ``CallContext``, which can be
invalid invalid if there was en error processing the call.
Handles errors and the call result accordingly.
:meta private:
"""
method = None
error = None
try:
context = self.CallContext.invalid_context()
try:
method, handler, args, kwargs = self.get_call_spec(call)
context = self.CallContext(method, handler, args, kwargs)
except BaseJSONRPCError as exception:
error = exception
yield context
except Exception as exception:
if isinstance(exception, BaseJSONRPCError):
error = exception
else:
LOGGER.error(
f'Error handling RPC method: {method}!',
exc_info=exception,
)
error = JSONRPCInternalError(str(exception))
finally:
if error is not None:
execute_context.results.append((call, error))
else:
execute_context.results.append((call, context.result))
@contextmanager
def execute_context(self):
"""
The execution context. Yields ``ExecuteContext``.
Handles errors and manages the serializer post execution.
:meta private:
"""
try:
context = self.ExecuteContext([])
yield context
except Exception as exc:
if isinstance(exc, BaseJSONRPCError):
context.results = [(None, exc)]
else:
raise
responses = self.process_results(context.results)
if responses is not None:
context.serializer = self.serializer(responses)
# pragma mark - Public interface
def deserialize_data(self, data: bytes) -> typing.Any:
"""
Deserializes *data* and returns the result.
Raises :py:exc:`JSONRPCParseError` if there was an error in the process.
Subclasses should also raise this exception, so it can be resulting
response object conforms to the spec.
"""
try:
return json.loads(data)
except Exception as exception:
LOGGER.error('Error deserializing RPC call!', exc_info=exception)
raise JSONRPCParseError() from exception
def list_methods(self, *args, **kwargs) -> list[str]:
"""
The handler for ``system.list_methods`` internal method.
Returns list of methods this *Executor* can handle.
"""
result = list(self.INTERNAL_METHODS)
result.extend(MethodRegistry.shared_registry().get_methods(
self.namespace,
))
return result
def enrich_args(self, args: list) -> list:
"""
Hook for subclasses to pass additional args to the handler. The default
implementation returns the *args* verbatim.
Example:
.. code-block:: python
class ExampleExecutor(Executor):
def enrich_args(self, args):
return ['spam', *args]
"""
return [*args]
def enrich_kwargs(self, kwargs: dict) -> dict:
"""
Hook for subclasses to pass additional kwaargs to the handler.
The default implementation returns the *kwargs* verbatim.
Example:
.. code-block:: python
class ExampleExecutor(Executor):
def enrich_kwargs(self, kwargs):
return {'spam': True, **kwargs}
"""
return {**kwargs}
def before_call(self, method: str, args: list, kwargs: dict):
"""
Hook for subclasses to perform additional operations before executing
the call.
If this method raises a subclass of
:py:exc:`BaseJSONRPCError`, it'll be used to construct the response
object directly. Any other exception will be wrapped in
:py:exc:`JSONRPCInternalError`.
The default implementation does nothing.
"""
pass
def execute(self,
payload: typing.Any,
) -> typing.Optional[JSONRPCSerializer]:
"""
Executes the JSONRPC request in *payload*.
Returns an instance of :py:class:`JSONRPCSerializer` or ``None`` if
the list of responses is empty.
"""
with self.execute_context() as execute_context:
data = self.deserialize_data(payload)
calls = self.get_calls(data)
for call in calls:
with self.call_context(execute_context, call) as call_context:
if call_context.is_valid is True:
self.before_call(
call_context.method,
call_context.args,
call_context.kwargs,
)
call_context.result = call_context.handler(
*call_context.args, **call_context.kwargs,
)
return execute_context.serializer

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
class MethodRegistry:
INSTANCE = None
DEFAULT_NAMESPACE = 'jsonrpc'
def __init__(self, *args, **kwargs):
self.registry = {}
self.registry[self.DEFAULT_NAMESPACE] = {}
@classmethod
def shared_registry(cls, *args, **kwargs):
if cls.INSTANCE is None:
cls.INSTANCE = cls(*args, **kwargs)
return cls.INSTANCE
def register_method(self, namespace, method, handler):
if namespace not in self.registry:
self.registry[namespace] = {}
self.registry[namespace][method] = handler
def get_methods(self, namespace):
return self.registry.get(namespace, {}).keys()
def get_handler(self, namespace, method):
return self.registry.get(namespace, {}).get(method, None)

181