1
0
This commit is contained in:
Tomek Wójcik 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 -*- # -*- 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
from .views import JSONRPCView # noqa 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 -*- # -*- 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 logging 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.exceptions import JSONRPCParseError
from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer
LOGGER = logging.getLogger('bthlabs_jsonrpc.aiohttp.executor') LOGGER = logging.getLogger('bthlabs_jsonrpc.aiohttp.executor')
TCanCall = typing.Callable[[web.Request, str, list, dict], typing.Awaitable[bool]]
class AioHttpExecutor(Executor): class AioHttpExecutor(Executor):
def __init__(self, request, can_call, namespace=None): """AioHttp-specific executor."""
super().__init__(namespace=namespace)
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.request = request
self.can_call = can_call 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() return super().list_methods()
async def deserialize_data(self, request): async def deserialize_data(self, request: web.Request) -> typing.Any: # type: ignore[override]
try: """
return await request.json() Deserializes *data* and returns the result.
except Exception as exception:
LOGGER.error('Error deserializing RPC call!', exc_info=exception)
raise JSONRPCParseError()
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)] 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) can_call = await self.can_call(self.request, method, args, kwargs)
if can_call is False: if can_call is False:
raise JSONRPCAccessDeniedError(data='can_call') 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: with self.execute_context() as execute_context:
data = await self.deserialize_data(self.request) data = await self.deserialize_data(self.request)

View File

@ -1,8 +1,11 @@
# -*- coding: utf-8 -*- # -*- 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 typing from __future__ import annotations
import inspect
from aiohttp import web from aiohttp import web
from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec
from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor
@ -20,14 +23,17 @@ class JSONRPCView:
app.add_routes([ app.add_routes([
web.post('/rpc', JSONRPCView()), 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): # pragma mark - Public interface
self.namespace: typing.Optional[str] = namespace
async def can_call(self, async def can_call(self,
request: web.Request, request: web.Request,
@ -40,14 +46,33 @@ class JSONRPCView:
""" """
return True 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: async def __call__(self, request: web.Request) -> web.Response:
"""The request handler.""" """The request handler."""
executor = AioHttpExecutor( executor = await self.get_executor(request)
request, self.can_call, namespace=self.namespace,
)
serializer = await executor.execute() serializer = await executor.execute()
if serializer is None: if serializer is None:
return web.Response(body='') 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. This section provides the API documentation for BTHLabs JSONRPC - aiohttp.
Executors
---------
.. autoclass:: AioHttpExecutor
:members:
Views Views
----- -----

View File

@ -1,3 +1,4 @@
# type: ignore
# Configuration file for the Sphinx documentation builder. # Configuration file for the Sphinx documentation builder.
# #
# This file only contains a selection of the most common options. For a full # 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' project = 'BTHLabs JSONRPC - aiohttp'
copyright = '2022-present Tomek Wójcik' copyright = '2022-present Tomek Wójcik'
author = 'Tomek Wójcik' author = 'Tomek Wójcik'
version = '1.0.0' version = '1.1.0'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = '1.0.0' release = '1.1.0b1'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- 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 asyncio
import datetime
import logging import logging
import os import os
import sys import sys
from aiohttp import web from aiohttp import web
from bthlabs_jsonrpc_core import register_method from bthlabs_jsonrpc_core import register_method
from bthlabs_jsonrpc_core.ext.jwt import ALGORITHMS, HMACKey, JWTCodec, KeyPair
from bthlabs_jsonrpc_aiohttp import JSONRPCView from bthlabs_jsonrpc_aiohttp import JSONRPCView
@ -20,12 +22,12 @@ formatter = logging.Formatter(
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app') jsonrpc_logger = logging.getLogger('bthlabs_jsonrpc')
jsonrpc_logger = logger = logging.getLogger('bthlabs_jsonrpc')
jsonrpc_logger.setLevel(logging.DEBUG) jsonrpc_logger.setLevel(logging.DEBUG)
jsonrpc_logger.addHandler(handler) jsonrpc_logger.addHandler(handler)
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app')
async def app_on_startup(app): async def app_on_startup(app):
logger.info('BTHLabs JSONRPC aiohttp integration example') logger.info('BTHLabs JSONRPC aiohttp integration example')
@ -43,18 +45,30 @@ async def async_test(request, delay):
return 'It works!' return 'It works!'
@register_method('hello', namespace='example') @register_method('hello', namespace='jwt')
async def hello_example(request): async def hello_example(request):
return 'Hello, Example!' 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): def create_app(loop=None):
app = web.Application(logger=app_logger, loop=loop) app = web.Application(logger=app_logger, loop=loop)
app.on_startup.append(app_on_startup) app.on_startup.append(app_on_startup)
app.add_routes([ app.add_routes([
web.post('/rpc', JSONRPCView()), web.post('/rpc', JSONRPCView()),
web.post('/example/rpc', JSONRPCView(namespace='example')), web.post('/jwt/rpc', JWTRPCView(namespace='jwt')),
]) ])
return app return app

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/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 exec adev runserver example.py

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,21 +1,26 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import json
from unittest import mock from unittest import mock
from bthlabs_jsonrpc_core import exceptions, serializer from bthlabs_jsonrpc_core import exceptions, serializer
import pytest import pytest
from bthlabs_jsonrpc_aiohttp import executor from bthlabs_jsonrpc_aiohttp import executor
from tests.fixtures import AsyncJSONCodec
@pytest.fixture @pytest.fixture
def fake_can_call(): def fake_can_call() -> mock.Mock:
result = mock.AsyncMock() result = mock.AsyncMock()
result.return_value = True result.return_value = True
return result return result
def test_init(fake_request, fake_can_call): def test_init(fake_request: mock.Mock, fake_can_call: mock.Mock):
# When # When
result = executor.AioHttpExecutor(fake_request, fake_can_call) 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 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 # Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) 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'] 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 # Given
fake_request.json.return_value = 'spam' fake_request.text.return_value = '"spam"'
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) 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' 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 # 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) 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) _ = await the_executor.deserialize_data(fake_request)
except Exception as exception: except Exception as exception:
assert isinstance(exception, exceptions.JSONRPCParseError) assert isinstance(exception, exceptions.JSONRPCParseError)
assert exception.__cause__ == error
else: else:
assert False, 'No exception raised?' 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 # Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) 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'] 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 # Given
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) 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 # Given
fake_can_call.return_value = False 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') @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 # Given
fake_method_registry = mock.Mock() fake_method_registry = mock.Mock()
fake_method_registry.get_handler.return_value = None 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) 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 -*- # -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock from unittest import mock
from aiohttp.test_utils import TestClient
from aiohttp import web 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 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(): def test_init():
@ -13,6 +24,7 @@ def test_init():
# Then # Then
assert result.namespace is None assert result.namespace is None
assert result.codec == codecs.JSONCodec
def test_init_with_namespace(): def test_init_with_namespace():
@ -23,7 +35,53 @@ def test_init_with_namespace():
assert result.namespace == 'testing' 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 # Given
view = views.JSONRPCView() view = views.JSONRPCView()
@ -34,7 +92,7 @@ async def test_can_call(fake_request):
assert result is True assert result is True
async def test_view(aiohttp_client): async def test_view(aiohttp_client: TestClient):
# Given # Given
view = views.JSONRPCView() view = views.JSONRPCView()
@ -87,7 +145,7 @@ async def test_view(aiohttp_client):
assert data == expected_result_data assert data == expected_result_data
async def test_view_empty_response(aiohttp_client): async def test_view_empty_response(aiohttp_client: TestClient):
# Given # Given
view = views.JSONRPCView() view = views.JSONRPCView()
@ -111,7 +169,7 @@ async def test_view_empty_response(aiohttp_client):
assert data == b'' assert data == b''
async def test_view_permission_denied(aiohttp_client): async def test_view_permission_denied(aiohttp_client: TestClient):
# Given # Given
view = views.JSONRPCView() view = views.JSONRPCView()
@ -146,3 +204,36 @@ async def test_view_permission_denied(aiohttp_client):
}, },
} }
assert data == expected_result_data 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

View File

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

View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import abc
import json
import typing
class Codec(abc.ABC):
"""Base class for codecs."""
# pragma mark - Abstract public interface
@abc.abstractmethod
def decode(self, payload: str | bytes, **decoder_kwargs) -> typing.Any:
"""
Decode the *payload*. *decoder_kwargs* are implementation specific.
Subclasses must implement this method.
"""
...
@abc.abstractmethod
def encode(self, payload: typing.Any, **encoder_kwargs) -> str:
"""
Encode the *payload*. *encoder_kwargs* are implementation specific.
Subclasses must implement this method.
"""
...
@abc.abstractmethod
def get_content_type(self) -> str:
"""
Return the MIME type for the encoded content.
Subclasses must implement this method.
"""
...
class JSONCodec(Codec):
"""JSON codec"""
# pragma mark - Public interface
def decode(self, payload: str | bytes, **decoder_kwargs) -> typing.Any:
"""
Decode *payload* using :py:func:`json.loads`. *decoder_kwargs* will
be passed verbatim to the decode function.
"""
return json.loads(payload, **decoder_kwargs)
def encode(self, payload: typing.Any, **encoder_kwargs) -> str:
"""
Encode *payload* using :py:func:`json.dumps`. *encoder_kwargs* will
be passed verbatim to the encode function.
"""
return json.dumps(payload, **encoder_kwargs)
def get_content_type(self):
"""Returns ``application/json``."""
return 'application/json'

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import typing import typing
from bthlabs_jsonrpc_core.registry import MethodRegistry from bthlabs_jsonrpc_core.registry import MethodRegistry
def register_method(method: str, def register_method(method: str,
namespace: typing.Optional[str] = None, namespace: str | None = None,
) -> typing.Callable: ) -> typing.Callable:
""" """
Registers the decorated function as JSONRPC *method* in *namespace*. Registers the decorated function as JSONRPC *method* in *namespace*.
@ -28,8 +30,8 @@ def register_method(method: str,
registry = MethodRegistry.shared_registry() registry = MethodRegistry.shared_registry()
registry.register_method(namespace, method, handler) registry.register_method(namespace, method, handler)
handler.jsonrpc_method = method handler.jsonrpc_method = method # type: ignore[attr-defined]
handler.jsonrpc_namespace = namespace handler.jsonrpc_namespace = namespace # type: ignore[attr-defined]
return handler return handler
return decorator return decorator

View File

@ -1,5 +1,8 @@
# -*- coding: utf-8 # -*- coding: utf-8
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
class BaseJSONRPCError(Exception): class BaseJSONRPCError(Exception):
""" """
Base class for JSONRPC exceptions. Base class for JSONRPC exceptions.
@ -16,6 +19,8 @@ class BaseJSONRPCError(Exception):
def __init__(self, data=None): def __init__(self, data=None):
self.data = data self.data = data
# pragma mark - Public interface
def to_rpc(self) -> dict: def to_rpc(self) -> dict:
"""Returns payload for :py:class:`JSONRPCSerializer`.""" """Returns payload for :py:class:`JSONRPCSerializer`."""
result = { result = {

View File

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from contextlib import contextmanager from __future__ import annotations
from dataclasses import dataclass
import json import contextlib
import dataclasses
import logging import logging
import typing import typing
from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec
from bthlabs_jsonrpc_core.exceptions import ( from bthlabs_jsonrpc_core.exceptions import (
BaseJSONRPCError, BaseJSONRPCError,
JSONRPCInternalError, JSONRPCInternalError,
@ -28,6 +30,9 @@ class Executor:
*namespace* will be used to look up called methods in the registry. If *namespace* will be used to look up called methods in the registry. If
omitted, it'll fall back to the default namespace. omitted, it'll fall back to the default namespace.
*codec* will be used to deserialize the request payload. If omitted,
it'll fall back to :py:class:`JSONCodec`.
Example: Example:
.. code-block:: python .. code-block:: python
@ -51,7 +56,7 @@ class Executor:
# The serializer registry class to use for response serialization. # The serializer registry class to use for response serialization.
serializer = JSONRPCSerializer serializer = JSONRPCSerializer
@dataclass @dataclasses.dataclass
class CallContext: class CallContext:
""" """
The context of a single call. The context of a single call.
@ -72,7 +77,7 @@ class Executor:
kwargs: dict kwargs: dict
#: Call result #: Call result
result: typing.Optional[typing.Any] = None result: typing.Any = None
@classmethod @classmethod
def invalid_context(cls): def invalid_context(cls):
@ -88,7 +93,7 @@ class Executor:
self.kwargs is not None, self.kwargs is not None,
)) ))
@dataclass @dataclasses.dataclass
class ExecuteContext: class ExecuteContext:
""" """
The context of an execute call. The context of an execute call.
@ -100,13 +105,16 @@ class Executor:
results: list results: list
#: The serializer instance. #: The serializer instance.
serializer: typing.Optional[JSONRPCSerializer] = None serializer: JSONRPCSerializer | None = None
def __init__(self,
namespace: str | None = None,
codec: Codec | None = None):
self.namespace = namespace or MethodRegistry.DEFAULT_NAMESPACE
self.codec = codec or JSONCodec()
# pragma mark - Private interface # 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: def get_internal_handler(self, method: str) -> typing.Callable:
""" """
Returns the internal handler for *method* or raises Returns the internal handler for *method* or raises
@ -194,7 +202,7 @@ class Executor:
def process_results(self, def process_results(self,
results: list, results: list,
) -> typing.Optional[typing.Union[list, dict]]: ) -> typing.Union[list, dict] | None:
""" """
Post-processes the *results* and returns responses. Post-processes the *results* and returns responses.
@ -236,8 +244,10 @@ class Executor:
return responses return responses
@contextmanager @contextlib.contextmanager
def call_context(self, execute_context: ExecuteContext, call: dict): def call_context(self,
execute_context: ExecuteContext,
call: dict) -> typing.Generator[CallContext, None, None]:
""" """
The call context manager. Yields ``CallContext``, which can be The call context manager. Yields ``CallContext``, which can be
invalid invalid if there was en error processing the call. invalid invalid if there was en error processing the call.
@ -263,7 +273,9 @@ class Executor:
error = exception error = exception
else: else:
LOGGER.error( LOGGER.error(
f'Error handling RPC method: {method}!', 'Unhandled exception when handling RPC method `%s`: %s',
method,
exception,
exc_info=exception, exc_info=exception,
) )
error = JSONRPCInternalError(str(exception)) error = JSONRPCInternalError(str(exception))
@ -273,8 +285,8 @@ class Executor:
else: else:
execute_context.results.append((call, context.result)) execute_context.results.append((call, context.result))
@contextmanager @contextlib.contextmanager
def execute_context(self): def execute_context(self) -> typing.Generator[ExecuteContext, None, None]:
""" """
The execution context. Yields ``ExecuteContext``. The execution context. Yields ``ExecuteContext``.
@ -297,7 +309,7 @@ class Executor:
# pragma mark - Public interface # pragma mark - Public interface
def deserialize_data(self, data: bytes) -> typing.Any: def deserialize_data(self, data: str | bytes) -> typing.Any:
""" """
Deserializes *data* and returns the result. Deserializes *data* and returns the result.
@ -306,9 +318,13 @@ class Executor:
response object conforms to the spec. response object conforms to the spec.
""" """
try: try:
return json.loads(data) return self.codec.decode(data)
except Exception as exception: except Exception as exception:
LOGGER.error('Error deserializing RPC call!', exc_info=exception) LOGGER.error(
'Unhandled exception when deserializing RPC call: %s',
exception,
exc_info=exception,
)
raise JSONRPCParseError() from exception raise JSONRPCParseError() from exception
def list_methods(self, *args, **kwargs) -> list[str]: def list_methods(self, *args, **kwargs) -> list[str]:
@ -368,9 +384,7 @@ class Executor:
""" """
pass pass
def execute(self, def execute(self, payload: typing.Any) -> JSONRPCSerializer | None:
payload: typing.Any,
) -> typing.Optional[JSONRPCSerializer]:
""" """
Executes the JSONRPC request in *payload*. Executes the JSONRPC request in *payload*.

View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import datetime
import dataclasses
import typing
from jose import jwt
from jose.constants import ALGORITHMS
from jose.jwk import ( # noqa: F401
ECKey,
HMACKey,
RSAKey,
)
import pytz
from bthlabs_jsonrpc_core.codecs import Codec
@dataclasses.dataclass
class KeyPair:
"""
Key pair used to verify and sign JWTs.
For HMAC, both *decode_key* and *encode_key* should be `HMACKey` instances,
wrapping the respective secrets.
For RSA and ECDSA, *decode_key* must be a public key for signature
verification. *encode_key* must be a private key for signing.
"""
decode_key: ECKey | HMACKey | RSAKey
encode_key: ECKey | HMACKey | RSAKey
@dataclasses.dataclass
class TimeClaims:
"""Time claims container."""
iat: datetime.datetime
nbf: datetime.datetime | None
exp: datetime.datetime | None
def as_claims(self) -> dict:
"""
Return dict representation of the claims suitable for including in a
JWT.
"""
result = {
'iat': self.iat,
}
if self.nbf is not None:
result['nbf'] = self.nbf
if self.exp is not None:
result['exp'] = self.exp
return result
class JWTCodec(Codec):
"""
JWT codec. Uses keys specified in *key_pair* when decoding and encoding
tokens.
*algorithm* specifies the signature algorithm to use. Defaults to
:py:attr:`ALGORITHMS.HS256`.
*issuer* specifies the ``iss`` claim. Defaults to ``None`` for no issuer.
*ttl* specifies the token's TTL. It'll be used to generate the ``exp``
claim. Defaults to ``None`` for non-expiring token.
*include_nbf* specifies if the ``nbf`` claim should be added to the token.
"""
def __init__(self,
key_pair: KeyPair,
*,
algorithm: str = ALGORITHMS.HS256,
issuer: str | None = None,
ttl: datetime.timedelta | None = None,
include_nbf: bool = True):
super().__init__()
self.key_pair = key_pair
self.algorithm = algorithm
self.issuer = issuer
self.ttl = ttl
self.include_nbf = include_nbf
# pragma mark - Private interface
def get_time_claims(self) -> TimeClaims:
"""
Get time claims.
:meta: private
"""
now = datetime.datetime.now().astimezone(pytz.utc)
exp: datetime.datetime | None = None
if self.ttl is not None:
exp = now + self.ttl
return TimeClaims(
iat=now,
nbf=now if self.include_nbf is True else None,
exp=exp,
)
# pragma mark - Public interface
def decode(self, payload: str | bytes, **decoder_kwargs) -> typing.Any:
"""
Decode payload using :py:func:`jose.jwt.decode`. *decoder_kwargs* will
be passed verbatim to the decode function.
Consult *python-jose* documentation for more information.
"""
decoded_payload = jwt.decode(
payload,
self.key_pair.decode_key,
algorithms=[self.algorithm],
**decoder_kwargs,
)
return decoded_payload['jsonrpc']
def encode(self, payload: typing.Any, **encoder_kwargs) -> str:
"""
Encode payload using :py:func:`jose.jwt.encode`. *encoder_kwargs* will
be passed verbatim to the encode function.
Consult *python-jose* documentation for more information.
"""
claims: dict = {
**self.get_time_claims().as_claims(),
'jsonrpc': payload,
}
if self.issuer is not None:
claims['iss'] = self.issuer
return jwt.encode(
claims,
self.key_pair.encode_key,
algorithm=self.algorithm,
**encoder_kwargs,
)
def get_content_type(self) -> str:
"""Returns ``application/jwt``."""
return 'application/jwt'

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from .fixtures import * # noqa: F401,F403

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
# type: ignore
from __future__ import annotations
from unittest import mock
import pytest
from bthlabs_jsonrpc_core.codecs import Codec
@pytest.fixture
def fake_custom_codec() -> mock.Mock:
return mock.Mock(spec=Codec)
@pytest.fixture
def single_call() -> dict:
return {
'jsonrpc': '2.0',
'id': 'test',
'method': 'system.list_methods',
}
@pytest.fixture
def batch_calls() -> list:
return [
{
'jsonrpc': '2.0',
'id': 'test',
'method': 'system.list_methods',
},
{
'jsonrpc': '2.0',
'id': 'test2',
'method': 'system.list_methods',
},
]

View File

@ -1,28 +1,51 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
class MethodRegistry: from __future__ import annotations
INSTANCE = None
DEFAULT_NAMESPACE = 'jsonrpc'
def __init__(self, *args, **kwargs): import typing
class MethodRegistry:
"""
The method registry. Maps method to handler within a namespace.
This class is a singleton. Use :py:meth:`MethodRegistry.shared_instance`
to get the shared instance.
"""
INSTANCE: MethodRegistry | None = None
#: Default namespace
DEFAULT_NAMESPACE: str = 'jsonrpc'
def __init__(self):
self.registry = {} self.registry = {}
self.registry[self.DEFAULT_NAMESPACE] = {} self.registry[self.DEFAULT_NAMESPACE] = {}
# pragma mark - Public interface
@classmethod @classmethod
def shared_registry(cls, *args, **kwargs): def shared_registry(cls: type[MethodRegistry]) -> MethodRegistry:
"""Return the shared instance."""
if cls.INSTANCE is None: if cls.INSTANCE is None:
cls.INSTANCE = cls(*args, **kwargs) cls.INSTANCE = cls()
return cls.INSTANCE return cls.INSTANCE
def register_method(self, namespace, method, handler): def register_method(self,
namespace: str,
method: str,
handler: typing.Callable):
"""Register a *method* with *handler* in a *namespace*."""
if namespace not in self.registry: if namespace not in self.registry:
self.registry[namespace] = {} self.registry[namespace] = {}
self.registry[namespace][method] = handler self.registry[namespace][method] = handler
def get_methods(self, namespace): def get_methods(self, namespace) -> list[str]:
"""Returns list of methods in a *namespace*."""
return self.registry.get(namespace, {}).keys() return self.registry.get(namespace, {}).keys()
def get_handler(self, namespace, method): def get_handler(self, namespace, method) -> typing.Callable:
"""Returns the handler for *method* in *namespace*."""
return self.registry.get(namespace, {}).get(method, None) return self.registry.get(namespace, {}).get(method, None)

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import datetime import datetime
import decimal import decimal
import typing import typing
@ -62,6 +64,8 @@ class JSONRPCSerializer:
def __init__(self, data): def __init__(self, data):
self._data = data self._data = data
# pragma mark - Private interface
def is_simple_value(self, value: typing.Any) -> bool: def is_simple_value(self, value: typing.Any) -> bool:
""" """
Returns ``True`` if *value* is a simple value. Returns ``True`` if *value* is a simple value.
@ -172,6 +176,8 @@ class JSONRPCSerializer:
return value return value
# pragma mark - Public interface
@property @property
def data(self) -> typing.Any: def data(self) -> typing.Any:
"""The serialized data.""" """The serialized data."""

View File

@ -5,6 +5,15 @@ API Documentation
This section provides the API documentation for BTHLabs JSONRPC - Core. This section provides the API documentation for BTHLabs JSONRPC - Core.
Codecs
------
.. autoclass:: Codec
:members:
.. autoclass:: JSONCodec
:members:
Decorators Decorators
---------- ----------
@ -29,15 +38,23 @@ Exceptions
.. autoexception:: JSONRPCSerializerError .. autoexception:: JSONRPCSerializerError
:members: :members:
Executor Executors
-------- ---------
.. autoclass:: Executor .. autoclass:: Executor
:members: :members:
Serializer Registries
---------- ----------
.. autoclass:: MethodRegistry
:members:
Serializers
-----------
.. autoclass:: JSONRPCSerializer .. autoclass:: JSONRPCSerializer
:members: :members:

View File

@ -1,3 +1,4 @@
# type: ignore
# Configuration file for the Sphinx documentation builder. # Configuration file for the Sphinx documentation builder.
# #
# This file only contains a selection of the most common options. For a full # 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 - Core' project = 'BTHLabs JSONRPC - Core'
copyright = '2022-present Tomek Wójcik' copyright = '2022-present Tomek Wójcik'
author = 'Tomek Wójcik' author = 'Tomek Wójcik'
version = '1.0.0' version = '1.1.0'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = '1.0.0' release = '1.1.0b1'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------

View File

@ -0,0 +1,51 @@
Extensions
==========
.. module:: bthlabs_jsonrpc_core.ext
This section provides documentation for built-in extensions for BTHLabs
JSONRPC - Core.
JWT Codec
---------
This extension implements codec for using JWT instead of JSON for request and
response payloads.
**Installation**
Since JWT has external dependencies it needs to explicitly named to be
installed.
.. code-block::
$ pip install bthlabs_jsonrpc_core[jwt]
This will install ``python-jose[cryptography]``. Consult *python-jose* docs for
additional information on supported backends. If you wish to install another
backend, install *python-jose* accordingly and skip the *jwt* extra when
installing *bthlabs-jsonrpc-core*.
**API**
.. data:: bthlabs_jsonrpc_core.ext.jwt.ALGORITHMS
Supported algorithms. Directly exported from :py:data:`jose.constants.ALGORITHMS`.
.. class:: bthlabs_jsonrpc_core.ext.jwt.ECKey
ECDSA key wrapper. Directly exported from :py:class:`jose.jwk.ECKey`.
.. class:: bthlabs_jsonrpc_core.ext.jwt.HMACKey
HMAC key wrapper. Directly exported from :py:class:`jose.jwk.HMACKey`.
.. class:: bthlabs_jsonrpc_core.ext.jwt.RSA
RSA key wrapper. Directly exported from :py:class:`jose.jwk.RSA`.
.. autoclass:: bthlabs_jsonrpc_core.ext.jwt.KeyPair
:members:
.. autoclass:: bthlabs_jsonrpc_core.ext.jwt.JWTCodec
:members:

View File

@ -16,3 +16,4 @@ The *core* package acts as a foundation for framework-specific integrations.
:maxdepth: 2 :maxdepth: 2
api api
ext

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bthlabs-jsonrpc-core" name = "bthlabs-jsonrpc-core"
version = "1.0.0" version = "1.1.0b1"
description = "BTHLabs JSONRPC - Core" description = "BTHLabs JSONRPC - Core"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"] authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
maintainers = ["BTHLabs <contact@bthlabs.pl>"] maintainers = ["BTHLabs <contact@bthlabs.pl>"]
@ -12,15 +12,26 @@ documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/core/"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
python-jose = {version = ">=3.3.0,<4.0", optional = true, extras = ["cryptography"]}
pytz = {version = ">=2023.3.post1", optional = true}
[tool.poetry.dev-dependencies] [tool.poetry.group.dev.dependencies]
flake8 = "4.0.1" flake8 = "6.1.0"
flake8-commas = "2.1.0" flake8-commas = "2.1.0"
mypy = "0.950" freezegun = "1.4.0"
pytest = "7.1.2" mypy = "1.8.0"
sphinx = "4.5.0" pytest = "7.4.3"
sphinx-rtd-theme = "1.0.0" python-jose = "3.3.0"
pytz = "2023.3.post1"
sphinx = "7.2.6"
sphinx-rtd-theme = "2.0.0"
[tool.poetry.extras]
jwt = ["python-jose", "pytz"]
[tool.poetry.plugins.pytest11]
bthlabs_jsonrpc_core = "bthlabs_jsonrpc_core.pytest_plugin"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View File

@ -1,4 +1,12 @@
[flake8] [flake8]
exclude = .venv/,.pytest_cache/ exclude = .venv/,.mypy_cache/,.pytest_cache/
ignore = E402 ignore = E402
max-line-length = 119 max-line-length = 119
[mypy]
[mypy-jose.*]
ignore_missing_imports = true
[mypy-pytz.*]
ignore_missing_imports = true

View File

@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import json
from unittest import mock
from bthlabs_jsonrpc_core import codecs
def test_decode():
# Given
json_codec = codecs.JSONCodec()
# When
result = json_codec.decode('{"spam": true, "eggs": false}')
# Then
expected_result = {
'spam': True,
'eggs': False,
}
assert result == expected_result
@mock.patch.object(codecs.json, 'loads')
def test_decode_decoder_kwargs(mock_json_loads: mock.Mock):
# Given
mock_json_loads.return_value = 'spam'
json_codec = codecs.JSONCodec()
payload = '{"spam": true, "eggs": false}'
fake_json_decoder = mock.Mock(spec=json.JSONDecoder)
fake_object_hook = mock.Mock()
fake_parse_float = mock.Mock()
fake_parse_int = mock.Mock()
fake_parse_constant = mock.Mock()
fake_object_pairs_hook = mock.Mock()
# When
_ = json_codec.decode(
payload,
cls=fake_json_decoder,
object_hook=fake_object_hook,
parse_float=fake_parse_float,
parse_int=fake_parse_int,
parse_constant=fake_parse_constant,
object_pairs_hook=fake_object_pairs_hook,
)
# Then
mock_json_loads.assert_called_once_with(
payload,
cls=fake_json_decoder,
object_hook=fake_object_hook,
parse_float=fake_parse_float,
parse_int=fake_parse_int,
parse_constant=fake_parse_constant,
object_pairs_hook=fake_object_pairs_hook,
)
def test_encode():
# Given
json_codec = codecs.JSONCodec()
# When
result = json_codec.encode({'spam': True, 'eggs': False})
# Then
expected_result = '{"spam": true, "eggs": false}'
assert result == expected_result
@mock.patch.object(codecs.json, 'dumps')
def test_encode_encoder_kwargs(mock_json_dumps: mock.Mock):
# Given
mock_json_dumps.return_value = 'spam'
json_codec = codecs.JSONCodec()
payload = {"spam": True, "eggs": False}
fake_json_encoder = mock.Mock(spec=json.JSONEncoder)
# When
_ = json_codec.encode(
payload,
skipkeys=False,
ensure_ascii=True,
check_circular=True,
allow_nan=True,
cls=fake_json_encoder,
indent=2, separators=(':', ','),
default='DEFAULT',
sort_keys=False,
)
# Then
mock_json_dumps.assert_called_once_with(
payload,
skipkeys=False,
ensure_ascii=True,
check_circular=True,
allow_nan=True,
cls=fake_json_encoder,
indent=2, separators=(':', ','),
default='DEFAULT',
sort_keys=False,
)
def test_get_content_type():
# Given
json_codec = codecs.JSONCodec()
# When
result = json_codec.get_content_type()
# Then
assert result == 'application/json'

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from unittest import mock from unittest import mock
import pytest import pytest
@ -8,15 +9,15 @@ from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer
@pytest.fixture @pytest.fixture
def fake_method_registry(): def fake_method_registry() -> mock.Mock:
return mock.Mock(spec=MethodRegistry) return mock.Mock(spec=MethodRegistry)
@pytest.fixture @pytest.fixture
def fake_handler(): def fake_handler() -> mock.Mock:
return mock.Mock() return mock.Mock()
@pytest.fixture @pytest.fixture
def fake_rpc_serializer(): def fake_rpc_serializer() -> mock.Mock:
return mock.Mock(spec=JSONRPCSerializer) return mock.Mock(spec=JSONRPCSerializer)

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock from unittest import mock
from bthlabs_jsonrpc_core import decorators from bthlabs_jsonrpc_core import decorators
@ -6,9 +9,9 @@ from bthlabs_jsonrpc_core.registry import MethodRegistry
@mock.patch.object(decorators.MethodRegistry, 'shared_registry') @mock.patch.object(decorators.MethodRegistry, 'shared_registry')
def test_default_namespace(mock_shared_registry, def test_default_namespace(mock_shared_registry: mock.Mock,
fake_method_registry, fake_method_registry: mock.Mock,
fake_handler): fake_handler: mock.Mock):
# Given # Given
mock_shared_registry.return_value = fake_method_registry mock_shared_registry.return_value = fake_method_registry
@ -30,9 +33,9 @@ def test_default_namespace(mock_shared_registry,
@mock.patch.object(decorators.MethodRegistry, 'shared_registry') @mock.patch.object(decorators.MethodRegistry, 'shared_registry')
def test_custom_namespace(mock_shared_registry, def test_custom_namespace(mock_shared_registry: mock.Mock,
fake_method_registry, fake_method_registry: mock.Mock,
fake_handler): fake_handler: mock.Mock):
# Given # Given
mock_shared_registry.return_value = fake_method_registry mock_shared_registry.return_value = fake_method_registry

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from bthlabs_jsonrpc_core import exceptions from bthlabs_jsonrpc_core import exceptions

View File

@ -1,46 +1,25 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import json import json
from unittest import mock from unittest import mock
import pytest import pytest
from bthlabs_jsonrpc_core import exceptions, executor from bthlabs_jsonrpc_core import exceptions, executor
from bthlabs_jsonrpc_core.codecs import JSONCodec
from bthlabs_jsonrpc_core.registry import MethodRegistry from bthlabs_jsonrpc_core.registry import MethodRegistry
from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer
@pytest.fixture @pytest.fixture
def single_call(): def jsonrpc_error() -> exceptions.BaseJSONRPCError:
return {
'jsonrpc': '2.0',
'id': 'test',
'method': 'test',
}
@pytest.fixture
def batch_calls():
return [
{
'jsonrpc': '2.0',
'id': 'test',
'method': 'test',
},
{
'jsonrpc': '2.0',
'id': 'test2',
'method': 'test',
},
]
@pytest.fixture
def jsonrpc_error():
return exceptions.BaseJSONRPCError('I HAZ FIAL') return exceptions.BaseJSONRPCError('I HAZ FIAL')
@pytest.fixture @pytest.fixture
def execute_context(): def execute_context() -> executor.Executor.ExecuteContext:
return executor.Executor.ExecuteContext([]) return executor.Executor.ExecuteContext([])
@ -55,7 +34,7 @@ def test_CallContext_invalid_context():
assert result.kwargs is None assert result.kwargs is None
def test_CallContext_is_valid_method_none(fake_handler): def test_CallContext_is_valid_method_none(fake_handler: mock.Mock):
# When # When
call_context = executor.Executor.CallContext(None, fake_handler, [], {}) call_context = executor.Executor.CallContext(None, fake_handler, [], {})
@ -71,7 +50,7 @@ def test_CallContext_is_valid_handler_none():
assert call_context.is_valid is False assert call_context.is_valid is False
def test_CallContext_is_valid_args_none(fake_handler): def test_CallContext_is_valid_args_none(fake_handler: mock.Mock):
# When # When
call_context = executor.Executor.CallContext( call_context = executor.Executor.CallContext(
'test', fake_handler, None, {}, 'test', fake_handler, None, {},
@ -81,7 +60,7 @@ def test_CallContext_is_valid_args_none(fake_handler):
assert call_context.is_valid is False assert call_context.is_valid is False
def test_CallContext_is_valid_kwargs_none(fake_handler): def test_CallContext_is_valid_kwargs_none(fake_handler: mock.Mock):
# When # When
call_context = executor.Executor.CallContext( call_context = executor.Executor.CallContext(
'test', fake_handler, [], None, 'test', fake_handler, [], None,
@ -91,7 +70,7 @@ def test_CallContext_is_valid_kwargs_none(fake_handler):
assert call_context.is_valid is False assert call_context.is_valid is False
def test_CallContext_is_valid(fake_handler): def test_CallContext_is_valid(fake_handler: mock.Mock):
# When # When
call_context = executor.Executor.CallContext('test', fake_handler, [], {}) call_context = executor.Executor.CallContext('test', fake_handler, [], {})
@ -99,12 +78,13 @@ def test_CallContext_is_valid(fake_handler):
assert call_context.is_valid is True assert call_context.is_valid is True
def test_init_default_namespace(): def test_init():
# When # When
result = executor.Executor() result = executor.Executor()
# Then # Then
assert result.namespace == MethodRegistry.DEFAULT_NAMESPACE assert result.namespace == MethodRegistry.DEFAULT_NAMESPACE
assert isinstance(result.codec, JSONCodec) is True
def test_init_custom_namespace(): def test_init_custom_namespace():
@ -115,8 +95,17 @@ def test_init_custom_namespace():
assert result.namespace == 'testing' assert result.namespace == 'testing'
def test_init_custom_codec(fake_custom_codec: mock.Mock):
# When
result = executor.Executor(codec=fake_custom_codec)
# Then
assert result.codec == fake_custom_codec
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_list_methods(mock_shared_registry, fake_method_registry): def test_list_methods(mock_shared_registry: mock.Mock,
fake_method_registry: mock.Mock):
# Given # Given
fake_method_registry.get_methods.return_value = ['test'] fake_method_registry.get_methods.return_value = ['test']
mock_shared_registry.return_value = fake_method_registry mock_shared_registry.return_value = fake_method_registry
@ -170,6 +159,21 @@ def test_deserialize_data():
assert result == 'spam' assert result == 'spam'
def test_deserialize_data_custom_codec(fake_custom_codec: mock.Mock):
# Given
fake_custom_codec.decode.return_value = 'spam'
the_executor = executor.Executor(codec=fake_custom_codec)
# When
result = the_executor.deserialize_data('"spam"')
# Then
assert result == 'spam'
fake_custom_codec.decode.assert_called_once_with('"spam"')
def test_deserialize_data_error(): def test_deserialize_data_error():
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -184,7 +188,25 @@ def test_deserialize_data_error():
assert False, 'No exception raised?' assert False, 'No exception raised?'
def test_get_calls_batch(batch_calls): def test_deserialize_data_custom_codec_error(fake_custom_codec: mock.Mock):
# Given
error = RuntimeError('I HAZ FAIL')
fake_custom_codec.decode.side_effect = error
the_executor = executor.Executor(codec=fake_custom_codec)
# When
try:
_ = the_executor.deserialize_data(None)
except Exception as exception:
# Then
assert isinstance(exception, exceptions.JSONRPCParseError)
assert exception.__cause__ == error
else:
assert False, 'No exception raised?'
def test_get_calls_batch(batch_calls: list):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -195,7 +217,7 @@ def test_get_calls_batch(batch_calls):
assert result == batch_calls assert result == batch_calls
def test_get_calls_single(single_call): def test_get_calls_single(single_call: dict):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -234,7 +256,7 @@ def test_get_call_spec_not_dict():
assert False, 'No exception raised?' assert False, 'No exception raised?'
def test_get_call_spec_wihtout_jsonrpc(single_call): def test_get_call_spec_wihtout_jsonrpc(single_call: dict):
# Given # Given
single_call.pop('jsonrpc') single_call.pop('jsonrpc')
@ -250,7 +272,7 @@ def test_get_call_spec_wihtout_jsonrpc(single_call):
assert False, 'No exception raised?' assert False, 'No exception raised?'
def test_get_call_spec_invalid_jsonrpc(single_call): def test_get_call_spec_invalid_jsonrpc(single_call: dict):
# Given # Given
single_call['jsonrpc'] = 'test' single_call['jsonrpc'] = 'test'
@ -266,7 +288,7 @@ def test_get_call_spec_invalid_jsonrpc(single_call):
assert False, 'No exception raised?' assert False, 'No exception raised?'
def test_get_call_spec_wihtout_method(single_call): def test_get_call_spec_wihtout_method(single_call: dict):
# Given # Given
single_call.pop('method') single_call.pop('method')
@ -283,9 +305,9 @@ def test_get_call_spec_wihtout_method(single_call):
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_get_call_spec_internal_method(mock_shared_registry, def test_get_call_spec_internal_method(mock_shared_registry: mock.Mock,
single_call, single_call: dict,
fake_handler): fake_handler: mock.Mock):
# Given # Given
single_call['method'] = 'system.list_methods' single_call['method'] = 'system.list_methods'
@ -307,11 +329,13 @@ def test_get_call_spec_internal_method(mock_shared_registry,
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_get_call_spec_registry_method(mock_shared_registry, def test_get_call_spec_registry_method(mock_shared_registry: mock.Mock,
single_call, single_call: dict,
fake_method_registry, fake_method_registry: mock.Mock,
fake_handler): fake_handler: mock.Mock):
# Given # Given
single_call['method'] = 'test'
fake_method_registry.get_handler.return_value = fake_handler fake_method_registry.get_handler.return_value = fake_handler
mock_shared_registry.return_value = fake_method_registry mock_shared_registry.return_value = fake_method_registry
@ -333,10 +357,12 @@ def test_get_call_spec_registry_method(mock_shared_registry,
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_get_call_spec_method_not_found(mock_shared_registry, def test_get_call_spec_method_not_found(mock_shared_registry: mock.Mock,
single_call, single_call: dict,
fake_method_registry): fake_method_registry: mock.Mock):
# Given # Given
single_call['method'] = 'test'
fake_method_registry.get_handler.return_value = None fake_method_registry.get_handler.return_value = None
mock_shared_registry.return_value = fake_method_registry mock_shared_registry.return_value = fake_method_registry
@ -353,10 +379,10 @@ def test_get_call_spec_method_not_found(mock_shared_registry,
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_get_call_spec_invalid_params(mock_shared_registry, def test_get_call_spec_invalid_params(mock_shared_registry: mock.Mock,
single_call, single_call: dict,
fake_method_registry, fake_method_registry: mock.Mock,
fake_handler): fake_handler: mock.Mock):
# Given # Given
single_call['params'] = 'spam' single_call['params'] = 'spam'
@ -376,10 +402,10 @@ def test_get_call_spec_invalid_params(mock_shared_registry,
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_get_call_spec_with_args(mock_shared_registry, def test_get_call_spec_with_args(mock_shared_registry: mock.Mock,
single_call, single_call: dict,
fake_method_registry, fake_method_registry: mock.Mock,
fake_handler): fake_handler: mock.Mock):
# Given # Given
single_call['params'] = ['spam'] single_call['params'] = ['spam']
@ -402,10 +428,10 @@ def test_get_call_spec_with_args(mock_shared_registry,
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_get_call_spec_with_kwargs(mock_shared_registry, def test_get_call_spec_with_kwargs(mock_shared_registry: mock.Mock,
single_call, single_call: dict,
fake_method_registry, fake_method_registry: mock.Mock,
fake_handler): fake_handler: mock.Mock):
# Given # Given
single_call['params'] = {'spam': True} single_call['params'] = {'spam': True}
@ -427,7 +453,9 @@ def test_get_call_spec_with_kwargs(mock_shared_registry,
mock_enrich_kwargs.assert_called_with({'spam': True}) mock_enrich_kwargs.assert_called_with({'spam': True})
def test_process_results(batch_calls, single_call, jsonrpc_error): def test_process_results(batch_calls: list,
single_call: dict,
jsonrpc_error: exceptions.BaseJSONRPCError):
# Given # Given
call_without_id = {**single_call} call_without_id = {**single_call}
call_without_id.pop('id') call_without_id.pop('id')
@ -465,7 +493,7 @@ def test_process_results(batch_calls, single_call, jsonrpc_error):
assert second_response == expected_second_response assert second_response == expected_second_response
def test_process_results_single_call(single_call): def test_process_results_single_call(single_call: dict):
# Given # Given
call_results = [ call_results = [
(single_call, 'OK'), (single_call, 'OK'),
@ -485,7 +513,7 @@ def test_process_results_single_call(single_call):
assert result == expected_result assert result == expected_result
def test_process_results_top_level_error(jsonrpc_error): def test_process_results_top_level_error(jsonrpc_error: exceptions.BaseJSONRPCError):
# Given # Given
call_results = [ call_results = [
(None, jsonrpc_error), (None, jsonrpc_error),
@ -505,7 +533,7 @@ def test_process_results_top_level_error(jsonrpc_error):
assert result == expected_result assert result == expected_result
def test_process_results_empty(single_call): def test_process_results_empty(single_call: dict):
# Given # Given
single_call.pop('id') single_call.pop('id')
@ -522,9 +550,9 @@ def test_process_results_empty(single_call):
assert result is None assert result is None
def test_call_context_invalid_context(jsonrpc_error, def test_call_context_invalid_context(jsonrpc_error: exceptions.BaseJSONRPCError,
execute_context, execute_context: executor.Executor.ExecuteContext,
single_call): single_call: dict):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -539,10 +567,10 @@ def test_call_context_invalid_context(jsonrpc_error,
assert result.is_valid is False assert result.is_valid is False
def test_call_context_handle_jsonrpc_error(fake_handler, def test_call_context_handle_jsonrpc_error(fake_handler: mock.Mock,
jsonrpc_error, jsonrpc_error: exceptions.BaseJSONRPCError,
execute_context, execute_context: executor.Executor.ExecuteContext,
single_call): single_call: dict):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -562,9 +590,9 @@ def test_call_context_handle_jsonrpc_error(fake_handler,
assert call_result[1] == jsonrpc_error assert call_result[1] == jsonrpc_error
def test_call_context_handle_exception(fake_handler, def test_call_context_handle_exception(fake_handler: mock.Mock,
execute_context, execute_context: executor.Executor.ExecuteContext,
single_call): single_call: dict):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -585,7 +613,9 @@ def test_call_context_handle_exception(fake_handler,
assert call_result[1].data == 'I HAZ FIAL' assert call_result[1].data == 'I HAZ FIAL'
def test_call_context(fake_handler, execute_context, single_call): def test_call_context(fake_handler: mock.Mock,
execute_context: executor.Executor.ExecuteContext,
single_call: dict):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -610,7 +640,7 @@ def test_call_context(fake_handler, execute_context, single_call):
assert execute_context.results[0] == expected_call_result assert execute_context.results[0] == expected_call_result
def test_execute_context_handle_jsonrpc_error(jsonrpc_error): def test_execute_context_handle_jsonrpc_error(jsonrpc_error: exceptions.BaseJSONRPCError):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -638,7 +668,7 @@ def test_execute_context_handle_exception():
assert result.serializer is None assert result.serializer is None
def test_execute_context_handle_empty_results(single_call): def test_execute_context_handle_empty_results(single_call: dict):
# Given # Given
the_executor = executor.Executor() the_executor = executor.Executor()
@ -656,7 +686,7 @@ def test_execute_context_handle_empty_results(single_call):
assert mock_serializer.called is False assert mock_serializer.called is False
def test_execute_context(fake_rpc_serializer, single_call): def test_execute_context(fake_rpc_serializer: mock.Mock, single_call: dict):
# Given # Given
fake_responses = { fake_responses = {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
@ -712,7 +742,7 @@ def test_before_call():
@mock.patch.object(executor.MethodRegistry, 'shared_registry') @mock.patch.object(executor.MethodRegistry, 'shared_registry')
def test_execute(mock_shared_registry, fake_method_registry): def test_execute(mock_shared_registry: mock.Mock, fake_method_registry: mock.Mock):
# Given # Given
fake_method_registry.get_handler.return_value = None fake_method_registry.get_handler.return_value = None
fake_method_registry.get_methods.return_value = [] fake_method_registry.get_methods.return_value = []

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import datetime
import pytest
@pytest.fixture
def iat() -> datetime.datetime:
return datetime.datetime.now()
@pytest.fixture
def nbf() -> datetime.datetime:
return datetime.datetime.now()
@pytest.fixture
def exp() -> datetime.datetime:
return datetime.datetime.now()

View File

@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import datetime
import typing
from unittest import mock
import freezegun
import jose
import pytest
from bthlabs_jsonrpc_core.ext import jwt
@pytest.fixture
def key_pair() -> jwt.KeyPair:
return jwt.KeyPair(
encode_key=jose.jwk.HMACKey(
'thisisntsecure', algorithm=jose.constants.ALGORITHMS.HS256,
),
decode_key=jose.jwk.HMACKey(
'thisisntsecure', algorithm=jose.constants.ALGORITHMS.HS256,
),
)
@pytest.fixture
def response() -> dict:
return {
'jsonrpc': '2.0',
'id': 'test',
'result': ['system.list_methods'],
}
@pytest.fixture
def encoded_payload(single_call: dict,
iat: datetime.datetime,
key_pair: jwt.KeyPair) -> str:
claims = {
'iss': 'bthlabs_jsonrpc_core_tests',
'jsonrpc': single_call,
'iat': iat,
}
return jose.jwt.encode(claims, key_pair.encode_key)
@pytest.fixture
def time_claims(iat: datetime.datetime) -> jwt.TimeClaims:
return jwt.TimeClaims(iat=iat, nbf=None, exp=None)
def test_init(key_pair: jwt.KeyPair):
# When
result = jwt.JWTCodec(key_pair)
# Then
assert result.key_pair == key_pair
assert result.algorithm == jose.constants.ALGORITHMS.HS256
assert result.issuer is None
assert result.ttl is None
assert result.include_nbf is True
@pytest.mark.parametrize(
'kwarg,value',
[
('algorithm', jose.constants.ALGORITHMS.RS256),
('issuer', 'bthlabs_jsonrpc_core_tests'),
('ttl', datetime.timedelta(seconds=10)),
('include_nbf', False),
],
)
def test_init_with_kwargs(kwarg: str,
value: typing.Any,
key_pair: jwt.KeyPair):
# Given
init_kwargs = {kwarg: value}
# When
result = jwt.JWTCodec(key_pair, **init_kwargs)
# Then
assert getattr(result, kwarg) == value
def test_decode(key_pair: jwt.KeyPair,
encoded_payload: str,
single_call: dict):
# Given
codec = jwt.JWTCodec(key_pair)
# When
result = codec.decode(encoded_payload)
# Then
assert result == single_call
def test_decode_jwt_decode_single_call(key_pair: jwt.KeyPair,
encoded_payload: str):
# Given
codec = jwt.JWTCodec(key_pair)
with mock.patch.object(jwt.jwt, 'decode') as mock_jwt_decode:
# When
_ = codec.decode(encoded_payload)
# Then
mock_jwt_decode.assert_called_once_with(
encoded_payload,
key_pair.decode_key,
algorithms=[codec.algorithm],
)
def test_decode_with_decoder_kwargs(key_pair: jwt.KeyPair,
encoded_payload: str):
# Given
codec = jwt.JWTCodec(key_pair)
with mock.patch.object(jwt.jwt, 'decode') as mock_jwt_decode:
# When
_ = codec.decode(encoded_payload, issuer='bthlabs_jsonrpc_core_tests')
# Then
mock_jwt_decode.assert_called_once_with(
encoded_payload,
key_pair.decode_key,
algorithms=[codec.algorithm],
issuer='bthlabs_jsonrpc_core_tests',
)
@freezegun.freeze_time('2024-01-11 07:09:43')
def test_encode(key_pair: jwt.KeyPair, response: dict):
# Given
codec = jwt.JWTCodec(key_pair)
# When
result = codec.encode(response)
# Then
assert isinstance(result, str) is True
decoded_result = jose.jwt.decode(result, key_pair.decode_key)
expected_decoded_result = {
'iat': 1704953383,
'nbf': 1704953383,
'jsonrpc': response,
}
assert decoded_result == expected_decoded_result
def test_encode_with_issuer(key_pair: jwt.KeyPair, response: dict):
# Given
codec = jwt.JWTCodec(key_pair, issuer='bthlabs_jsonrpc_core_tests')
# When
result = codec.encode(response)
# Then
assert isinstance(result, str) is True
decoded_result = jose.jwt.decode(result, key_pair.decode_key)
assert decoded_result['iss'] == 'bthlabs_jsonrpc_core_tests'
def test_encode_with_ttl(key_pair: jwt.KeyPair, response: dict):
# Given
codec = jwt.JWTCodec(key_pair, ttl=datetime.timedelta(seconds=60))
# When
result = codec.encode(response)
# Then
assert isinstance(result, str) is True
decoded_result = jose.jwt.decode(result, key_pair.decode_key)
assert 'exp' in decoded_result
assert decoded_result['exp'] - decoded_result['iat'] == 60
def test_encode_without_nbf(key_pair: jwt.KeyPair, response: dict):
# Given
codec = jwt.JWTCodec(key_pair, include_nbf=False)
# When
result = codec.encode(response)
# Then
assert isinstance(result, str) is True
decoded_result = jose.jwt.decode(result, key_pair.decode_key)
assert 'nbf' not in decoded_result
def test_encode_jwt_encode_single_call(key_pair: jwt.KeyPair,
time_claims: jwt.TimeClaims,
response: dict):
# Given
codec = jwt.JWTCodec(key_pair)
with mock.patch.object(codec, 'get_time_claims') as mock_get_time_claims:
with mock.patch.object(jwt.jwt, 'encode') as mock_jwt_encode:
mock_get_time_claims.return_value = time_claims
# When
_ = codec.encode(response)
mock_jwt_encode.assert_called_once_with(
{
'iat': time_claims.iat,
'jsonrpc': response,
},
key_pair.encode_key,
algorithm=codec.algorithm,
)
def test_encode_with_encoder_kwargs(key_pair: jwt.KeyPair,
time_claims: jwt.TimeClaims,
response: dict):
# Given
codec = jwt.JWTCodec(key_pair)
with mock.patch.object(codec, 'get_time_claims') as mock_get_time_claims:
with mock.patch.object(jwt.jwt, 'encode') as mock_jwt_encode:
mock_get_time_claims.return_value = time_claims
# When
_ = codec.encode(response, headers={'cty': 'JWT'})
mock_jwt_encode.assert_called_once_with(
{
'iat': time_claims.iat,
'jsonrpc': response,
},
key_pair.encode_key,
algorithm=codec.algorithm,
headers={'cty': 'JWT'},
)
def test_get_content_type():
# Given
codec = jwt.JWTCodec(key_pair)
# When
result = codec.get_content_type()
# Then
assert result == 'application/jwt'

View File

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
from bthlabs_jsonrpc_core.ext import jwt
def test_as_claims(iat: datetime.datetime):
# Given
time_claims = jwt.TimeClaims(iat=iat, nbf=None, exp=None)
# When
result = time_claims.as_claims()
# Then
expected_claims = {
'iat': iat,
}
assert result == expected_claims
def test_as_claims_with_nbf(iat: datetime.datetime, nbf: datetime.datetime):
# Given
time_claims = jwt.TimeClaims(iat=iat, nbf=nbf, exp=None)
# When
result = time_claims.as_claims()
# Then
expected_claims = {
'iat': iat,
'nbf': nbf,
}
assert result == expected_claims
def test_as_claims_with_exp(iat: datetime.datetime, exp: datetime.datetime):
# Given
time_claims = jwt.TimeClaims(iat=iat, nbf=None, exp=exp)
# When
result = time_claims.as_claims()
# Then
expected_claims = {
'iat': iat,
'exp': exp,
}
assert result == expected_claims

View File

@ -13,7 +13,7 @@ def test_init():
@mock.patch.object(registry.MethodRegistry, '__init__') @mock.patch.object(registry.MethodRegistry, '__init__')
def test_shared_registry(mock_init): def test_shared_registry(mock_init: mock.Mock):
# Given # Given
mock_init.return_value = None mock_init.return_value = None
@ -32,7 +32,8 @@ def test_shared_registry(mock_init):
@mock.patch.object(registry.MethodRegistry, '__init__') @mock.patch.object(registry.MethodRegistry, '__init__')
def test_shared_registry_with_instance(mock_init, fake_method_registry): def test_shared_registry_with_instance(mock_init: mock.Mock,
fake_method_registry: mock.Mock):
# Given # Given
mock_init.return_value = None mock_init.return_value = None
@ -52,7 +53,7 @@ def test_shared_registry_with_instance(mock_init, fake_method_registry):
registry.MethodRegistry.INSTANCE = None registry.MethodRegistry.INSTANCE = None
def test_register_method(fake_handler): def test_register_method(fake_handler: mock.Mock):
# Given # Given
the_registry = registry.MethodRegistry() the_registry = registry.MethodRegistry()
@ -64,7 +65,7 @@ def test_register_method(fake_handler):
assert the_registry.registry['testing'] == expected_namespace assert the_registry.registry['testing'] == expected_namespace
def test_register_method_existing_namespace(fake_handler): def test_register_method_existing_namespace(fake_handler: mock.Mock):
# Given # Given
spam_handler = mock.Mock() spam_handler = mock.Mock()
@ -93,7 +94,7 @@ def test_get_methods():
assert set(result) == expected_methods assert set(result) == expected_methods
def test_get_handler(fake_handler): def test_get_handler(fake_handler: mock.Mock):
# Given # Given
spam_handler = mock.Mock() spam_handler = mock.Mock()

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import datetime import datetime
import decimal import decimal
import typing
import uuid import uuid
import pytest import pytest
@ -20,7 +21,7 @@ def test_init():
'value,expected', 'value,expected',
[(None, True), (False, True), (0, True), ('spam', True), ([], False)], [(None, True), (False, True), (0, True), ('spam', True), ([], False)],
) )
def test_is_simple_value(value, expected): def test_is_simple_value(value: typing.Any, expected: bool):
# Given # Given
the_serializer = serializer.JSONRPCSerializer('spam') the_serializer = serializer.JSONRPCSerializer('spam')
@ -38,7 +39,7 @@ def test_is_simple_value(value, expected):
(tuple(), True), ({}, False), (tuple(), True), ({}, False),
], ],
) )
def test_is_sequence_value(value, expected): def test_is_sequence_value(value: typing.Any, expected: bool):
# Given # Given
the_serializer = serializer.JSONRPCSerializer('spam') the_serializer = serializer.JSONRPCSerializer('spam')
@ -53,7 +54,7 @@ def test_is_sequence_value(value, expected):
'value,expected', 'value,expected',
[({}, True), ([], False)], [({}, True), ([], False)],
) )
def test_is_dict_value(value, expected): def test_is_dict_value(value: typing.Any, expected: bool):
# Given # Given
the_serializer = serializer.JSONRPCSerializer('spam') the_serializer = serializer.JSONRPCSerializer('spam')
@ -68,7 +69,7 @@ def test_is_dict_value(value, expected):
'value,expected', 'value,expected',
[(uuid.uuid4(), True), (decimal.Decimal('42'), True), ([], False)], [(uuid.uuid4(), True), (decimal.Decimal('42'), True), ([], False)],
) )
def test_is_string_coercible_value(value, expected): def test_is_string_coercible_value(value: typing.Any, expected: bool):
# Given # Given
the_serializer = serializer.JSONRPCSerializer('spam') the_serializer = serializer.JSONRPCSerializer('spam')
@ -92,7 +93,7 @@ def test_is_string_coercible_value(value, expected):
), ),
], ],
) )
def test_serialize_datetime(value, expected): def test_serialize_datetime(value: typing.Any, expected: bool):
# Given # Given
the_serializer = serializer.JSONRPCSerializer('spam') the_serializer = serializer.JSONRPCSerializer('spam')

View File

@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from .auth_checks import ( # noqa from .auth_checks import ( # noqa: F401
has_perms, has_perms,
is_authenticated, is_authenticated,
is_staff, is_staff,
) )
from .views import JSONRPCView # noqa from .codecs import DjangoJSONCodec # noqa: F401
from .executor import DjangoExecutor # noqa: F401
from .serializer import DjangoJSONRPCSerializer # noqa: F401
from .views import JSONRPCView # noqa: F401
__version__ = '1.0.0' __version__ = '1.1.0b1'

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import importlib import importlib
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import typing import typing
from django.http import HttpRequest from django.http import HttpRequest

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import typing
from bthlabs_jsonrpc_core import JSONCodec
from django.core.serializers.json import DjangoJSONEncoder
class DjangoJSONCodec(JSONCodec):
"""Django-specific JSON codec"""
# pragma mark - Public interface
def encode(self, payload: typing.Any, **encoder_kwargs) -> str:
"""
Before handing off control to the superclass, this method will default
the *cls* encoder kwarg to
:py:class:`django.core.serializers.json.DjangoJSONEncoder`.
"""
effective_encoder_kwargs = {
'cls': DjangoJSONEncoder,
**encoder_kwargs,
}
return super().encode(payload, **effective_encoder_kwargs)

View File

@ -1,28 +1,45 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import typing import typing
from bthlabs_jsonrpc_core import Executor, JSONRPCAccessDeniedError from bthlabs_jsonrpc_core import Codec, Executor, JSONRPCAccessDeniedError
from django.http import HttpRequest from django.http import HttpRequest
from bthlabs_jsonrpc_django.serializer import DjangoJSONRPCSerializer from bthlabs_jsonrpc_django.serializer import DjangoJSONRPCSerializer
TCanCall = typing.Callable[[HttpRequest, str, list, dict], bool]
class DjangoExecutor(Executor): class DjangoExecutor(Executor):
"""Django-specific executor"""
serializer = DjangoJSONRPCSerializer serializer = DjangoJSONRPCSerializer
def __init__(self, def __init__(self,
request: HttpRequest, request: HttpRequest,
can_call: typing.Callable, can_call: TCanCall,
namespace: typing.Optional[str] = None): namespace: str | None = None,
super().__init__(namespace=namespace) codec: Codec | None = None):
self.request: HttpRequest = request super().__init__(namespace=namespace, codec=codec)
self.can_call: typing.Callable = can_call self.request = request
self.can_call = can_call
def enrich_args(self, args): # pragma mark - Public interface
def enrich_args(self, args: list) -> list:
"""
Injects the current :py:class:`django.http.HttpRequest` as the first
argument.
"""
return [self.request, *super().enrich_args(args)] return [self.request, *super().enrich_args(args)]
def before_call(self, method, args, kwargs): def before_call(self, method: str, args: list, kwargs: dict):
"""
Executes *can_call* and raises :py:exc:`JSONRPCAccessDeniedError`
accordingly.
"""
can_call = self.can_call(self.request, method, args, kwargs) can_call = self.can_call(self.request, method, args, kwargs)
if can_call is False: if can_call is False:
raise JSONRPCAccessDeniedError(data='can_call') raise JSONRPCAccessDeniedError(data='can_call')

View File

@ -1,8 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import typing
from bthlabs_jsonrpc_core import JSONRPCSerializer from bthlabs_jsonrpc_core import JSONRPCSerializer
from django.db.models import QuerySet from django.db.models import QuerySet
class DjangoJSONRPCSerializer(JSONRPCSerializer): class DjangoJSONRPCSerializer(JSONRPCSerializer):
SEQUENCE_TYPES = (QuerySet, *JSONRPCSerializer.SEQUENCE_TYPES) """Django-specific serializer"""
SEQUENCE_TYPES: typing.Any = (QuerySet, *JSONRPCSerializer.SEQUENCE_TYPES)

View File

@ -1,14 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import typing import typing
from bthlabs_jsonrpc_core import Executor from bthlabs_jsonrpc_core.codecs import Codec
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse
from django.utils.decorators import classonlymethod from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import View from django.views.generic.base import View
from bthlabs_jsonrpc_django.codecs import DjangoJSONCodec
from bthlabs_jsonrpc_django.executor import DjangoExecutor from bthlabs_jsonrpc_django.executor import DjangoExecutor
@ -35,18 +38,19 @@ class JSONRPCView(View):
] ]
""" """
# pragma mark - Private class attributes
# The executor class.
executor: Executor = DjangoExecutor
# pragma mark - Public class attributes # pragma mark - Public class attributes
#: The executor class.
executor: type[DjangoExecutor] = DjangoExecutor
#: List of auth check functions. #: List of auth check functions.
auth_checks: list[typing.Callable] = [] auth_checks: list[typing.Callable] = []
#: Namespace of this endpoint. #: Namespace of this endpoint.
namespace: typing.Optional[str] = None namespace: str | None = None
#: The codec class.
codec: type[Codec] = DjangoJSONCodec
# pragma mark - Private interface # pragma mark - Private interface
@ -69,6 +73,19 @@ class JSONRPCView(View):
if has_auth is False: if has_auth is False:
raise PermissionDenied('This RPC endpoint requires auth.') raise PermissionDenied('This RPC endpoint requires auth.')
def get_executor(self, request: HttpRequest) -> DjangoExecutor:
"""
Returns an executor configured for the *request*.
:meta private:
"""
return self.executor(
request,
self.can_call,
self.namespace,
codec=self.get_codec(request),
)
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
""" """
Dispatches the *request*. Dispatches the *request*.
@ -84,7 +101,7 @@ class JSONRPCView(View):
self.ensure_auth(request) self.ensure_auth(request)
return handler(request, *args, **kwargs) return handler(request, *args, **kwargs) # type: ignore[misc]
def post(self, request: HttpRequest) -> HttpResponse: def post(self, request: HttpRequest) -> HttpResponse:
""" """
@ -92,15 +109,18 @@ class JSONRPCView(View):
:meta private: :meta private:
""" """
executor = self.executor( executor = self.get_executor(request)
request, self.can_call, self.namespace,
)
serializer = executor.execute(request.body) serializer = executor.execute(request.body)
if serializer is None: if serializer is None:
return HttpResponse('') return HttpResponse('')
return JsonResponse(serializer.data, safe=False) codec = self.get_codec(request)
return HttpResponse(
content=codec.encode(serializer.data),
content_type=codec.get_content_type(),
)
# pragma mark - Public interface # pragma mark - Public interface
@ -120,3 +140,7 @@ class JSONRPCView(View):
etc. The default implementation returns ``True``. etc. The default implementation returns ``True``.
""" """
return True return True
def get_codec(self, request: HttpRequest) -> Codec:
"""Returns a codec configured for the *request*."""
return self.codec()

View File

@ -3,7 +3,7 @@ API Documentation
.. module:: bthlabs_jsonrpc_django .. module:: bthlabs_jsonrpc_django
This section provides the API documentation for BTHLabs JSONRPC - Core. This section provides the API documentation for BTHLabs JSONRPC - Django.
Auth checks Auth checks
----------- -----------
@ -14,8 +14,26 @@ Auth checks
.. autofunction:: is_staff .. autofunction:: is_staff
Codecs
------
.. autoclass:: DjangoJSONCodec
:members:
Executors
---------
.. autoclass:: DjangoExecutor
:members:
Serializers
-----------
.. autoclass:: DjangoJSONRPCSerializer
:members:
Views Views
----- -----
.. autoclass:: JSONRPCView .. autoclass:: JSONRPCView
:members: as_view, auth_checks, can_call, namespace :members:

View File

@ -1,3 +1,4 @@
# type: ignore
# Configuration file for the Sphinx documentation builder. # Configuration file for the Sphinx documentation builder.
# #
# This file only contains a selection of the most common options. For a full # 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 - Django' project = 'BTHLabs JSONRPC - Django'
copyright = '2022-present Tomek Wójcik' copyright = '2022-present Tomek Wójcik'
author = 'Tomek Wójcik' author = 'Tomek Wójcik'
version = '1.0.0' version = '1.1.0'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = '1.0.0' release = '1.1.0b1'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------

View File

@ -1,12 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
ASGI config for django_jsonrpc_django_example project. from __future__ import annotations
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os import os

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from django.contrib.auth.middleware import RemoteUserMiddleware from django.contrib.auth.middleware import RemoteUserMiddleware

View File

@ -1,7 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from pathlib import Path # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
# type: ignore
import pathlib
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = pathlib.Path(__file__).resolve().parent.parent.parent
SECRET_KEY = None SECRET_KEY = None
DEBUG = False DEBUG = False
@ -71,6 +73,10 @@ STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
]
JSONRPC_METHOD_MODULES = [ JSONRPC_METHOD_MODULES = [
'bthlabs_jsonrpc_django_example.things.rpc_methods', 'bthlabs_jsonrpc_django_example.things.rpc_methods',
] ]

View File

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from django.contrib import admin from __future__ import annotations
from django.contrib import admin
from bthlabs_jsonrpc_django_example.things import models from bthlabs_jsonrpc_django_example.things import models

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from django.db import models from django.db import models

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from bthlabs_jsonrpc_core import JSONRPCAccessDeniedError, register_method from bthlabs_jsonrpc_core import JSONRPCAccessDeniedError, register_method
from bthlabs_jsonrpc_django_example.things.models import Thing from bthlabs_jsonrpc_django_example.things.models import Thing

View File

@ -1,14 +1,35 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import datetime
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from bthlabs_jsonrpc_django import JSONRPCView, is_authenticated, is_staff from bthlabs_jsonrpc_django import JSONRPCView, is_authenticated, is_staff
from bthlabs_jsonrpc_core.ext.jwt import ALGORITHMS, HMACKey, JWTCodec, KeyPair
class JWTRPCView(JSONRPCView):
codec = JWTCodec
def get_codec(self, request):
return JWTCodec(
KeyPair(
decode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256),
encode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256),
),
issuer='bthlabs_jsonrpc_django_example',
ttl=datetime.timedelta(seconds=3600),
)
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path( path(
'rpc/admin', 'rpc/admin',
JSONRPCView.as_view( JWTRPCView.as_view(
auth_checks=[is_authenticated, is_staff], auth_checks=[is_authenticated, is_staff],
namespace='admin', namespace='admin',
), ),

View File

@ -1,12 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
WSGI config for django_jsonrpc_django_example project. from __future__ import annotations
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os import os

View File

@ -1,15 +1,18 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks."""
os.environ.setdefault( os.environ.setdefault(
'DJANGO_SETTINGS_MODULE', 'DJANGO_SETTINGS_MODULE',
'bthlabs_jsonrpc_django_example.settings.local', 'bthlabs_jsonrpc_django_example.settings.local',
) )
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@ -20,6 +23,7 @@ def main():
"forget to activate a virtual environment?" "forget to activate a virtual environment?"
), ),
) from exc ) from exc
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bthlabs-jsonrpc-django" name = "bthlabs-jsonrpc-django"
version = "1.0.0" version = "1.1.0b1"
description = "BTHLabs JSONRPC - Django integration" description = "BTHLabs JSONRPC - Django integration"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"] authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
maintainers = ["BTHLabs <contact@bthlabs.pl>"] maintainers = ["BTHLabs <contact@bthlabs.pl>"]
@ -13,20 +13,20 @@ documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
django = ">=3.2,<5.0" django = ">=3.2,<5.0"
bthlabs-jsonrpc-core = "1.0.0" bthlabs-jsonrpc-core = "1.1.0b1"
[tool.poetry.dev-dependencies] [tool.poetry.group.dev.dependencies]
bthlabs-jsonrpc-core = { path = "../bthlabs-jsonrpc-core", develop = true } bthlabs-jsonrpc-core = { path = "../bthlabs-jsonrpc-core", develop = true }
django = "3.2.13" django = "3.2.23"
factory-boy = "3.2.1" factory-boy = "3.3.0"
flake8 = "4.0.1" flake8 = "6.1.0"
flake8-commas = "2.1.0" flake8-commas = "2.1.0"
mypy = "0.950" mypy = "1.8.0"
pytest = "7.1.2" pytest = "7.4.3"
pytest-django = "4.5.2" pytest-django = "4.7.0"
sphinx = "4.5.0" sphinx = "7.2.6"
sphinx-rtd-theme = "1.0.0" sphinx-rtd-theme = "2.0.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View File

@ -1,7 +1,15 @@
[flake8] [flake8]
exclude = .venv/,.pytest_cache/,example/*/migrations/*.py,testing/migrations/*.py exclude = .venv/,.mypy_cache/,.pytest_cache/,example/*/migrations/*.py,testing/migrations/*.py
ignore = E402 ignore = E402
max-line-length = 119 max-line-length = 119
[tool:pytest] [tool:pytest]
DJANGO_SETTINGS_MODULE = testing.settings DJANGO_SETTINGS_MODULE = testing.settings
[mypy]
[mypy-django.*]
ignore_missing_imports = true
[mypy-factory.*]
ignore_missing_imports = true

View File

@ -1,3 +1,7 @@
# -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
import factory import factory
from testing.models import Thing from testing.models import Thing

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from django.db import models from django.db import models

View File

@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
# type: ignore
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent

View File

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License
from __future__ import annotations
from django.urls import path from django.urls import path
from bthlabs_jsonrpc_django import JSONRPCView, is_authenticated from bthlabs_jsonrpc_django import JSONRPCView, is_authenticated

View File

@ -1,10 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from unittest import mock from unittest import mock
from django.contrib.auth.models import User
from django.test import RequestFactory
from bthlabs_jsonrpc_django import auth_checks from bthlabs_jsonrpc_django import auth_checks
def test_has_perms_regular_user(rf, user): def test_has_perms_regular_user(rf: RequestFactory, user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = user request.user = user
@ -18,7 +22,7 @@ def test_has_perms_regular_user(rf, user):
assert result is False assert result is False
def test_has_perms_ok(rf, user): def test_has_perms_ok(rf: RequestFactory, user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = user request.user = user
@ -37,7 +41,7 @@ def test_has_perms_ok(rf, user):
mock_has_perms.assert_called_with(['can_use_rpc']) mock_has_perms.assert_called_with(['can_use_rpc'])
def test_has_perms_ok_super_user(rf, super_user): def test_has_perms_ok_super_user(rf: RequestFactory, super_user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = super_user request.user = super_user

View File

@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import User
from django.test import RequestFactory
from bthlabs_jsonrpc_django import auth_checks from bthlabs_jsonrpc_django import auth_checks
def test_is_authenticated_anonymous_user(rf): def test_is_authenticated_anonymous_user(rf: RequestFactory):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = AnonymousUser() request.user = AnonymousUser()
@ -16,7 +19,7 @@ def test_is_authenticated_anonymous_user(rf):
assert result is False assert result is False
def test_is_authenticated_inactive(rf, inactive_user): def test_is_authenticated_inactive(rf: RequestFactory, inactive_user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = inactive_user request.user = inactive_user
@ -28,7 +31,7 @@ def test_is_authenticated_inactive(rf, inactive_user):
assert result is False assert result is False
def test_is_authenticated_ok(rf, user): def test_is_authenticated_ok(rf: RequestFactory, user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = user request.user = user

View File

@ -1,8 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from django.contrib.auth.models import User
from django.test import RequestFactory
from bthlabs_jsonrpc_django import auth_checks from bthlabs_jsonrpc_django import auth_checks
def test_is_staff_regular_user(rf, user): def test_is_staff_regular_user(rf: RequestFactory, user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = user request.user = user
@ -14,7 +18,7 @@ def test_is_staff_regular_user(rf, user):
assert result is False assert result is False
def test_is_staff_ok(rf, staff_user): def test_is_staff_ok(rf: RequestFactory, staff_user: User):
# Given # Given
request = rf.get('/') request = rf.get('/')
request.user = staff_user request.user = staff_user

View File

@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# type: ignore
import decimal
import datetime
from unittest import mock
import uuid
from bthlabs_jsonrpc_django import codecs
from django.core.serializers.json import DjangoJSONEncoder
import pytest
@pytest.fixture
def payload() -> dict:
return {
'str': 'This is a string',
'int': 42,
'float': 3.14,
'decimal': decimal.Decimal('2.71828'),
'datetime': datetime.datetime(2021, 1, 19, 8, 0, 0),
'date': datetime.date(2022, 8, 25),
'uuid': uuid.UUID('{ab3eacec-e205-413d-b900-940e14f61518}'),
}
def test_encode(payload: dict):
# Given
codec = codecs.DjangoJSONCodec()
# When
result = codec.encode(payload)
# Then
expected_result = (
'{'
'"str": "This is a string", '
'"int": 42, '
'"float": 3.14, '
'"decimal": "2.71828", '
'"datetime": "2021-01-19T08:00:00", '
'"date": "2022-08-25", '
'"uuid": "ab3eacec-e205-413d-b900-940e14f61518"'
'}'
)
assert result == expected_result
def test_encode_super_encode_call(payload: dict):
# Given
codec = codecs.DjangoJSONCodec()
with mock.patch.object(codecs.JSONCodec, 'encode') as mock_super_encode:
# When
_ = codec.encode(payload)
# Then
mock_super_encode.assert_called_once_with(
payload, cls=DjangoJSONEncoder,
)
def test_encode_super_encode_call_encoder_kwargs(payload: dict):
# Given
codec = codecs.DjangoJSONCodec()
with mock.patch.object(codecs.JSONCodec, 'encode') as mock_super_encode:
# When
_ = codec.encode(payload, cls=None)
# Then
mock_super_encode.assert_called_once_with(
payload, cls=None,
)

View File

@ -1,46 +1,26 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from django.contrib.auth.models import User from django.contrib.auth.models import User
import factory
import pytest import pytest
from .factories import UserFactory
class UserFactory(factory.django.DjangoModelFactory):
username = factory.Faker('email')
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
email = factory.Faker('email')
is_staff = False
is_superuser = False
is_active = True
class Meta:
model = User
@pytest.fixture @pytest.fixture
def user(db): def user(db) -> User:
return UserFactory() return UserFactory()
@pytest.fixture @pytest.fixture
def inactive_user(db): def inactive_user(db) -> User:
return UserFactory(is_active=False) return UserFactory(is_active=False)
@pytest.fixture @pytest.fixture
def staff_user(db): def staff_user(db) -> User:
return UserFactory(is_staff=True) return UserFactory(is_staff=True)
@pytest.fixture @pytest.fixture
def super_user(db): def super_user(db) -> User:
return UserFactory(is_superuser=True) return UserFactory(is_superuser=True)
@pytest.fixture
def call():
return {
'jsonrpc': '2.0',
'id': 'system.list_methods',
'method': 'system.list_methods',
}

View File

@ -1,18 +1,20 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from unittest import mock from unittest import mock
from bthlabs_jsonrpc_core import exceptions from bthlabs_jsonrpc_core import exceptions
from django.test import RequestFactory
import pytest import pytest
from bthlabs_jsonrpc_django import executor from bthlabs_jsonrpc_django import executor
@pytest.fixture @pytest.fixture
def fake_can_call(): def fake_can_call() -> mock.Mock:
return mock.Mock() return mock.Mock()
def test_init(rf, fake_can_call): def test_init(rf: RequestFactory, fake_can_call: mock.Mock):
# Given # Given
request = rf.get('/') request = rf.get('/')
@ -24,7 +26,7 @@ def test_init(rf, fake_can_call):
assert result.can_call == fake_can_call assert result.can_call == fake_can_call
def test_enrich_args(rf, fake_can_call): def test_enrich_args(rf: RequestFactory, fake_can_call: mock.Mock):
# Given # Given
request = rf.get('/') request = rf.get('/')
@ -37,7 +39,7 @@ def test_enrich_args(rf, fake_can_call):
assert result == [request, 'spam'] assert result == [request, 'spam']
def test_before_call(rf, fake_can_call): def test_before_call(rf: RequestFactory, fake_can_call: mock.Mock):
# Given # Given
request = rf.get('/') request = rf.get('/')
@ -50,7 +52,8 @@ def test_before_call(rf, fake_can_call):
fake_can_call.assert_called_with(request, 'test', ['spam'], {'spam': True}) fake_can_call.assert_called_with(request, 'test', ['spam'], {'spam': True})
def test_before_call_access_denied(rf, fake_can_call): def test_before_call_access_denied(rf: RequestFactory,
fake_can_call: mock.Mock):
# Given # Given
fake_can_call.return_value = False fake_can_call.return_value = False

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from django.contrib.auth.models import User
import factory
class UserFactory(factory.django.DjangoModelFactory):
username = factory.Faker('email')
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
email = factory.Faker('email')
is_staff = False
is_superuser = False
is_active = True
class Meta:
model = User

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
import pytest import pytest
from bthlabs_jsonrpc_django import serializer from bthlabs_jsonrpc_django import serializer

View File

@ -1,8 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# type: ignore
from bthlabs_jsonrpc_core import exceptions from bthlabs_jsonrpc_core import exceptions
from django.contrib.auth.models import User
from django.test import Client
def test_view(client): def test_view(client: Client):
# Given # Given
batch = [ batch = [
{ {
@ -48,25 +51,27 @@ def test_view(client):
assert data == expected_result_data assert data == expected_result_data
def test_view_empty_response(client, call): def test_view_empty_response(client: Client, single_call: dict):
# Given # Given
call.pop('id') single_call.pop('id')
# When # When
response = client.post('/rpc', data=call, content_type='application/json') response = client.post(
'/rpc', data=single_call, content_type='application/json',
)
# Then # Then
assert response.status_code == 200 assert response.status_code == 200
assert response.content == b'' assert response.content == b''
def test_view_with_auth_checks(client, user, call): def test_view_with_auth_checks(client: Client, user: User, single_call: dict):
# Given # Given
client.force_login(user) client.force_login(user)
# When # When
response = client.post( response = client.post(
'/rpc/private', data=call, content_type='application/json', '/rpc/private', data=single_call, content_type='application/json',
) )
# Then # Then
@ -75,16 +80,17 @@ def test_view_with_auth_checks(client, user, call):
data = response.json() data = response.json()
expected_result_data = { expected_result_data = {
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'id': 'system.list_methods', 'id': 'test',
'result': ['system.list_methods'], 'result': ['system.list_methods'],
} }
assert data == expected_result_data assert data == expected_result_data
def test_view_with_auth_checks_permission_denied(client, call): def test_view_with_auth_checks_permission_denied(client: Client,
single_call: dict):
# When # When
response = client.post( response = client.post(
'/rpc/private', data=call, content_type='application/json', '/rpc/private', data=single_call, content_type='application/json',
) )
# Then # Then