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

View File

@@ -1,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'

View File

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

View File

@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
# 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

View File

@@ -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 = {

View File

@@ -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*.

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,51 @@
# -*- coding: utf-8 -*-
# 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)

View File

@@ -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."""

View File

@@ -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:

View File

@@ -1,3 +1,4 @@
# type: ignore
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
@@ -20,10 +21,10 @@ sys.path.insert(0, os.path.abspath('../../'))
project = 'BTHLabs JSONRPC - 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 ---------------------------------------------------

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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

View File

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

View File

@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# 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)

View File

@@ -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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ def test_init():
@mock.patch.object(registry.MethodRegistry, '__init__')
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()

View File

@@ -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')