1
0
This commit is contained in:
2024-01-15 20:20:10 +00:00
parent c75ea4ea9d
commit 38cd64ea9a
87 changed files with 3946 additions and 2040 deletions

View File

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

View File

@@ -1,38 +1,85 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
import logging
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from bthlabs_jsonrpc_core import Executor, JSONRPCAccessDeniedError
import logging
import inspect
import typing
from aiohttp import web
from bthlabs_jsonrpc_core import Codec, Executor, JSONRPCAccessDeniedError
from bthlabs_jsonrpc_core.exceptions import JSONRPCParseError
from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer
LOGGER = logging.getLogger('bthlabs_jsonrpc.aiohttp.executor')
TCanCall = typing.Callable[[web.Request, str, list, dict], typing.Awaitable[bool]]
class AioHttpExecutor(Executor):
def __init__(self, request, can_call, namespace=None):
super().__init__(namespace=namespace)
"""AioHttp-specific executor."""
def __init__(self,
request: web.Request,
can_call: TCanCall,
namespace: str | None = None,
codec: Codec | None = None,
):
super().__init__(namespace=namespace, codec=codec)
self.request = request
self.can_call = can_call
async def list_methods(self, *args, **kwargs):
# pragma mark - Public interface
async def list_methods(self, *args, **kwargs) -> list[str]: # type: ignore[override]
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()
async def deserialize_data(self, request: web.Request) -> typing.Any: # type: ignore[override]
"""
Deserializes *data* and returns the result.
def enrich_args(self, args):
Raises :py:exc:`JSONRPCParseError` if there was an error in the process.
"""
try:
payload = await request.text()
result = self.codec.decode(payload)
if inspect.isawaitable(result):
return await result
return result
except Exception as exception:
LOGGER.error(
'Unhandled exception when deserializing RPC call: %s',
exception,
exc_info=exception,
)
raise JSONRPCParseError() from exception
def enrich_args(self, args: list) -> list:
"""
Injects the current :py:class:`aiohttp.web.Request` as the first
argument.
"""
return [self.request, *super().enrich_args(args)]
async def before_call(self, method, args, kwargs):
async def before_call(self, method: str, args: list, kwargs: dict):
"""
Executes *can_call* and raises :py:exc:`JSONRPCAccessDeniedError`
accordingly.
"""
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):
async def execute(self) -> JSONRPCSerializer | None: # type: ignore[override]
"""
Executes the JSONRPC request.
Returns an instance of :py:class:`JSONRPCSerializer` or ``None`` if
the list of responses is empty.
"""
with self.execute_context() as execute_context:
data = await self.deserialize_data(self.request)

View File

@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
import typing
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import inspect
from aiohttp import web
from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec
from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor
@@ -20,14 +23,17 @@ class JSONRPCView:
app.add_routes([
web.post('/rpc', JSONRPCView()),
web.post('/example/rpc', JSONRPCView(namespace='examnple')),
web.post('/example/rpc', JSONRPCView(namespace='example')),
])
"""
# pragma mark - Public interface
def __init__(self,
namespace: str | None = None,
codec: type[Codec] | None = None):
self.namespace: str | None = namespace
self.codec: type[Codec] = codec or JSONCodec
def __init__(self, namespace: typing.Optional[str] = None):
self.namespace: typing.Optional[str] = namespace
# pragma mark - Public interface
async def can_call(self,
request: web.Request,
@@ -40,14 +46,33 @@ class JSONRPCView:
"""
return True
async def get_codec(self, request: web.Request) -> Codec:
"""Returns a codec configured for the *request*."""
return self.codec()
async def get_executor(self, request: web.Request) -> AioHttpExecutor:
"""Returns an executor configured for the *request*."""
codec = await self.get_codec(request)
return AioHttpExecutor(
request, self.can_call, namespace=self.namespace, codec=codec,
)
async def __call__(self, request: web.Request) -> web.Response:
"""The request handler."""
executor = AioHttpExecutor(
request, self.can_call, namespace=self.namespace,
)
executor = await self.get_executor(request)
serializer = await executor.execute()
if serializer is None:
return web.Response(body='')
return web.json_response(serializer.data)
codec = await self.get_codec(request)
body = codec.encode(serializer.data)
if inspect.isawaitable(body):
body = await body
return web.Response(
body=body,
content_type=codec.get_content_type(),
)

View File

@@ -5,6 +5,12 @@ API Documentation
This section provides the API documentation for BTHLabs JSONRPC - aiohttp.
Executors
---------
.. autoclass:: AioHttpExecutor
:members:
Views
-----

View File

@@ -1,3 +1,4 @@
# type: ignore
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
@@ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../'))
project = 'BTHLabs JSONRPC - aiohttp'
copyright = '2022-present Tomek Wójcik'
author = 'Tomek Wójcik'
version = '1.0.0'
version = '1.1.0'
# The full version, including alpha/beta/rc tags
release = '1.0.0'
release = '1.1.0b1'
# -- General configuration ---------------------------------------------------

View File

@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
import asyncio
import datetime
import logging
import os
import sys
from aiohttp import web
from bthlabs_jsonrpc_core import register_method
from bthlabs_jsonrpc_core.ext.jwt import ALGORITHMS, HMACKey, JWTCodec, KeyPair
from bthlabs_jsonrpc_aiohttp import JSONRPCView
@@ -20,12 +22,12 @@ formatter = logging.Formatter(
handler.setFormatter(formatter)
logger.addHandler(handler)
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app')
jsonrpc_logger = logger = logging.getLogger('bthlabs_jsonrpc')
jsonrpc_logger = logging.getLogger('bthlabs_jsonrpc')
jsonrpc_logger.setLevel(logging.DEBUG)
jsonrpc_logger.addHandler(handler)
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app')
async def app_on_startup(app):
logger.info('BTHLabs JSONRPC aiohttp integration example')
@@ -43,18 +45,30 @@ async def async_test(request, delay):
return 'It works!'
@register_method('hello', namespace='example')
@register_method('hello', namespace='jwt')
async def hello_example(request):
return 'Hello, Example!'
class JWTRPCView(JSONRPCView):
async def get_codec(self, request):
return JWTCodec(
KeyPair(
decode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256),
encode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256),
),
issuer='bthlabs_jsonrpc_aiohttp_example',
ttl=datetime.timedelta(seconds=3600),
)
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')),
web.post('/jwt/rpc', JWTRPCView(namespace='jwt')),
])
return app

View File

@@ -1,3 +1,3 @@
#!/bin/bash
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
# bthlabs-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

@@ -1,6 +1,6 @@
[tool.poetry]
name = "bthlabs-jsonrpc-aiohttp"
version = "1.0.0"
version = "1.1.0b1"
description = "BTHLabs JSONRPC - aiohttp integration"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
maintainers = ["BTHLabs <contact@bthlabs.pl>"]
@@ -13,20 +13,21 @@ 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"
bthlabs-jsonrpc-core = "1.1.0b1"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
aiohttp = "3.9.1"
bthlabs-jsonrpc-core = { path = "../bthlabs-jsonrpc-core", develop = true }
aiohttp-devtools = "1.0.post0"
flake8 = "4.0.1"
aiohttp-devtools = "1.1.2"
flake8 = "6.1.0"
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"
mypy = "1.8.0"
pytest = "7.4.3"
pytest-aiohttp = "1.0.5"
pytest-asyncio = "0.23.3"
sphinx = "7.2.6"
sphinx-rtd-theme = "2.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -1,5 +1,5 @@
[flake8]
exclude = .venv/,.pytest_cache/
exclude = .venv/,.mypy_cache/,.pytest_cache/
ignore = E402
max-line-length = 119

View File

@@ -1,10 +1,20 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock
from aiohttp.web import Request
import pytest
from .fixtures import AsyncJSONCodec
@pytest.fixture
def fake_request():
def fake_request() -> mock.Mock:
return mock.Mock(spec=Request)
@pytest.fixture
def async_json_codec() -> AsyncJSONCodec:
return AsyncJSONCodec()

View File

@@ -1,21 +1,26 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import json
from unittest import mock
from bthlabs_jsonrpc_core import exceptions, serializer
import pytest
from bthlabs_jsonrpc_aiohttp import executor
from tests.fixtures import AsyncJSONCodec
@pytest.fixture
def fake_can_call():
def fake_can_call() -> mock.Mock:
result = mock.AsyncMock()
result.return_value = True
return result
def test_init(fake_request, fake_can_call):
def test_init(fake_request: mock.Mock, fake_can_call: mock.Mock):
# When
result = executor.AioHttpExecutor(fake_request, fake_can_call)
@@ -24,7 +29,7 @@ def test_init(fake_request, fake_can_call):
assert result.can_call == fake_can_call
async def test_list_methods(fake_request, fake_can_call):
async def test_list_methods(fake_request: mock.Mock, fake_can_call: mock.Mock):
# Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
@@ -35,9 +40,10 @@ async def test_list_methods(fake_request, fake_can_call):
assert result == ['system.list_methods']
async def test_deserialize_data(fake_request, fake_can_call):
async def test_deserialize_data(fake_request: mock.Mock,
fake_can_call: mock.Mock):
# Given
fake_request.json.return_value = 'spam'
fake_request.text.return_value = '"spam"'
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
@@ -48,9 +54,28 @@ async def test_deserialize_data(fake_request, fake_can_call):
assert result == 'spam'
async def test_deserialize_data_error(fake_request, fake_can_call):
async def test_deserialize_data_async_codec_decode(fake_request: mock.Mock,
async_json_codec: AsyncJSONCodec,
fake_can_call: mock.Mock):
# Given
fake_request.json.side_effect = RuntimeError('I HAZ FAIL')
fake_request.text.return_value = '"spam"'
the_executor = executor.AioHttpExecutor(
fake_request, fake_can_call, codec=async_json_codec,
)
# When
result = await the_executor.deserialize_data(fake_request)
# Then
assert result == 'spam'
async def test_deserialize_data_error(fake_request: mock.Mock,
fake_can_call: mock.Mock):
# Given
error = RuntimeError('I HAZ FAIL')
fake_request.text.side_effect = error
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
@@ -59,11 +84,12 @@ async def test_deserialize_data_error(fake_request, fake_can_call):
_ = await the_executor.deserialize_data(fake_request)
except Exception as exception:
assert isinstance(exception, exceptions.JSONRPCParseError)
assert exception.__cause__ == error
else:
assert False, 'No exception raised?'
def test_enrich_args(fake_request, fake_can_call):
def test_enrich_args(fake_request: mock.Mock, fake_can_call: mock.Mock):
# Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
@@ -74,7 +100,7 @@ def test_enrich_args(fake_request, fake_can_call):
assert result == [fake_request, 'spam']
async def test_before_call(fake_request, fake_can_call):
async def test_before_call(fake_request: mock.Mock, fake_can_call: mock.Mock):
# Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
@@ -87,7 +113,8 @@ async def test_before_call(fake_request, fake_can_call):
)
async def test_before_call_access_denied(fake_request, fake_can_call):
async def test_before_call_access_denied(fake_request: mock.Mock,
fake_can_call: mock.Mock):
# Given
fake_can_call.return_value = False
@@ -103,7 +130,9 @@ async def test_before_call_access_denied(fake_request, fake_can_call):
@mock.patch('bthlabs_jsonrpc_core.registry.MethodRegistry.shared_registry')
async def test_execute(mock_shared_registry, fake_request, fake_can_call):
async def test_execute(mock_shared_registry: mock.Mock,
fake_request: mock.Mock,
fake_can_call: mock.Mock):
# Given
fake_method_registry = mock.Mock()
fake_method_registry.get_handler.return_value = None
@@ -129,7 +158,7 @@ async def test_execute(mock_shared_registry, fake_request, fake_can_call):
},
]
fake_request.json.return_value = batch
fake_request.text.return_value = json.dumps(batch)
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import typing
from bthlabs_jsonrpc_core import codecs
class AsyncJSONCodec(codecs.JSONCodec):
async def decode(self,
payload: str | bytes,
**decoder_kwargs) -> typing.Any:
return super().decode(payload, **decoder_kwargs)
async def encode(self, payload: typing.Any, **encoder_kwargs) -> str:
return super().encode(payload, **encoder_kwargs)

View File

@@ -1,10 +1,21 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock
from aiohttp.test_utils import TestClient
from aiohttp import web
from bthlabs_jsonrpc_core import exceptions
from bthlabs_jsonrpc_core import codecs, exceptions
import pytest
from bthlabs_jsonrpc_aiohttp import views
from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor
@pytest.fixture
def fake_aiohttp_executor() -> mock.Mock:
return mock.Mock(spec=AioHttpExecutor)
def test_init():
@@ -13,6 +24,7 @@ def test_init():
# Then
assert result.namespace is None
assert result.codec == codecs.JSONCodec
def test_init_with_namespace():
@@ -23,7 +35,53 @@ def test_init_with_namespace():
assert result.namespace == 'testing'
async def test_can_call(fake_request):
def test_init_with_codec(fake_custom_codec: mock.Mock):
# When
result = views.JSONRPCView(codec=fake_custom_codec)
# Then
assert result.codec == fake_custom_codec
async def test_get_executor(fake_request: mock.Mock):
# Given
view = views.JSONRPCView()
# When
result = await view.get_executor(fake_request)
# Then
assert isinstance(result, views.AioHttpExecutor) is True
async def test_get_executor_dependency_calls(fake_aiohttp_executor: mock.Mock,
fake_custom_codec: mock.Mock,
fake_request: mock.Mock):
# Given
with mock.patch.object(views, 'AioHttpExecutor') as mock_aiohttp_executor:
with mock.patch.object(views.JSONRPCView, 'get_codec') as mock_get_codec:
mock_aiohttp_executor.return_value = fake_aiohttp_executor
mock_get_codec.return_value = fake_custom_codec
view = views.JSONRPCView()
# When
result = await view.get_executor(fake_request)
# Then
assert result == fake_aiohttp_executor
mock_get_codec.assert_awaited_once_with(fake_request)
mock_aiohttp_executor.assert_called_once_with(
fake_request,
view.can_call,
namespace=view.namespace,
codec=fake_custom_codec,
)
async def test_can_call(fake_request: mock.Mock):
# Given
view = views.JSONRPCView()
@@ -34,7 +92,7 @@ async def test_can_call(fake_request):
assert result is True
async def test_view(aiohttp_client):
async def test_view(aiohttp_client: TestClient):
# Given
view = views.JSONRPCView()
@@ -87,7 +145,7 @@ async def test_view(aiohttp_client):
assert data == expected_result_data
async def test_view_empty_response(aiohttp_client):
async def test_view_empty_response(aiohttp_client: TestClient):
# Given
view = views.JSONRPCView()
@@ -111,7 +169,7 @@ async def test_view_empty_response(aiohttp_client):
assert data == b''
async def test_view_permission_denied(aiohttp_client):
async def test_view_permission_denied(aiohttp_client: TestClient):
# Given
view = views.JSONRPCView()
@@ -146,3 +204,36 @@ async def test_view_permission_denied(aiohttp_client):
},
}
assert data == expected_result_data
async def test_view_async_codec_encode(async_json_codec: mock.Mock,
aiohttp_client: TestClient):
# Given
mock_codec = mock.Mock(return_value=async_json_codec)
view = views.JSONRPCView(codec=mock_codec)
app = web.Application()
app.router.add_post('/', view)
client = await aiohttp_client(app)
call = {
'jsonrpc': '2.0',
'id': 'test',
'method': 'system.list_methods',
}
# When
response = await client.post('/', json=call)
# Then
assert response.status == 200
data = await response.json()
expected_result_data = {
'jsonrpc': '2.0',
'id': 'test',
'result': ['system.list_methods'],
}
assert data == expected_result_data