v1.1.0b1
This commit is contained in:
parent
c75ea4ea9d
commit
38cd64ea9a
|
@ -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'
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
|
|
@ -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 ---------------------------------------------------
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
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]
|
[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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
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 -*-
|
# -*- 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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
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 -*-
|
# -*- 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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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*.
|
||||||
|
|
||||||
|
|
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 -*-
|
# -*- 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)
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 ---------------------------------------------------
|
||||||
|
|
51
packages/bthlabs-jsonrpc-core/docs/source/ext.rst
Normal file
51
packages/bthlabs-jsonrpc-core/docs/source/ext.rst
Normal 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:
|
|
@ -16,3 +16,4 @@ The *core* package acts as a foundation for framework-specific integrations.
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
api
|
api
|
||||||
|
ext
|
||||||
|
|
1119
packages/bthlabs-jsonrpc-core/poetry.lock
generated
1119
packages/bthlabs-jsonrpc-core/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
121
packages/bthlabs-jsonrpc-core/tests/codecs/test_JSONCodec.py
Normal file
121
packages/bthlabs-jsonrpc-core/tests/codecs/test_JSONCodec.py
Normal 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'
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
0
packages/bthlabs-jsonrpc-core/tests/ext/__init__.py
Normal file
0
packages/bthlabs-jsonrpc-core/tests/ext/__init__.py
Normal file
22
packages/bthlabs-jsonrpc-core/tests/ext/jwt/conftest.py
Normal file
22
packages/bthlabs-jsonrpc-core/tests/ext/jwt/conftest.py
Normal 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()
|
255
packages/bthlabs-jsonrpc-core/tests/ext/jwt/test_JWTCodec.py
Normal file
255
packages/bthlabs-jsonrpc-core/tests/ext/jwt/test_JWTCodec.py
Normal 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'
|
|
@ -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
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
@ -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')
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 ---------------------------------------------------
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
1014
packages/bthlabs-jsonrpc-django/poetry.lock
generated
1014
packages/bthlabs-jsonrpc-django/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
0
packages/bthlabs-jsonrpc-django/tests/__init__.py
Normal file
0
packages/bthlabs-jsonrpc-django/tests/__init__.py
Normal 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
|
@ -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',
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
19
packages/bthlabs-jsonrpc-django/tests/factories.py
Normal file
19
packages/bthlabs-jsonrpc-django/tests/factories.py
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user