1
0

Initial public releases

* `bthlabs-jsonrpc-aiohttp` v1.0.0
* `bthlabs-jsonrpc-core` v1.0.0
* `bthlabs-jsonrpc-django` v1.0.0
This commit is contained in:
2022-06-04 10:41:53 +02:00
commit c75ea4ea9d
111 changed files with 7193 additions and 0 deletions

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