v1.1.0b1
This commit is contained in:
commit
7baef8a7bc
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from .views import JSONRPCView # noqa
|
||||
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from .executor import AioHttpExecutor # noqa: F401
|
||||
from .views import JSONRPCView # noqa: F401
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__version__ = '1.1.0b1'
|
||||
|
|
|
@ -1,38 +1,85 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
import logging
|
||||
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import Executor, JSONRPCAccessDeniedError
|
||||
import logging
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
from aiohttp import web
|
||||
from bthlabs_jsonrpc_core import Codec, Executor, JSONRPCAccessDeniedError
|
||||
from bthlabs_jsonrpc_core.exceptions import JSONRPCParseError
|
||||
from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer
|
||||
|
||||
LOGGER = logging.getLogger('bthlabs_jsonrpc.aiohttp.executor')
|
||||
|
||||
TCanCall = typing.Callable[[web.Request, str, list, dict], typing.Awaitable[bool]]
|
||||
|
||||
|
||||
class AioHttpExecutor(Executor):
|
||||
def __init__(self, request, can_call, namespace=None):
|
||||
super().__init__(namespace=namespace)
|
||||
"""AioHttp-specific executor."""
|
||||
|
||||
def __init__(self,
|
||||
request: web.Request,
|
||||
can_call: TCanCall,
|
||||
namespace: str | None = None,
|
||||
codec: Codec | None = None,
|
||||
):
|
||||
super().__init__(namespace=namespace, codec=codec)
|
||||
self.request = request
|
||||
self.can_call = can_call
|
||||
|
||||
async def list_methods(self, *args, **kwargs):
|
||||
# pragma mark - Public interface
|
||||
|
||||
async def list_methods(self, *args, **kwargs) -> list[str]: # type: ignore[override]
|
||||
return super().list_methods()
|
||||
|
||||
async def deserialize_data(self, request):
|
||||
try:
|
||||
return await request.json()
|
||||
except Exception as exception:
|
||||
LOGGER.error('Error deserializing RPC call!', exc_info=exception)
|
||||
raise JSONRPCParseError()
|
||||
async def deserialize_data(self, request: web.Request) -> typing.Any: # type: ignore[override]
|
||||
"""
|
||||
Deserializes *data* and returns the result.
|
||||
|
||||
def enrich_args(self, args):
|
||||
Raises :py:exc:`JSONRPCParseError` if there was an error in the process.
|
||||
"""
|
||||
try:
|
||||
payload = await request.text()
|
||||
|
||||
result = self.codec.decode(payload)
|
||||
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
|
||||
return result
|
||||
except Exception as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception when deserializing RPC call: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise JSONRPCParseError() from exception
|
||||
|
||||
def enrich_args(self, args: list) -> list:
|
||||
"""
|
||||
Injects the current :py:class:`aiohttp.web.Request` as the first
|
||||
argument.
|
||||
"""
|
||||
return [self.request, *super().enrich_args(args)]
|
||||
|
||||
async def before_call(self, method, args, kwargs):
|
||||
async def before_call(self, method: str, args: list, kwargs: dict):
|
||||
"""
|
||||
Executes *can_call* and raises :py:exc:`JSONRPCAccessDeniedError`
|
||||
accordingly.
|
||||
"""
|
||||
can_call = await self.can_call(self.request, method, args, kwargs)
|
||||
if can_call is False:
|
||||
raise JSONRPCAccessDeniedError(data='can_call')
|
||||
|
||||
async def execute(self):
|
||||
async def execute(self) -> JSONRPCSerializer | None: # type: ignore[override]
|
||||
"""
|
||||
Executes the JSONRPC request.
|
||||
|
||||
Returns an instance of :py:class:`JSONRPCSerializer` or ``None`` if
|
||||
the list of responses is empty.
|
||||
"""
|
||||
with self.execute_context() as execute_context:
|
||||
data = await self.deserialize_data(self.request)
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
import typing
|
||||
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from aiohttp import web
|
||||
from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec
|
||||
|
||||
from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor
|
||||
|
||||
|
@ -20,14 +23,17 @@ class JSONRPCView:
|
|||
|
||||
app.add_routes([
|
||||
web.post('/rpc', JSONRPCView()),
|
||||
web.post('/example/rpc', JSONRPCView(namespace='examnple')),
|
||||
web.post('/example/rpc', JSONRPCView(namespace='example')),
|
||||
])
|
||||
"""
|
||||
|
||||
# pragma mark - Public interface
|
||||
def __init__(self,
|
||||
namespace: str | None = None,
|
||||
codec: type[Codec] | None = None):
|
||||
self.namespace: str | None = namespace
|
||||
self.codec: type[Codec] = codec or JSONCodec
|
||||
|
||||
def __init__(self, namespace: typing.Optional[str] = None):
|
||||
self.namespace: typing.Optional[str] = namespace
|
||||
# pragma mark - Public interface
|
||||
|
||||
async def can_call(self,
|
||||
request: web.Request,
|
||||
|
@ -40,14 +46,33 @@ class JSONRPCView:
|
|||
"""
|
||||
return True
|
||||
|
||||
async def get_codec(self, request: web.Request) -> Codec:
|
||||
"""Returns a codec configured for the *request*."""
|
||||
return self.codec()
|
||||
|
||||
async def get_executor(self, request: web.Request) -> AioHttpExecutor:
|
||||
"""Returns an executor configured for the *request*."""
|
||||
codec = await self.get_codec(request)
|
||||
|
||||
return AioHttpExecutor(
|
||||
request, self.can_call, namespace=self.namespace, codec=codec,
|
||||
)
|
||||
|
||||
async def __call__(self, request: web.Request) -> web.Response:
|
||||
"""The request handler."""
|
||||
executor = AioHttpExecutor(
|
||||
request, self.can_call, namespace=self.namespace,
|
||||
)
|
||||
executor = await self.get_executor(request)
|
||||
|
||||
serializer = await executor.execute()
|
||||
if serializer is None:
|
||||
return web.Response(body='')
|
||||
|
||||
return web.json_response(serializer.data)
|
||||
codec = await self.get_codec(request)
|
||||
|
||||
body = codec.encode(serializer.data)
|
||||
if inspect.isawaitable(body):
|
||||
body = await body
|
||||
|
||||
return web.Response(
|
||||
body=body,
|
||||
content_type=codec.get_content_type(),
|
||||
)
|
||||
|
|
|
@ -5,6 +5,12 @@ API Documentation
|
|||
|
||||
This section provides the API documentation for BTHLabs JSONRPC - aiohttp.
|
||||
|
||||
Executors
|
||||
---------
|
||||
|
||||
.. autoclass:: AioHttpExecutor
|
||||
:members:
|
||||
|
||||
Views
|
||||
-----
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# type: ignore
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
|
@ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../'))
|
|||
project = 'BTHLabs JSONRPC - aiohttp'
|
||||
copyright = '2022-present Tomek Wójcik'
|
||||
author = 'Tomek Wójcik'
|
||||
version = '1.0.0'
|
||||
version = '1.1.0'
|
||||
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = '1.0.0'
|
||||
release = '1.1.0b1'
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from aiohttp import web
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from bthlabs_jsonrpc_core.ext.jwt import ALGORITHMS, HMACKey, JWTCodec, KeyPair
|
||||
|
||||
from bthlabs_jsonrpc_aiohttp import JSONRPCView
|
||||
|
||||
|
@ -20,12 +22,12 @@ formatter = logging.Formatter(
|
|||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app')
|
||||
|
||||
jsonrpc_logger = logger = logging.getLogger('bthlabs_jsonrpc')
|
||||
jsonrpc_logger = logging.getLogger('bthlabs_jsonrpc')
|
||||
jsonrpc_logger.setLevel(logging.DEBUG)
|
||||
jsonrpc_logger.addHandler(handler)
|
||||
|
||||
app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app')
|
||||
|
||||
|
||||
async def app_on_startup(app):
|
||||
logger.info('BTHLabs JSONRPC aiohttp integration example')
|
||||
|
@ -43,18 +45,30 @@ async def async_test(request, delay):
|
|||
return 'It works!'
|
||||
|
||||
|
||||
@register_method('hello', namespace='example')
|
||||
@register_method('hello', namespace='jwt')
|
||||
async def hello_example(request):
|
||||
return 'Hello, Example!'
|
||||
|
||||
|
||||
class JWTRPCView(JSONRPCView):
|
||||
async def get_codec(self, request):
|
||||
return JWTCodec(
|
||||
KeyPair(
|
||||
decode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256),
|
||||
encode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256),
|
||||
),
|
||||
issuer='bthlabs_jsonrpc_aiohttp_example',
|
||||
ttl=datetime.timedelta(seconds=3600),
|
||||
)
|
||||
|
||||
|
||||
def create_app(loop=None):
|
||||
app = web.Application(logger=app_logger, loop=loop)
|
||||
app.on_startup.append(app_on_startup)
|
||||
|
||||
app.add_routes([
|
||||
web.post('/rpc', JSONRPCView()),
|
||||
web.post('/example/rpc', JSONRPCView(namespace='example')),
|
||||
web.post('/jwt/rpc', JWTRPCView(namespace='jwt')),
|
||||
])
|
||||
|
||||
return app
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
#!/bin/bash
|
||||
# django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
# bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License
|
||||
exec adev runserver example.py
|
||||
|
|
1786
packages/bthlabs-jsonrpc-aiohttp/poetry.lock
generated
1786
packages/bthlabs-jsonrpc-aiohttp/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "bthlabs-jsonrpc-aiohttp"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0b1"
|
||||
description = "BTHLabs JSONRPC - aiohttp integration"
|
||||
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
|
||||
maintainers = ["BTHLabs <contact@bthlabs.pl>"]
|
||||
|
@ -13,20 +13,21 @@ documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/"
|
|||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
aiohttp = ">=3.6,<4.0"
|
||||
bthlabs-jsonrpc-core = "1.0.0"
|
||||
bthlabs-jsonrpc-core = "1.1.0b1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
aiohttp = "3.9.1"
|
||||
bthlabs-jsonrpc-core = { path = "../bthlabs-jsonrpc-core", develop = true }
|
||||
aiohttp-devtools = "1.0.post0"
|
||||
flake8 = "4.0.1"
|
||||
aiohttp-devtools = "1.1.2"
|
||||
flake8 = "6.1.0"
|
||||
flake8-commas = "2.1.0"
|
||||
mypy = "0.950"
|
||||
pytest = "7.1.2"
|
||||
pytest-aiohttp = "1.0.4"
|
||||
pytest-asyncio = "0.18.3"
|
||||
sphinx = "4.5.0"
|
||||
sphinx-rtd-theme = "1.0.0"
|
||||
mypy = "1.8.0"
|
||||
pytest = "7.4.3"
|
||||
pytest-aiohttp = "1.0.5"
|
||||
pytest-asyncio = "0.23.3"
|
||||
sphinx = "7.2.6"
|
||||
sphinx-rtd-theme = "2.0.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
[flake8]
|
||||
exclude = .venv/,.pytest_cache/
|
||||
exclude = .venv/,.mypy_cache/,.pytest_cache/
|
||||
ignore = E402
|
||||
max-line-length = 119
|
||||
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp.web import Request
|
||||
import pytest
|
||||
|
||||
from .fixtures import AsyncJSONCodec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_request():
|
||||
def fake_request() -> mock.Mock:
|
||||
return mock.Mock(spec=Request)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_json_codec() -> AsyncJSONCodec:
|
||||
return AsyncJSONCodec()
|
||||
|
|
|
@ -1,21 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from bthlabs_jsonrpc_core import exceptions, serializer
|
||||
import pytest
|
||||
|
||||
from bthlabs_jsonrpc_aiohttp import executor
|
||||
from tests.fixtures import AsyncJSONCodec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_can_call():
|
||||
def fake_can_call() -> mock.Mock:
|
||||
result = mock.AsyncMock()
|
||||
result.return_value = True
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def test_init(fake_request, fake_can_call):
|
||||
def test_init(fake_request: mock.Mock, fake_can_call: mock.Mock):
|
||||
# When
|
||||
result = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
@ -24,7 +29,7 @@ def test_init(fake_request, fake_can_call):
|
|||
assert result.can_call == fake_can_call
|
||||
|
||||
|
||||
async def test_list_methods(fake_request, fake_can_call):
|
||||
async def test_list_methods(fake_request: mock.Mock, fake_can_call: mock.Mock):
|
||||
# Given
|
||||
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
@ -35,9 +40,10 @@ async def test_list_methods(fake_request, fake_can_call):
|
|||
assert result == ['system.list_methods']
|
||||
|
||||
|
||||
async def test_deserialize_data(fake_request, fake_can_call):
|
||||
async def test_deserialize_data(fake_request: mock.Mock,
|
||||
fake_can_call: mock.Mock):
|
||||
# Given
|
||||
fake_request.json.return_value = 'spam'
|
||||
fake_request.text.return_value = '"spam"'
|
||||
|
||||
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
@ -48,9 +54,28 @@ async def test_deserialize_data(fake_request, fake_can_call):
|
|||
assert result == 'spam'
|
||||
|
||||
|
||||
async def test_deserialize_data_error(fake_request, fake_can_call):
|
||||
async def test_deserialize_data_async_codec_decode(fake_request: mock.Mock,
|
||||
async_json_codec: AsyncJSONCodec,
|
||||
fake_can_call: mock.Mock):
|
||||
# Given
|
||||
fake_request.json.side_effect = RuntimeError('I HAZ FAIL')
|
||||
fake_request.text.return_value = '"spam"'
|
||||
|
||||
the_executor = executor.AioHttpExecutor(
|
||||
fake_request, fake_can_call, codec=async_json_codec,
|
||||
)
|
||||
|
||||
# When
|
||||
result = await the_executor.deserialize_data(fake_request)
|
||||
|
||||
# Then
|
||||
assert result == 'spam'
|
||||
|
||||
|
||||
async def test_deserialize_data_error(fake_request: mock.Mock,
|
||||
fake_can_call: mock.Mock):
|
||||
# Given
|
||||
error = RuntimeError('I HAZ FAIL')
|
||||
fake_request.text.side_effect = error
|
||||
|
||||
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
@ -59,11 +84,12 @@ async def test_deserialize_data_error(fake_request, fake_can_call):
|
|||
_ = await the_executor.deserialize_data(fake_request)
|
||||
except Exception as exception:
|
||||
assert isinstance(exception, exceptions.JSONRPCParseError)
|
||||
assert exception.__cause__ == error
|
||||
else:
|
||||
assert False, 'No exception raised?'
|
||||
|
||||
|
||||
def test_enrich_args(fake_request, fake_can_call):
|
||||
def test_enrich_args(fake_request: mock.Mock, fake_can_call: mock.Mock):
|
||||
# Given
|
||||
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
@ -74,7 +100,7 @@ def test_enrich_args(fake_request, fake_can_call):
|
|||
assert result == [fake_request, 'spam']
|
||||
|
||||
|
||||
async def test_before_call(fake_request, fake_can_call):
|
||||
async def test_before_call(fake_request: mock.Mock, fake_can_call: mock.Mock):
|
||||
# Given
|
||||
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
@ -87,7 +113,8 @@ async def test_before_call(fake_request, fake_can_call):
|
|||
)
|
||||
|
||||
|
||||
async def test_before_call_access_denied(fake_request, fake_can_call):
|
||||
async def test_before_call_access_denied(fake_request: mock.Mock,
|
||||
fake_can_call: mock.Mock):
|
||||
# Given
|
||||
fake_can_call.return_value = False
|
||||
|
||||
|
@ -103,7 +130,9 @@ async def test_before_call_access_denied(fake_request, fake_can_call):
|
|||
|
||||
|
||||
@mock.patch('bthlabs_jsonrpc_core.registry.MethodRegistry.shared_registry')
|
||||
async def test_execute(mock_shared_registry, fake_request, fake_can_call):
|
||||
async def test_execute(mock_shared_registry: mock.Mock,
|
||||
fake_request: mock.Mock,
|
||||
fake_can_call: mock.Mock):
|
||||
# Given
|
||||
fake_method_registry = mock.Mock()
|
||||
fake_method_registry.get_handler.return_value = None
|
||||
|
@ -129,7 +158,7 @@ async def test_execute(mock_shared_registry, fake_request, fake_can_call):
|
|||
},
|
||||
]
|
||||
|
||||
fake_request.json.return_value = batch
|
||||
fake_request.text.return_value = json.dumps(batch)
|
||||
|
||||
the_executor = executor.AioHttpExecutor(fake_request, fake_can_call)
|
||||
|
||||
|
|
17
packages/bthlabs-jsonrpc-aiohttp/tests/fixtures.py
Normal file
17
packages/bthlabs-jsonrpc-aiohttp/tests/fixtures.py
Normal 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)
|
|
@ -1,10 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
from aiohttp import web
|
||||
from bthlabs_jsonrpc_core import exceptions
|
||||
from bthlabs_jsonrpc_core import codecs, exceptions
|
||||
import pytest
|
||||
|
||||
from bthlabs_jsonrpc_aiohttp import views
|
||||
from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_aiohttp_executor() -> mock.Mock:
|
||||
return mock.Mock(spec=AioHttpExecutor)
|
||||
|
||||
|
||||
def test_init():
|
||||
|
@ -13,6 +24,7 @@ def test_init():
|
|||
|
||||
# Then
|
||||
assert result.namespace is None
|
||||
assert result.codec == codecs.JSONCodec
|
||||
|
||||
|
||||
def test_init_with_namespace():
|
||||
|
@ -23,7 +35,53 @@ def test_init_with_namespace():
|
|||
assert result.namespace == 'testing'
|
||||
|
||||
|
||||
async def test_can_call(fake_request):
|
||||
def test_init_with_codec(fake_custom_codec: mock.Mock):
|
||||
# When
|
||||
result = views.JSONRPCView(codec=fake_custom_codec)
|
||||
|
||||
# Then
|
||||
assert result.codec == fake_custom_codec
|
||||
|
||||
|
||||
async def test_get_executor(fake_request: mock.Mock):
|
||||
# Given
|
||||
view = views.JSONRPCView()
|
||||
|
||||
# When
|
||||
result = await view.get_executor(fake_request)
|
||||
|
||||
# Then
|
||||
assert isinstance(result, views.AioHttpExecutor) is True
|
||||
|
||||
|
||||
async def test_get_executor_dependency_calls(fake_aiohttp_executor: mock.Mock,
|
||||
fake_custom_codec: mock.Mock,
|
||||
fake_request: mock.Mock):
|
||||
# Given
|
||||
with mock.patch.object(views, 'AioHttpExecutor') as mock_aiohttp_executor:
|
||||
with mock.patch.object(views.JSONRPCView, 'get_codec') as mock_get_codec:
|
||||
mock_aiohttp_executor.return_value = fake_aiohttp_executor
|
||||
mock_get_codec.return_value = fake_custom_codec
|
||||
|
||||
view = views.JSONRPCView()
|
||||
|
||||
# When
|
||||
result = await view.get_executor(fake_request)
|
||||
|
||||
# Then
|
||||
assert result == fake_aiohttp_executor
|
||||
|
||||
mock_get_codec.assert_awaited_once_with(fake_request)
|
||||
|
||||
mock_aiohttp_executor.assert_called_once_with(
|
||||
fake_request,
|
||||
view.can_call,
|
||||
namespace=view.namespace,
|
||||
codec=fake_custom_codec,
|
||||
)
|
||||
|
||||
|
||||
async def test_can_call(fake_request: mock.Mock):
|
||||
# Given
|
||||
view = views.JSONRPCView()
|
||||
|
||||
|
@ -34,7 +92,7 @@ async def test_can_call(fake_request):
|
|||
assert result is True
|
||||
|
||||
|
||||
async def test_view(aiohttp_client):
|
||||
async def test_view(aiohttp_client: TestClient):
|
||||
# Given
|
||||
view = views.JSONRPCView()
|
||||
|
||||
|
@ -87,7 +145,7 @@ async def test_view(aiohttp_client):
|
|||
assert data == expected_result_data
|
||||
|
||||
|
||||
async def test_view_empty_response(aiohttp_client):
|
||||
async def test_view_empty_response(aiohttp_client: TestClient):
|
||||
# Given
|
||||
view = views.JSONRPCView()
|
||||
|
||||
|
@ -111,7 +169,7 @@ async def test_view_empty_response(aiohttp_client):
|
|||
assert data == b''
|
||||
|
||||
|
||||
async def test_view_permission_denied(aiohttp_client):
|
||||
async def test_view_permission_denied(aiohttp_client: TestClient):
|
||||
# Given
|
||||
view = views.JSONRPCView()
|
||||
|
||||
|
@ -146,3 +204,36 @@ async def test_view_permission_denied(aiohttp_client):
|
|||
},
|
||||
}
|
||||
assert data == expected_result_data
|
||||
|
||||
|
||||
async def test_view_async_codec_encode(async_json_codec: mock.Mock,
|
||||
aiohttp_client: TestClient):
|
||||
# Given
|
||||
mock_codec = mock.Mock(return_value=async_json_codec)
|
||||
|
||||
view = views.JSONRPCView(codec=mock_codec)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post('/', view)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
call = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 'test',
|
||||
'method': 'system.list_methods',
|
||||
}
|
||||
|
||||
# When
|
||||
response = await client.post('/', json=call)
|
||||
|
||||
# Then
|
||||
assert response.status == 200
|
||||
|
||||
data = await response.json()
|
||||
expected_result_data = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 'test',
|
||||
'result': ['system.list_methods'],
|
||||
}
|
||||
assert data == expected_result_data
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from .decorators import register_method # noqa
|
||||
from .exceptions import ( # noqa
|
||||
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from .codecs import Codec, JSONCodec # noqa: F401
|
||||
from .decorators import register_method # noqa: F401
|
||||
from .exceptions import ( # noqa: F401
|
||||
BaseJSONRPCError,
|
||||
JSONRPCAccessDeniedError,
|
||||
JSONRPCInternalError,
|
||||
JSONRPCParseError,
|
||||
JSONRPCSerializerError,
|
||||
)
|
||||
from .executor import Executor # noqa
|
||||
from .serializer import JSONRPCSerializer # noqa
|
||||
from .executor import Executor # noqa: F401
|
||||
from .registry import MethodRegistry # noqa: F401
|
||||
from .serializer import JSONRPCSerializer # noqa: F401
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__version__ = '1.1.0b1'
|
||||
|
|
64
packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/codecs.py
Normal file
64
packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/codecs.py
Normal 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'
|
|
@ -1,12 +1,14 @@
|
|||
# -*- 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
|
||||
|
||||
from bthlabs_jsonrpc_core.registry import MethodRegistry
|
||||
|
||||
|
||||
def register_method(method: str,
|
||||
namespace: typing.Optional[str] = None,
|
||||
namespace: str | None = None,
|
||||
) -> typing.Callable:
|
||||
"""
|
||||
Registers the decorated function as JSONRPC *method* in *namespace*.
|
||||
|
@ -28,8 +30,8 @@ def register_method(method: str,
|
|||
registry = MethodRegistry.shared_registry()
|
||||
registry.register_method(namespace, method, handler)
|
||||
|
||||
handler.jsonrpc_method = method
|
||||
handler.jsonrpc_namespace = namespace
|
||||
handler.jsonrpc_method = method # type: ignore[attr-defined]
|
||||
handler.jsonrpc_namespace = namespace # type: ignore[attr-defined]
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
|
|
@ -1,5 +1,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):
|
||||
"""
|
||||
Base class for JSONRPC exceptions.
|
||||
|
@ -16,6 +19,8 @@ class BaseJSONRPCError(Exception):
|
|||
def __init__(self, data=None):
|
||||
self.data = data
|
||||
|
||||
# pragma mark - Public interface
|
||||
|
||||
def to_rpc(self) -> dict:
|
||||
"""Returns payload for :py:class:`JSONRPCSerializer`."""
|
||||
result = {
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec
|
||||
from bthlabs_jsonrpc_core.exceptions import (
|
||||
BaseJSONRPCError,
|
||||
JSONRPCInternalError,
|
||||
|
@ -28,6 +30,9 @@ class Executor:
|
|||
*namespace* will be used to look up called methods in the registry. If
|
||||
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:
|
||||
|
||||
.. code-block:: python
|
||||
|
@ -51,7 +56,7 @@ class Executor:
|
|||
# The serializer registry class to use for response serialization.
|
||||
serializer = JSONRPCSerializer
|
||||
|
||||
@dataclass
|
||||
@dataclasses.dataclass
|
||||
class CallContext:
|
||||
"""
|
||||
The context of a single call.
|
||||
|
@ -72,7 +77,7 @@ class Executor:
|
|||
kwargs: dict
|
||||
|
||||
#: Call result
|
||||
result: typing.Optional[typing.Any] = None
|
||||
result: typing.Any = None
|
||||
|
||||
@classmethod
|
||||
def invalid_context(cls):
|
||||
|
@ -88,7 +93,7 @@ class Executor:
|
|||
self.kwargs is not None,
|
||||
))
|
||||
|
||||
@dataclass
|
||||
@dataclasses.dataclass
|
||||
class ExecuteContext:
|
||||
"""
|
||||
The context of an execute call.
|
||||
|
@ -100,13 +105,16 @@ class Executor:
|
|||
results: list
|
||||
|
||||
#: 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
|
||||
|
||||
def __init__(self, namespace=None):
|
||||
self.namespace = namespace or MethodRegistry.DEFAULT_NAMESPACE
|
||||
|
||||
def get_internal_handler(self, method: str) -> typing.Callable:
|
||||
"""
|
||||
Returns the internal handler for *method* or raises
|
||||
|
@ -194,7 +202,7 @@ class Executor:
|
|||
|
||||
def process_results(self,
|
||||
results: list,
|
||||
) -> typing.Optional[typing.Union[list, dict]]:
|
||||
) -> typing.Union[list, dict] | None:
|
||||
"""
|
||||
Post-processes the *results* and returns responses.
|
||||
|
||||
|
@ -236,8 +244,10 @@ class Executor:
|
|||
|
||||
return responses
|
||||
|
||||
@contextmanager
|
||||
def call_context(self, execute_context: ExecuteContext, call: dict):
|
||||
@contextlib.contextmanager
|
||||
def call_context(self,
|
||||
execute_context: ExecuteContext,
|
||||
call: dict) -> typing.Generator[CallContext, None, None]:
|
||||
"""
|
||||
The call context manager. Yields ``CallContext``, which can be
|
||||
invalid invalid if there was en error processing the call.
|
||||
|
@ -263,7 +273,9 @@ class Executor:
|
|||
error = exception
|
||||
else:
|
||||
LOGGER.error(
|
||||
f'Error handling RPC method: {method}!',
|
||||
'Unhandled exception when handling RPC method `%s`: %s',
|
||||
method,
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
error = JSONRPCInternalError(str(exception))
|
||||
|
@ -273,8 +285,8 @@ class Executor:
|
|||
else:
|
||||
execute_context.results.append((call, context.result))
|
||||
|
||||
@contextmanager
|
||||
def execute_context(self):
|
||||
@contextlib.contextmanager
|
||||
def execute_context(self) -> typing.Generator[ExecuteContext, None, None]:
|
||||
"""
|
||||
The execution context. Yields ``ExecuteContext``.
|
||||
|
||||
|
@ -297,7 +309,7 @@ class Executor:
|
|||
|
||||
# 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.
|
||||
|
||||
|
@ -306,9 +318,13 @@ class Executor:
|
|||
response object conforms to the spec.
|
||||
"""
|
||||
try:
|
||||
return json.loads(data)
|
||||
return self.codec.decode(data)
|
||||
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
|
||||
|
||||
def list_methods(self, *args, **kwargs) -> list[str]:
|
||||
|
@ -368,9 +384,7 @@ class Executor:
|
|||
"""
|
||||
pass
|
||||
|
||||
def execute(self,
|
||||
payload: typing.Any,
|
||||
) -> typing.Optional[JSONRPCSerializer]:
|
||||
def execute(self, payload: typing.Any) -> JSONRPCSerializer | None:
|
||||
"""
|
||||
Executes the JSONRPC request in *payload*.
|
||||
|
||||
|
|
152
packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/ext/jwt.py
Normal file
152
packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/ext/jwt.py
Normal 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'
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from .fixtures import * # noqa: F401,F403
|
|
@ -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',
|
||||
},
|
||||
]
|
|
@ -1,28 +1,51 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
class MethodRegistry:
|
||||
INSTANCE = None
|
||||
DEFAULT_NAMESPACE = 'jsonrpc'
|
||||
# bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
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.DEFAULT_NAMESPACE] = {}
|
||||
|
||||
# pragma mark - Public interface
|
||||
|
||||
@classmethod
|
||||
def shared_registry(cls, *args, **kwargs):
|
||||
def shared_registry(cls: type[MethodRegistry]) -> MethodRegistry:
|
||||
"""Return the shared instance."""
|
||||
if cls.INSTANCE is None:
|
||||
cls.INSTANCE = cls(*args, **kwargs)
|
||||
cls.INSTANCE = cls()
|
||||
|
||||
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:
|
||||
self.registry[namespace] = {}
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# -*- 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 decimal
|
||||
import typing
|
||||
|
@ -62,6 +64,8 @@ class JSONRPCSerializer:
|
|||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
# pragma mark - Private interface
|
||||
|
||||
def is_simple_value(self, value: typing.Any) -> bool:
|
||||
"""
|
||||
Returns ``True`` if *value* is a simple value.
|
||||
|
@ -172,6 +176,8 @@ class JSONRPCSerializer:
|
|||
|
||||
return value
|
||||
|
||||
# pragma mark - Public interface
|
||||
|
||||
@property
|
||||
def data(self) -> typing.Any:
|
||||
"""The serialized data."""
|
||||
|
|
|
@ -5,6 +5,15 @@ API Documentation
|
|||
|
||||
This section provides the API documentation for BTHLabs JSONRPC - Core.
|
||||
|
||||
Codecs
|
||||
------
|
||||
|
||||
.. autoclass:: Codec
|
||||
:members:
|
||||
|
||||
.. autoclass:: JSONCodec
|
||||
:members:
|
||||
|
||||
Decorators
|
||||
----------
|
||||
|
||||
|
@ -29,15 +38,23 @@ Exceptions
|
|||
.. autoexception:: JSONRPCSerializerError
|
||||
:members:
|
||||
|
||||
Executor
|
||||
--------
|
||||
Executors
|
||||
---------
|
||||
|
||||
.. autoclass:: Executor
|
||||
:members:
|
||||
|
||||
|
||||
Serializer
|
||||
Registries
|
||||
----------
|
||||
|
||||
|
||||
.. autoclass:: MethodRegistry
|
||||
:members:
|
||||
|
||||
|
||||
Serializers
|
||||
-----------
|
||||
|
||||
.. autoclass:: JSONRPCSerializer
|
||||
:members:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# type: ignore
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
|
@ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../'))
|
|||
project = 'BTHLabs JSONRPC - Core'
|
||||
copyright = '2022-present Tomek Wójcik'
|
||||
author = 'Tomek Wójcik'
|
||||
version = ' |