v1.1.0b1
This commit is contained in:
		
						commit
						7baef8a7bc
					
				|  | @ -1,5 +1,6 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .views import JSONRPCView  # noqa | ||||
| # bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .executor import AioHttpExecutor  # noqa: F401 | ||||
| from .views import JSONRPCView  # noqa: F401 | ||||
| 
 | ||||
| __version__ = '1.0.0' | ||||
| __version__ = '1.1.0b1' | ||||
|  |  | |||
|  | @ -1,38 +1,85 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| import logging | ||||
| # bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core import Executor, JSONRPCAccessDeniedError | ||||
| import logging | ||||
| import inspect | ||||
| import typing | ||||
| 
 | ||||
| from aiohttp import web | ||||
| from bthlabs_jsonrpc_core import Codec, Executor, JSONRPCAccessDeniedError | ||||
| from bthlabs_jsonrpc_core.exceptions import JSONRPCParseError | ||||
| from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer | ||||
| 
 | ||||
| LOGGER = logging.getLogger('bthlabs_jsonrpc.aiohttp.executor') | ||||
| 
 | ||||
| TCanCall = typing.Callable[[web.Request, str, list, dict], typing.Awaitable[bool]] | ||||
| 
 | ||||
| 
 | ||||
| class AioHttpExecutor(Executor): | ||||
|     def __init__(self, request, can_call, namespace=None): | ||||
|         super().__init__(namespace=namespace) | ||||
|     """AioHttp-specific executor.""" | ||||
| 
 | ||||
|     def __init__(self, | ||||
|                  request: web.Request, | ||||
|                  can_call: TCanCall, | ||||
|                  namespace: str | None = None, | ||||
|                  codec: Codec | None = None, | ||||
|                  ): | ||||
|         super().__init__(namespace=namespace, codec=codec) | ||||
|         self.request = request | ||||
|         self.can_call = can_call | ||||
| 
 | ||||
|     async def list_methods(self, *args, **kwargs): | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     async def list_methods(self, *args, **kwargs) -> list[str]:  # type: ignore[override] | ||||
|         return super().list_methods() | ||||
| 
 | ||||
|     async def deserialize_data(self, request): | ||||
|         try: | ||||
|             return await request.json() | ||||
|         except Exception as exception: | ||||
|             LOGGER.error('Error deserializing RPC call!', exc_info=exception) | ||||
|             raise JSONRPCParseError() | ||||
|     async def deserialize_data(self, request: web.Request) -> typing.Any:  # type: ignore[override] | ||||
|         """ | ||||
|         Deserializes *data* and returns the result. | ||||
| 
 | ||||
|     def enrich_args(self, args): | ||||
|         Raises :py:exc:`JSONRPCParseError` if there was an error in the process. | ||||
|         """ | ||||
|         try: | ||||
|             payload = await request.text() | ||||
| 
 | ||||
|             result = self.codec.decode(payload) | ||||
| 
 | ||||
|             if inspect.isawaitable(result): | ||||
|                 return await result | ||||
| 
 | ||||
|             return result | ||||
|         except Exception as exception: | ||||
|             LOGGER.error( | ||||
|                 'Unhandled exception when deserializing RPC call: %s', | ||||
|                 exception, | ||||
|                 exc_info=exception, | ||||
|             ) | ||||
|             raise JSONRPCParseError() from exception | ||||
| 
 | ||||
|     def enrich_args(self, args: list) -> list: | ||||
|         """ | ||||
|         Injects the current :py:class:`aiohttp.web.Request` as the first | ||||
|         argument. | ||||
|         """ | ||||
|         return [self.request, *super().enrich_args(args)] | ||||
| 
 | ||||
|     async def before_call(self, method, args, kwargs): | ||||
|     async def before_call(self, method: str, args: list, kwargs: dict): | ||||
|         """ | ||||
|         Executes *can_call* and raises :py:exc:`JSONRPCAccessDeniedError` | ||||
|         accordingly. | ||||
|         """ | ||||
|         can_call = await self.can_call(self.request, method, args, kwargs) | ||||
|         if can_call is False: | ||||
|             raise JSONRPCAccessDeniedError(data='can_call') | ||||
| 
 | ||||
|     async def execute(self): | ||||
|     async def execute(self) -> JSONRPCSerializer | None:  # type: ignore[override] | ||||
|         """ | ||||
|         Executes the JSONRPC request. | ||||
| 
 | ||||
|         Returns an instance of :py:class:`JSONRPCSerializer` or ``None`` if | ||||
|         the list of responses is empty. | ||||
|         """ | ||||
|         with self.execute_context() as execute_context: | ||||
|             data = await self.deserialize_data(self.request) | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,8 +1,11 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| import typing | ||||
| # bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import inspect | ||||
| 
 | ||||
| from aiohttp import web | ||||
| from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec | ||||
| 
 | ||||
| from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor | ||||
| 
 | ||||
|  | @ -20,14 +23,17 @@ class JSONRPCView: | |||
| 
 | ||||
|         app.add_routes([ | ||||
|             web.post('/rpc', JSONRPCView()), | ||||
|             web.post('/example/rpc', JSONRPCView(namespace='examnple')), | ||||
|             web.post('/example/rpc', JSONRPCView(namespace='example')), | ||||
|         ]) | ||||
|     """ | ||||
| 
 | ||||
|     # pragma mark - Public interface | ||||
|     def __init__(self, | ||||
|                  namespace: str | None = None, | ||||
|                  codec: type[Codec] | None = None): | ||||
|         self.namespace: str | None = namespace | ||||
|         self.codec: type[Codec] = codec or JSONCodec | ||||
| 
 | ||||
|     def __init__(self, namespace: typing.Optional[str] = None): | ||||
|         self.namespace: typing.Optional[str] = namespace | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     async def can_call(self, | ||||
|                        request: web.Request, | ||||
|  | @ -40,14 +46,33 @@ class JSONRPCView: | |||
|         """ | ||||
|         return True | ||||
| 
 | ||||
|     async def get_codec(self, request: web.Request) -> Codec: | ||||
|         """Returns a codec configured for the *request*.""" | ||||
|         return self.codec() | ||||
| 
 | ||||
|     async def get_executor(self, request: web.Request) -> AioHttpExecutor: | ||||
|         """Returns an executor configured for the *request*.""" | ||||
|         codec = await self.get_codec(request) | ||||
| 
 | ||||
|         return AioHttpExecutor( | ||||
|             request, self.can_call, namespace=self.namespace, codec=codec, | ||||
|         ) | ||||
| 
 | ||||
|     async def __call__(self, request: web.Request) -> web.Response: | ||||
|         """The request handler.""" | ||||
|         executor = AioHttpExecutor( | ||||
|             request, self.can_call, namespace=self.namespace, | ||||
|         ) | ||||
|         executor = await self.get_executor(request) | ||||
| 
 | ||||
|         serializer = await executor.execute() | ||||
|         if serializer is None: | ||||
|             return web.Response(body='') | ||||
| 
 | ||||
|         return web.json_response(serializer.data) | ||||
|         codec = await self.get_codec(request) | ||||
| 
 | ||||
|         body = codec.encode(serializer.data) | ||||
|         if inspect.isawaitable(body): | ||||
|             body = await body | ||||
| 
 | ||||
|         return web.Response( | ||||
|             body=body, | ||||
|             content_type=codec.get_content_type(), | ||||
|         ) | ||||
|  |  | |||
|  | @ -5,6 +5,12 @@ API Documentation | |||
| 
 | ||||
| This section provides the API documentation for BTHLabs JSONRPC - aiohttp. | ||||
| 
 | ||||
| Executors | ||||
| --------- | ||||
| 
 | ||||
| .. autoclass:: AioHttpExecutor | ||||
|     :members: | ||||
| 
 | ||||
| Views | ||||
| ----- | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| # type: ignore | ||||
| # Configuration file for the Sphinx documentation builder. | ||||
| # | ||||
| # This file only contains a selection of the most common options. For a full | ||||
|  | @ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../')) | |||
| project = 'BTHLabs JSONRPC - aiohttp' | ||||
| copyright = '2022-present Tomek Wójcik' | ||||
| author = 'Tomek Wójcik' | ||||
| version = '1.0.0' | ||||
| version = '1.1.0' | ||||
| 
 | ||||
| # The full version, including alpha/beta/rc tags | ||||
| release = '1.0.0' | ||||
| release = '1.1.0b1' | ||||
| 
 | ||||
| 
 | ||||
| # -- General configuration --------------------------------------------------- | ||||
|  |  | |||
|  | @ -1,12 +1,14 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| import asyncio | ||||
| import datetime | ||||
| import logging | ||||
| import os | ||||
| import sys | ||||
| 
 | ||||
| from aiohttp import web | ||||
| from bthlabs_jsonrpc_core import register_method | ||||
| from bthlabs_jsonrpc_core.ext.jwt import ALGORITHMS, HMACKey, JWTCodec, KeyPair | ||||
| 
 | ||||
| from bthlabs_jsonrpc_aiohttp import JSONRPCView | ||||
| 
 | ||||
|  | @ -20,12 +22,12 @@ formatter = logging.Formatter( | |||
| handler.setFormatter(formatter) | ||||
| logger.addHandler(handler) | ||||
| 
 | ||||
| app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app') | ||||
| 
 | ||||
| jsonrpc_logger = logger = logging.getLogger('bthlabs_jsonrpc') | ||||
| jsonrpc_logger = logging.getLogger('bthlabs_jsonrpc') | ||||
| jsonrpc_logger.setLevel(logging.DEBUG) | ||||
| jsonrpc_logger.addHandler(handler) | ||||
| 
 | ||||
| app_logger = logging.getLogger('bthlabs_jsonrpc_aiohttp_example.app') | ||||
| 
 | ||||
| 
 | ||||
| async def app_on_startup(app): | ||||
|     logger.info('BTHLabs JSONRPC aiohttp integration example') | ||||
|  | @ -43,18 +45,30 @@ async def async_test(request, delay): | |||
|     return 'It works!' | ||||
| 
 | ||||
| 
 | ||||
| @register_method('hello', namespace='example') | ||||
| @register_method('hello', namespace='jwt') | ||||
| async def hello_example(request): | ||||
|     return 'Hello, Example!' | ||||
| 
 | ||||
| 
 | ||||
| class JWTRPCView(JSONRPCView): | ||||
|     async def get_codec(self, request): | ||||
|         return JWTCodec( | ||||
|             KeyPair( | ||||
|                 decode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256), | ||||
|                 encode_key=HMACKey('thisisntasecrurekeydontuseitpls=', ALGORITHMS.HS256), | ||||
|             ), | ||||
|             issuer='bthlabs_jsonrpc_aiohttp_example', | ||||
|             ttl=datetime.timedelta(seconds=3600), | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| def create_app(loop=None): | ||||
|     app = web.Application(logger=app_logger, loop=loop) | ||||
|     app.on_startup.append(app_on_startup) | ||||
| 
 | ||||
|     app.add_routes([ | ||||
|         web.post('/rpc', JSONRPCView()), | ||||
|         web.post('/example/rpc', JSONRPCView(namespace='example')), | ||||
|         web.post('/jwt/rpc', JWTRPCView(namespace='jwt')), | ||||
|     ]) | ||||
| 
 | ||||
|     return app | ||||
|  |  | |||
|  | @ -1,3 +1,3 @@ | |||
| #!/bin/bash | ||||
| # django-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # bthlabs-jsonrpc-aiohttp | (c) 2022-present Tomek Wójcik | MIT License | ||||
| exec adev runserver example.py | ||||
|  |  | |||
							
								
								
									
										1786
									
								
								packages/bthlabs-jsonrpc-aiohttp/poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1786
									
								
								packages/bthlabs-jsonrpc-aiohttp/poetry.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -1,6 +1,6 @@ | |||
| [tool.poetry] | ||||
| name = "bthlabs-jsonrpc-aiohttp" | ||||
| version = "1.0.0" | ||||
| version = "1.1.0b1" | ||||
| description = "BTHLabs JSONRPC - aiohttp integration" | ||||
| authors = ["Tomek Wójcik <contact@bthlabs.pl>"] | ||||
| maintainers = ["BTHLabs <contact@bthlabs.pl>"] | ||||
|  | @ -13,20 +13,21 @@ documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/" | |||
| [tool.poetry.dependencies] | ||||
| python = "^3.10" | ||||
| aiohttp = ">=3.6,<4.0" | ||||
| bthlabs-jsonrpc-core = "1.0.0" | ||||
| bthlabs-jsonrpc-core = "1.1.0b1" | ||||
| 
 | ||||
| [tool.poetry.dev-dependencies] | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| aiohttp = "3.9.1" | ||||
| bthlabs-jsonrpc-core = { path = "../bthlabs-jsonrpc-core", develop = true } | ||||
| aiohttp-devtools = "1.0.post0" | ||||
| flake8 = "4.0.1" | ||||
| aiohttp-devtools = "1.1.2" | ||||
| flake8 = "6.1.0" | ||||
| flake8-commas = "2.1.0" | ||||
| mypy = "0.950" | ||||
| pytest = "7.1.2" | ||||
| pytest-aiohttp = "1.0.4" | ||||
| pytest-asyncio = "0.18.3" | ||||
| sphinx = "4.5.0" | ||||
| sphinx-rtd-theme = "1.0.0" | ||||
| mypy = "1.8.0" | ||||
| pytest = "7.4.3" | ||||
| pytest-aiohttp = "1.0.5" | ||||
| pytest-asyncio = "0.23.3" | ||||
| sphinx = "7.2.6" | ||||
| sphinx-rtd-theme = "2.0.0" | ||||
| 
 | ||||
| [build-system] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
| requires = ["poetry-core"] | ||||
| build-backend = "poetry.core.masonry.api" | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| [flake8] | ||||
| exclude = .venv/,.pytest_cache/ | ||||
| exclude = .venv/,.mypy_cache/,.pytest_cache/ | ||||
| ignore = E402 | ||||
| max-line-length = 119 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,10 +1,20 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from unittest import mock | ||||
| 
 | ||||
| from aiohttp.web import Request | ||||
| import pytest | ||||
| 
 | ||||
| from .fixtures import AsyncJSONCodec | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_request(): | ||||
| def fake_request() -> mock.Mock: | ||||
|     return mock.Mock(spec=Request) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def async_json_codec() -> AsyncJSONCodec: | ||||
|     return AsyncJSONCodec() | ||||
|  |  | |||
|  | @ -1,21 +1,26 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import json | ||||
| from unittest import mock | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core import exceptions, serializer | ||||
| import pytest | ||||
| 
 | ||||
| from bthlabs_jsonrpc_aiohttp import executor | ||||
| from tests.fixtures import AsyncJSONCodec | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_can_call(): | ||||
| def fake_can_call() -> mock.Mock: | ||||
|     result = mock.AsyncMock() | ||||
|     result.return_value = True | ||||
| 
 | ||||
|     return result | ||||
| 
 | ||||
| 
 | ||||
| def test_init(fake_request, fake_can_call): | ||||
| def test_init(fake_request: mock.Mock, fake_can_call: mock.Mock): | ||||
|     # When | ||||
|     result = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  | @ -24,7 +29,7 @@ def test_init(fake_request, fake_can_call): | |||
|     assert result.can_call == fake_can_call | ||||
| 
 | ||||
| 
 | ||||
| async def test_list_methods(fake_request, fake_can_call): | ||||
| async def test_list_methods(fake_request: mock.Mock, fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  | @ -35,9 +40,10 @@ async def test_list_methods(fake_request, fake_can_call): | |||
|     assert result == ['system.list_methods'] | ||||
| 
 | ||||
| 
 | ||||
| async def test_deserialize_data(fake_request, fake_can_call): | ||||
| async def test_deserialize_data(fake_request: mock.Mock, | ||||
|                                 fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     fake_request.json.return_value = 'spam' | ||||
|     fake_request.text.return_value = '"spam"' | ||||
| 
 | ||||
|     the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  | @ -48,9 +54,28 @@ async def test_deserialize_data(fake_request, fake_can_call): | |||
|     assert result == 'spam' | ||||
| 
 | ||||
| 
 | ||||
| async def test_deserialize_data_error(fake_request, fake_can_call): | ||||
| async def test_deserialize_data_async_codec_decode(fake_request: mock.Mock, | ||||
|                                                    async_json_codec: AsyncJSONCodec, | ||||
|                                                    fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     fake_request.json.side_effect = RuntimeError('I HAZ FAIL') | ||||
|     fake_request.text.return_value = '"spam"' | ||||
| 
 | ||||
|     the_executor = executor.AioHttpExecutor( | ||||
|         fake_request, fake_can_call, codec=async_json_codec, | ||||
|     ) | ||||
| 
 | ||||
|     # When | ||||
|     result = await the_executor.deserialize_data(fake_request) | ||||
| 
 | ||||
|     # Then | ||||
|     assert result == 'spam' | ||||
| 
 | ||||
| 
 | ||||
| async def test_deserialize_data_error(fake_request: mock.Mock, | ||||
|                                       fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     error = RuntimeError('I HAZ FAIL') | ||||
|     fake_request.text.side_effect = error | ||||
| 
 | ||||
|     the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  | @ -59,11 +84,12 @@ async def test_deserialize_data_error(fake_request, fake_can_call): | |||
|         _ = await the_executor.deserialize_data(fake_request) | ||||
|     except Exception as exception: | ||||
|         assert isinstance(exception, exceptions.JSONRPCParseError) | ||||
|         assert exception.__cause__ == error | ||||
|     else: | ||||
|         assert False, 'No exception raised?' | ||||
| 
 | ||||
| 
 | ||||
| def test_enrich_args(fake_request, fake_can_call): | ||||
| def test_enrich_args(fake_request: mock.Mock, fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  | @ -74,7 +100,7 @@ def test_enrich_args(fake_request, fake_can_call): | |||
|     assert result == [fake_request, 'spam'] | ||||
| 
 | ||||
| 
 | ||||
| async def test_before_call(fake_request, fake_can_call): | ||||
| async def test_before_call(fake_request: mock.Mock, fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  | @ -87,7 +113,8 @@ async def test_before_call(fake_request, fake_can_call): | |||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| async def test_before_call_access_denied(fake_request, fake_can_call): | ||||
| async def test_before_call_access_denied(fake_request: mock.Mock, | ||||
|                                          fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     fake_can_call.return_value = False | ||||
| 
 | ||||
|  | @ -103,7 +130,9 @@ async def test_before_call_access_denied(fake_request, fake_can_call): | |||
| 
 | ||||
| 
 | ||||
| @mock.patch('bthlabs_jsonrpc_core.registry.MethodRegistry.shared_registry') | ||||
| async def test_execute(mock_shared_registry, fake_request, fake_can_call): | ||||
| async def test_execute(mock_shared_registry: mock.Mock, | ||||
|                        fake_request: mock.Mock, | ||||
|                        fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     fake_method_registry = mock.Mock() | ||||
|     fake_method_registry.get_handler.return_value = None | ||||
|  | @ -129,7 +158,7 @@ async def test_execute(mock_shared_registry, fake_request, fake_can_call): | |||
|         }, | ||||
|     ] | ||||
| 
 | ||||
|     fake_request.json.return_value = batch | ||||
|     fake_request.text.return_value = json.dumps(batch) | ||||
| 
 | ||||
|     the_executor = executor.AioHttpExecutor(fake_request, fake_can_call) | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										17
									
								
								packages/bthlabs-jsonrpc-aiohttp/tests/fixtures.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/bthlabs-jsonrpc-aiohttp/tests/fixtures.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import typing | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core import codecs | ||||
| 
 | ||||
| 
 | ||||
| class AsyncJSONCodec(codecs.JSONCodec): | ||||
|     async def decode(self, | ||||
|                      payload: str | bytes, | ||||
|                      **decoder_kwargs) -> typing.Any: | ||||
|         return super().decode(payload, **decoder_kwargs) | ||||
| 
 | ||||
|     async def encode(self, payload: typing.Any, **encoder_kwargs) -> str: | ||||
|         return super().encode(payload, **encoder_kwargs) | ||||
|  | @ -1,10 +1,21 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from unittest import mock | ||||
| 
 | ||||
| from aiohttp.test_utils import TestClient | ||||
| from aiohttp import web | ||||
| from bthlabs_jsonrpc_core import exceptions | ||||
| from bthlabs_jsonrpc_core import codecs, exceptions | ||||
| import pytest | ||||
| 
 | ||||
| from bthlabs_jsonrpc_aiohttp import views | ||||
| from bthlabs_jsonrpc_aiohttp.executor import AioHttpExecutor | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_aiohttp_executor() -> mock.Mock: | ||||
|     return mock.Mock(spec=AioHttpExecutor) | ||||
| 
 | ||||
| 
 | ||||
| def test_init(): | ||||
|  | @ -13,6 +24,7 @@ def test_init(): | |||
| 
 | ||||
|     # Then | ||||
|     assert result.namespace is None | ||||
|     assert result.codec == codecs.JSONCodec | ||||
| 
 | ||||
| 
 | ||||
| def test_init_with_namespace(): | ||||
|  | @ -23,7 +35,53 @@ def test_init_with_namespace(): | |||
|     assert result.namespace == 'testing' | ||||
| 
 | ||||
| 
 | ||||
| async def test_can_call(fake_request): | ||||
| def test_init_with_codec(fake_custom_codec: mock.Mock): | ||||
|     # When | ||||
|     result = views.JSONRPCView(codec=fake_custom_codec) | ||||
| 
 | ||||
|     # Then | ||||
|     assert result.codec == fake_custom_codec | ||||
| 
 | ||||
| 
 | ||||
| async def test_get_executor(fake_request: mock.Mock): | ||||
|     # Given | ||||
|     view = views.JSONRPCView() | ||||
| 
 | ||||
|     # When | ||||
|     result = await view.get_executor(fake_request) | ||||
| 
 | ||||
|     # Then | ||||
|     assert isinstance(result, views.AioHttpExecutor) is True | ||||
| 
 | ||||
| 
 | ||||
| async def test_get_executor_dependency_calls(fake_aiohttp_executor: mock.Mock, | ||||
|                                              fake_custom_codec: mock.Mock, | ||||
|                                              fake_request: mock.Mock): | ||||
|     # Given | ||||
|     with mock.patch.object(views, 'AioHttpExecutor') as mock_aiohttp_executor: | ||||
|         with mock.patch.object(views.JSONRPCView, 'get_codec') as mock_get_codec: | ||||
|             mock_aiohttp_executor.return_value = fake_aiohttp_executor | ||||
|             mock_get_codec.return_value = fake_custom_codec | ||||
| 
 | ||||
|             view = views.JSONRPCView() | ||||
| 
 | ||||
|             # When | ||||
|             result = await view.get_executor(fake_request) | ||||
| 
 | ||||
|             # Then | ||||
|             assert result == fake_aiohttp_executor | ||||
| 
 | ||||
|             mock_get_codec.assert_awaited_once_with(fake_request) | ||||
| 
 | ||||
|             mock_aiohttp_executor.assert_called_once_with( | ||||
|                 fake_request, | ||||
|                 view.can_call, | ||||
|                 namespace=view.namespace, | ||||
|                 codec=fake_custom_codec, | ||||
|             ) | ||||
| 
 | ||||
| 
 | ||||
| async def test_can_call(fake_request: mock.Mock): | ||||
|     # Given | ||||
|     view = views.JSONRPCView() | ||||
| 
 | ||||
|  | @ -34,7 +92,7 @@ async def test_can_call(fake_request): | |||
|     assert result is True | ||||
| 
 | ||||
| 
 | ||||
| async def test_view(aiohttp_client): | ||||
| async def test_view(aiohttp_client: TestClient): | ||||
|     # Given | ||||
|     view = views.JSONRPCView() | ||||
| 
 | ||||
|  | @ -87,7 +145,7 @@ async def test_view(aiohttp_client): | |||
|     assert data == expected_result_data | ||||
| 
 | ||||
| 
 | ||||
| async def test_view_empty_response(aiohttp_client): | ||||
| async def test_view_empty_response(aiohttp_client: TestClient): | ||||
|     # Given | ||||
|     view = views.JSONRPCView() | ||||
| 
 | ||||
|  | @ -111,7 +169,7 @@ async def test_view_empty_response(aiohttp_client): | |||
|     assert data == b'' | ||||
| 
 | ||||
| 
 | ||||
| async def test_view_permission_denied(aiohttp_client): | ||||
| async def test_view_permission_denied(aiohttp_client: TestClient): | ||||
|     # Given | ||||
|     view = views.JSONRPCView() | ||||
| 
 | ||||
|  | @ -146,3 +204,36 @@ async def test_view_permission_denied(aiohttp_client): | |||
|             }, | ||||
|         } | ||||
|         assert data == expected_result_data | ||||
| 
 | ||||
| 
 | ||||
| async def test_view_async_codec_encode(async_json_codec: mock.Mock, | ||||
|                                        aiohttp_client: TestClient): | ||||
|     # Given | ||||
|     mock_codec = mock.Mock(return_value=async_json_codec) | ||||
| 
 | ||||
|     view = views.JSONRPCView(codec=mock_codec) | ||||
| 
 | ||||
|     app = web.Application() | ||||
|     app.router.add_post('/', view) | ||||
| 
 | ||||
|     client = await aiohttp_client(app) | ||||
| 
 | ||||
|     call = { | ||||
|         'jsonrpc': '2.0', | ||||
|         'id': 'test', | ||||
|         'method': 'system.list_methods', | ||||
|     } | ||||
| 
 | ||||
|     # When | ||||
|     response = await client.post('/', json=call) | ||||
| 
 | ||||
|     # Then | ||||
|     assert response.status == 200 | ||||
| 
 | ||||
|     data = await response.json() | ||||
|     expected_result_data = { | ||||
|         'jsonrpc': '2.0', | ||||
|         'id': 'test', | ||||
|         'result': ['system.list_methods'], | ||||
|     } | ||||
|     assert data == expected_result_data | ||||
|  |  | |||
|  | @ -1,14 +1,16 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .decorators import register_method  # noqa | ||||
| from .exceptions import (  # noqa | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .codecs import Codec, JSONCodec  # noqa: F401 | ||||
| from .decorators import register_method  # noqa: F401 | ||||
| from .exceptions import (  # noqa: F401 | ||||
|     BaseJSONRPCError, | ||||
|     JSONRPCAccessDeniedError, | ||||
|     JSONRPCInternalError, | ||||
|     JSONRPCParseError, | ||||
|     JSONRPCSerializerError, | ||||
| ) | ||||
| from .executor import Executor  # noqa | ||||
| from .serializer import JSONRPCSerializer  # noqa | ||||
| from .executor import Executor  # noqa: F401 | ||||
| from .registry import MethodRegistry  # noqa: F401 | ||||
| from .serializer import JSONRPCSerializer  # noqa: F401 | ||||
| 
 | ||||
| __version__ = '1.0.0' | ||||
| __version__ = '1.1.0b1' | ||||
|  |  | |||
							
								
								
									
										64
									
								
								packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/codecs.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/codecs.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import abc | ||||
| import json | ||||
| import typing | ||||
| 
 | ||||
| 
 | ||||
| class Codec(abc.ABC): | ||||
|     """Base class for codecs.""" | ||||
| 
 | ||||
|     # pragma mark - Abstract public interface | ||||
| 
 | ||||
|     @abc.abstractmethod | ||||
|     def decode(self, payload: str | bytes, **decoder_kwargs) -> typing.Any: | ||||
|         """ | ||||
|         Decode the *payload*. *decoder_kwargs* are implementation specific. | ||||
| 
 | ||||
|         Subclasses must implement this method. | ||||
|         """ | ||||
|         ... | ||||
| 
 | ||||
|     @abc.abstractmethod | ||||
|     def encode(self, payload: typing.Any, **encoder_kwargs) -> str: | ||||
|         """ | ||||
|         Encode the *payload*. *encoder_kwargs* are implementation specific. | ||||
| 
 | ||||
|         Subclasses must implement this method. | ||||
|         """ | ||||
|         ... | ||||
| 
 | ||||
|     @abc.abstractmethod | ||||
|     def get_content_type(self) -> str: | ||||
|         """ | ||||
|         Return the MIME type for the encoded content. | ||||
| 
 | ||||
|         Subclasses must implement this method. | ||||
|         """ | ||||
|         ... | ||||
| 
 | ||||
| 
 | ||||
| class JSONCodec(Codec): | ||||
|     """JSON codec""" | ||||
| 
 | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     def decode(self, payload: str | bytes, **decoder_kwargs) -> typing.Any: | ||||
|         """ | ||||
|         Decode *payload* using :py:func:`json.loads`. *decoder_kwargs* will | ||||
|         be passed verbatim to the decode function. | ||||
|         """ | ||||
|         return json.loads(payload, **decoder_kwargs) | ||||
| 
 | ||||
|     def encode(self, payload: typing.Any, **encoder_kwargs) -> str: | ||||
|         """ | ||||
|         Encode *payload* using :py:func:`json.dumps`. *encoder_kwargs* will | ||||
|         be passed verbatim to the encode function. | ||||
|         """ | ||||
|         return json.dumps(payload, **encoder_kwargs) | ||||
| 
 | ||||
|     def get_content_type(self): | ||||
|         """Returns ``application/json``.""" | ||||
|         return 'application/json' | ||||
|  | @ -1,12 +1,14 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import typing | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core.registry import MethodRegistry | ||||
| 
 | ||||
| 
 | ||||
| def register_method(method: str, | ||||
|                     namespace: typing.Optional[str] = None, | ||||
|                     namespace: str | None = None, | ||||
|                     ) -> typing.Callable: | ||||
|     """ | ||||
|     Registers the decorated function as JSONRPC *method* in *namespace*. | ||||
|  | @ -28,8 +30,8 @@ def register_method(method: str, | |||
|         registry = MethodRegistry.shared_registry() | ||||
|         registry.register_method(namespace, method, handler) | ||||
| 
 | ||||
|         handler.jsonrpc_method = method | ||||
|         handler.jsonrpc_namespace = namespace | ||||
|         handler.jsonrpc_method = method  # type: ignore[attr-defined] | ||||
|         handler.jsonrpc_namespace = namespace  # type: ignore[attr-defined] | ||||
|         return handler | ||||
| 
 | ||||
|     return decorator | ||||
|  |  | |||
|  | @ -1,5 +1,8 @@ | |||
| # -*- coding: utf-8 | ||||
| # django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| 
 | ||||
| class BaseJSONRPCError(Exception): | ||||
|     """ | ||||
|     Base class for JSONRPC exceptions. | ||||
|  | @ -16,6 +19,8 @@ class BaseJSONRPCError(Exception): | |||
|     def __init__(self, data=None): | ||||
|         self.data = data | ||||
| 
 | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     def to_rpc(self) -> dict: | ||||
|         """Returns payload for :py:class:`JSONRPCSerializer`.""" | ||||
|         result = { | ||||
|  |  | |||
|  | @ -1,11 +1,13 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from contextlib import contextmanager | ||||
| from dataclasses import dataclass | ||||
| import json | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import contextlib | ||||
| import dataclasses | ||||
| import logging | ||||
| import typing | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core.codecs import Codec, JSONCodec | ||||
| from bthlabs_jsonrpc_core.exceptions import ( | ||||
|     BaseJSONRPCError, | ||||
|     JSONRPCInternalError, | ||||
|  | @ -28,6 +30,9 @@ class Executor: | |||
|     *namespace* will be used to look up called methods in the registry. If | ||||
|     omitted, it'll fall back to the default namespace. | ||||
| 
 | ||||
|     *codec* will be used to deserialize the request payload. If omitted, | ||||
|     it'll fall back to :py:class:`JSONCodec`. | ||||
| 
 | ||||
|     Example: | ||||
| 
 | ||||
|     .. code-block:: python | ||||
|  | @ -51,7 +56,7 @@ class Executor: | |||
|     # The serializer registry class to use for response serialization. | ||||
|     serializer = JSONRPCSerializer | ||||
| 
 | ||||
|     @dataclass | ||||
|     @dataclasses.dataclass | ||||
|     class CallContext: | ||||
|         """ | ||||
|         The context of a single call. | ||||
|  | @ -72,7 +77,7 @@ class Executor: | |||
|         kwargs: dict | ||||
| 
 | ||||
|         #: Call result | ||||
|         result: typing.Optional[typing.Any] = None | ||||
|         result: typing.Any = None | ||||
| 
 | ||||
|         @classmethod | ||||
|         def invalid_context(cls): | ||||
|  | @ -88,7 +93,7 @@ class Executor: | |||
|                 self.kwargs is not None, | ||||
|             )) | ||||
| 
 | ||||
|     @dataclass | ||||
|     @dataclasses.dataclass | ||||
|     class ExecuteContext: | ||||
|         """ | ||||
|         The context of an execute call. | ||||
|  | @ -100,13 +105,16 @@ class Executor: | |||
|         results: list | ||||
| 
 | ||||
|         #: The serializer instance. | ||||
|         serializer: typing.Optional[JSONRPCSerializer] = None | ||||
|         serializer: JSONRPCSerializer | None = None | ||||
| 
 | ||||
|     def __init__(self, | ||||
|                  namespace: str | None = None, | ||||
|                  codec: Codec | None = None): | ||||
|         self.namespace = namespace or MethodRegistry.DEFAULT_NAMESPACE | ||||
|         self.codec = codec or JSONCodec() | ||||
| 
 | ||||
|     # pragma mark - Private interface | ||||
| 
 | ||||
|     def __init__(self, namespace=None): | ||||
|         self.namespace = namespace or MethodRegistry.DEFAULT_NAMESPACE | ||||
| 
 | ||||
|     def get_internal_handler(self, method: str) -> typing.Callable: | ||||
|         """ | ||||
|         Returns the internal handler for *method* or raises | ||||
|  | @ -194,7 +202,7 @@ class Executor: | |||
| 
 | ||||
|     def process_results(self, | ||||
|                         results: list, | ||||
|                         ) -> typing.Optional[typing.Union[list, dict]]: | ||||
|                         ) -> typing.Union[list, dict] | None: | ||||
|         """ | ||||
|         Post-processes the *results* and returns responses. | ||||
| 
 | ||||
|  | @ -236,8 +244,10 @@ class Executor: | |||
| 
 | ||||
|         return responses | ||||
| 
 | ||||
|     @contextmanager | ||||
|     def call_context(self, execute_context: ExecuteContext, call: dict): | ||||
|     @contextlib.contextmanager | ||||
|     def call_context(self, | ||||
|                      execute_context: ExecuteContext, | ||||
|                      call: dict) -> typing.Generator[CallContext, None, None]: | ||||
|         """ | ||||
|         The call context manager. Yields ``CallContext``, which can be | ||||
|         invalid invalid if there was en error processing the call. | ||||
|  | @ -263,7 +273,9 @@ class Executor: | |||
|                 error = exception | ||||
|             else: | ||||
|                 LOGGER.error( | ||||
|                     f'Error handling RPC method: {method}!', | ||||
|                     'Unhandled exception when handling RPC method `%s`: %s', | ||||
|                     method, | ||||
|                     exception, | ||||
|                     exc_info=exception, | ||||
|                 ) | ||||
|                 error = JSONRPCInternalError(str(exception)) | ||||
|  | @ -273,8 +285,8 @@ class Executor: | |||
|             else: | ||||
|                 execute_context.results.append((call, context.result)) | ||||
| 
 | ||||
|     @contextmanager | ||||
|     def execute_context(self): | ||||
|     @contextlib.contextmanager | ||||
|     def execute_context(self) -> typing.Generator[ExecuteContext, None, None]: | ||||
|         """ | ||||
|         The execution context. Yields ``ExecuteContext``. | ||||
| 
 | ||||
|  | @ -297,7 +309,7 @@ class Executor: | |||
| 
 | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     def deserialize_data(self, data: bytes) -> typing.Any: | ||||
|     def deserialize_data(self, data: str | bytes) -> typing.Any: | ||||
|         """ | ||||
|         Deserializes *data* and returns the result. | ||||
| 
 | ||||
|  | @ -306,9 +318,13 @@ class Executor: | |||
|         response object conforms to the spec. | ||||
|         """ | ||||
|         try: | ||||
|             return json.loads(data) | ||||
|             return self.codec.decode(data) | ||||
|         except Exception as exception: | ||||
|             LOGGER.error('Error deserializing RPC call!', exc_info=exception) | ||||
|             LOGGER.error( | ||||
|                 'Unhandled exception when deserializing RPC call: %s', | ||||
|                 exception, | ||||
|                 exc_info=exception, | ||||
|             ) | ||||
|             raise JSONRPCParseError() from exception | ||||
| 
 | ||||
|     def list_methods(self, *args, **kwargs) -> list[str]: | ||||
|  | @ -368,9 +384,7 @@ class Executor: | |||
|         """ | ||||
|         pass | ||||
| 
 | ||||
|     def execute(self, | ||||
|                 payload: typing.Any, | ||||
|                 ) -> typing.Optional[JSONRPCSerializer]: | ||||
|     def execute(self, payload: typing.Any) -> JSONRPCSerializer | None: | ||||
|         """ | ||||
|         Executes the JSONRPC request in *payload*. | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										152
									
								
								packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/ext/jwt.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								packages/bthlabs-jsonrpc-core/bthlabs_jsonrpc_core/ext/jwt.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,152 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import datetime | ||||
| import dataclasses | ||||
| import typing | ||||
| 
 | ||||
| from jose import jwt | ||||
| from jose.constants import ALGORITHMS | ||||
| from jose.jwk import (  # noqa: F401 | ||||
|     ECKey, | ||||
|     HMACKey, | ||||
|     RSAKey, | ||||
| ) | ||||
| import pytz | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core.codecs import Codec | ||||
| 
 | ||||
| 
 | ||||
| @dataclasses.dataclass | ||||
| class KeyPair: | ||||
|     """ | ||||
|     Key pair used to verify and sign JWTs. | ||||
| 
 | ||||
|     For HMAC, both *decode_key* and *encode_key* should be `HMACKey` instances, | ||||
|     wrapping the respective secrets. | ||||
| 
 | ||||
|     For RSA and ECDSA, *decode_key* must be a public key for signature | ||||
|     verification. *encode_key* must be a private key for signing. | ||||
|     """ | ||||
|     decode_key: ECKey | HMACKey | RSAKey | ||||
|     encode_key: ECKey | HMACKey | RSAKey | ||||
| 
 | ||||
| 
 | ||||
| @dataclasses.dataclass | ||||
| class TimeClaims: | ||||
|     """Time claims container.""" | ||||
|     iat: datetime.datetime | ||||
|     nbf: datetime.datetime | None | ||||
|     exp: datetime.datetime | None | ||||
| 
 | ||||
|     def as_claims(self) -> dict: | ||||
|         """ | ||||
|         Return dict representation of the claims suitable for including in a | ||||
|         JWT. | ||||
|         """ | ||||
|         result = { | ||||
|             'iat': self.iat, | ||||
|         } | ||||
| 
 | ||||
|         if self.nbf is not None: | ||||
|             result['nbf'] = self.nbf | ||||
| 
 | ||||
|         if self.exp is not None: | ||||
|             result['exp'] = self.exp | ||||
| 
 | ||||
|         return result | ||||
| 
 | ||||
| 
 | ||||
| class JWTCodec(Codec): | ||||
|     """ | ||||
|     JWT codec. Uses keys specified in *key_pair* when decoding and encoding | ||||
|     tokens. | ||||
| 
 | ||||
|     *algorithm* specifies the signature algorithm to use. Defaults to | ||||
|     :py:attr:`ALGORITHMS.HS256`. | ||||
| 
 | ||||
|     *issuer* specifies the ``iss`` claim. Defaults to ``None`` for no issuer. | ||||
| 
 | ||||
|     *ttl* specifies the token's TTL. It'll be used to generate the ``exp`` | ||||
|     claim. Defaults to ``None`` for non-expiring token. | ||||
| 
 | ||||
|     *include_nbf* specifies if the ``nbf`` claim should be added to the token. | ||||
|     """ | ||||
|     def __init__(self, | ||||
|                  key_pair: KeyPair, | ||||
|                  *, | ||||
|                  algorithm: str = ALGORITHMS.HS256, | ||||
|                  issuer: str | None = None, | ||||
|                  ttl: datetime.timedelta | None = None, | ||||
|                  include_nbf: bool = True): | ||||
|         super().__init__() | ||||
|         self.key_pair = key_pair | ||||
|         self.algorithm = algorithm | ||||
|         self.issuer = issuer | ||||
|         self.ttl = ttl | ||||
|         self.include_nbf = include_nbf | ||||
| 
 | ||||
|     # pragma mark - Private interface | ||||
| 
 | ||||
|     def get_time_claims(self) -> TimeClaims: | ||||
|         """ | ||||
|         Get time claims. | ||||
| 
 | ||||
|         :meta: private | ||||
|         """ | ||||
|         now = datetime.datetime.now().astimezone(pytz.utc) | ||||
| 
 | ||||
|         exp: datetime.datetime | None = None | ||||
|         if self.ttl is not None: | ||||
|             exp = now + self.ttl | ||||
| 
 | ||||
|         return TimeClaims( | ||||
|             iat=now, | ||||
|             nbf=now if self.include_nbf is True else None, | ||||
|             exp=exp, | ||||
|         ) | ||||
| 
 | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     def decode(self, payload: str | bytes, **decoder_kwargs) -> typing.Any: | ||||
|         """ | ||||
|         Decode payload using :py:func:`jose.jwt.decode`. *decoder_kwargs* will | ||||
|         be passed verbatim to the decode function. | ||||
| 
 | ||||
|         Consult *python-jose* documentation for more information. | ||||
|         """ | ||||
|         decoded_payload = jwt.decode( | ||||
|             payload, | ||||
|             self.key_pair.decode_key, | ||||
|             algorithms=[self.algorithm], | ||||
|             **decoder_kwargs, | ||||
|         ) | ||||
| 
 | ||||
|         return decoded_payload['jsonrpc'] | ||||
| 
 | ||||
|     def encode(self, payload: typing.Any, **encoder_kwargs) -> str: | ||||
|         """ | ||||
|         Encode payload using :py:func:`jose.jwt.encode`. *encoder_kwargs* will | ||||
|         be passed verbatim to the encode function. | ||||
| 
 | ||||
|         Consult *python-jose* documentation for more information. | ||||
|         """ | ||||
|         claims: dict = { | ||||
|             **self.get_time_claims().as_claims(), | ||||
|             'jsonrpc': payload, | ||||
|         } | ||||
| 
 | ||||
|         if self.issuer is not None: | ||||
|             claims['iss'] = self.issuer | ||||
| 
 | ||||
|         return jwt.encode( | ||||
|             claims, | ||||
|             self.key_pair.encode_key, | ||||
|             algorithm=self.algorithm, | ||||
|             **encoder_kwargs, | ||||
|         ) | ||||
| 
 | ||||
|     def get_content_type(self) -> str: | ||||
|         """Returns ``application/jwt``.""" | ||||
|         return 'application/jwt' | ||||
|  | @ -0,0 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .fixtures import *  # noqa: F401,F403 | ||||
|  | @ -0,0 +1,40 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from unittest import mock | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core.codecs import Codec | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_custom_codec() -> mock.Mock: | ||||
|     return mock.Mock(spec=Codec) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def single_call() -> dict: | ||||
|     return { | ||||
|         'jsonrpc': '2.0', | ||||
|         'id': 'test', | ||||
|         'method': 'system.list_methods', | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def batch_calls() -> list: | ||||
|     return [ | ||||
|         { | ||||
|             'jsonrpc': '2.0', | ||||
|             'id': 'test', | ||||
|             'method': 'system.list_methods', | ||||
|         }, | ||||
|         { | ||||
|             'jsonrpc': '2.0', | ||||
|             'id': 'test2', | ||||
|             'method': 'system.list_methods', | ||||
|         }, | ||||
|     ] | ||||
|  | @ -1,28 +1,51 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| class MethodRegistry: | ||||
|     INSTANCE = None | ||||
|     DEFAULT_NAMESPACE = 'jsonrpc' | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
|     def __init__(self, *args, **kwargs): | ||||
| import typing | ||||
| 
 | ||||
| 
 | ||||
| class MethodRegistry: | ||||
|     """ | ||||
|     The method registry. Maps method to handler within a namespace. | ||||
| 
 | ||||
|     This class is a singleton. Use :py:meth:`MethodRegistry.shared_instance` | ||||
|     to get the shared instance. | ||||
|     """ | ||||
| 
 | ||||
|     INSTANCE: MethodRegistry | None = None | ||||
| 
 | ||||
|     #: Default namespace | ||||
|     DEFAULT_NAMESPACE: str = 'jsonrpc' | ||||
| 
 | ||||
|     def __init__(self): | ||||
|         self.registry = {} | ||||
|         self.registry[self.DEFAULT_NAMESPACE] = {} | ||||
| 
 | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     @classmethod | ||||
|     def shared_registry(cls, *args, **kwargs): | ||||
|     def shared_registry(cls: type[MethodRegistry]) -> MethodRegistry: | ||||
|         """Return the shared instance.""" | ||||
|         if cls.INSTANCE is None: | ||||
|             cls.INSTANCE = cls(*args, **kwargs) | ||||
|             cls.INSTANCE = cls() | ||||
| 
 | ||||
|         return cls.INSTANCE | ||||
| 
 | ||||
|     def register_method(self, namespace, method, handler): | ||||
|     def register_method(self, | ||||
|                         namespace: str, | ||||
|                         method: str, | ||||
|                         handler: typing.Callable): | ||||
|         """Register a *method* with *handler* in a *namespace*.""" | ||||
|         if namespace not in self.registry: | ||||
|             self.registry[namespace] = {} | ||||
| 
 | ||||
|         self.registry[namespace][method] = handler | ||||
| 
 | ||||
|     def get_methods(self, namespace): | ||||
|     def get_methods(self, namespace) -> list[str]: | ||||
|         """Returns list of methods in a *namespace*.""" | ||||
|         return self.registry.get(namespace, {}).keys() | ||||
| 
 | ||||
|     def get_handler(self, namespace, method): | ||||
|     def get_handler(self, namespace, method) -> typing.Callable: | ||||
|         """Returns the handler for *method* in *namespace*.""" | ||||
|         return self.registry.get(namespace, {}).get(method, None) | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # bthlabs-jsonrpc-core | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import datetime | ||||
| import decimal | ||||
| import typing | ||||
|  | @ -62,6 +64,8 @@ class JSONRPCSerializer: | |||
|     def __init__(self, data): | ||||
|         self._data = data | ||||
| 
 | ||||
|     # pragma mark - Private interface | ||||
| 
 | ||||
|     def is_simple_value(self, value: typing.Any) -> bool: | ||||
|         """ | ||||
|         Returns ``True`` if *value* is a simple value. | ||||
|  | @ -172,6 +176,8 @@ class JSONRPCSerializer: | |||
| 
 | ||||
|         return value | ||||
| 
 | ||||
|     # pragma mark - Public interface | ||||
| 
 | ||||
|     @property | ||||
|     def data(self) -> typing.Any: | ||||
|         """The serialized data.""" | ||||
|  |  | |||
|  | @ -5,6 +5,15 @@ API Documentation | |||
| 
 | ||||
| This section provides the API documentation for BTHLabs JSONRPC - Core. | ||||
| 
 | ||||
| Codecs | ||||
| ------ | ||||
| 
 | ||||
| .. autoclass:: Codec | ||||
|     :members: | ||||
| 
 | ||||
| .. autoclass:: JSONCodec | ||||
|     :members: | ||||
| 
 | ||||
| Decorators | ||||
| ---------- | ||||
| 
 | ||||
|  | @ -29,15 +38,23 @@ Exceptions | |||
| .. autoexception:: JSONRPCSerializerError | ||||
|     :members: | ||||
| 
 | ||||
| Executor | ||||
| -------- | ||||
| Executors | ||||
| --------- | ||||
| 
 | ||||
| .. autoclass:: Executor | ||||
|     :members: | ||||
| 
 | ||||
| 
 | ||||
| Serializer | ||||
| Registries | ||||
| ---------- | ||||
| 
 | ||||
| 
 | ||||
| .. autoclass:: MethodRegistry | ||||
|     :members: | ||||
| 
 | ||||
| 
 | ||||
| Serializers | ||||
| ----------- | ||||
| 
 | ||||
| .. autoclass:: JSONRPCSerializer | ||||
|     :members: | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| # type: ignore | ||||
| # Configuration file for the Sphinx documentation builder. | ||||
| # | ||||
| # This file only contains a selection of the most common options. For a full | ||||
|  | @ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../')) | |||
| project = 'BTHLabs JSONRPC - Core' | ||||
| copyright = '2022-present Tomek Wójcik' | ||||
| author = 'Tomek Wójcik' | ||||
| version = '1.0.0' | ||||
| version = '1.1.0' | ||||
| 
 | ||||
| # The full version, including alpha/beta/rc tags | ||||
| release = '1.0.0' | ||||
| release = '1.1.0b1' | ||||
| 
 | ||||
| 
 | ||||
| # -- General configuration --------------------------------------------------- | ||||
|  |  | |||
							
								
								
									
										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 | ||||
| 
 | ||||
|    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] | ||||
| name = "bthlabs-jsonrpc-core" | ||||
| version = "1.0.0" | ||||
| version = "1.1.0b1" | ||||
| description = "BTHLabs JSONRPC - Core" | ||||
| authors = ["Tomek Wójcik <contact@bthlabs.pl>"] | ||||
| maintainers = ["BTHLabs <contact@bthlabs.pl>"] | ||||
|  | @ -12,15 +12,26 @@ documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/core/" | |||
| 
 | ||||
| [tool.poetry.dependencies] | ||||
| 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] | ||||
| flake8 = "4.0.1" | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| flake8 = "6.1.0" | ||||
| flake8-commas = "2.1.0" | ||||
| mypy = "0.950" | ||||
| pytest = "7.1.2" | ||||
| sphinx = "4.5.0" | ||||
| sphinx-rtd-theme = "1.0.0" | ||||
| freezegun = "1.4.0" | ||||
| mypy = "1.8.0" | ||||
| pytest = "7.4.3" | ||||
| 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] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
| requires = ["poetry-core"] | ||||
| build-backend = "poetry.core.masonry.api" | ||||
|  |  | |||
|  | @ -1,4 +1,12 @@ | |||
| [flake8] | ||||
| exclude = .venv/,.pytest_cache/ | ||||
| exclude = .venv/,.mypy_cache/,.pytest_cache/ | ||||
| ignore = E402 | ||||
| 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 -*- | ||||
| # type: ignore | ||||
| from unittest import mock | ||||
| 
 | ||||
| import pytest | ||||
|  | @ -8,15 +9,15 @@ from bthlabs_jsonrpc_core.serializer import JSONRPCSerializer | |||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_method_registry(): | ||||
| def fake_method_registry() -> mock.Mock: | ||||
|     return mock.Mock(spec=MethodRegistry) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_handler(): | ||||
| def fake_handler() -> mock.Mock: | ||||
|     return mock.Mock() | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_rpc_serializer(): | ||||
| def fake_rpc_serializer() -> mock.Mock: | ||||
|     return mock.Mock(spec=JSONRPCSerializer) | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from unittest import mock | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core import decorators | ||||
|  | @ -6,9 +9,9 @@ from bthlabs_jsonrpc_core.registry import MethodRegistry | |||
| 
 | ||||
| 
 | ||||
| @mock.patch.object(decorators.MethodRegistry, 'shared_registry') | ||||
| def test_default_namespace(mock_shared_registry, | ||||
|                            fake_method_registry, | ||||
|                            fake_handler): | ||||
| def test_default_namespace(mock_shared_registry: mock.Mock, | ||||
|                            fake_method_registry: mock.Mock, | ||||
|                            fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     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') | ||||
| def test_custom_namespace(mock_shared_registry, | ||||
|                           fake_method_registry, | ||||
|                           fake_handler): | ||||
| def test_custom_namespace(mock_shared_registry: mock.Mock, | ||||
|                           fake_method_registry: mock.Mock, | ||||
|                           fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     mock_shared_registry.return_value = fake_method_registry | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core import exceptions | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,46 +1,25 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import json | ||||
| from unittest import mock | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
| 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.serializer import JSONRPCSerializer | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def single_call(): | ||||
|     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(): | ||||
| def jsonrpc_error() -> exceptions.BaseJSONRPCError: | ||||
|     return exceptions.BaseJSONRPCError('I HAZ FIAL') | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def execute_context(): | ||||
| def execute_context() -> executor.Executor.ExecuteContext: | ||||
|     return executor.Executor.ExecuteContext([]) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -55,7 +34,7 @@ def test_CallContext_invalid_context(): | |||
|     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 | ||||
|     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 | ||||
| 
 | ||||
| 
 | ||||
| def test_CallContext_is_valid_args_none(fake_handler): | ||||
| def test_CallContext_is_valid_args_none(fake_handler: mock.Mock): | ||||
|     # When | ||||
|     call_context = executor.Executor.CallContext( | ||||
|         'test', fake_handler, None, {}, | ||||
|  | @ -81,7 +60,7 @@ def test_CallContext_is_valid_args_none(fake_handler): | |||
|     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 | ||||
|     call_context = executor.Executor.CallContext( | ||||
|         'test', fake_handler, [], None, | ||||
|  | @ -91,7 +70,7 @@ def test_CallContext_is_valid_kwargs_none(fake_handler): | |||
|     assert call_context.is_valid is False | ||||
| 
 | ||||
| 
 | ||||
| def test_CallContext_is_valid(fake_handler): | ||||
| def test_CallContext_is_valid(fake_handler: mock.Mock): | ||||
|     # When | ||||
|     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 | ||||
| 
 | ||||
| 
 | ||||
| def test_init_default_namespace(): | ||||
| def test_init(): | ||||
|     # When | ||||
|     result = executor.Executor() | ||||
| 
 | ||||
|     # Then | ||||
|     assert result.namespace == MethodRegistry.DEFAULT_NAMESPACE | ||||
|     assert isinstance(result.codec, JSONCodec) is True | ||||
| 
 | ||||
| 
 | ||||
| def test_init_custom_namespace(): | ||||
|  | @ -115,8 +95,17 @@ def test_init_custom_namespace(): | |||
|     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') | ||||
| 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 | ||||
|     fake_method_registry.get_methods.return_value = ['test'] | ||||
|     mock_shared_registry.return_value = fake_method_registry | ||||
|  | @ -170,6 +159,21 @@ def test_deserialize_data(): | |||
|     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(): | ||||
|     # Given | ||||
|     the_executor = executor.Executor() | ||||
|  | @ -184,7 +188,25 @@ def test_deserialize_data_error(): | |||
|         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 | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -195,7 +217,7 @@ def test_get_calls_batch(batch_calls): | |||
|     assert result == batch_calls | ||||
| 
 | ||||
| 
 | ||||
| def test_get_calls_single(single_call): | ||||
| def test_get_calls_single(single_call: dict): | ||||
|     # Given | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -234,7 +256,7 @@ def test_get_call_spec_not_dict(): | |||
|         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 | ||||
|     single_call.pop('jsonrpc') | ||||
| 
 | ||||
|  | @ -250,7 +272,7 @@ def test_get_call_spec_wihtout_jsonrpc(single_call): | |||
|         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 | ||||
|     single_call['jsonrpc'] = 'test' | ||||
| 
 | ||||
|  | @ -266,7 +288,7 @@ def test_get_call_spec_invalid_jsonrpc(single_call): | |||
|         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 | ||||
|     single_call.pop('method') | ||||
| 
 | ||||
|  | @ -283,9 +305,9 @@ def test_get_call_spec_wihtout_method(single_call): | |||
| 
 | ||||
| 
 | ||||
| @mock.patch.object(executor.MethodRegistry, 'shared_registry') | ||||
| def test_get_call_spec_internal_method(mock_shared_registry, | ||||
|                                        single_call, | ||||
|                                        fake_handler): | ||||
| def test_get_call_spec_internal_method(mock_shared_registry: mock.Mock, | ||||
|                                        single_call: dict, | ||||
|                                        fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     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') | ||||
| def test_get_call_spec_registry_method(mock_shared_registry, | ||||
|                                        single_call, | ||||
|                                        fake_method_registry, | ||||
|                                        fake_handler): | ||||
| def test_get_call_spec_registry_method(mock_shared_registry: mock.Mock, | ||||
|                                        single_call: dict, | ||||
|                                        fake_method_registry: mock.Mock, | ||||
|                                        fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     single_call['method'] = 'test' | ||||
| 
 | ||||
|     fake_method_registry.get_handler.return_value = fake_handler | ||||
|     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') | ||||
| def test_get_call_spec_method_not_found(mock_shared_registry, | ||||
|                                         single_call, | ||||
|                                         fake_method_registry): | ||||
| def test_get_call_spec_method_not_found(mock_shared_registry: mock.Mock, | ||||
|                                         single_call: dict, | ||||
|                                         fake_method_registry: mock.Mock): | ||||
|     # Given | ||||
|     single_call['method'] = 'test' | ||||
| 
 | ||||
|     fake_method_registry.get_handler.return_value = None | ||||
|     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') | ||||
| def test_get_call_spec_invalid_params(mock_shared_registry, | ||||
|                                       single_call, | ||||
|                                       fake_method_registry, | ||||
|                                       fake_handler): | ||||
| def test_get_call_spec_invalid_params(mock_shared_registry: mock.Mock, | ||||
|                                       single_call: dict, | ||||
|                                       fake_method_registry: mock.Mock, | ||||
|                                       fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     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') | ||||
| def test_get_call_spec_with_args(mock_shared_registry, | ||||
|                                  single_call, | ||||
|                                  fake_method_registry, | ||||
|                                  fake_handler): | ||||
| def test_get_call_spec_with_args(mock_shared_registry: mock.Mock, | ||||
|                                  single_call: dict, | ||||
|                                  fake_method_registry: mock.Mock, | ||||
|                                  fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     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') | ||||
| def test_get_call_spec_with_kwargs(mock_shared_registry, | ||||
|                                    single_call, | ||||
|                                    fake_method_registry, | ||||
|                                    fake_handler): | ||||
| def test_get_call_spec_with_kwargs(mock_shared_registry: mock.Mock, | ||||
|                                    single_call: dict, | ||||
|                                    fake_method_registry: mock.Mock, | ||||
|                                    fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     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}) | ||||
| 
 | ||||
| 
 | ||||
| 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 | ||||
|     call_without_id = {**single_call} | ||||
|     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 | ||||
| 
 | ||||
| 
 | ||||
| def test_process_results_single_call(single_call): | ||||
| def test_process_results_single_call(single_call: dict): | ||||
|     # Given | ||||
|     call_results = [ | ||||
|         (single_call, 'OK'), | ||||
|  | @ -485,7 +513,7 @@ def test_process_results_single_call(single_call): | |||
|     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 | ||||
|     call_results = [ | ||||
|         (None, jsonrpc_error), | ||||
|  | @ -505,7 +533,7 @@ def test_process_results_top_level_error(jsonrpc_error): | |||
|     assert result == expected_result | ||||
| 
 | ||||
| 
 | ||||
| def test_process_results_empty(single_call): | ||||
| def test_process_results_empty(single_call: dict): | ||||
|     # Given | ||||
|     single_call.pop('id') | ||||
| 
 | ||||
|  | @ -522,9 +550,9 @@ def test_process_results_empty(single_call): | |||
|     assert result is None | ||||
| 
 | ||||
| 
 | ||||
| def test_call_context_invalid_context(jsonrpc_error, | ||||
|                                       execute_context, | ||||
|                                       single_call): | ||||
| def test_call_context_invalid_context(jsonrpc_error: exceptions.BaseJSONRPCError, | ||||
|                                       execute_context: executor.Executor.ExecuteContext, | ||||
|                                       single_call: dict): | ||||
|     # Given | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -539,10 +567,10 @@ def test_call_context_invalid_context(jsonrpc_error, | |||
|         assert result.is_valid is False | ||||
| 
 | ||||
| 
 | ||||
| def test_call_context_handle_jsonrpc_error(fake_handler, | ||||
|                                            jsonrpc_error, | ||||
|                                            execute_context, | ||||
|                                            single_call): | ||||
| def test_call_context_handle_jsonrpc_error(fake_handler: mock.Mock, | ||||
|                                            jsonrpc_error: exceptions.BaseJSONRPCError, | ||||
|                                            execute_context: executor.Executor.ExecuteContext, | ||||
|                                            single_call: dict): | ||||
|     # Given | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -562,9 +590,9 @@ def test_call_context_handle_jsonrpc_error(fake_handler, | |||
|         assert call_result[1] == jsonrpc_error | ||||
| 
 | ||||
| 
 | ||||
| def test_call_context_handle_exception(fake_handler, | ||||
|                                        execute_context, | ||||
|                                        single_call): | ||||
| def test_call_context_handle_exception(fake_handler: mock.Mock, | ||||
|                                        execute_context: executor.Executor.ExecuteContext, | ||||
|                                        single_call: dict): | ||||
|     # Given | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -585,7 +613,9 @@ def test_call_context_handle_exception(fake_handler, | |||
|         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 | ||||
|     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 | ||||
| 
 | ||||
| 
 | ||||
| def test_execute_context_handle_jsonrpc_error(jsonrpc_error): | ||||
| def test_execute_context_handle_jsonrpc_error(jsonrpc_error: exceptions.BaseJSONRPCError): | ||||
|     # Given | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -638,7 +668,7 @@ def test_execute_context_handle_exception(): | |||
|     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 | ||||
|     the_executor = executor.Executor() | ||||
| 
 | ||||
|  | @ -656,7 +686,7 @@ def test_execute_context_handle_empty_results(single_call): | |||
|             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 | ||||
|     fake_responses = { | ||||
|         'jsonrpc': '2.0', | ||||
|  | @ -712,7 +742,7 @@ def test_before_call(): | |||
| 
 | ||||
| 
 | ||||
| @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 | ||||
|     fake_method_registry.get_handler.return_value = None | ||||
|     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__') | ||||
| def test_shared_registry(mock_init): | ||||
| def test_shared_registry(mock_init: mock.Mock): | ||||
|     # Given | ||||
|     mock_init.return_value = None | ||||
| 
 | ||||
|  | @ -32,7 +32,8 @@ def test_shared_registry(mock_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 | ||||
|     mock_init.return_value = None | ||||
| 
 | ||||
|  | @ -52,7 +53,7 @@ def test_shared_registry_with_instance(mock_init, fake_method_registry): | |||
|     registry.MethodRegistry.INSTANCE = None | ||||
| 
 | ||||
| 
 | ||||
| def test_register_method(fake_handler): | ||||
| def test_register_method(fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     the_registry = registry.MethodRegistry() | ||||
| 
 | ||||
|  | @ -64,7 +65,7 @@ def test_register_method(fake_handler): | |||
|     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 | ||||
|     spam_handler = mock.Mock() | ||||
| 
 | ||||
|  | @ -93,7 +94,7 @@ def test_get_methods(): | |||
|     assert set(result) == expected_methods | ||||
| 
 | ||||
| 
 | ||||
| def test_get_handler(fake_handler): | ||||
| def test_get_handler(fake_handler: mock.Mock): | ||||
|     # Given | ||||
|     spam_handler = mock.Mock() | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| import datetime | ||||
| import decimal | ||||
| import typing | ||||
| import uuid | ||||
| 
 | ||||
| import pytest | ||||
|  | @ -20,7 +21,7 @@ def test_init(): | |||
|     'value,expected', | ||||
|     [(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 | ||||
|     the_serializer = serializer.JSONRPCSerializer('spam') | ||||
| 
 | ||||
|  | @ -38,7 +39,7 @@ def test_is_simple_value(value, expected): | |||
|         (tuple(), True), ({}, False), | ||||
|     ], | ||||
| ) | ||||
| def test_is_sequence_value(value, expected): | ||||
| def test_is_sequence_value(value: typing.Any, expected: bool): | ||||
|     # Given | ||||
|     the_serializer = serializer.JSONRPCSerializer('spam') | ||||
| 
 | ||||
|  | @ -53,7 +54,7 @@ def test_is_sequence_value(value, expected): | |||
|     'value,expected', | ||||
|     [({}, True), ([], False)], | ||||
| ) | ||||
| def test_is_dict_value(value, expected): | ||||
| def test_is_dict_value(value: typing.Any, expected: bool): | ||||
|     # Given | ||||
|     the_serializer = serializer.JSONRPCSerializer('spam') | ||||
| 
 | ||||
|  | @ -68,7 +69,7 @@ def test_is_dict_value(value, expected): | |||
|     'value,expected', | ||||
|     [(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 | ||||
|     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 | ||||
|     the_serializer = serializer.JSONRPCSerializer('spam') | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,10 +1,13 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .auth_checks import (  # noqa | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from .auth_checks import (  # noqa: F401 | ||||
|     has_perms, | ||||
|     is_authenticated, | ||||
|     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 -*- | ||||
| # 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 | ||||
| 
 | ||||
| from django.apps import AppConfig | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| # -*- 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 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 -*- | ||||
| # 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 Executor, JSONRPCAccessDeniedError | ||||
| from bthlabs_jsonrpc_core import Codec, Executor, JSONRPCAccessDeniedError | ||||
| from django.http import HttpRequest | ||||
| 
 | ||||
| from bthlabs_jsonrpc_django.serializer import DjangoJSONRPCSerializer | ||||
| 
 | ||||
| TCanCall = typing.Callable[[HttpRequest, str, list, dict], bool] | ||||
| 
 | ||||
| 
 | ||||
| class DjangoExecutor(Executor): | ||||
|     """Django-specific executor""" | ||||
| 
 | ||||
|     serializer = DjangoJSONRPCSerializer | ||||
| 
 | ||||
|     def __init__(self, | ||||
|                  request: HttpRequest, | ||||
|                  can_call: typing.Callable, | ||||
|                  namespace: typing.Optional[str] = None): | ||||
|         super().__init__(namespace=namespace) | ||||
|         self.request: HttpRequest = request | ||||
|         self.can_call: typing.Callable = can_call | ||||
|                  can_call: TCanCall, | ||||
|                  namespace: str | None = None, | ||||
|                  codec: Codec | None = None): | ||||
|         super().__init__(namespace=namespace, codec=codec) | ||||
|         self.request = request | ||||
|         self.can_call = can_call | ||||
| 
 | ||||
|     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)] | ||||
| 
 | ||||
|     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) | ||||
|         if can_call is False: | ||||
|             raise JSONRPCAccessDeniedError(data='can_call') | ||||
|  |  | |||
|  | @ -1,8 +1,13 @@ | |||
| # -*- 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 django.db.models import QuerySet | ||||
| 
 | ||||
| 
 | ||||
| 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 -*- | ||||
| # 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 Executor | ||||
| from bthlabs_jsonrpc_core.codecs import Codec | ||||
| 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.views.decorators.csrf import csrf_exempt | ||||
| from django.views.generic.base import View | ||||
| 
 | ||||
| from bthlabs_jsonrpc_django.codecs import DjangoJSONCodec | ||||
| 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 | ||||
| 
 | ||||
|     #: The executor class. | ||||
|     executor: type[DjangoExecutor] = DjangoExecutor | ||||
| 
 | ||||
|     #: List of auth check functions. | ||||
|     auth_checks: list[typing.Callable] = [] | ||||
| 
 | ||||
|     #: Namespace of this endpoint. | ||||
|     namespace: typing.Optional[str] = None | ||||
|     namespace: str | None = None | ||||
| 
 | ||||
|     #: The codec class. | ||||
|     codec: type[Codec] = DjangoJSONCodec | ||||
| 
 | ||||
|     # pragma mark - Private interface | ||||
| 
 | ||||
|  | @ -69,6 +73,19 @@ class JSONRPCView(View): | |||
|         if has_auth is False: | ||||
|             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: | ||||
|         """ | ||||
|         Dispatches the *request*. | ||||
|  | @ -84,7 +101,7 @@ class JSONRPCView(View): | |||
| 
 | ||||
|         self.ensure_auth(request) | ||||
| 
 | ||||
|         return handler(request, *args, **kwargs) | ||||
|         return handler(request, *args, **kwargs)  # type: ignore[misc] | ||||
| 
 | ||||
|     def post(self, request: HttpRequest) -> HttpResponse: | ||||
|         """ | ||||
|  | @ -92,15 +109,18 @@ class JSONRPCView(View): | |||
| 
 | ||||
|         :meta private: | ||||
|         """ | ||||
|         executor = self.executor( | ||||
|             request, self.can_call, self.namespace, | ||||
|         ) | ||||
|         executor = self.get_executor(request) | ||||
| 
 | ||||
|         serializer = executor.execute(request.body) | ||||
|         if serializer is None: | ||||
|             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 | ||||
| 
 | ||||
|  | @ -120,3 +140,7 @@ class JSONRPCView(View): | |||
|         etc. The default implementation returns ``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 | ||||
| 
 | ||||
| This section provides the API documentation for BTHLabs JSONRPC - Core. | ||||
| This section provides the API documentation for BTHLabs JSONRPC - Django. | ||||
| 
 | ||||
| Auth checks | ||||
| ----------- | ||||
|  | @ -14,8 +14,26 @@ Auth checks | |||
| 
 | ||||
| .. autofunction:: is_staff | ||||
| 
 | ||||
| Codecs | ||||
| ------ | ||||
| 
 | ||||
| .. autoclass:: DjangoJSONCodec | ||||
|     :members: | ||||
| 
 | ||||
| Executors | ||||
| --------- | ||||
| 
 | ||||
| .. autoclass:: DjangoExecutor | ||||
|     :members: | ||||
| 
 | ||||
| Serializers | ||||
| ----------- | ||||
| 
 | ||||
| .. autoclass:: DjangoJSONRPCSerializer | ||||
|     :members: | ||||
| 
 | ||||
| Views | ||||
| ----- | ||||
| 
 | ||||
| .. autoclass:: JSONRPCView | ||||
|     :members: as_view, auth_checks, can_call, namespace | ||||
|     :members: | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| # type: ignore | ||||
| # Configuration file for the Sphinx documentation builder. | ||||
| # | ||||
| # This file only contains a selection of the most common options. For a full | ||||
|  | @ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../')) | |||
| project = 'BTHLabs JSONRPC - Django' | ||||
| copyright = '2022-present Tomek Wójcik' | ||||
| author = 'Tomek Wójcik' | ||||
| version = '1.0.0' | ||||
| version = '1.1.0' | ||||
| 
 | ||||
| # The full version, including alpha/beta/rc tags | ||||
| release = '1.0.0' | ||||
| release = '1.1.0b1' | ||||
| 
 | ||||
| 
 | ||||
| # -- General configuration --------------------------------------------------- | ||||
|  |  | |||
|  | @ -1,12 +1,6 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| """ | ||||
| ASGI config for django_jsonrpc_django_example project. | ||||
| 
 | ||||
| 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/ | ||||
| """ | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import os | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| # -*- 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 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,7 +1,9 @@ | |||
| # -*- 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 | ||||
| DEBUG = False | ||||
|  | @ -71,6 +73,10 @@ STATIC_URL = 'static/' | |||
| 
 | ||||
| DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' | ||||
| 
 | ||||
| AUTHENTICATION_BACKENDS = [ | ||||
|     'django.contrib.auth.backends.ModelBackend', | ||||
| ] | ||||
| 
 | ||||
| JSONRPC_METHOD_MODULES = [ | ||||
|     'bthlabs_jsonrpc_django_example.things.rpc_methods', | ||||
| ] | ||||
|  |  | |||
|  | @ -1,7 +1,8 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # django-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from django.contrib import admin | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from django.contrib import admin | ||||
| 
 | ||||
| from bthlabs_jsonrpc_django_example.things import models | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| # -*- 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 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| # -*- 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 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| # -*- 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_django_example.things.models import Thing | ||||
|  |  | |||
|  | @ -1,14 +1,35 @@ | |||
| # -*- 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.urls import path | ||||
| 
 | ||||
| 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 = [ | ||||
|     path('admin/', admin.site.urls), | ||||
|     path( | ||||
|         'rpc/admin', | ||||
|         JSONRPCView.as_view( | ||||
|         JWTRPCView.as_view( | ||||
|             auth_checks=[is_authenticated, is_staff], | ||||
|             namespace='admin', | ||||
|         ), | ||||
|  |  | |||
|  | @ -1,12 +1,6 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| """ | ||||
| WSGI config for django_jsonrpc_django_example project. | ||||
| 
 | ||||
| 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/ | ||||
| """ | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import os | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,15 +1,18 @@ | |||
| #!/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 sys | ||||
| 
 | ||||
| 
 | ||||
| def main(): | ||||
|     """Run administrative tasks.""" | ||||
|     os.environ.setdefault( | ||||
|         'DJANGO_SETTINGS_MODULE', | ||||
|         'bthlabs_jsonrpc_django_example.settings.local', | ||||
|     ) | ||||
| 
 | ||||
|     try: | ||||
|         from django.core.management import execute_from_command_line | ||||
|     except ImportError as exc: | ||||
|  | @ -20,6 +23,7 @@ def main(): | |||
|                 "forget to activate a virtual environment?" | ||||
|             ), | ||||
|         ) from exc | ||||
| 
 | ||||
|     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] | ||||
| name = "bthlabs-jsonrpc-django" | ||||
| version = "1.0.0" | ||||
| version = "1.1.0b1" | ||||
| description = "BTHLabs JSONRPC - Django integration" | ||||
| authors = ["Tomek Wójcik <contact@bthlabs.pl>"] | ||||
| maintainers = ["BTHLabs <contact@bthlabs.pl>"] | ||||
|  | @ -13,20 +13,20 @@ documentation = "https://projects.bthlabs.pl/bthlabs-jsonrpc/aiohttp/" | |||
| [tool.poetry.dependencies] | ||||
| python = "^3.10" | ||||
| 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 } | ||||
| django = "3.2.13" | ||||
| factory-boy = "3.2.1" | ||||
| flake8 = "4.0.1" | ||||
| django = "3.2.23" | ||||
| factory-boy = "3.3.0" | ||||
| flake8 = "6.1.0" | ||||
| flake8-commas = "2.1.0" | ||||
| mypy = "0.950" | ||||
| pytest = "7.1.2" | ||||
| pytest-django = "4.5.2" | ||||
| sphinx = "4.5.0" | ||||
| sphinx-rtd-theme = "1.0.0" | ||||
| mypy = "1.8.0" | ||||
| pytest = "7.4.3" | ||||
| pytest-django = "4.7.0" | ||||
| sphinx = "7.2.6" | ||||
| sphinx-rtd-theme = "2.0.0" | ||||
| 
 | ||||
| [build-system] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
| requires = ["poetry-core"] | ||||
| build-backend = "poetry.core.masonry.api" | ||||
|  |  | |||
|  | @ -1,7 +1,15 @@ | |||
| [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 | ||||
| max-line-length = 119 | ||||
| 
 | ||||
| [tool:pytest] | ||||
| 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 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| import factory | ||||
| 
 | ||||
| from testing.models import Thing | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from django.db import models | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,6 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| # type: ignore | ||||
| from pathlib import Path | ||||
| 
 | ||||
| BASE_DIR = Path(__file__).resolve().parent | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # bthlabs-jsonrpc-django | (c) 2022-present Tomek Wójcik | MIT License | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from django.urls import path | ||||
| 
 | ||||
| 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 -*- | ||||
| # type: ignore | ||||
| from unittest import mock | ||||
| 
 | ||||
| from django.contrib.auth.models import User | ||||
| from django.test import RequestFactory | ||||
| 
 | ||||
| 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 | ||||
|     request = rf.get('/') | ||||
|     request.user = user | ||||
|  | @ -18,7 +22,7 @@ def test_has_perms_regular_user(rf, user): | |||
|     assert result is False | ||||
| 
 | ||||
| 
 | ||||
| def test_has_perms_ok(rf, user): | ||||
| def test_has_perms_ok(rf: RequestFactory, user: User): | ||||
|     # Given | ||||
|     request = rf.get('/') | ||||
|     request.user = user | ||||
|  | @ -37,7 +41,7 @@ def test_has_perms_ok(rf, user): | |||
|         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 | ||||
|     request = rf.get('/') | ||||
|     request.user = super_user | ||||
|  |  | |||
|  | @ -1,10 +1,13 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| 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 | ||||
| 
 | ||||
| 
 | ||||
| def test_is_authenticated_anonymous_user(rf): | ||||
| def test_is_authenticated_anonymous_user(rf: RequestFactory): | ||||
|     # Given | ||||
|     request = rf.get('/') | ||||
|     request.user = AnonymousUser() | ||||
|  | @ -16,7 +19,7 @@ def test_is_authenticated_anonymous_user(rf): | |||
|     assert result is False | ||||
| 
 | ||||
| 
 | ||||
| def test_is_authenticated_inactive(rf, inactive_user): | ||||
| def test_is_authenticated_inactive(rf: RequestFactory, inactive_user: User): | ||||
|     # Given | ||||
|     request = rf.get('/') | ||||
|     request.user = inactive_user | ||||
|  | @ -28,7 +31,7 @@ def test_is_authenticated_inactive(rf, inactive_user): | |||
|     assert result is False | ||||
| 
 | ||||
| 
 | ||||
| def test_is_authenticated_ok(rf, user): | ||||
| def test_is_authenticated_ok(rf: RequestFactory, user: User): | ||||
|     # Given | ||||
|     request = rf.get('/') | ||||
|     request.user = user | ||||
|  |  | |||
|  | @ -1,8 +1,12 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| from django.contrib.auth.models import User | ||||
| from django.test import RequestFactory | ||||
| 
 | ||||
| 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 | ||||
|     request = rf.get('/') | ||||
|     request.user = user | ||||
|  | @ -14,7 +18,7 @@ def test_is_staff_regular_user(rf, user): | |||
|     assert result is False | ||||
| 
 | ||||
| 
 | ||||
| def test_is_staff_ok(rf, staff_user): | ||||
| def test_is_staff_ok(rf: RequestFactory, staff_user: User): | ||||
|     # Given | ||||
|     request = rf.get('/') | ||||
|     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 -*- | ||||
| # type: ignore | ||||
| from django.contrib.auth.models import User | ||||
| import factory | ||||
| import pytest | ||||
| 
 | ||||
| 
 | ||||
| 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 | ||||
| from .factories import UserFactory | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def user(db): | ||||
| def user(db) -> User: | ||||
|     return UserFactory() | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def inactive_user(db): | ||||
| def inactive_user(db) -> User: | ||||
|     return UserFactory(is_active=False) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def staff_user(db): | ||||
| def staff_user(db) -> User: | ||||
|     return UserFactory(is_staff=True) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def super_user(db): | ||||
| def super_user(db) -> User: | ||||
|     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 -*- | ||||
| # type: ignore | ||||
| from unittest import mock | ||||
| 
 | ||||
| from bthlabs_jsonrpc_core import exceptions | ||||
| from django.test import RequestFactory | ||||
| import pytest | ||||
| 
 | ||||
| from bthlabs_jsonrpc_django import executor | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture | ||||
| def fake_can_call(): | ||||
| def fake_can_call() -> mock.Mock: | ||||
|     return mock.Mock() | ||||
| 
 | ||||
| 
 | ||||
| def test_init(rf, fake_can_call): | ||||
| def test_init(rf: RequestFactory, fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     request = rf.get('/') | ||||
| 
 | ||||
|  | @ -24,7 +26,7 @@ def test_init(rf, 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 | ||||
|     request = rf.get('/') | ||||
| 
 | ||||
|  | @ -37,7 +39,7 @@ def test_enrich_args(rf, fake_can_call): | |||
|     assert result == [request, 'spam'] | ||||
| 
 | ||||
| 
 | ||||
| def test_before_call(rf, fake_can_call): | ||||
| def test_before_call(rf: RequestFactory, fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     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}) | ||||
| 
 | ||||
| 
 | ||||
| def test_before_call_access_denied(rf, fake_can_call): | ||||
| def test_before_call_access_denied(rf: RequestFactory, | ||||
|                                    fake_can_call: mock.Mock): | ||||
|     # Given | ||||
|     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 -*- | ||||
| # type: ignore | ||||
| import pytest | ||||
| 
 | ||||
| from bthlabs_jsonrpc_django import serializer | ||||
|  |  | |||
|  | @ -1,8 +1,11 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| # type: ignore | ||||
| 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 | ||||
|     batch = [ | ||||
|         { | ||||
|  | @ -48,25 +51,27 @@ def test_view(client): | |||
|     assert data == expected_result_data | ||||
| 
 | ||||
| 
 | ||||
| def test_view_empty_response(client, call): | ||||
| def test_view_empty_response(client: Client, single_call: dict): | ||||
|     # Given | ||||
|     call.pop('id') | ||||
|     single_call.pop('id') | ||||
| 
 | ||||
|     # When | ||||
|     response = client.post('/rpc', data=call, content_type='application/json') | ||||
|     response = client.post( | ||||
|         '/rpc', data=single_call, content_type='application/json', | ||||
|     ) | ||||
| 
 | ||||
|     # Then | ||||
|     assert response.status_code == 200 | ||||
|     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 | ||||
|     client.force_login(user) | ||||
| 
 | ||||
|     # When | ||||
|     response = client.post( | ||||
|         '/rpc/private', data=call, content_type='application/json', | ||||
|         '/rpc/private', data=single_call, content_type='application/json', | ||||
|     ) | ||||
| 
 | ||||
|     # Then | ||||
|  | @ -75,16 +80,17 @@ def test_view_with_auth_checks(client, user, call): | |||
|     data = response.json() | ||||
|     expected_result_data = { | ||||
|         'jsonrpc': '2.0', | ||||
|         'id': 'system.list_methods', | ||||
|         'id': 'test', | ||||
|         'result': ['system.list_methods'], | ||||
|     } | ||||
|     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 | ||||
|     response = client.post( | ||||
|         '/rpc/private', data=call, content_type='application/json', | ||||
|         '/rpc/private', data=single_call, content_type='application/json', | ||||
|     ) | ||||
| 
 | ||||
|     # Then | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user