├── pjrpc ├── py.typed ├── client │ ├── backend │ │ ├── __init__.py │ │ ├── requests.py │ │ └── aiohttp.py │ ├── integrations │ │ ├── __init__.py │ │ ├── pytest_aiohttp.py │ │ └── pytest_requests.py │ ├── __init__.py │ ├── validators.py │ └── exceptions.py ├── server │ ├── integration │ │ ├── __init__.py │ │ ├── werkzeug.py │ │ └── aio_pika.py │ ├── validators │ │ ├── __init__.py │ │ ├── base.py │ │ ├── pydantic_v1.py │ │ └── pydantic.py │ ├── __init__.py │ ├── specs │ │ ├── __init__.py │ │ ├── extractors │ │ │ └── __init__.py │ │ ├── openapi │ │ │ └── ui.py │ │ └── schemas.py │ ├── utils.py │ ├── typedefs.py │ └── exceptions.py ├── common │ ├── typedefs.py │ ├── common.py │ ├── encoder.py │ ├── __init__.py │ ├── generators.py │ └── exceptions.py └── __init__.py ├── .flake8 ├── tests ├── common.py ├── client │ ├── conftest.py │ ├── test_client_middleware.py │ ├── test_generators.py │ └── test_client_response.py ├── server │ ├── conftest.py │ ├── test_server_response.py │ ├── test_pydantic_validator.py │ ├── test_base_validator.py │ ├── test_middleware.py │ ├── test_flask.py │ └── test_werkzeug.py └── common │ ├── test_error.py │ └── test_request_v2.py ├── docs ├── source │ ├── _static │ │ ├── css │ │ │ └── custom.css │ │ ├── redoc-screenshot.png │ │ ├── rapidoc-screenshot.png │ │ └── swagger-ui-screenshot.png │ ├── pjrpc │ │ ├── api │ │ │ ├── index.rst │ │ │ ├── common.rst │ │ │ ├── client.rst │ │ │ └── server.rst │ │ ├── development.rst │ │ ├── installation.rst │ │ ├── specification.rst │ │ ├── retries.rst │ │ ├── validation.rst │ │ ├── server.rst │ │ ├── extending.rst │ │ ├── examples.rst │ │ ├── tracing.rst │ │ ├── errors.rst │ │ ├── client.rst │ │ └── testing.rst │ ├── conf.py │ └── index.rst ├── Makefile └── make.bat ├── pytest.ini ├── .readthedocs.yaml ├── examples ├── flask_server.py ├── requests_client.py ├── aiohttp_client.py ├── werkzeug_server.py ├── aio_pika_client.py ├── multiple_clients.py ├── sentry.py ├── aiohttp_server_pytest.py ├── aio_pika_server.py ├── aiohttp_server.py ├── aiohttp_client_retry.py ├── flask_versioning.py ├── aiohttp_versioning.py ├── middlewares.py ├── flask_server_pytest.py ├── pydantic_validator.py ├── client_prometheus_metrics.py ├── aiohttp_pytest.py ├── aiohttp_client_batch.py ├── client_tracing.py ├── requests_pytest.py ├── server_prometheus_metrics.py ├── httpserver.py ├── aiohttp_dishka_di.py ├── server_tracing.py ├── openapi_flask.py └── openrpc_aiohttp.py ├── Makefile ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── LICENSE ├── .gitignore ├── .pre-commit-config.yaml └── pyproject.toml /pjrpc/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pjrpc/client/backend/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON-RPC client library backends. 3 | """ 4 | -------------------------------------------------------------------------------- /pjrpc/client/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON-RPC client library integrations. 3 | """ 4 | -------------------------------------------------------------------------------- /pjrpc/server/integration/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON-RPC server framework integrations. 3 | """ 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | per-file-ignores = 4 | pjrpc/*/__init__.py: F401 5 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Any: 4 | def __eq__(self, other): 5 | return True 6 | 7 | 8 | _ = Any() 9 | -------------------------------------------------------------------------------- /docs/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .content { 2 | width: 55em; 3 | } 4 | 5 | .sidebar-drawer { 6 | width: 15em; 7 | } 8 | -------------------------------------------------------------------------------- /docs/source/_static/redoc-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dapper91/pjrpc/HEAD/docs/source/_static/redoc-screenshot.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode=auto 3 | 4 | filterwarnings = 5 | ignore::DeprecationWarning 6 | default:::pjrpc 7 | -------------------------------------------------------------------------------- /docs/source/_static/rapidoc-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dapper91/pjrpc/HEAD/docs/source/_static/rapidoc-screenshot.png -------------------------------------------------------------------------------- /docs/source/_static/swagger-ui-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dapper91/pjrpc/HEAD/docs/source/_static/swagger-ui-screenshot.png -------------------------------------------------------------------------------- /tests/client/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aioresponses import aioresponses 3 | 4 | 5 | @pytest.fixture 6 | def responses(): 7 | with aioresponses() as mocker: 8 | yield mocker 9 | -------------------------------------------------------------------------------- /pjrpc/server/validators/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON-RPC method parameters validators. 3 | """ 4 | 5 | from .base import BaseValidator, BaseValidatorFactory, ExcludeFunc, ValidationError 6 | 7 | __all__ = [ 8 | 'BaseValidator', 9 | 'BaseValidatorFactory', 10 | 'ExcludeFunc', 11 | 'ValidationError', 12 | ] 13 | -------------------------------------------------------------------------------- /docs/source/pjrpc/api/index.rst: -------------------------------------------------------------------------------- 1 | .. _api_index: 2 | 3 | Developer Interface 4 | =================== 5 | 6 | .. currentmodule:: pjrpc 7 | 8 | 9 | .. automodule:: pjrpc 10 | 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | common 16 | 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | client 22 | 23 | 24 | .. toctree:: 25 | :maxdepth: 2 26 | 27 | server 28 | -------------------------------------------------------------------------------- /docs/source/pjrpc/development.rst: -------------------------------------------------------------------------------- 1 | .. development: 2 | 3 | Development 4 | =========== 5 | 6 | Install pre-commit hooks: 7 | 8 | .. code-block:: console 9 | 10 | $ pre-commit install 11 | 12 | For more information see `pre-commit `_ 13 | 14 | 15 | You can run code check manually: 16 | 17 | .. code-block:: console 18 | 19 | $ pre-commit run --all-file 20 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | version: 2 4 | 5 | build: 6 | os: ubuntu-20.04 7 | tools: 8 | python: "3.12" 9 | jobs: 10 | post_install: 11 | - pip install poetry 12 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs 13 | 14 | sphinx: 15 | configuration: docs/source/conf.py 16 | -------------------------------------------------------------------------------- /examples/flask_server.py: -------------------------------------------------------------------------------- 1 | import pjrpc 2 | from pjrpc.server.integration import flask as integration 3 | 4 | methods = pjrpc.server.MethodRegistry() 5 | 6 | 7 | @methods.add() 8 | def sum(a: int, b: int) -> int: 9 | return a + b 10 | 11 | 12 | json_rpc = integration.JsonRPC('/api/v1') 13 | json_rpc.add_methods(methods) 14 | 15 | if __name__ == "__main__": 16 | json_rpc.http_app.run(port=8080) 17 | -------------------------------------------------------------------------------- /examples/requests_client.py: -------------------------------------------------------------------------------- 1 | import pjrpc 2 | from pjrpc.client.backend import requests as pjrpc_client 3 | 4 | client = pjrpc_client.Client('http://localhost:8080/api/v1') 5 | 6 | response: pjrpc.Response = client.send(pjrpc.Request('sum', params=[1, 2], id=1)) 7 | print(f"1 + 2 = {response.result}") 8 | 9 | result = client('sum', a=1, b=2) 10 | print(f"1 + 2 = {result}") 11 | 12 | result = client.proxy.sum(1, 2) 13 | print(f"1 + 2 = {result}") 14 | 15 | client.notify('tick') 16 | -------------------------------------------------------------------------------- /docs/source/pjrpc/api/common.rst: -------------------------------------------------------------------------------- 1 | .. _api_common: 2 | 3 | Common 4 | ------ 5 | 6 | Misc 7 | ~~~~ 8 | 9 | .. automodule:: pjrpc.common 10 | :members: 11 | 12 | 13 | Types 14 | ~~~~~ 15 | 16 | .. automodule:: pjrpc.common.typedefs 17 | :members: 18 | 19 | 20 | Exceptions 21 | ~~~~~~~~~~ 22 | 23 | .. automodule:: pjrpc.common.exceptions 24 | :members: 25 | 26 | Identifier generators 27 | ~~~~~~~~~~~~~~~~~~~~~ 28 | 29 | .. automodule:: pjrpc.common.generators 30 | :members: 31 | -------------------------------------------------------------------------------- /pjrpc/client/integrations/pytest_aiohttp.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | from typing import Generator 3 | 4 | import pytest 5 | 6 | from .pytest import PjRpcMocker 7 | 8 | PjRpcAiohttpMocker = ft.partial(PjRpcMocker, target='pjrpc.client.backend.aiohttp.Client._request') 9 | 10 | 11 | @pytest.fixture 12 | def pjrpc_aiohttp_mocker() -> Generator[PjRpcMocker, None, None]: 13 | """ 14 | Aiohttp client mocking fixture. 15 | """ 16 | 17 | with PjRpcAiohttpMocker() as mocker: 18 | yield mocker 19 | -------------------------------------------------------------------------------- /pjrpc/client/integrations/pytest_requests.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | from typing import Generator 3 | 4 | import pytest 5 | 6 | from .pytest import PjRpcMocker 7 | 8 | PjRpcRequestsMocker = ft.partial(PjRpcMocker, target='pjrpc.client.backend.requests.Client._request') 9 | 10 | 11 | @pytest.fixture 12 | def pjrpc_requests_mocker() -> Generator[PjRpcMocker, None, None]: 13 | """ 14 | Requests client mocking fixture. 15 | """ 16 | 17 | with PjRpcRequestsMocker() as mocker: 18 | yield mocker 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | init: 3 | pip install poetry --upgrade 4 | # Updates poetry.lock in case pyproject.toml was updated for install: 5 | poetry update 6 | poetry install --no-root --extras test 7 | 8 | export PYTHONWARNINGS=ignore::DeprecationWarning 9 | test: 10 | poetry run py.test 11 | 12 | coverage: 13 | poetry run py.test --verbose --cov-report term --cov=pjrpc tests 14 | 15 | check-code: 16 | pre-commit run --all-file 17 | 18 | docs: 19 | cd docs && make html 20 | 21 | .PHONY: docs init test coverage publish check-code 22 | -------------------------------------------------------------------------------- /pjrpc/common/typedefs.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | __all__ = [ 4 | 'JsonRpcParamsT', 5 | 'JsonRpcRequestIdT', 6 | 'JsonT', 7 | ] 8 | 9 | JsonT = Union[str, int, float, bool, None, list['JsonT'], tuple['JsonT', ...], dict[str, 'JsonT']] 10 | '''JSON type''' # for sphinx autodoc 11 | 12 | JsonRpcRequestIdT = Union[str, int] 13 | '''JSON-RPC identifier type''' # for sphinx autodoc 14 | 15 | JsonRpcParamsT = Union[list[JsonT], tuple[JsonT, ...], dict[str, JsonT]] 16 | '''JSON-RPC parameters type''' # for sphinx autodoc 17 | -------------------------------------------------------------------------------- /pjrpc/client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON-RPC client. 3 | """ 4 | 5 | from . import exceptions, validators 6 | from .client import AbstractAsyncClient, AbstractClient, AsyncMiddleware, AsyncMiddlewareHandler, Batch, Middleware 7 | from .client import MiddlewareHandler 8 | from .exceptions import JsonRpcError 9 | 10 | __all__ = [ 11 | 'AbstractAsyncClient', 12 | 'AbstractClient', 13 | 'AsyncMiddleware', 14 | 'AsyncMiddlewareHandler', 15 | 'Batch', 16 | 'exceptions', 17 | 'JsonRpcError', 18 | 'Middleware', 19 | 'MiddlewareHandler', 20 | 'validators', 21 | ] 22 | -------------------------------------------------------------------------------- /pjrpc/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extensible `JSON-RPC `_ client/server library. 3 | """ 4 | 5 | from pjrpc.common import AbstractRequest, AbstractResponse, BatchRequest, BatchResponse, JSONEncoder, Request, Response 6 | from pjrpc.common import exceptions, typedefs 7 | 8 | exc = exceptions 9 | 10 | __all__ = [ 11 | 'AbstractRequest', 12 | 'AbstractResponse', 13 | 'exceptions', 14 | 'exc', 15 | 'typedefs', 16 | 'Request', 17 | 'Response', 18 | 'BatchRequest', 19 | 'Response', 20 | 'Response', 21 | 'BatchResponse', 22 | 'JSONEncoder', 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: '3.x' 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip poetry 20 | poetry install 21 | - name: Build and publish 22 | run: | 23 | poetry build 24 | poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }} 25 | -------------------------------------------------------------------------------- /examples/aiohttp_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pjrpc 4 | from pjrpc.client.backend import aiohttp as pjrpc_client 5 | 6 | 7 | async def main(): 8 | async with pjrpc_client.Client('http://localhost:8080/api/v1') as client: 9 | response = await client.send(pjrpc.Request('sum', params=[1, 2], id=1)) 10 | print(f"1 + 2 = {response.result}") 11 | 12 | result = await client('sum', a=1, b=2) 13 | print(f"1 + 2 = {result}") 14 | 15 | result = await client.proxy.sum(1, 2) 16 | print(f"1 + 2 = {result}") 17 | 18 | await client.notify('ping') 19 | 20 | 21 | asyncio.run(main()) 22 | -------------------------------------------------------------------------------- /examples/werkzeug_server.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import werkzeug 4 | 5 | import pjrpc.server 6 | from pjrpc.server.integration import werkzeug as integration 7 | 8 | methods = pjrpc.server.MethodRegistry() 9 | 10 | 11 | @methods.add(pass_context='request') 12 | def add_user(request: werkzeug.Request, user: dict): 13 | user_id = uuid.uuid4().hex 14 | request.environ['app'].users[user_id] = user 15 | 16 | return {'id': user_id, **user} 17 | 18 | 19 | app = integration.JsonRPC('/api/v1') 20 | app.dispatcher.add_methods(methods) 21 | app.users = {} 22 | 23 | 24 | if __name__ == '__main__': 25 | werkzeug.serving.run_simple('127.0.0.1', 8080, app) 26 | -------------------------------------------------------------------------------- /docs/source/pjrpc/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | This part of the documentation covers the installation of ``pjrpc`` library. 7 | 8 | 9 | Installation using pip 10 | ------------------------ 11 | 12 | To install ``pjrpc``, run: 13 | 14 | .. code-block:: console 15 | 16 | $ pip install pjrpc 17 | 18 | Installation from source code 19 | ----------------------------- 20 | 21 | You can clone the repository: 22 | 23 | .. code-block:: console 24 | 25 | $ git clone git@github.com:dapper91/pjrpc.git 26 | 27 | Then install it: 28 | 29 | .. code-block:: console 30 | 31 | $ cd pjrpc 32 | $ pip install . 33 | -------------------------------------------------------------------------------- /pjrpc/common/common.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Any, Literal, TypeVar, Union 3 | 4 | 5 | class UnsetType(enum.Enum): 6 | """ 7 | `Sentinel `_ object. 8 | Used to distinct unset (missing) values from ``None`` ones. 9 | """ 10 | 11 | UNSET = "UNSET" 12 | 13 | def __bool__(self) -> Literal[False]: 14 | return False 15 | 16 | def __repr__(self) -> str: 17 | return "UNSET" 18 | 19 | def __str__(self) -> str: 20 | return repr(self) 21 | 22 | 23 | UNSET: UnsetType = UnsetType.UNSET 24 | 25 | MaybeSetType = TypeVar('MaybeSetType') 26 | MaybeSet = Union[UnsetType, MaybeSetType] 27 | 28 | JsonT = Any 29 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /pjrpc/common/encoder.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from . import exceptions 5 | from .request import BatchRequest, Request 6 | from .response import BatchResponse, Response 7 | 8 | 9 | class JSONEncoder(json.JSONEncoder): 10 | """ 11 | Library default JSON encoder. Encodes request, response and error objects to be json serializable. 12 | All custom encoders should be inherited from it. 13 | """ 14 | 15 | def default(self, o: Any) -> Any: 16 | if isinstance( 17 | o, ( 18 | Response, Request, 19 | BatchResponse, BatchRequest, 20 | exceptions.JsonRpcError, 21 | ), 22 | ): 23 | return o.to_json() 24 | 25 | return super().default(o) 26 | -------------------------------------------------------------------------------- /tests/server/conftest.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from typing import Callable, Optional 3 | 4 | import pytest 5 | 6 | THIS_DIR = pathlib.Path(__file__).parent 7 | 8 | 9 | @pytest.fixture 10 | def dyn_method(request): 11 | signature = request.param 12 | context = globals().copy() 13 | exec(f"def dynamic_method({signature}): pass", context) 14 | 15 | return context['dynamic_method'] 16 | 17 | 18 | @pytest.fixture(scope='session') 19 | def resources(): 20 | def getter(name: str, loader: Optional[Callable] = None) -> str: 21 | resource_file = THIS_DIR / 'resources' / name 22 | data = resource_file.read_text() 23 | if loader: 24 | return loader(data) 25 | else: 26 | return data 27 | 28 | return getter 29 | -------------------------------------------------------------------------------- /examples/aio_pika_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from yarl import URL 4 | 5 | import pjrpc 6 | from pjrpc.client.backend import aio_pika as pjrpc_client 7 | 8 | 9 | async def main(): 10 | async with pjrpc_client.Client( 11 | broker_url=URL('amqp://guest:guest@localhost:5672/v1'), 12 | routing_key='math-service', 13 | ) as client: 14 | response: pjrpc.Response = await client.send(pjrpc.Request('sum', params=[1, 2], id=1)) 15 | print(f"1 + 2 = {response.result}") 16 | 17 | result = await client('sum', a=1, b=2) 18 | print(f"1 + 2 = {result}") 19 | 20 | result = await client.proxy.sum(1, 2) 21 | print(f"1 + 2 = {result}") 22 | 23 | await client.notify('ping') 24 | 25 | 26 | if __name__ == "__main__": 27 | asyncio.run(main()) 28 | -------------------------------------------------------------------------------- /pjrpc/server/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON-RPC server package. 3 | """ 4 | 5 | from . import exceptions, typedefs 6 | from .dispatcher import AsyncDispatcher, BaseDispatcher, Dispatcher, JSONEncoder, Method, MethodRegistry 7 | from .exceptions import JsonRpcError 8 | from .typedefs import AsyncHandlerType, AsyncMiddlewareType, HandlerType, MiddlewareType 9 | from .utils import exclude_named_param, exclude_positional_param 10 | 11 | __all__ = [ 12 | 'AsyncDispatcher', 13 | 'AsyncHandlerType', 14 | 'AsyncMiddlewareType', 15 | 'BaseDispatcher', 16 | 'Dispatcher', 17 | 'exceptions', 18 | 'exclude_named_param', 19 | 'exclude_positional_param', 20 | 'HandlerType', 21 | 'JSONEncoder', 22 | 'JsonRpcError', 23 | 'Method', 24 | 'MethodRegistry', 25 | 'MiddlewareType', 26 | 'typedefs', 27 | ] 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /examples/multiple_clients.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import pjrpc 4 | from pjrpc.client.backend import requests as jrpc_client 5 | 6 | 7 | class ErrorV1(pjrpc.client.exceptions.TypedError, base=True): 8 | pass 9 | 10 | 11 | class PermissionDenied(ErrorV1): 12 | CODE: ClassVar[int] = 1 13 | MESSAGE: ClassVar[str] = 'permission denied' 14 | 15 | 16 | class ErrorV2(pjrpc.client.exceptions.TypedError, base=True): 17 | pass 18 | 19 | 20 | class ResourceNotFound(ErrorV2): 21 | CODE: ClassVar[int] = 1 22 | MESSAGE: ClassVar[str] = 'resource not found' 23 | 24 | 25 | client_v1 = jrpc_client.Client('http://localhost:8080/api/v1', error_cls=ErrorV1) 26 | client_v2 = jrpc_client.Client('http://localhost:8080/api/v2', error_cls=ErrorV2) 27 | 28 | try: 29 | client_v1.proxy.add_user(user={}) 30 | except PermissionDenied as e: 31 | print(e) 32 | 33 | try: 34 | client_v2.proxy.add_user(user={}) 35 | except ResourceNotFound as e: 36 | print(e) 37 | -------------------------------------------------------------------------------- /examples/sentry.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | from aiohttp import web 3 | 4 | import pjrpc.server 5 | from pjrpc.common import Request, Response 6 | from pjrpc.server import AsyncHandlerType 7 | from pjrpc.server.integration import aiohttp 8 | 9 | methods = pjrpc.server.MethodRegistry() 10 | 11 | 12 | @methods.add(pass_context='request') 13 | async def sum(request: web.Request, a: int, b: int) -> int: 14 | return a + b 15 | 16 | 17 | async def sentry_middleware(request: Request, context: web.Request, handler: AsyncHandlerType) -> Response: 18 | try: 19 | return await handler(request, context) 20 | except pjrpc.server.exceptions.JsonRpcError as e: 21 | sentry_sdk.capture_exception(e) 22 | raise 23 | 24 | 25 | jsonrpc_app = aiohttp.Application( 26 | '/api/v1', middlewares=( 27 | sentry_middleware, 28 | ), 29 | ) 30 | jsonrpc_app.add_methods(methods) 31 | 32 | if __name__ == "__main__": 33 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 34 | -------------------------------------------------------------------------------- /docs/source/pjrpc/api/client.rst: -------------------------------------------------------------------------------- 1 | .. _api_client: 2 | 3 | Client 4 | ------ 5 | 6 | Misc 7 | ~~~~ 8 | 9 | .. automodule:: pjrpc.client 10 | :members: 11 | 12 | 13 | Backends 14 | ~~~~~~~~ 15 | 16 | requests 17 | ________ 18 | 19 | .. automodule:: pjrpc.client.backend.requests 20 | :members: 21 | 22 | httpx 23 | _____ 24 | 25 | .. automodule:: pjrpc.client.backend.httpx 26 | :members: 27 | 28 | aiohttp 29 | ________ 30 | 31 | .. automodule:: pjrpc.client.backend.aiohttp 32 | :members: 33 | 34 | aio-pika 35 | ________ 36 | 37 | .. automodule:: pjrpc.client.backend.aio_pika 38 | :members: 39 | 40 | 41 | Retry 42 | ~~~~~ 43 | 44 | .. automodule:: pjrpc.client.retry 45 | :members: 46 | 47 | 48 | Integrations 49 | ~~~~~~~~~~~~ 50 | 51 | .. automodule:: pjrpc.client.integrations.pytest 52 | :members: 53 | 54 | .. automodule:: pjrpc.client.integrations.pytest_aiohttp 55 | :members: 56 | 57 | .. automodule:: pjrpc.client.integrations.pytest_requests 58 | :members: 59 | -------------------------------------------------------------------------------- /examples/aiohttp_server_pytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp import web 3 | 4 | import pjrpc.server 5 | from pjrpc.client.backend import aiohttp as async_client 6 | from pjrpc.server.integration import aiohttp as integration 7 | 8 | methods = pjrpc.server.MethodRegistry() 9 | 10 | 11 | @methods.add() 12 | async def div(a: int, b: int) -> float: 13 | return a / b 14 | 15 | 16 | @pytest.fixture 17 | def http_app(): 18 | return web.Application() 19 | 20 | 21 | @pytest.fixture 22 | def jsonrpc_app(http_app): 23 | jsonrpc_app = integration.Application('/api/v1', http_app=http_app) 24 | jsonrpc_app.add_methods(methods) 25 | 26 | return jsonrpc_app 27 | 28 | 29 | async def test_pjrpc_server(aiohttp_client, http_app, jsonrpc_app): 30 | jsonrpc_cli = async_client.Client('/api/v1', session=await aiohttp_client(http_app)) 31 | 32 | result = await jsonrpc_cli.proxy.div(4, 2) 33 | assert result == 2 34 | 35 | result = await jsonrpc_cli.proxy.div(6, 2) 36 | assert result == 3 37 | -------------------------------------------------------------------------------- /examples/aio_pika_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import aio_pika 5 | from yarl import URL 6 | 7 | import pjrpc 8 | from pjrpc.server.integration import aio_pika as integration 9 | 10 | methods = pjrpc.server.MethodRegistry() 11 | 12 | 13 | @methods.add(pass_context='message') 14 | def sum(message: aio_pika.IncomingMessage, a: int, b: int) -> int: 15 | return a + b 16 | 17 | 18 | @methods.add(pass_context=True) 19 | def sub(context: aio_pika.IncomingMessage, a: int, b: int) -> int: 20 | return a - b 21 | 22 | 23 | @methods.add() 24 | async def ping() -> None: 25 | logging.info("ping") 26 | 27 | 28 | executor = integration.Executor(URL('amqp://guest:guest@localhost:5672/v1'), request_queue_name='math-service') 29 | executor.dispatcher.add_methods(methods) 30 | 31 | if __name__ == "__main__": 32 | logging.basicConfig(level=logging.INFO) 33 | loop = asyncio.get_event_loop() 34 | 35 | loop.run_until_complete(executor.start()) 36 | try: 37 | loop.run_forever() 38 | finally: 39 | loop.run_until_complete(executor.shutdown()) 40 | -------------------------------------------------------------------------------- /examples/aiohttp_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiohttp import web 4 | 5 | import pjrpc.server 6 | from pjrpc.server.integration import aiohttp 7 | 8 | methods = pjrpc.server.MethodRegistry() 9 | 10 | 11 | @methods.add(pass_context='request') 12 | async def sum(request: web.Request, a: int, b: int) -> int: 13 | return a + b 14 | 15 | 16 | @methods.add(pass_context='request') 17 | async def sub(request: web.Request, a: int, b: int) -> int: 18 | return a - b 19 | 20 | 21 | @methods.add(pass_context='request') 22 | async def div(request: web.Request, a: int, b: int) -> float: 23 | return a / b 24 | 25 | 26 | @methods.add(pass_context='request') 27 | async def mult(request: web.Request, a: int, b: int) -> int: 28 | return a * b 29 | 30 | 31 | @methods.add() 32 | async def ping() -> None: 33 | logging.info("ping") 34 | 35 | 36 | jsonrpc_app = aiohttp.Application('/api/v1') 37 | jsonrpc_app.add_methods(methods) 38 | 39 | if __name__ == "__main__": 40 | logging.basicConfig(level=logging.INFO) 41 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 42 | -------------------------------------------------------------------------------- /tests/client/test_client_middleware.py: -------------------------------------------------------------------------------- 1 | from pjrpc import client 2 | from pjrpc.common import Response 3 | 4 | 5 | def test_request_middleware(mocker): 6 | middleware_mock = mocker.stub() 7 | 8 | class Client(client.AbstractClient): 9 | def _request(self, *args, **kwargs): 10 | pass 11 | 12 | def middleware(request, request_kwargs, /, handler): 13 | middleware_mock() 14 | return Response(result=None) 15 | 16 | cli = Client(middlewares=[middleware]) 17 | cli.call("test") 18 | 19 | assert middleware_mock.call_count == 1 20 | 21 | 22 | async def test_async_request_middleware(mocker): 23 | middleware_mock = mocker.stub() 24 | 25 | class Client(client.AbstractAsyncClient): 26 | async def _request(self, *args, **kwargs): 27 | pass 28 | 29 | async def middleware(request, request_kwargs, /, handler): 30 | middleware_mock() 31 | return Response(result=None) 32 | 33 | cli = Client(middlewares=[middleware]) 34 | await cli.call("test") 35 | 36 | assert middleware_mock.call_count == 1 37 | -------------------------------------------------------------------------------- /examples/aiohttp_client_retry.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | 4 | import aiohttp 5 | 6 | import pjrpc 7 | from pjrpc.client.backend import aiohttp as pjrpc_client 8 | from pjrpc.client.retry import AsyncRetryMiddleware, ExponentialBackoff, RetryStrategy 9 | 10 | 11 | async def main(): 12 | default_retry_strategy = RetryStrategy( 13 | exceptions={TimeoutError}, 14 | backoff=ExponentialBackoff(attempts=3, base=1.0, factor=2.0, jitter=lambda n: random.gauss(mu=0.5, sigma=0.1)), 15 | ) 16 | 17 | async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=0.2)) as session: 18 | async with pjrpc_client.Client( 19 | 'http://localhost:8080/api/v1', 20 | session=session, 21 | middlewares=[AsyncRetryMiddleware(default_retry_strategy)], 22 | ) as client: 23 | response = await client.send(pjrpc.Request('sum', params=[1, 2], id=1)) 24 | print(f"1 + 2 = {response.result}") 25 | 26 | result = await client.proxy.sum(1, 2) 27 | print(f"1 + 2 = {result}") 28 | 29 | 30 | asyncio.run(main()) 31 | -------------------------------------------------------------------------------- /examples/flask_versioning.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import flask 4 | 5 | import pjrpc.server 6 | from pjrpc.server.integration import flask as integration 7 | 8 | methods_v1 = pjrpc.server.MethodRegistry() 9 | 10 | 11 | @methods_v1.add(name="add_user") 12 | def add_user_v1(user: dict): 13 | user_id = uuid.uuid4().hex 14 | flask.current_app.users[user_id] = user 15 | 16 | return {'id': user_id, **user} 17 | 18 | 19 | methods_v2 = pjrpc.server.MethodRegistry() 20 | 21 | 22 | @methods_v2.add(name="add_user") 23 | def add_user_v2(user: dict): 24 | user_id = uuid.uuid4().hex 25 | flask.current_app.users[user_id] = user 26 | 27 | return {'id': user_id, **user} 28 | 29 | 30 | json_rpc = integration.JsonRPC('/api') 31 | json_rpc.http_app.users = {} 32 | 33 | json_rpc_v1 = integration.JsonRPC(http_app=flask.Blueprint("v1", __name__)) 34 | json_rpc_v1.add_methods(methods_v1) 35 | json_rpc.add_subapp('/v1', json_rpc_v1) 36 | 37 | json_rpc_v2 = integration.JsonRPC(http_app=flask.Blueprint("v2", __name__)) 38 | json_rpc_v2.add_methods(methods_v2) 39 | json_rpc.add_subapp('/v2', json_rpc_v2) 40 | 41 | 42 | if __name__ == "__main__": 43 | json_rpc.http_app.run(port=8080) 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | - master 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install poetry 28 | poetry install --no-root --all-extras 29 | - name: Run pre-commit hooks 30 | run: poetry run pre-commit run --hook-stage merge-commit --all-files 31 | - name: Run tests 32 | run: PYTHONPATH="$(pwd):$PYTHONPATH" poetry run py.test --cov=pjrpc --cov-report=xml tests 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v1 35 | with: 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | files: ./coverage.xml 38 | flags: unittests 39 | fail_ci_if_error: true 40 | -------------------------------------------------------------------------------- /examples/aiohttp_versioning.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | 4 | from aiohttp import web 5 | 6 | import pjrpc.server 7 | from pjrpc.server.integration import aiohttp 8 | 9 | methods_v1 = pjrpc.server.MethodRegistry() 10 | 11 | 12 | @methods_v1.add(name="add_user", pass_context='request') 13 | async def add_user_v1(request: web.Request, user: dict) -> dict: 14 | user_id = uuid.uuid4().hex 15 | request.config_dict['users'][user_id] = user 16 | 17 | return {'id': user_id, **user} 18 | 19 | 20 | methods_v2 = pjrpc.server.MethodRegistry() 21 | 22 | 23 | @methods_v2.add(name="add_user", pass_context='request') 24 | async def add_user_v2(request: web.Request, user: dict) -> dict: 25 | user_id = uuid.uuid4().hex 26 | request.config_dict['users'][user_id] = user 27 | 28 | return {'id': user_id, **user} 29 | 30 | 31 | app = web.Application() 32 | app['users'] = {} 33 | 34 | app_v1 = aiohttp.Application() 35 | app_v1.add_methods(methods_v1) 36 | app.add_subapp('/api/v1', app_v1.http_app) 37 | 38 | 39 | app_v2 = aiohttp.Application() 40 | app_v2.add_methods(methods_v2) 41 | app.add_subapp('/api/v2', app_v2.http_app) 42 | 43 | if __name__ == "__main__": 44 | logging.basicConfig(level=logging.INFO) 45 | web.run_app(app, host='localhost', port=8080) 46 | -------------------------------------------------------------------------------- /examples/middlewares.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | import pjrpc.server 4 | from pjrpc.common import Request 5 | from pjrpc.server.integration import aiohttp 6 | from pjrpc.server.typedefs import AsyncHandlerType, ContextType, MiddlewareResponse 7 | 8 | methods = pjrpc.server.MethodRegistry() 9 | 10 | 11 | @methods.add(pass_context='request') 12 | async def sum(request: web.Request, a: int, b: int) -> int: 13 | return a + b 14 | 15 | 16 | async def middleware1(request: Request, context: ContextType, handler: AsyncHandlerType) -> MiddlewareResponse: 17 | print("middleware1 started") 18 | result = await handler(request, context) 19 | print("middleware1 finished") 20 | 21 | return result 22 | 23 | 24 | async def middleware2(request: Request, context: ContextType, handler: AsyncHandlerType) -> MiddlewareResponse: 25 | print("middleware2 started") 26 | result = await handler(request, context) 27 | print("middleware2 finished") 28 | 29 | return result 30 | 31 | jsonrpc_app = aiohttp.Application( 32 | '/api/v1', 33 | middlewares=[ 34 | middleware1, 35 | middleware2, 36 | ], 37 | ) 38 | jsonrpc_app.add_methods(methods) 39 | 40 | if __name__ == "__main__": 41 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /pjrpc/common/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client and server common functions, types and classes that implements JSON-RPC protocol itself 3 | and agnostic to any transport protocol layer (http, socket, amqp) and server-side implementation. 4 | """ 5 | 6 | from . import exceptions, generators, typedefs 7 | from .common import UNSET, JsonT, MaybeSet, UnsetType 8 | from .encoder import JSONEncoder 9 | from .exceptions import JsonRpcError 10 | from .request import AbstractRequest, BatchRequest, Request 11 | from .response import AbstractResponse, BatchResponse, Response 12 | 13 | DEFAULT_CONTENT_TYPE = 'application/json' 14 | '''default JSON-RPC client/server content type''' # for sphinx autodoc 15 | 16 | REQUEST_CONTENT_TYPES = ('application/json', 'application/json-rpc', 'application/jsonrequest') 17 | '''allowed JSON-RPC server requests content types''' # for sphinx autodoc 18 | 19 | RESPONSE_CONTENT_TYPES = ('application/json', 'application/json-rpc') 20 | '''allowed JSON-RPC client responses content types''' # for sphinx autodoc 21 | 22 | 23 | __all__ = [ 24 | 'AbstractRequest', 25 | 'AbstractResponse', 26 | 'BatchRequest', 27 | 'BatchResponse', 28 | 'DEFAULT_CONTENT_TYPE', 29 | 'exceptions', 30 | 'generators', 31 | 'JSONEncoder', 32 | 'JsonRpcError', 33 | 'JsonT', 34 | 'MaybeSet', 35 | 'Request', 36 | 'REQUEST_CONTENT_TYPES', 37 | 'Response', 38 | 'RESPONSE_CONTENT_TYPES', 39 | 'typedefs', 40 | 'UNSET', 41 | 'UnsetType', 42 | ] 43 | -------------------------------------------------------------------------------- /pjrpc/server/specs/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import enum 3 | import json 4 | import pathlib 5 | from typing import Any, Iterable, Mapping 6 | 7 | from pjrpc.server import Method 8 | 9 | 10 | class JSONEncoder(json.JSONEncoder): 11 | """ 12 | Schema JSON encoder. 13 | """ 14 | 15 | def default(self, o: Any) -> Any: 16 | if isinstance(o, enum.Enum): 17 | return o.value 18 | 19 | return super().default(o) 20 | 21 | 22 | class BaseUI(abc.ABC): 23 | """ 24 | Base UI. 25 | """ 26 | 27 | @abc.abstractmethod 28 | def get_static_folder(self) -> pathlib.Path: 29 | """ 30 | Returns ui statics folder. 31 | """ 32 | 33 | @abc.abstractmethod 34 | def get_index_page(self, spec_url: str) -> str: 35 | """ 36 | Returns ui index webpage. 37 | 38 | :param spec_url: specification url. 39 | """ 40 | 41 | 42 | class Specification(abc.ABC): 43 | """ 44 | JSON-RPC specification. 45 | """ 46 | 47 | @abc.abstractmethod 48 | def generate(self, root_endpoint: str, methods: Mapping[str, Iterable[Method]]) -> dict[str, Any]: 49 | """ 50 | Returns specification schema. 51 | 52 | :param root_endpoint: root endpoint all the methods are served on 53 | :param methods: methods map the specification is generated for. 54 | Each item is a mapping from a endpoint to methods on which the methods will be served 55 | """ 56 | -------------------------------------------------------------------------------- /tests/client/test_generators.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import uuid 4 | 5 | import pytest 6 | 7 | from pjrpc.common import generators 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'start, step, length, result', [ 12 | (0, 1, 3, [0, 1, 2]), 13 | (1, 2, 3, [1, 3, 5]), 14 | ], 15 | ) 16 | def test_sequential(start, step, length, result): 17 | gen = generators.sequential(start, step) 18 | assert [next(gen) for _ in range(length)] == result 19 | 20 | 21 | @pytest.mark.parametrize( 22 | 'a, b, length, seed, result', [ 23 | (0, 10, 5, 1, [2, 9, 1, 4, 1]), 24 | ], 25 | ) 26 | def test_randint(a, b, length, seed, result): 27 | random.seed(seed) 28 | gen = generators.randint(a, b) 29 | assert [next(gen) for _ in range(length)] == result 30 | 31 | 32 | @pytest.mark.parametrize( 33 | 'length, chars, seed, result', [ 34 | (5, string.ascii_lowercase, 1, ['eszyc', 'idpyo', 'pumzg', 'dpamn', 'tyyaw']), 35 | (5, string.digits, 1, ['29141', '77763', '17066', '90743', '91500']), 36 | ], 37 | ) 38 | def test_random(length, chars, seed, result): 39 | random.seed(seed) 40 | gen = generators.random(length, chars) 41 | assert [next(gen) for _ in range(length)] == result 42 | 43 | 44 | def test_uuid(mocker): 45 | mocked_uuid = uuid.UUID('226a2c23-c98b-4729-b398-0dae550e99ff') 46 | mocker.patch('uuid.uuid4', return_value=mocked_uuid) 47 | 48 | gen = generators.uuid() 49 | assert [next(gen) for _ in range(2)] == [mocked_uuid] * 2 50 | -------------------------------------------------------------------------------- /docs/source/pjrpc/api/server.rst: -------------------------------------------------------------------------------- 1 | .. _api_server: 2 | 3 | 4 | Server 5 | ------ 6 | 7 | Misc 8 | ~~~~ 9 | 10 | .. automodule:: pjrpc.server 11 | :members: 12 | 13 | 14 | Types 15 | ~~~~~ 16 | 17 | .. automodule:: pjrpc.server.typedefs 18 | :members: 19 | 20 | 21 | Integrations 22 | ~~~~~~~~~~~~ 23 | 24 | aiohttp 25 | _______ 26 | 27 | .. automodule:: pjrpc.server.integration.aiohttp 28 | :members: 29 | 30 | flask 31 | _____ 32 | 33 | .. automodule:: pjrpc.server.integration.flask 34 | :members: 35 | 36 | aio_pika 37 | ________ 38 | 39 | .. automodule:: pjrpc.server.integration.aio_pika 40 | :members: 41 | 42 | 43 | werkzeug 44 | ________ 45 | 46 | .. automodule:: pjrpc.server.integration.werkzeug 47 | :members: 48 | 49 | 50 | Validators 51 | ~~~~~~~~~~ 52 | 53 | .. automodule:: pjrpc.server.validators 54 | :members: 55 | 56 | 57 | pydantic 58 | ________ 59 | 60 | .. automodule:: pjrpc.server.validators.pydantic 61 | :members: 62 | 63 | 64 | Specification 65 | ~~~~~~~~~~~~~ 66 | 67 | .. automodule:: pjrpc.server.specs 68 | :members: 69 | 70 | extractors 71 | __________ 72 | 73 | .. automodule:: pjrpc.server.specs.extractors 74 | :members: 75 | 76 | 77 | .. automodule:: pjrpc.server.specs.extractors.pydantic 78 | :members: 79 | 80 | 81 | schemas 82 | _______ 83 | 84 | openapi 85 | ....... 86 | 87 | .. automodule:: pjrpc.server.specs.openapi 88 | :members: 89 | 90 | 91 | openrpc 92 | ....... 93 | 94 | .. automodule:: pjrpc.server.specs.openrpc 95 | :members: 96 | -------------------------------------------------------------------------------- /pjrpc/common/generators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Builtin request id generators. Implements several identifier types and generation strategies. 3 | """ 4 | 5 | import itertools as it 6 | import random as _random 7 | import string 8 | import uuid as std_uuid 9 | from typing import Generator 10 | 11 | 12 | def sequential(start: int = 1, step: int = 1) -> Generator[int, None, None]: 13 | """ 14 | Sequential id generator. Returns consecutive values starting from `start` with step `step`. 15 | 16 | :param start: starting number 17 | :param step: step 18 | """ 19 | 20 | yield from it.count(start, step) 21 | 22 | 23 | def randint(a: int, b: int) -> Generator[int, None, None]: 24 | """ 25 | Random integer id generator. Returns random integers between `a` and `b`. 26 | 27 | :param a: from 28 | :param b: to 29 | """ 30 | 31 | while True: 32 | yield _random.randint(a, b) 33 | 34 | 35 | def random(length: int = 8, chars: str = string.digits + string.ascii_lowercase) -> Generator[str, None, None]: 36 | """ 37 | Random string id generator. Returns random strings of length `length` using alphabet `chars`. 38 | 39 | :param length: string length 40 | :param chars: string characters 41 | """ 42 | 43 | while True: 44 | yield ''.join((_random.choice(chars) for _ in range(length))) 45 | 46 | 47 | def uuid() -> Generator[std_uuid.UUID, None, None]: 48 | """ 49 | UUID id generator. Returns random UUIDs. 50 | """ 51 | 52 | while True: 53 | yield std_uuid.uuid4() 54 | -------------------------------------------------------------------------------- /examples/flask_server_pytest.py: -------------------------------------------------------------------------------- 1 | import flask.testing 2 | import pytest 3 | import werkzeug.test 4 | 5 | import pjrpc.server 6 | from pjrpc.client.backend import requests as client 7 | from pjrpc.client.integrations.pytest_requests import PjRpcRequestsMocker 8 | from pjrpc.server.integration import flask as integration 9 | 10 | methods = pjrpc.server.MethodRegistry() 11 | 12 | 13 | @methods.add() 14 | def div(a: int, b: int) -> float: 15 | return a / b 16 | 17 | 18 | @pytest.fixture() 19 | def http_app(): 20 | return flask.Flask(__name__) 21 | 22 | 23 | @pytest.fixture 24 | def jsonrpc_app(http_app): 25 | json_rpc = integration.JsonRPC('/api/v1', http_app=http_app) 26 | json_rpc.add_methods(methods) 27 | 28 | return jsonrpc_app 29 | 30 | 31 | class Response(werkzeug.test.Response): 32 | def raise_for_status(self): 33 | if self.status_code >= 400: 34 | raise Exception('client response error') 35 | 36 | @property 37 | def text(self): 38 | return self.data.decode() 39 | 40 | 41 | @pytest.fixture() 42 | def app_client(http_app): 43 | return flask.testing.FlaskClient(http_app, Response) 44 | 45 | 46 | def test_pjrpc_server(http_app, jsonrpc_app, app_client): 47 | with PjRpcRequestsMocker(passthrough=True) as mocker: 48 | jsonrpc_cli = client.Client('/api/v1', session=app_client) 49 | 50 | mocker.add('http://127.0.0.2:8000/api/v1', 'div', result=2) 51 | result = jsonrpc_cli.proxy.div(4, 2) 52 | assert result == 2 53 | 54 | result = jsonrpc_cli.proxy.div(6, 2) 55 | assert result == 3 56 | -------------------------------------------------------------------------------- /pjrpc/server/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Optional 2 | 3 | 4 | def remove_prefix(s: str, prefix: str) -> str: 5 | """ 6 | Removes a prefix from a string. 7 | 8 | :param s: string to be processed 9 | :param prefix: prefix to be removed 10 | :return: processed string 11 | """ 12 | 13 | if s.startswith(prefix): 14 | return s[len(prefix):] 15 | else: 16 | return s 17 | 18 | 19 | def remove_suffix(s: str, suffix: str) -> str: 20 | """ 21 | Removes a suffix from a string. 22 | 23 | :param s: string to be processed 24 | :param suffix: suffix to be removed 25 | :return: processed string 26 | """ 27 | 28 | if suffix and s.endswith(suffix): 29 | return s[0:-len(suffix)] 30 | else: 31 | return s 32 | 33 | 34 | def join_path(path: str, *paths: str) -> str: 35 | result = path 36 | for path in paths: 37 | if path: 38 | result = f'{result.rstrip("/")}/{path.lstrip("/")}' 39 | 40 | return result 41 | 42 | 43 | ExcludeFunc = Callable[[int, str, Optional[type[Any]], Optional[Any]], bool] 44 | 45 | 46 | def exclude_positional_param(param_index: int) -> ExcludeFunc: 47 | def exclude(index: int, name: str, typ: Optional[type[Any]], default: Optional[Any]) -> bool: 48 | return index == param_index 49 | 50 | return exclude 51 | 52 | 53 | def exclude_named_param(param_name: str) -> ExcludeFunc: 54 | def exclude(index: int, name: str, typ: Optional[type[Any]], default: Optional[Any]) -> bool: 55 | return name == param_name 56 | 57 | return exclude 58 | -------------------------------------------------------------------------------- /pjrpc/server/typedefs.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Protocol, TypeVar 2 | 3 | from pjrpc.common import MaybeSet, Request, Response 4 | 5 | __all__ = [ 6 | 'AsyncHandlerType', 7 | 'AsyncMiddlewareType', 8 | 'ContextType', 9 | 'HandlerType', 10 | 'MiddlewareResponse', 11 | 'MiddlewareType', 12 | ] 13 | 14 | 15 | ContextType = TypeVar('ContextType') 16 | '''Context argument for RPC methods and middlewares''' # for sphinx autodoc 17 | 18 | AsyncHandlerType = Callable[[Request, ContextType], Awaitable[MaybeSet[Response]]] 19 | '''Async RPC handler method, passed to middlewares''' # for sphinx autodoc 20 | 21 | HandlerType = Callable[[Request, ContextType], MaybeSet[Response]] 22 | '''Blocking RPC handler method, passed to middlewares''' # for sphinx autodoc 23 | 24 | MiddlewareResponse = MaybeSet[Response] 25 | '''middlewares and handlers return Response or UnsetType''' # for sphinx autodoc 26 | 27 | 28 | class AsyncMiddlewareType(Protocol[ContextType]): 29 | """ 30 | Asynchronous middleware type 31 | """ 32 | 33 | async def __call__( 34 | self, 35 | request: Request, 36 | context: ContextType, 37 | handler: AsyncHandlerType[ContextType], 38 | ) -> MaybeSet[MiddlewareResponse]: 39 | pass 40 | 41 | 42 | class MiddlewareType(Protocol[ContextType]): 43 | """ 44 | Synchronous middleware type 45 | """ 46 | 47 | async def __call__( 48 | self, 49 | request: Request, 50 | context: ContextType, 51 | handler: HandlerType[ContextType], 52 | ) -> MaybeSet[MiddlewareResponse]: 53 | pass 54 | -------------------------------------------------------------------------------- /examples/pydantic_validator.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import uuid 3 | 4 | import pydantic 5 | from aiohttp import web 6 | 7 | import pjrpc.server 8 | from pjrpc.server.integration import aiohttp 9 | from pjrpc.server.validators import pydantic as validators 10 | 11 | methods = pjrpc.server.MethodRegistry( 12 | validator_factory=validators.PydanticValidatorFactory(exclude=aiohttp.is_aiohttp_request), 13 | ) 14 | 15 | 16 | class ContactType(enum.Enum): 17 | PHONE = 'phone' 18 | EMAIL = 'email' 19 | 20 | 21 | class Contact(pydantic.BaseModel): 22 | type: ContactType 23 | value: str 24 | 25 | 26 | class User(pydantic.BaseModel): 27 | name: str 28 | surname: str 29 | age: int 30 | contacts: list[Contact] 31 | 32 | 33 | class UserOut(User): 34 | id: uuid.UUID 35 | 36 | 37 | @methods.add(pass_context='request') 38 | async def add_user(request: web.Request, user: User) -> UserOut: 39 | user_id = uuid.uuid4() 40 | request.app['users'][user_id] = user 41 | 42 | return UserOut(id=user_id, **user.model_dump()) 43 | 44 | 45 | class JSONEncoder(pjrpc.server.JSONEncoder): 46 | def default(self, o): 47 | if isinstance(o, uuid.UUID): 48 | return o.hex 49 | if isinstance(o, enum.Enum): 50 | return o.value 51 | if isinstance(o, pydantic.BaseModel): 52 | return o.model_dump() 53 | 54 | return super().default(o) 55 | 56 | 57 | jsonrpc_app = aiohttp.Application('/api/v1', json_encoder=JSONEncoder) 58 | jsonrpc_app.add_methods(methods) 59 | jsonrpc_app.http_app['users'] = {} 60 | 61 | if __name__ == "__main__": 62 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 63 | -------------------------------------------------------------------------------- /examples/client_prometheus_metrics.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Mapping, Optional 3 | 4 | import prometheus_client as prom_cli 5 | 6 | from pjrpc import AbstractRequest, AbstractResponse, BatchRequest, Request 7 | from pjrpc.client import MiddlewareHandler 8 | from pjrpc.client.backend import requests as pjrpc_client 9 | 10 | method_latency_hist = prom_cli.Histogram('method_latency', 'Method latency', labelnames=['method']) 11 | method_call_total = prom_cli.Counter('method_call_total', 'Method call count', labelnames=['method']) 12 | method_errors_total = prom_cli.Counter('method_errors_total', 'Method errors count', labelnames=['method', 'code']) 13 | 14 | 15 | def prometheus_tracing_middleware( 16 | request: AbstractRequest, 17 | request_kwargs: Mapping[str, Any], 18 | /, 19 | handler: MiddlewareHandler, 20 | ) -> Optional[AbstractResponse]: 21 | if isinstance(request, Request): 22 | started_at = time.time() 23 | method_call_total.labels(request.method).inc() 24 | response = handler(request, request_kwargs) 25 | if response.is_error: 26 | method_call_total.labels(request.method, response.unwrap_error().code).inc() 27 | 28 | method_latency_hist.labels(request.method).observe(time.time() - started_at) 29 | 30 | elif isinstance(request, BatchRequest): 31 | response = handler(request, request_kwargs) 32 | 33 | else: 34 | raise AssertionError("unreachable") 35 | 36 | return response 37 | 38 | 39 | client = pjrpc_client.Client( 40 | 'http://localhost:8080/api/v1', 41 | middlewares=( 42 | prometheus_tracing_middleware, 43 | ), 44 | ) 45 | 46 | result = client.proxy.sum(1, 2) 47 | -------------------------------------------------------------------------------- /examples/aiohttp_pytest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | import pjrpc 6 | from pjrpc.client.backend import aiohttp as aiohttp_client 7 | from pjrpc.client.integrations.pytest_aiohttp import PjRpcAiohttpMocker 8 | 9 | 10 | async def test_using_fixture(pjrpc_aiohttp_mocker): 11 | client = aiohttp_client.Client('http://localhost/api/v1') 12 | 13 | pjrpc_aiohttp_mocker.add('http://localhost/api/v1', 'sum', result=2) 14 | result = await client.proxy.sum(1, 1) 15 | assert result == 2 16 | 17 | pjrpc_aiohttp_mocker.replace( 18 | 'http://localhost/api/v1', 19 | 'sum', 20 | error=pjrpc.client.exceptions.JsonRpcError(code=1, message='error', data='oops'), 21 | ) 22 | with pytest.raises(pjrpc.client.exceptions.JsonRpcError) as exc_info: 23 | await client.proxy.sum(a=1, b=1) 24 | 25 | assert exc_info.type is pjrpc.client.exceptions.JsonRpcError 26 | assert exc_info.value.code == 1 27 | assert exc_info.value.message == 'error' 28 | assert exc_info.value.data == 'oops' 29 | 30 | localhost_calls = pjrpc_aiohttp_mocker.calls['http://localhost/api/v1'] 31 | assert localhost_calls[('2.0', 'sum')].call_count == 2 32 | assert localhost_calls[('2.0', 'sum')].mock_calls == [mock.call(1, 1), mock.call(a=1, b=1)] 33 | 34 | 35 | async def test_using_resource_manager(): 36 | client = aiohttp_client.Client('http://localhost/api/v1') 37 | 38 | with PjRpcAiohttpMocker() as mocker: 39 | mocker.add('http://localhost/api/v1', 'div', result=2) 40 | result = await client.proxy.div(4, 2) 41 | assert result == 2 42 | 43 | localhost_calls = mocker.calls['http://localhost/api/v1'] 44 | assert localhost_calls[('2.0', 'div')].mock_calls == [mock.call(4, 2)] 45 | -------------------------------------------------------------------------------- /examples/aiohttp_client_batch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pjrpc 4 | from pjrpc.client.backend import aiohttp as pjrpc_client 5 | 6 | 7 | async def main(): 8 | async with pjrpc_client.Client('http://localhost:8080/api/v1') as client: 9 | async with client.batch() as batch: 10 | batch.send(pjrpc.Request('sum', [2, 2], id=1)) 11 | batch.send(pjrpc.Request('sub', [2, 2], id=2)) 12 | batch.send(pjrpc.Request('div', [2, 2], id=3)) 13 | batch.send(pjrpc.Request('mult', [2, 2], id=4)) 14 | 15 | response = batch.get_response() 16 | print(f"2 + 2 = {response[0].result}") 17 | print(f"2 - 2 = {response[1].result}") 18 | print(f"2 / 2 = {response[2].result}") 19 | print(f"2 * 2 = {response[3].result}") 20 | 21 | async with client.batch() as batch: 22 | batch('sum', 2, 2) 23 | batch('sub', 2, 2) 24 | batch('div', 2, 2) 25 | batch('mult', 2, 2) 26 | 27 | result = batch.get_results() 28 | print(f"2 + 2 = {result[0]}") 29 | print(f"2 - 2 = {result[1]}") 30 | print(f"2 / 2 = {result[2]}") 31 | print(f"2 * 2 = {result[3]}") 32 | 33 | async with client.batch() as batch: 34 | batch.proxy.sum(2, 2) 35 | batch.proxy.sub(2, 2) 36 | batch.proxy.div(2, 2) 37 | batch.proxy.mult(2, 2) 38 | 39 | result = batch.get_results() 40 | print(f"2 + 2 = {result[0]}") 41 | print(f"2 - 2 = {result[1]}") 42 | print(f"2 / 2 = {result[2]}") 43 | print(f"2 * 2 = {result[3]}") 44 | 45 | async with client.batch() as batch: 46 | batch.notify('tick') 47 | batch.notify('tack') 48 | 49 | 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .idea 107 | 108 | poetry.lock 109 | -------------------------------------------------------------------------------- /tests/client/test_client_response.py: -------------------------------------------------------------------------------- 1 | from pjrpc.client import exceptions 2 | from pjrpc.common import BatchResponse, Response 3 | 4 | 5 | def test_response_error_serialization(): 6 | response = Response(error=exceptions.MethodNotFoundError()) 7 | actual_dict = response.to_json() 8 | expected_dict = { 9 | 'jsonrpc': '2.0', 10 | 'id': None, 11 | 'error': { 12 | 'code': -32601, 13 | 'message': 'Method not found', 14 | }, 15 | } 16 | 17 | assert actual_dict == expected_dict 18 | 19 | 20 | def test_batch_response_error_serialization(): 21 | response = BatchResponse(error=exceptions.MethodNotFoundError()) 22 | actual_dict = response.to_json() 23 | expected_dict = { 24 | 'jsonrpc': '2.0', 25 | 'id': None, 26 | 'error': { 27 | 'code': -32601, 28 | 'message': 'Method not found', 29 | }, 30 | } 31 | 32 | assert actual_dict == expected_dict 33 | 34 | 35 | def test_response_error_deserialization(): 36 | data = { 37 | 'jsonrpc': '2.0', 38 | 'id': None, 39 | 'error': { 40 | 'code': -32601, 41 | 'message': 'Method not found', 42 | }, 43 | } 44 | response = Response.from_json(data, error_cls=exceptions.JsonRpcError) 45 | 46 | assert response.is_error 47 | assert response.error == exceptions.MethodNotFoundError() 48 | 49 | 50 | def test_batch_response_error_deserialization(): 51 | data = { 52 | 'jsonrpc': '2.0', 53 | 'id': None, 54 | 'error': { 55 | 'code': -32601, 56 | 'message': 'Method not found', 57 | }, 58 | } 59 | response = BatchResponse.from_json(data, error_cls=exceptions.JsonRpcError) 60 | 61 | assert response.is_error 62 | assert response.error == exceptions.MethodNotFoundError() 63 | -------------------------------------------------------------------------------- /tests/server/test_server_response.py: -------------------------------------------------------------------------------- 1 | from pjrpc.common import BatchResponse, Response 2 | from pjrpc.server import exceptions 3 | 4 | 5 | def test_response_error_serialization(): 6 | response = Response(error=exceptions.MethodNotFoundError()) 7 | actual_dict = response.to_json() 8 | expected_dict = { 9 | 'jsonrpc': '2.0', 10 | 'id': None, 11 | 'error': { 12 | 'code': -32601, 13 | 'message': 'Method not found', 14 | }, 15 | } 16 | 17 | assert actual_dict == expected_dict 18 | 19 | 20 | def test_batch_response_error_serialization(): 21 | response = BatchResponse(error=exceptions.MethodNotFoundError()) 22 | actual_dict = response.to_json() 23 | expected_dict = { 24 | 'jsonrpc': '2.0', 25 | 'id': None, 26 | 'error': { 27 | 'code': -32601, 28 | 'message': 'Method not found', 29 | }, 30 | } 31 | 32 | assert actual_dict == expected_dict 33 | 34 | 35 | def test_response_error_deserialization(): 36 | data = { 37 | 'jsonrpc': '2.0', 38 | 'id': None, 39 | 'error': { 40 | 'code': -32601, 41 | 'message': 'Method not found', 42 | }, 43 | } 44 | response = Response.from_json(data, error_cls=exceptions.JsonRpcError) 45 | 46 | assert response.is_error 47 | assert response.error == exceptions.MethodNotFoundError() 48 | 49 | 50 | def test_batch_response_error_deserialization(): 51 | data = { 52 | 'jsonrpc': '2.0', 53 | 'id': None, 54 | 'error': { 55 | 'code': -32601, 56 | 'message': 'Method not found', 57 | }, 58 | } 59 | response = BatchResponse.from_json(data, error_cls=exceptions.JsonRpcError) 60 | 61 | assert response.is_error 62 | assert response.error == exceptions.MethodNotFoundError() 63 | -------------------------------------------------------------------------------- /examples/client_tracing.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Mapping, Optional 2 | 3 | import opentracing 4 | from opentracing import propagation, tags 5 | 6 | from pjrpc.client import MiddlewareHandler 7 | from pjrpc.client.backend import requests as pjrpc_client 8 | from pjrpc.common import AbstractRequest, AbstractResponse, BatchRequest, Request 9 | 10 | tracer = opentracing.global_tracer() 11 | 12 | 13 | def tracing_middleware( 14 | request: AbstractRequest, 15 | request_kwargs: Mapping[str, Any], 16 | /, 17 | handler: MiddlewareHandler, 18 | ) -> Optional[AbstractResponse]: 19 | if isinstance(request, Request): 20 | span = tracer.start_active_span(f'jsonrpc.{request.method}').span 21 | span.set_tag(tags.COMPONENT, 'pjrpc.client') 22 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_CLIENT) 23 | if http_headers := request_kwargs.get('headers', {}): 24 | tracer.inject( 25 | span_context=span, 26 | format=propagation.Format.HTTP_HEADERS, 27 | carrier=http_headers, 28 | ) 29 | 30 | response = handler(request, request_kwargs) 31 | if response.is_error: 32 | span = tracer.active_span 33 | span.set_tag(tags.ERROR, response.is_error) 34 | span.set_tag('jsonrpc.error_code', response.unwrap_error().code) 35 | span.set_tag('jsonrpc.error_message', response.unwrap_error().message) 36 | 37 | span.finish() 38 | 39 | elif isinstance(request, BatchRequest): 40 | response = handler(request, request_kwargs) 41 | 42 | else: 43 | raise AssertionError("unreachable") 44 | 45 | return response 46 | 47 | 48 | client = pjrpc_client.Client( 49 | 'http://localhost:8080/api/v1', 50 | middlewares=[ 51 | tracing_middleware, 52 | ], 53 | ) 54 | 55 | result = client.proxy.sum(1, 2) 56 | -------------------------------------------------------------------------------- /docs/source/pjrpc/specification.rst: -------------------------------------------------------------------------------- 1 | .. _specification: 2 | 3 | Specification 4 | ============= 5 | 6 | 7 | ``pjrpc`` has built-in `OpenAPI `_ and `OpenRPC `_ 8 | specification generation support implemented by :py:class:`pjrpc.server.specs.openapi.OpenAPI` 9 | and :py:class:`pjrpc.server.specs.openrpc.OpenRPC` respectively. 10 | 11 | 12 | Method description, tags, errors, examples, parameters and return value schemas can be provided by hand 13 | using :py:func:`pjrpc.server.specs.openapi.metadata` or automatically extracted using schema extractor. 14 | ``pjrpc`` provides pydantic extractor: :py:class:`pjrpc.server.specs.extractors.pydantic.PydanticMethodInfoExtractor`. 15 | They uses `pydantic `_ models for method summary, 16 | description, errors, examples and schema extraction respectively. You can implement your own schema extractor 17 | inheriting it from :py:class:`pjrpc.server.specs.extractors.BaseMethodInfoExtractor` and implementing abstract methods. 18 | 19 | .. code-block:: python 20 | 21 | @methods.add( 22 | metadata=[ 23 | openapi.metadata( 24 | tags=['users'], 25 | errors=[AlreadyExistsError], 26 | ) 27 | ] 28 | ) 29 | def add_user(user: UserIn) -> UserOut: 30 | """ 31 | Creates a user. 32 | 33 | :param object user: user data 34 | :return object: registered user 35 | :raise AlreadyExistsError: user already exists 36 | """ 37 | 38 | for existing_user in flask.current_app.users_db.values(): 39 | if user.name == existing_user.name: 40 | raise AlreadyExistsError() 41 | 42 | user_id = uuid.uuid4().hex 43 | flask.current_app.users_db[user_id] = user 44 | 45 | return UserOut(id=user_id, **user.dict()) 46 | -------------------------------------------------------------------------------- /docs/source/pjrpc/retries.rst: -------------------------------------------------------------------------------- 1 | .. _retires: 2 | 3 | Retries 4 | ======= 5 | 6 | ``pjrpc`` supports request retries based on response code or received exception using customizable backoff strategy. 7 | ``pjrpc`` provides several built-in backoff algorithms (see :py:mod:`pjrpc.client.retry`), but you can 8 | implement your own one like this: 9 | 10 | .. code-block:: python 11 | 12 | import dataclasses as dc 13 | import random 14 | from pjrpc.client.retry import Backoff 15 | 16 | @dc.dataclass(frozen=True) 17 | class RandomBackoff(Backoff): 18 | def __call__(self) -> Iterator[float]: 19 | return (random.random() for _ in range(self.attempts)) 20 | 21 | 22 | Retry strategy can be configured for all client requests by passing a strategy to a client constructor 23 | as a `retry_strategy` argument. 24 | 25 | The following example illustrate request retries api usage: 26 | 27 | .. code-block:: python 28 | 29 | import asyncio 30 | import random 31 | 32 | import aiohttp 33 | 34 | import pjrpc 35 | from pjrpc.client.backend import aiohttp as pjrpc_client 36 | from pjrpc.client.retry import AsyncRetryMiddleware, ExponentialBackoff, RetryStrategy 37 | 38 | 39 | async def main(): 40 | default_retry_strategy = RetryStrategy( 41 | exceptions={TimeoutError}, 42 | backoff=ExponentialBackoff(attempts=3, base=1.0, factor=2.0, jitter=lambda n: random.gauss(mu=0.5, sigma=0.1)), 43 | ) 44 | 45 | async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=0.2)) as session: 46 | async with pjrpc_client.Client( 47 | 'http://localhost:8080/api/v1', 48 | session=session, 49 | middlewares=[AsyncRetryMiddleware(default_retry_strategy)], 50 | ) as client: 51 | response = await client.send(pjrpc.Request('sum', params=[1, 2], id=1)) 52 | print(f"1 + 2 = {response.result}") 53 | 54 | result = await client.proxy.sum(1, 2) 55 | print(f"1 + 2 = {result}") 56 | 57 | 58 | asyncio.run(main()) 59 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: 2 | - commit 3 | - merge-commit 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-yaml 10 | - id: check-toml 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | stages: 14 | - commit 15 | - id: mixed-line-ending 16 | name: fix line ending 17 | stages: 18 | - commit 19 | args: 20 | - --fix=lf 21 | - id: mixed-line-ending 22 | name: check line ending 23 | stages: 24 | - merge-commit 25 | args: 26 | - --fix=no 27 | - repo: https://github.com/asottile/add-trailing-comma 28 | rev: v3.2.0 29 | hooks: 30 | - id: add-trailing-comma 31 | stages: 32 | - commit 33 | - repo: https://github.com/pre-commit/mirrors-autopep8 34 | rev: v2.0.4 35 | hooks: 36 | - id: autopep8 37 | stages: 38 | - commit 39 | args: 40 | - --diff 41 | - repo: https://github.com/pycqa/flake8 42 | rev: 7.2.0 43 | hooks: 44 | - id: flake8 45 | - repo: https://github.com/pycqa/isort 46 | rev: 6.0.1 47 | hooks: 48 | - id: isort 49 | name: fix import order 50 | stages: 51 | - commit 52 | args: 53 | - --line-length=120 54 | - --multi-line=9 55 | - --project=pjrpc 56 | - id: isort 57 | name: check import order 58 | stages: 59 | - merge-commit 60 | args: 61 | - --check-only 62 | - --line-length=120 63 | - --multi-line=9 64 | - --project=pjrpc 65 | - repo: https://github.com/pre-commit/mirrors-mypy 66 | rev: v1.15.0 67 | hooks: 68 | - id: mypy 69 | stages: 70 | - commit 71 | name: mypy 72 | pass_filenames: false 73 | args: ["--package", "pjrpc"] 74 | additional_dependencies: 75 | - aiohttp>=3.7 76 | - httpx>=0.23.0 77 | - pydantic>=2.0 78 | - types-requests>=2.0 79 | - aio-pika>=8.0 80 | - werkzeug>=2.0 81 | - pytest>=7.4.0 82 | - flask>=2.0.0 83 | - openapi-ui-bundles>=0.3.0 84 | -------------------------------------------------------------------------------- /pjrpc/server/integration/werkzeug.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, Iterable 2 | 3 | import werkzeug 4 | from werkzeug import exceptions 5 | 6 | import pjrpc.server 7 | 8 | WerkzeugDispatcher = pjrpc.server.Dispatcher[werkzeug.Request] 9 | 10 | 11 | class JsonRPC: 12 | """ 13 | `werkzeug `_ server JSON-RPC integration. 14 | 15 | :param path: JSON-RPC handler base path 16 | :param kwargs: arguments to be passed to the dispatcher :py:class:`pjrpc.server.Dispatcher` 17 | """ 18 | 19 | def __init__(self, path: str, **kwargs: Any): 20 | self._path = path 21 | self._dispatcher = WerkzeugDispatcher(**kwargs) 22 | 23 | def __call__(self, environ: Dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: 24 | return self.wsgi_app(environ, start_response) 25 | 26 | def wsgi_app(self, environ: Dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: 27 | environ['app'] = self 28 | request = werkzeug.Request(environ) 29 | if request.path != self._path: 30 | response = werkzeug.Response(status=404) 31 | else: 32 | response = self._rpc_handle(request) 33 | 34 | return response(environ, start_response) 35 | 36 | @property 37 | def dispatcher(self) -> WerkzeugDispatcher: 38 | """ 39 | JSON-RPC method dispatcher. 40 | """ 41 | 42 | return self._dispatcher 43 | 44 | def _rpc_handle(self, request: werkzeug.Request) -> werkzeug.Response: 45 | """ 46 | Handles JSON-RPC request. 47 | 48 | :returns: werkzeug response 49 | """ 50 | 51 | if request.content_type not in pjrpc.common.REQUEST_CONTENT_TYPES: 52 | raise exceptions.UnsupportedMediaType() 53 | 54 | try: 55 | request_text = request.get_data(as_text=True) 56 | except UnicodeDecodeError as e: 57 | raise exceptions.BadRequest() from e 58 | 59 | response = self._dispatcher.dispatch(request_text, context=request) 60 | if response is None: 61 | return werkzeug.Response() 62 | else: 63 | response_text, error_codes = response 64 | return werkzeug.Response(response_text, mimetype=pjrpc.common.DEFAULT_CONTENT_TYPE) 65 | -------------------------------------------------------------------------------- /examples/requests_pytest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | import pjrpc 6 | from pjrpc.client.backend import requests as requests_client 7 | from pjrpc.client.integrations.pytest_requests import PjRpcRequestsMocker 8 | 9 | 10 | def test_using_fixture(pjrpc_requests_mocker): 11 | client = requests_client.Client('http://localhost/api/v1') 12 | 13 | pjrpc_requests_mocker.add('http://localhost/api/v1', 'sum', result=2) 14 | result = client.proxy.sum(1, 1) 15 | assert result == 2 16 | 17 | pjrpc_requests_mocker.replace( 18 | 'http://localhost/api/v1', 19 | 'sum', 20 | error=pjrpc.client.exceptions.JsonRpcError(code=1, message='error', data='oops'), 21 | ) 22 | with pytest.raises(pjrpc.client.exceptions.JsonRpcError) as exc_info: 23 | client.proxy.sum(a=1, b=1) 24 | 25 | assert exc_info.type is pjrpc.client.exceptions.JsonRpcError 26 | assert exc_info.value.code == 1 27 | assert exc_info.value.message == 'error' 28 | assert exc_info.value.data == 'oops' 29 | 30 | localhost_calls = pjrpc_requests_mocker.calls['http://localhost/api/v1'] 31 | assert localhost_calls[('2.0', 'sum')].call_count == 2 32 | assert localhost_calls[('2.0', 'sum')].mock_calls == [mock.call(1, 1), mock.call(a=1, b=1)] 33 | 34 | client = requests_client.Client('http://localhost/api/v2') 35 | with pytest.raises(ConnectionRefusedError): 36 | client.proxy.sum(1, 1) 37 | 38 | 39 | def test_using_resource_manager(): 40 | client = requests_client.Client('http://localhost/api/v1') 41 | 42 | with PjRpcRequestsMocker() as mocker: 43 | mocker.add('http://localhost/api/v1', 'mult', result=4) 44 | mocker.add('http://localhost/api/v1', 'div', callback=lambda a, b: a/b) 45 | 46 | with client.batch() as batch: 47 | batch.proxy.div(4, 2) 48 | batch.proxy.mult(2, 2) 49 | 50 | result = batch.get_results() 51 | assert result == [2, 4] 52 | 53 | localhost_calls = mocker.calls['http://localhost/api/v1'] 54 | assert localhost_calls[('2.0', 'div')].mock_calls == [mock.call(4, 2)] 55 | assert localhost_calls[('2.0', 'mult')].mock_calls == [mock.call(2, 2)] 56 | 57 | with pytest.raises(pjrpc.client.exceptions.MethodNotFoundError): 58 | client.proxy.sub(4, 2) 59 | -------------------------------------------------------------------------------- /examples/server_prometheus_metrics.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import prometheus_client as pc 4 | from aiohttp import web 5 | 6 | import pjrpc.server 7 | from pjrpc import Request, Response 8 | from pjrpc.server import AsyncHandlerType 9 | from pjrpc.server.integration import aiohttp 10 | 11 | method_error_count = pc.Counter('method_error_count', 'Method error count', labelnames=['method', 'code']) 12 | method_latency_hist = pc.Histogram('method_latency', 'Method latency', labelnames=['method']) 13 | method_active_count = pc.Gauge('method_active_count', 'Method active count', labelnames=['method']) 14 | 15 | 16 | async def metrics(request): 17 | return web.Response(body=pc.generate_latest()) 18 | 19 | http_app = web.Application() 20 | http_app.add_routes([web.get('/metrics', metrics)]) 21 | 22 | 23 | methods = pjrpc.server.MethodRegistry() 24 | 25 | 26 | @methods.add(pass_context='context') 27 | async def sum(context: web.Request, a: int, b: int) -> int: 28 | print("method started") 29 | await asyncio.sleep(1) 30 | print("method finished") 31 | 32 | return a + b 33 | 34 | 35 | async def latency_metric_middleware(request: Request, context: web.Request, handler: AsyncHandlerType) -> Response: 36 | with method_latency_hist.labels(method=request.method).time(): 37 | return await handler(request, context) 38 | 39 | 40 | async def active_count_metric_middleware(request: Request, context: web.Request, handler: AsyncHandlerType) -> Response: 41 | with method_active_count.labels(method=request.method).track_inprogress(): 42 | return await handler(request, context) 43 | 44 | 45 | async def error_counter_middleware(request: Request, context: web.Request, handler: AsyncHandlerType) -> Response: 46 | if response := await handler(request, context): 47 | if response.is_error: 48 | method_error_count.labels(method=request.method, code=response.unwrap_error().code).inc() 49 | 50 | return response 51 | 52 | 53 | jsonrpc_app = aiohttp.Application( 54 | '/api/v1', 55 | http_app=http_app, 56 | middlewares=( 57 | latency_metric_middleware, 58 | active_count_metric_middleware, 59 | error_counter_middleware, 60 | ), 61 | ) 62 | jsonrpc_app.add_methods(methods) 63 | 64 | if __name__ == "__main__": 65 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 66 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | import sys 14 | from pathlib import Path 15 | 16 | import toml 17 | 18 | THIS_PATH = Path(__file__).parent 19 | ROOT_PATH = THIS_PATH.parent.parent 20 | sys.path.insert(0, str(ROOT_PATH)) 21 | 22 | PYPROJECT = toml.load(ROOT_PATH / 'pyproject.toml') 23 | PROJECT_INFO = PYPROJECT['tool']['poetry'] 24 | 25 | project = PROJECT_INFO['name'] 26 | copyright = f"2023, {PROJECT_INFO['name']}" 27 | author = PROJECT_INFO['authors'][0] 28 | release = PROJECT_INFO['version'] 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.doctest', 38 | 'sphinx.ext.intersphinx', 39 | 'sphinx.ext.autosectionlabel', 40 | 'sphinx.ext.viewcode', 41 | 'sphinx_copybutton', 42 | 'sphinx_design', 43 | ] 44 | 45 | intersphinx_mapping = { 46 | 'python': ('https://docs.python.org/3', None), 47 | 'aiohttp': ('https://aiohttp.readthedocs.io/en/stable/', None), 48 | 'requests': ('https://requests.kennethreitz.org/en/master/', None), 49 | } 50 | 51 | autodoc_typehints = 'description' 52 | autodoc_typehints_format = 'short' 53 | autodoc_member_order = 'bysource' 54 | autodoc_default_options = { 55 | 'show-inheritance': True, 56 | } 57 | 58 | 59 | autosectionlabel_prefix_document = True 60 | 61 | html_theme_options = {} 62 | html_title = PROJECT_INFO['name'] 63 | 64 | templates_path = ['_templates'] 65 | exclude_patterns = [] 66 | 67 | # -- Options for HTML output ------------------------------------------------- 68 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 69 | 70 | html_theme = 'furo' 71 | html_static_path = ['_static'] 72 | html_css_files = ['css/custom.css'] 73 | -------------------------------------------------------------------------------- /pjrpc/client/validators.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Mapping, Optional 2 | 3 | from pjrpc.client import exceptions 4 | from pjrpc.client.client import AsyncMiddlewareHandler, MiddlewareHandler 5 | from pjrpc.common import AbstractRequest, AbstractResponse, BatchRequest, BatchResponse, Request, Response 6 | 7 | 8 | def validate_response_id_middleware( 9 | request: AbstractRequest, 10 | request_kwargs: Mapping[str, Any], 11 | /, 12 | handler: MiddlewareHandler, 13 | ) -> Optional[AbstractResponse]: 14 | response = handler(request, request_kwargs) 15 | if response is not None: 16 | _validate_any_response_id(request, response) 17 | 18 | return response 19 | 20 | 21 | async def async_validate_response_id_middleware( 22 | request: AbstractRequest, 23 | request_kwargs: Mapping[str, Any], 24 | /, 25 | handler: AsyncMiddlewareHandler, 26 | ) -> Optional[AbstractResponse]: 27 | response = await handler(request, request_kwargs) 28 | if response is not None: 29 | _validate_any_response_id(request, response) 30 | 31 | return response 32 | 33 | 34 | def _validate_any_response_id(request: AbstractRequest, response: AbstractResponse) -> None: 35 | if isinstance(request, Request) and isinstance(response, Response): 36 | _validate_response_id(request, response) 37 | elif isinstance(request, BatchRequest) and isinstance(response, BatchResponse): 38 | _validate_batch_response_ids(request, response) 39 | 40 | 41 | def _validate_response_id(request: Request, response: Response) -> None: 42 | if response.id is not None and response.id != request.id: 43 | raise exceptions.IdentityError( 44 | f"response id doesn't match the request one: expected {request.id}, got {response.id}", 45 | ) 46 | 47 | 48 | def _validate_batch_response_ids(batch_request: BatchRequest, batch_response: BatchResponse) -> None: 49 | if batch_response.is_success: 50 | response_map = {response.id: response for response in batch_response if response.id is not None} 51 | 52 | for request in batch_request: 53 | if request.id is not None: 54 | response = response_map.pop(request.id, None) 55 | if response is None: 56 | raise exceptions.IdentityError(f"response '{request.id}' not found") 57 | 58 | if response_map: 59 | raise exceptions.IdentityError(f"unexpected response found: {response_map.keys()}") 60 | -------------------------------------------------------------------------------- /examples/httpserver.py: -------------------------------------------------------------------------------- 1 | import http.server 2 | import socketserver 3 | 4 | import pjrpc 5 | import pjrpc.server 6 | 7 | 8 | class JsonRpcHandler(http.server.BaseHTTPRequestHandler): 9 | """ 10 | JSON-RPC handler. 11 | """ 12 | 13 | def do_POST(self): 14 | """ 15 | Handles JSON-RPC request. 16 | """ 17 | 18 | content_type = self.headers.get('Content-Type') 19 | if content_type not in pjrpc.common.REQUEST_CONTENT_TYPES: 20 | self.send_error(http.HTTPStatus.UNSUPPORTED_MEDIA_TYPE) 21 | return 22 | 23 | try: 24 | content_length = int(self.headers.get('Content-Length', -1)) 25 | request_text = self.rfile.read(content_length).decode() 26 | except UnicodeDecodeError: 27 | self.send_error(http.HTTPStatus.BAD_REQUEST) 28 | return 29 | 30 | response = self.server.dispatcher.dispatch(request_text, context=self) 31 | if response is None: 32 | self.send_response_only(http.HTTPStatus.OK) 33 | self.end_headers() 34 | else: 35 | response_text, error_codes = response 36 | self.send_response(http.HTTPStatus.OK) 37 | self.send_header("Content-type", pjrpc.common.DEFAULT_CONTENT_TYPE) 38 | self.end_headers() 39 | 40 | self.wfile.write(response_text.encode()) 41 | 42 | 43 | class JsonRpcServer(http.server.HTTPServer): 44 | """ 45 | :py:class:`http.server.HTTPServer` based JSON-RPC server. 46 | 47 | :param path: JSON-RPC handler base path 48 | :param kwargs: arguments to be passed to the dispatcher :py:class:`pjrpc.server.Dispatcher` 49 | """ 50 | 51 | def __init__(self, server_address, RequestHandlerClass=JsonRpcHandler, bind_and_activate=True, **kwargs): 52 | super().__init__(server_address, RequestHandlerClass, bind_and_activate) 53 | self._dispatcher = pjrpc.server.Dispatcher(**kwargs) 54 | 55 | @property 56 | def dispatcher(self): 57 | """ 58 | JSON-RPC method dispatcher. 59 | """ 60 | 61 | return self._dispatcher 62 | 63 | 64 | methods = pjrpc.server.MethodRegistry() 65 | 66 | 67 | @methods.add(pass_context='request') 68 | def sum(request: http.server.BaseHTTPRequestHandler, a: int, b: int) -> int: 69 | return a + b 70 | 71 | 72 | class ThreadingJsonRpcServer(socketserver.ThreadingMixIn, JsonRpcServer): 73 | users = {} 74 | 75 | 76 | with ThreadingJsonRpcServer(("localhost", 8080)) as server: 77 | server.dispatcher.add_methods(methods) 78 | 79 | server.serve_forever() 80 | -------------------------------------------------------------------------------- /docs/source/pjrpc/validation.rst: -------------------------------------------------------------------------------- 1 | .. _validation: 2 | 3 | Validation 4 | ========== 5 | 6 | 7 | Very often besides dumb method parameters validation you need to implement more "deep" validation and provide 8 | comprehensive errors description to your clients. Fortunately ``pjrpc`` has builtin parameter validation based on 9 | `pydantic `_ library which uses python type annotation based validation. 10 | Look at the following example. All you need to annotate method parameters (or describe more complex type if necessary), 11 | that's it. ``pjrpc`` will be validating method parameters and returning informative errors to clients: 12 | 13 | .. code-block:: python 14 | 15 | import enum 16 | import uuid 17 | 18 | import pydantic 19 | from aiohttp import web 20 | 21 | import pjrpc.server 22 | from pjrpc.server.integration import aiohttp 23 | from pjrpc.server.validators import pydantic as validators 24 | 25 | methods = pjrpc.server.MethodRegistry( 26 | validator_factory=validators.PydanticValidatorFactory(exclude=aiohttp.is_aiohttp_request), 27 | ) 28 | 29 | 30 | class ContactType(enum.Enum): 31 | PHONE = 'phone' 32 | EMAIL = 'email' 33 | 34 | 35 | class Contact(pydantic.BaseModel): 36 | type: ContactType 37 | value: str 38 | 39 | 40 | class User(pydantic.BaseModel): 41 | name: str 42 | surname: str 43 | age: int 44 | contacts: list[Contact] 45 | 46 | 47 | class UserOut(User): 48 | id: uuid.UUID 49 | 50 | 51 | @methods.add(pass_context='request') 52 | async def add_user(request: web.Request, user: User) -> UserOut: 53 | user_id = uuid.uuid4() 54 | request.app['users'][user_id] = user 55 | 56 | return UserOut(id=user_id, **user.model_dump()) 57 | 58 | 59 | class JSONEncoder(pjrpc.server.JSONEncoder): 60 | def default(self, o): 61 | if isinstance(o, uuid.UUID): 62 | return o.hex 63 | if isinstance(o, enum.Enum): 64 | return o.value 65 | if isinstance(o, pydantic.BaseModel): 66 | return o.model_dump() 67 | 68 | return super().default(o) 69 | 70 | 71 | jsonrpc_app = aiohttp.Application('/api/v1', json_encoder=JSONEncoder) 72 | jsonrpc_app.add_methods(methods) 73 | jsonrpc_app.http_app['users'] = {} 74 | 75 | if __name__ == "__main__": 76 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 77 | 78 | 79 | In case you like any other validation library/framework it can be easily integrated in ``pjrpc`` library. 80 | -------------------------------------------------------------------------------- /tests/server/test_pydantic_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pjrpc.server.utils import exclude_named_param 4 | from pjrpc.server.validators import ValidationError, pydantic 5 | 6 | 7 | @pytest.mark.parametrize( 8 | 'dyn_method, params', [ 9 | ('param1: int, param2: int', [1, 2]), 10 | ('param1: int = 1', []), 11 | ('param1: int, param2: int = 2', [1]), 12 | ('param1: int, param2: int', {'param1': 1, 'param2': 2}), 13 | ('param1: int = 1', {}), 14 | ('param1: int, param2: int = 2', {'param1': 1}), 15 | ('param1, param2: int', [1, 2]), 16 | ('param1, param2: int', ['param1', 2]), 17 | ], indirect=['dyn_method'], 18 | ) 19 | def test_validation_success(dyn_method, params): 20 | validator_factory = pydantic.PydanticValidatorFactory() 21 | validator = validator_factory.build(dyn_method) 22 | 23 | validator.validate_params(params) 24 | validator.validate_params(params) 25 | 26 | 27 | @pytest.mark.parametrize( 28 | 'dyn_method, params', [ 29 | ('param1: int, param2: int', [1]), 30 | ('param1: int, param2: int', [1, 2, 3]), 31 | ('param1: int, param2: int', {'param1': 1}), 32 | ('param1: int, param2: int', {'param1': 1, 'param2': 2, 'param3': 3}), 33 | ], indirect=['dyn_method'], 34 | ) 35 | def test_validation_error(dyn_method, params): 36 | validator_factory = pydantic.PydanticValidatorFactory() 37 | validator = validator_factory.build(dyn_method) 38 | 39 | with pytest.raises(ValidationError): 40 | validator.validate_params(params) 41 | 42 | 43 | @pytest.mark.parametrize( 44 | 'dyn_method, exclude, params', [ 45 | ('context, param1: int', ('context',), [1]), 46 | ('context, param1: int', ('context',), {'param1': 1}), 47 | ], indirect=['dyn_method'], 48 | ) 49 | def test_validation_exclude_success(dyn_method, exclude, params): 50 | validator_factory = pydantic.PydanticValidatorFactory(exclude=exclude_named_param('context')) 51 | validator = validator_factory.build(dyn_method) 52 | 53 | validator.validate_params(params) 54 | 55 | 56 | @pytest.mark.parametrize( 57 | 'dyn_method, exclude, params', [ 58 | ('context, param1: int', ('context',), [1, 2]), 59 | ('context, param1: int', ('context',), {'context': '', 'param1': 1}), 60 | ], indirect=['dyn_method'], 61 | ) 62 | def test_validation_exclude_error(dyn_method, exclude, params): 63 | validator_factory = pydantic.PydanticValidatorFactory(exclude=exclude_named_param('context')) 64 | validator = validator_factory.build(dyn_method) 65 | 66 | with pytest.raises(ValidationError): 67 | validator.validate_params(params) 68 | -------------------------------------------------------------------------------- /docs/source/pjrpc/server.rst: -------------------------------------------------------------------------------- 1 | .. _server: 2 | 3 | Server 4 | ====== 5 | 6 | 7 | ``pjrpc`` supports popular backend frameworks like `aiohttp `_, 8 | `flask `_ and message brokers like `aio_pika `_. 9 | 10 | 11 | Running of aiohttp based JSON-RPC server is a very simple process. Just define methods, add them to the 12 | registry and run the server: 13 | 14 | .. code-block:: python 15 | 16 | import uuid 17 | 18 | from aiohttp import web 19 | 20 | import pjrpc.server 21 | from pjrpc.server.integration import aiohttp 22 | 23 | methods = pjrpc.server.MethodRegistry() 24 | 25 | 26 | @methods.add(context='request') 27 | async def add_user(request: web.Request, user: dict) -> dict: 28 | user_id = uuid.uuid4().hex 29 | request.app['users'][user_id] = user 30 | 31 | return {'id': user_id, **user} 32 | 33 | 34 | jsonrpc_app = aiohttp.Application('/api/v1') 35 | jsonrpc_app.add_methods(methods) 36 | jsonrpc_app.app['users'] = {} 37 | 38 | if __name__ == "__main__": 39 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 40 | 41 | 42 | 43 | API versioning 44 | -------------- 45 | 46 | API versioning is a framework dependant feature but ``pjrpc`` has a full support for that. 47 | Look at the following example illustrating how aiohttp JSON-RPC versioning is simple: 48 | 49 | .. code-block:: python 50 | 51 | import uuid 52 | 53 | from aiohttp import web 54 | 55 | import pjrpc.server 56 | from pjrpc.server.integration import aiohttp 57 | 58 | methods_v1 = pjrpc.server.MethodRegistry() 59 | 60 | 61 | @methods_v1.add(context='request') 62 | async def add_user(request: web.Request, user: dict) -> dict: 63 | user_id = uuid.uuid4().hex 64 | request.config_dict['users'][user_id] = user 65 | 66 | return {'id': user_id, **user} 67 | 68 | 69 | methods_v2 = pjrpc.server.MethodRegistry() 70 | 71 | 72 | @methods_v2.add(context='request') 73 | async def add_user(request: web.Request, user: dict) -> dict: 74 | user_id = uuid.uuid4().hex 75 | request.config_dict['users'][user_id] = user 76 | 77 | return {'id': user_id, **user} 78 | 79 | 80 | app = web.Application() 81 | app['users'] = {} 82 | 83 | app_v1 = aiohttp.Application() 84 | app_v1.add_methods(methods_v1) 85 | app.add_subapp('/api/v1', app_v1) 86 | 87 | 88 | app_v2 = aiohttp.Application() 89 | app_v2.add_methods(methods_v2) 90 | app.add_subapp('/api/v2', app_v2) 91 | 92 | if __name__ == "__main__": 93 | web.run_app(app, host='localhost', port=8080) 94 | -------------------------------------------------------------------------------- /docs/source/pjrpc/extending.rst: -------------------------------------------------------------------------------- 1 | .. _extending: 2 | 3 | Extending 4 | ========= 5 | 6 | ``pjrpc`` can be easily extended without writing a lot of boilerplate code. The following example illustrate 7 | an JSON-RPC server implementation based on :py:mod:`http.server` standard python library module: 8 | 9 | .. code-block:: python 10 | 11 | import http.server 12 | import socketserver 13 | import uuid 14 | 15 | import pjrpc 16 | import pjrpc.server 17 | 18 | 19 | class JsonRpcHandler(http.server.BaseHTTPRequestHandler): 20 | def do_POST(self): 21 | content_type = self.headers.get('Content-Type') 22 | if content_type not in pjrpc.common.REQUEST_CONTENT_TYPES: 23 | self.send_error(http.HTTPStatus.UNSUPPORTED_MEDIA_TYPE) 24 | return 25 | 26 | try: 27 | content_length = int(self.headers.get('Content-Length', -1)) 28 | request_text = self.rfile.read(content_length).decode() 29 | except UnicodeDecodeError: 30 | self.send_error(http.HTTPStatus.BAD_REQUEST) 31 | return 32 | 33 | response = self.server.dispatcher.dispatch(request_text, context=self) 34 | if response is None: 35 | self.send_response_only(http.HTTPStatus.OK) 36 | self.end_headers() 37 | else: 38 | response_text, error_codes = response 39 | self.send_response(http.HTTPStatus.OK) 40 | self.send_header("Content-type", pjrpc.common.DEFAULT_CONTENT_TYPE) 41 | self.end_headers() 42 | 43 | self.wfile.write(response_text.encode()) 44 | 45 | 46 | class JsonRpcServer(http.server.HTTPServer): 47 | def __init__(self, server_address, RequestHandlerClass=JsonRpcHandler, bind_and_activate=True, **kwargs): 48 | super().__init__(server_address, RequestHandlerClass, bind_and_activate) 49 | self._dispatcher = pjrpc.server.Dispatcher(**kwargs) 50 | 51 | @property 52 | def dispatcher(self): 53 | return self._dispatcher 54 | 55 | 56 | methods = pjrpc.server.MethodRegistry() 57 | 58 | 59 | @methods.add(context='request') 60 | def add_user(request: http.server.BaseHTTPRequestHandler, user: dict): 61 | user_id = uuid.uuid4().hex 62 | request.server.users[user_id] = user 63 | 64 | return {'id': user_id, **user} 65 | 66 | 67 | class ThreadingJsonRpcServer(socketserver.ThreadingMixIn, JsonRpcServer): 68 | users = {} 69 | 70 | 71 | with ThreadingJsonRpcServer(("localhost", 8080)) as server: 72 | server.dispatcher.add_methods(methods) 73 | 74 | server.serve_forever() 75 | -------------------------------------------------------------------------------- /examples/aiohttp_dishka_di.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any, Optional, Type 3 | 4 | import pydantic 5 | from aiohttp import web 6 | from dishka import FromDishka, Provider, Scope, make_async_container, provide 7 | from dishka.integrations.aiohttp import inject, setup_dishka 8 | 9 | import pjrpc.server 10 | import pjrpc.server.specs.extractors.pydantic 11 | from pjrpc.server.integration import aiohttp 12 | from pjrpc.server.specs import extractors, openapi 13 | from pjrpc.server.validators import pydantic as validators 14 | 15 | 16 | def is_di_injected(idx: int, name: str, annotation: Type[Any], default: Any) -> bool: 17 | return annotation is FromDishka 18 | 19 | 20 | def exclude_param(idx: int, name: str, annotation: Type[Any], default: Any) -> bool: 21 | return aiohttp.is_aiohttp_request(idx, name, annotation, default) or is_di_injected(idx, name, annotation, default) 22 | 23 | 24 | methods = pjrpc.server.MethodRegistry( 25 | validator_factory=validators.PydanticValidatorFactory(exclude=exclude_param), 26 | metadata_processors=[ 27 | openapi.MethodSpecificationGenerator( 28 | extractors.pydantic.PydanticMethodInfoExtractor(exclude=exclude_param), 29 | ), 30 | ], 31 | ) 32 | 33 | 34 | class UserService: 35 | def __init__(self): 36 | self._users = {} 37 | 38 | def add_user(self, user: dict) -> str: 39 | user_id = uuid.uuid4().hex 40 | self._users[user_id] = user 41 | 42 | return user_id 43 | 44 | def get_user(self, user_id: uuid.UUID) -> Optional[dict]: 45 | return self._users.get(user_id) 46 | 47 | 48 | class User(pydantic.BaseModel): 49 | name: str 50 | surname: str 51 | age: int 52 | 53 | 54 | @methods.add( 55 | pass_context=True, 56 | metadata=[ 57 | openapi.metadata( 58 | summary='Creates a user', 59 | tags=['users'], 60 | ), 61 | ], 62 | ) 63 | @inject 64 | async def add_user( 65 | request: web.Request, 66 | user_service: FromDishka[UserService], 67 | user: User, 68 | ) -> dict: 69 | user_dict = user.model_dump() 70 | user_id = user_service.add_user(user_dict) 71 | 72 | return {'id': user_id, **user_dict} 73 | 74 | 75 | openapi_spec = openapi.OpenAPI(info=openapi.Info(version="1.0.0", title="User storage")) 76 | 77 | jsonrpc_app = aiohttp.Application('/api/v1') 78 | jsonrpc_app.add_methods(methods) 79 | jsonrpc_app.add_spec(openapi_spec, path='openapi.json') 80 | 81 | jsonrpc_app.http_app['users'] = {} 82 | 83 | 84 | class ServiceProvider(Provider): 85 | user_service = provide(UserService, scope=Scope.APP) 86 | 87 | 88 | setup_dishka( 89 | app=jsonrpc_app.http_app, 90 | container=make_async_container( 91 | ServiceProvider(), 92 | ), 93 | ) 94 | 95 | if __name__ == "__main__": 96 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 97 | -------------------------------------------------------------------------------- /pjrpc/server/exceptions.py: -------------------------------------------------------------------------------- 1 | import dataclasses as dc 2 | from typing import Any, ClassVar, Optional 3 | 4 | from pjrpc.common import UNSET, JsonT, MaybeSet, exceptions 5 | from pjrpc.common.exceptions import BaseError, DeserializationError, IdentityError, ProtocolError 6 | 7 | __all__ = [ 8 | 'BaseError', 9 | 'DeserializationError', 10 | 'IdentityError', 11 | 'InternalError', 12 | 'InvalidParamsError', 13 | 'InvalidRequestError', 14 | 'JsonRpcError', 15 | 'MethodNotFoundError', 16 | 'ParseError', 17 | 'ProtocolError', 18 | 'ServerError', 19 | 'TypedError', 20 | ] 21 | 22 | 23 | @dc.dataclass 24 | class JsonRpcError(exceptions.JsonRpcError): 25 | """ 26 | Server JSON-RPC error. 27 | """ 28 | 29 | # typed subclasses error mapping 30 | __TYPED_ERRORS__: ClassVar[dict[int, type['TypedError']]] = {} 31 | 32 | @classmethod 33 | def get_typed_error_by_code(cls, code: int, message: str, data: MaybeSet[JsonT]) -> Optional['JsonRpcError']: 34 | if error_cls := cls.__TYPED_ERRORS__.get(code): 35 | return error_cls(message, data) 36 | else: 37 | return None 38 | 39 | 40 | class TypedError(JsonRpcError): 41 | """ 42 | Typed JSON-RPC error. 43 | Must not be instantiated directly, only subclassed. 44 | """ 45 | 46 | # a number that indicates the error type that occurred 47 | CODE: ClassVar[int] 48 | 49 | # a string providing a short description of the error. 50 | # the message SHOULD be limited to a concise single sentence. 51 | MESSAGE: ClassVar[str] 52 | 53 | def __init_subclass__(cls, base: bool = False, **kwargs: Any): 54 | super().__init_subclass__(**kwargs) 55 | 56 | if issubclass(cls, TypedError) and (code := getattr(cls, 'CODE', None)) is not None: 57 | cls.__TYPED_ERRORS__[code] = cls 58 | 59 | def __init__(self, message: Optional[str] = None, data: MaybeSet[JsonT] = UNSET): 60 | super().__init__(self.CODE, message or self.MESSAGE, data) 61 | 62 | 63 | class ParseError(TypedError, exceptions.ParseError): 64 | """ 65 | Invalid JSON was received by the server. 66 | An error occurred on the server while parsing the JSON text. 67 | """ 68 | 69 | 70 | class InvalidRequestError(TypedError, exceptions.InvalidRequestError): 71 | """ 72 | The JSON sent is not a valid request object. 73 | """ 74 | 75 | 76 | class MethodNotFoundError(TypedError, exceptions.MethodNotFoundError): 77 | """ 78 | The method does not exist / is not available. 79 | """ 80 | 81 | 82 | class InvalidParamsError(TypedError, exceptions.InvalidParamsError): 83 | """ 84 | Invalid method parameter(s). 85 | """ 86 | 87 | 88 | class InternalError(TypedError, exceptions.InternalError): 89 | """ 90 | Internal JSON-RPC error. 91 | """ 92 | 93 | 94 | class ServerError(TypedError, exceptions.ServerError): 95 | """ 96 | Reserved for implementation-defined server-errors. 97 | Codes from -32000 to -32099. 98 | """ 99 | -------------------------------------------------------------------------------- /tests/server/test_base_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pjrpc.server import validators 4 | from pjrpc.server.utils import exclude_named_param 5 | 6 | 7 | @pytest.mark.parametrize( 8 | 'dyn_method, params', [ 9 | ('param1, param2', [1, 2]), 10 | ('param1, *args', [1, 2, 3]), 11 | ('param1, *args', [1]), 12 | ('param1=1', []), 13 | ('param1, param2=2', [1]), 14 | ('param1, param2', {'param1': 1, 'param2': 2}), 15 | ('param1, **kwargs', {'param1': 1, 'param2': 2, 'param3': 3}), 16 | ('param1, *, param2, param3', {'param1': 1, 'param2': 2, 'param3': 3}), 17 | ('param1=1', {}), 18 | ('param1, param2=2', {'param1': 1}), 19 | ('param1, *, param2=2', {'param1': 1}), 20 | ], indirect=['dyn_method'], 21 | ) 22 | def test_validation_success(dyn_method, params): 23 | validator_factory = validators.BaseValidatorFactory() 24 | validator = validator_factory.build(dyn_method) 25 | 26 | validator.validate_params(params) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | 'dyn_method, params', [ 31 | ('param1, param2', [1]), 32 | ('param1, param2', [1, 2, 3]), 33 | ('param1, *args', []), 34 | ('param1, param2', {'param1': 1}), 35 | ('param1, param2', {'param1': 1, 'param2': 2, 'param3': 3}), 36 | ('param1, **kwargs', {'param2': 2}), 37 | ('param1, *, param2, param3', {'param2': 1}), 38 | ], indirect=['dyn_method'], 39 | ) 40 | def test_validation_error(dyn_method, params): 41 | validator_factory = validators.BaseValidatorFactory() 42 | validator = validator_factory.build(dyn_method) 43 | 44 | with pytest.raises(validators.ValidationError): 45 | validator.validate_params(params) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | 'dyn_method, exclude, params', [ 50 | ('context, param1', ('context',), [1]), 51 | ('context, *args', ('context',), []), 52 | ('context, param1', ('context',), {'param1': 1}), 53 | ('context, **kwargs', ('context',), {}), 54 | ('context, *, param1', ('context',), {'param1': 1}), 55 | ], indirect=['dyn_method'], 56 | ) 57 | def test_validation_exclude_success(dyn_method, exclude, params): 58 | validator_factory = validators.BaseValidatorFactory(exclude=exclude_named_param('context')) 59 | validator = validator_factory.build(dyn_method) 60 | 61 | validator.validate_params(params) 62 | 63 | 64 | @pytest.mark.parametrize( 65 | 'dyn_method, exclude, params', [ 66 | ('context, param1', ('context',), [1, 2]), 67 | ('context, param1', ('context',), {'context': '', 'param1': 1}), 68 | ('context, *, param1', ('context',), {'context': 1}), 69 | ], indirect=['dyn_method'], 70 | ) 71 | def test_validation_exclude_error(dyn_method, exclude, params): 72 | validator_factory = validators.BaseValidatorFactory(exclude=exclude_named_param('context')) 73 | validator = validator_factory.build(dyn_method) 74 | 75 | with pytest.raises(validators.ValidationError): 76 | validator.validate_params(params) 77 | -------------------------------------------------------------------------------- /pjrpc/server/validators/base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Callable, Optional 3 | 4 | from pjrpc.common.typedefs import JsonRpcParamsT 5 | 6 | 7 | class ValidationError(Exception): 8 | """ 9 | Method parameters validation error. Raised when parameters validation failed. 10 | """ 11 | 12 | 13 | ExcludeFunc = Callable[[int, str, Optional[type[Any]], Optional[Any]], bool] 14 | MethodType = Callable[..., Any] 15 | 16 | 17 | class BaseValidatorFactory: 18 | """ 19 | Base method parameters validator factory. Uses :py:func:`inspect.signature` for validation. 20 | 21 | :param exclude: a function that decides if the parameters must be excluded 22 | from validation (useful for dependency injection) 23 | """ 24 | 25 | def __init__(self, exclude: Optional[ExcludeFunc] = None): 26 | self._exclude = exclude 27 | 28 | def build(self, method: MethodType) -> 'BaseValidator': 29 | return BaseValidator(method, self._exclude) 30 | 31 | 32 | class BaseValidator: 33 | """ 34 | Base method parameters validator. 35 | """ 36 | 37 | def __init__(self, method: MethodType, exclude: Optional[ExcludeFunc] = None): 38 | self._method = method 39 | self._exclude = exclude 40 | self._signature = self._build_signature(method, exclude) 41 | 42 | def validate_params(self, params: Optional['JsonRpcParamsT']) -> dict[str, Any]: 43 | """ 44 | Validates params against method signature. 45 | 46 | :param params: parameters to be validated 47 | 48 | :raises: :py:class:`pjrpc.server.validators.ValidationError` 49 | :returns: bound method parameters 50 | """ 51 | 52 | return self._bind(params).arguments 53 | 54 | def _build_signature(self, method: MethodType, exclude: Optional[ExcludeFunc]) -> inspect.Signature: 55 | """ 56 | Returns method signature. 57 | 58 | :param method: method to get signature of 59 | :returns: signature 60 | """ 61 | 62 | signature = inspect.signature(method) 63 | 64 | method_parameters: list[inspect.Parameter] = [] 65 | for idx, param in enumerate(signature.parameters.values()): 66 | if exclude is None or not exclude(idx, param.name, param.annotation, param.default): 67 | method_parameters.append(param) 68 | 69 | return signature.replace(parameters=method_parameters) 70 | 71 | def _bind(self, params: Optional['JsonRpcParamsT']) -> inspect.BoundArguments: 72 | """ 73 | Binds parameters to method. 74 | :param params: parameters to be bound 75 | 76 | :raises: ValidationError is parameters binding failed 77 | :returns: bound parameters 78 | """ 79 | 80 | method_args = params if isinstance(params, (list, tuple)) else () 81 | method_kwargs = params if isinstance(params, dict) else {} 82 | 83 | try: 84 | return self._signature.bind(*method_args, **method_kwargs) 85 | except TypeError as e: 86 | raise ValidationError(str(e)) from e 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pjrpc" 3 | version = "2.1.2" 4 | description = "Extensible JSON-RPC library" 5 | authors = ["Dmitry Pershin "] 6 | license = "Unlicense" 7 | readme = "README.rst" 8 | homepage = "https://github.com/dapper91/pjrpc" 9 | repository = "https://github.com/dapper91/pjrpc" 10 | documentation = "https://pjrpc.readthedocs.io" 11 | keywords = ['json-rpc', 'jsonrpc-client', 'jsonrpc-server', 'openapi', 'openrpc'] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Intended Audience :: Developers", 15 | "Natural Language :: English", 16 | "License :: Public Domain", 17 | "Framework :: AsyncIO", 18 | "Framework :: Flask", 19 | "Framework :: Pytest", 20 | "Typing :: Typed", 21 | "Topic :: Internet :: WWW/HTTP", 22 | "Topic :: Software Development :: Libraries", 23 | "Topic :: Software Development :: Libraries :: Application Frameworks", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | ] 31 | 32 | 33 | [tool.poetry.dependencies] 34 | python = ">=3.9,<4.0" 35 | aio-pika = { version = ">=8.0", optional = true } 36 | aiohttp = { version = ">=3.7", optional = true } 37 | flask = { version = ">=2.0.0", optional = true } 38 | httpx = { version = ">=0.23.0", optional = true } 39 | openapi-ui-bundles = { version = ">=0.1", optional = true } 40 | pydantic = {version = ">=1.10.20", optional = true} 41 | requests = { version = ">=2.0", optional = true } 42 | werkzeug = { version = ">=2.0", optional = true} 43 | 44 | 45 | [tool.poetry.extras] 46 | aio-pika = ['aio-pika'] 47 | aiohttp = ['aiohttp'] 48 | flask = ['flask'] 49 | httpx = ['httpx'] 50 | openapi-ui-bundles = ['openapi-ui-bundles'] 51 | pydantic = ['pydantic'] 52 | requests = ['requests'] 53 | werkzeug = ['werkzeug'] 54 | 55 | 56 | [tool.poetry.group.docs] 57 | optional = true 58 | 59 | [tool.poetry.group.docs.dependencies] 60 | furo = "^2022.12.7" 61 | Sphinx = "^5.3.0" 62 | sphinx-copybutton = "^0.5.1" 63 | sphinx_design = "^0.3.0" 64 | toml = "^0.10.2" 65 | standard-imghdr = "^3.13.0" 66 | 67 | 68 | [tool.poetry.group.dev.dependencies] 69 | codecov = "^2.1.13" 70 | mypy = "^1.15.0" 71 | pre-commit = "~3.2.0" 72 | aioresponses = "^0.7.4" 73 | asynctest = "^0.13.0" 74 | deepdiff = "^8.0.1" 75 | pytest = "^7.4.0" 76 | pytest-aiohttp = "^1.0.4" 77 | pytest-cov = "^4.1.0" 78 | pytest-mock = "^3.11.1" 79 | responses = "^0.23.3" 80 | respx = "^0.22.0" 81 | types-requests = "^2.32.0.20241016" 82 | jsonschema = "^4.25.1" 83 | 84 | [build-system] 85 | requires = ["poetry-core>=1.0.0"] 86 | build-backend = "poetry.core.masonry.api" 87 | 88 | [tool.mypy] 89 | allow_redefinition = true 90 | disallow_any_generics = true 91 | disallow_incomplete_defs = true 92 | disallow_untyped_decorators = false 93 | disallow_untyped_defs = true 94 | no_implicit_optional = true 95 | show_error_codes = true 96 | strict_equality = true 97 | warn_unused_ignores = true 98 | -------------------------------------------------------------------------------- /examples/server_tracing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import opentracing 4 | from aiohttp import web 5 | from aiohttp.typedefs import Handler as HttpHandler 6 | from opentracing import tags 7 | 8 | import pjrpc.server 9 | from pjrpc import Request, Response 10 | from pjrpc.server import AsyncHandlerType 11 | from pjrpc.server.integration import aiohttp 12 | 13 | 14 | @web.middleware 15 | async def http_tracing_middleware(request: web.Request, handler: HttpHandler) -> web.StreamResponse: 16 | """ 17 | aiohttp server tracer. 18 | """ 19 | 20 | tracer = opentracing.global_tracer() 21 | try: 22 | span_ctx = tracer.extract(format=opentracing.Format.HTTP_HEADERS, carrier=request.headers) 23 | except (opentracing.InvalidCarrierException, opentracing.SpanContextCorruptedException): 24 | span_ctx = None 25 | 26 | span = tracer.start_span(f'http.{request.method}', child_of=span_ctx) 27 | span.set_tag(tags.COMPONENT, 'aiohttp.server') 28 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_SERVER) 29 | span.set_tag(tags.PEER_ADDRESS, request.remote) 30 | span.set_tag(tags.HTTP_URL, str(request.url)) 31 | span.set_tag(tags.HTTP_METHOD, request.method) 32 | 33 | with tracer.scope_manager.activate(span, finish_on_close=True): 34 | response = await handler(request) 35 | span.set_tag(tags.HTTP_STATUS_CODE, response.status) 36 | span.set_tag(tags.ERROR, response.status >= 400) 37 | 38 | return response 39 | 40 | http_app = web.Application( 41 | middlewares=( 42 | http_tracing_middleware, 43 | ), 44 | ) 45 | 46 | methods = pjrpc.server.MethodRegistry() 47 | 48 | 49 | @methods.add(pass_context='context') 50 | async def sum(context: web.Request, a: int, b: int) -> int: 51 | print("method started") 52 | await asyncio.sleep(1) 53 | print("method finished") 54 | 55 | return a + b 56 | 57 | 58 | async def jsonrpc_tracing_middleware(request: Request, context: web.Request, handler: AsyncHandlerType) -> Response: 59 | tracer = opentracing.global_tracer() 60 | span = tracer.start_span(f'jsonrpc.{request.method}') 61 | 62 | span.set_tag(tags.COMPONENT, 'pjrpc') 63 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_SERVER) 64 | span.set_tag('jsonrpc.version', request.version) 65 | span.set_tag('jsonrpc.id', request.id) 66 | span.set_tag('jsonrpc.method', request.method) 67 | 68 | with tracer.scope_manager.activate(span, finish_on_close=True): 69 | if response := await handler(request, context): 70 | if response.is_error: 71 | span.set_tag('jsonrpc.error_code', response.error.code) 72 | span.set_tag('jsonrpc.error_message', response.error.message) 73 | span.set_tag(tags.ERROR, True) 74 | else: 75 | span.set_tag(tags.ERROR, False) 76 | 77 | return response 78 | 79 | jsonrpc_app = aiohttp.Application( 80 | '/api/v1', 81 | http_app=http_app, 82 | middlewares=[ 83 | jsonrpc_tracing_middleware, 84 | ], 85 | ) 86 | jsonrpc_app.add_methods(methods) 87 | 88 | if __name__ == "__main__": 89 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 90 | -------------------------------------------------------------------------------- /pjrpc/client/exceptions.py: -------------------------------------------------------------------------------- 1 | import dataclasses as dc 2 | from typing import Any, ClassVar, Optional 3 | 4 | from pjrpc.common import UNSET, JsonT, MaybeSet, exceptions 5 | from pjrpc.common.exceptions import BaseError, DeserializationError, IdentityError, ProtocolError 6 | 7 | __all__ = [ 8 | 'BaseError', 9 | 'DeserializationError', 10 | 'IdentityError', 11 | 'InternalError', 12 | 'InvalidParamsError', 13 | 'InvalidRequestError', 14 | 'JsonRpcError', 15 | 'MethodNotFoundError', 16 | 'ParseError', 17 | 'ProtocolError', 18 | 'ServerError', 19 | 'TypedError', 20 | ] 21 | 22 | 23 | @dc.dataclass 24 | class JsonRpcError(exceptions.JsonRpcError): 25 | """ 26 | Client JSON-RPC error. 27 | """ 28 | 29 | # typed subclasses error mapping 30 | __TYPED_ERRORS__: ClassVar[dict[int, type['TypedError']]] = {} 31 | 32 | @classmethod 33 | def get_typed_error_by_code(cls, code: int, message: str, data: MaybeSet[JsonT]) -> Optional['JsonRpcError']: 34 | if error_cls := cls.__TYPED_ERRORS__.get(code): 35 | return error_cls(message, data) 36 | else: 37 | return None 38 | 39 | 40 | class TypedError(JsonRpcError): 41 | """ 42 | Typed JSON-RPC error. 43 | Must not be instantiated directly, only subclassed. 44 | """ 45 | 46 | # a number that indicates the error type that occurred 47 | CODE: ClassVar[int] 48 | 49 | # a string providing a short description of the error. 50 | # the message SHOULD be limited to a concise single sentence. 51 | MESSAGE: ClassVar[str] 52 | 53 | def __init_subclass__(cls, base: bool = False, **kwargs: Any): 54 | super().__init_subclass__(**kwargs) 55 | if base: 56 | cls.__TYPED_ERRORS__ = cls.__TYPED_ERRORS__.copy() 57 | 58 | if issubclass(cls, TypedError) and (code := getattr(cls, 'CODE', None)) is not None: 59 | cls.__TYPED_ERRORS__[code] = cls 60 | 61 | def __init__(self, message: Optional[str] = None, data: MaybeSet[JsonT] = UNSET): 62 | super().__init__(self.CODE, message or self.MESSAGE, data) 63 | 64 | 65 | class ParseError(TypedError, exceptions.ParseError): 66 | """ 67 | Invalid JSON was received by the server. 68 | An error occurred on the server while parsing the JSON text. 69 | """ 70 | 71 | 72 | class InvalidRequestError(TypedError, exceptions.InvalidRequestError): 73 | """ 74 | The JSON sent is not a valid request object. 75 | """ 76 | 77 | 78 | class MethodNotFoundError(TypedError, exceptions.MethodNotFoundError): 79 | """ 80 | The method does not exist / is not available. 81 | """ 82 | 83 | 84 | class InvalidParamsError(TypedError, exceptions.InvalidParamsError): 85 | """ 86 | Invalid method parameter(s). 87 | """ 88 | 89 | 90 | class InternalError(TypedError, exceptions.InternalError): 91 | """ 92 | Internal JSON-RPC error. 93 | """ 94 | 95 | 96 | class ServerError(TypedError, exceptions.ServerError): 97 | """ 98 | Reserved for implementation-defined server-errors. 99 | Codes from -32000 to -32099. 100 | """ 101 | -------------------------------------------------------------------------------- /tests/common/test_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import pjrpc 4 | from pjrpc.client import exceptions 5 | 6 | 7 | def test_error_serialization(): 8 | error = exceptions.ServerError() 9 | 10 | actual_dict = error.to_json() 11 | expected_dict = { 12 | 'code': -32000, 13 | 'message': 'Server error', 14 | } 15 | 16 | assert actual_dict == expected_dict 17 | 18 | 19 | def test_error_deserialization(): 20 | data = { 21 | 'code': -32000, 22 | 'message': 'Server error', 23 | } 24 | 25 | error = exceptions.ServerError.from_json(data) 26 | 27 | assert error.code == -32000 28 | assert error.message == 'Server error' 29 | 30 | 31 | def test_error_data_serialization(): 32 | error = exceptions.MethodNotFoundError(data='method_name') 33 | 34 | actual_dict = error.to_json() 35 | expected_dict = { 36 | 'code': -32601, 37 | 'message': 'Method not found', 38 | 'data': 'method_name', 39 | } 40 | 41 | assert actual_dict == expected_dict 42 | 43 | 44 | def test_custom_error_data_serialization(): 45 | error = exceptions.JsonRpcError(code=2001, message='Custom error', data='additional data') 46 | 47 | actual_dict = error.to_json() 48 | expected_dict = { 49 | 'code': 2001, 50 | 'message': 'Custom error', 51 | 'data': 'additional data', 52 | } 53 | 54 | assert actual_dict == expected_dict 55 | 56 | 57 | def test_custom_error_data_deserialization(): 58 | data = { 59 | 'code': -32601, 60 | 'message': 'Method not found', 61 | 'data': 'method_name', 62 | } 63 | 64 | error = exceptions.JsonRpcError.from_json(data) 65 | 66 | assert error.code == -32601 67 | assert error.message == 'Method not found' 68 | assert error.data == 'method_name' 69 | 70 | 71 | def test_error_deserialization_errors(): 72 | with pytest.raises(pjrpc.exc.DeserializationError, match="data must be of type dict"): 73 | exceptions.JsonRpcError.from_json([]) 74 | 75 | with pytest.raises(pjrpc.exc.DeserializationError, match="required field 'message' not found"): 76 | exceptions.JsonRpcError.from_json({'code': 1}) 77 | 78 | with pytest.raises(pjrpc.exc.DeserializationError, match="required field 'code' not found"): 79 | exceptions.JsonRpcError.from_json({'message': ""}) 80 | 81 | with pytest.raises(pjrpc.exc.DeserializationError, match="field 'code' must be of type integer"): 82 | exceptions.JsonRpcError.from_json({'code': "1", 'message': ""}) 83 | 84 | with pytest.raises(pjrpc.exc.DeserializationError, match="field 'message' must be of type string"): 85 | exceptions.JsonRpcError.from_json({'code': 1, 'message': 2}) 86 | 87 | 88 | def test_error_repr(): 89 | assert repr(exceptions.ServerError()) == "ServerError(code=-32000, message='Server error')" 90 | assert str(exceptions.ServerError()) == "(-32000) Server error" 91 | 92 | 93 | def test_custom_error_registration(): 94 | data = { 95 | 'code': 2000, 96 | 'message': 'Custom error', 97 | 'data': 'custom_data', 98 | } 99 | 100 | class CustomError(exceptions.TypedError): 101 | CODE = 2000 102 | MESSAGE = 'Custom error' 103 | 104 | error = exceptions.JsonRpcError.from_json(data) 105 | 106 | assert isinstance(error, CustomError) 107 | assert error.code == 2000 108 | assert error.message == 'Custom error' 109 | assert error.data == 'custom_data' 110 | -------------------------------------------------------------------------------- /docs/source/pjrpc/examples.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Examples 4 | ======== 5 | 6 | 7 | aio_pika client 8 | --------------- 9 | 10 | .. literalinclude:: ../../../examples/aio_pika_client.py 11 | :language: python 12 | 13 | 14 | aio_pika server 15 | --------------- 16 | 17 | .. literalinclude:: ../../../examples/aio_pika_server.py 18 | :language: python 19 | 20 | 21 | aiohttp client 22 | -------------- 23 | 24 | .. literalinclude:: ../../../examples/aiohttp_client.py 25 | :language: python 26 | 27 | 28 | aiohttp client batch request 29 | ---------------------------- 30 | 31 | .. literalinclude:: ../../../examples/aiohttp_client_batch.py 32 | :language: python 33 | 34 | 35 | aiohttp pytest integration 36 | -------------------------- 37 | 38 | .. literalinclude:: ../../../examples/aiohttp_pytest.py 39 | :language: python 40 | 41 | 42 | aiohttp server 43 | -------------- 44 | 45 | .. literalinclude:: ../../../examples/aiohttp_server.py 46 | :language: python 47 | 48 | 49 | aiohttp versioning 50 | ------------------ 51 | 52 | .. literalinclude:: ../../../examples/aiohttp_versioning.py 53 | :language: python 54 | 55 | 56 | client prometheus metrics 57 | ------------------------- 58 | 59 | .. literalinclude:: ../../../examples/client_prometheus_metrics.py 60 | :language: python 61 | 62 | 63 | client tracing 64 | -------------- 65 | 66 | .. literalinclude:: ../../../examples/client_tracing.py 67 | :language: python 68 | 69 | 70 | flask server 71 | ------------ 72 | 73 | .. literalinclude:: ../../../examples/flask_server.py 74 | :language: python 75 | 76 | 77 | flask versioning 78 | ---------------- 79 | 80 | .. literalinclude:: ../../../examples/flask_versioning.py 81 | :language: python 82 | 83 | 84 | httpserver 85 | ---------- 86 | 87 | .. literalinclude:: ../../../examples/httpserver.py 88 | :language: python 89 | 90 | 91 | middlewares 92 | ----------- 93 | 94 | .. literalinclude:: ../../../examples/middlewares.py 95 | :language: python 96 | 97 | 98 | multiple clients 99 | ---------------- 100 | 101 | .. literalinclude:: ../../../examples/multiple_clients.py 102 | :language: python 103 | 104 | 105 | pydantic validator 106 | ------------------ 107 | 108 | .. literalinclude:: ../../../examples/pydantic_validator.py 109 | :language: python 110 | 111 | 112 | requests client 113 | --------------- 114 | 115 | .. literalinclude:: ../../../examples/requests_client.py 116 | :language: python 117 | 118 | 119 | requests pytest 120 | --------------- 121 | 122 | .. literalinclude:: ../../../examples/requests_pytest.py 123 | :language: python 124 | 125 | 126 | sentry 127 | ------ 128 | 129 | .. literalinclude:: ../../../examples/sentry.py 130 | :language: python 131 | 132 | 133 | server prometheus metrics 134 | ------------------------- 135 | 136 | .. literalinclude:: ../../../examples/server_prometheus_metrics.py 137 | :language: python 138 | 139 | 140 | server tracing 141 | -------------- 142 | 143 | .. literalinclude:: ../../../examples/server_tracing.py 144 | :language: python 145 | 146 | 147 | werkzeug server 148 | --------------- 149 | 150 | .. literalinclude:: ../../../examples/werkzeug_server.py 151 | :language: python 152 | 153 | 154 | flask OpenAPI specification 155 | --------------------------- 156 | 157 | .. literalinclude:: ../../../examples/openapi_flask.py 158 | :language: python 159 | 160 | 161 | aiohttp OpenAPI specification 162 | ----------------------------- 163 | 164 | .. literalinclude:: ../../../examples/openapi_aiohttp.py 165 | :language: python 166 | -------------------------------------------------------------------------------- /tests/server/test_middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pjrpc 4 | from pjrpc.server.dispatcher import AsyncDispatcher, Dispatcher, MethodRegistry 5 | from pjrpc.server.utils import exclude_positional_param 6 | from pjrpc.server.validators import BaseValidatorFactory 7 | 8 | 9 | def test_middleware(mocker): 10 | test_result = 'the result' 11 | test_request = pjrpc.common.Request('test_method', params=dict(param='param'), id=1) 12 | test_response = pjrpc.common.Response(id=1, result=test_result) 13 | test_context = object() 14 | middleware_call_order = [] 15 | 16 | def test_method(context, param): 17 | assert context is test_context 18 | assert param == 'param' 19 | return test_result 20 | 21 | def test_middleware1(request, context, handler): 22 | middleware_call_order.append(test_middleware1) 23 | assert request == test_request 24 | assert context is test_context 25 | 26 | return handler(request, context) 27 | 28 | def test_middleware2(request, context, handler): 29 | middleware_call_order.append(test_middleware2) 30 | assert request == test_request 31 | assert context is test_context 32 | 33 | return handler(request, context) 34 | 35 | registry = MethodRegistry(validator_factory=BaseValidatorFactory(exclude=exclude_positional_param(0))) 36 | registry.add_method(test_method, 'test_method', pass_context=True) 37 | 38 | dispatcher = Dispatcher(middlewares=(test_middleware1, test_middleware2)) 39 | dispatcher.add_methods(registry) 40 | 41 | request_text = json.dumps(test_request.to_json()) 42 | response_text, error_codes = dispatcher.dispatch(request_text, test_context) 43 | actual_response = pjrpc.common.Response.from_json(json.loads(response_text)) 44 | assert actual_response == test_response 45 | assert error_codes == (0,) 46 | 47 | assert middleware_call_order == [test_middleware1, test_middleware2] 48 | 49 | 50 | async def test_async_middleware(mocker): 51 | test_result = 'the result' 52 | test_request = pjrpc.common.Request('test_method', params=dict(param='param'), id=1) 53 | test_response = pjrpc.common.Response(id=1, result=test_result) 54 | test_context = object() 55 | middleware_call_order = [] 56 | 57 | async def test_method(context, param): 58 | assert context is test_context 59 | assert param == 'param' 60 | return test_result 61 | 62 | async def test_middleware1(request, context, handler): 63 | middleware_call_order.append(test_middleware1) 64 | assert request == test_request 65 | assert context is test_context 66 | 67 | return await handler(request, context) 68 | 69 | async def test_middleware2(request, context, handler): 70 | middleware_call_order.append(test_middleware2) 71 | assert request == test_request 72 | assert context is test_context 73 | 74 | return await handler(request, context) 75 | 76 | registry = MethodRegistry(validator_factory=BaseValidatorFactory(exclude=exclude_positional_param(0))) 77 | registry.add_method(test_method, 'test_method', pass_context=True) 78 | 79 | dispatcher = AsyncDispatcher(middlewares=(test_middleware1, test_middleware2)) 80 | dispatcher.add_methods(registry) 81 | 82 | request_text = json.dumps(test_request.to_json()) 83 | response_text, error_codes = await dispatcher.dispatch(request_text, test_context) 84 | actual_response = pjrpc.common.Response.from_json(json.loads(response_text)) 85 | assert actual_response == test_response 86 | assert error_codes == (0,) 87 | 88 | assert middleware_call_order == [test_middleware1, test_middleware2] 89 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. pjrpc documentation master file, created by 2 | sphinx-quickstart on Wed Oct 23 21:38:52 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Python JSON-RPC without boilerplate 7 | =================================== 8 | 9 | 10 | .. image:: https://static.pepy.tech/personalized-badge/pjrpc?period=month&units=international_system&left_color=grey&right_color=orange&left_text=Downloads/month 11 | :target: https://pepy.tech/project/pjrpc 12 | :alt: Downloads/month 13 | .. image:: https://github.com/dapper91/pjrpc/actions/workflows/test.yml/badge.svg?branch=master 14 | :target: https://github.com/dapper91/pjrpc/actions/workflows/test.yml 15 | :alt: Build status 16 | .. image:: https://img.shields.io/pypi/l/pjrpc.svg 17 | :target: https://pypi.org/project/pjrpc 18 | :alt: License 19 | .. image:: https://img.shields.io/pypi/pyversions/pjrpc.svg 20 | :target: https://pypi.org/project/pjrpc 21 | :alt: Supported Python versions 22 | .. image:: https://codecov.io/gh/dapper91/pjrpc/branch/master/graph/badge.svg 23 | :target: https://codecov.io/gh/dapper91/pjrpc 24 | :alt: Code coverage 25 | .. image:: https://readthedocs.org/projects/pjrpc/badge/?version=stable&style=flat 26 | :alt: ReadTheDocs status 27 | :target: https://pjrpc.readthedocs.io/en/stable/ 28 | 29 | 30 | ``pjrpc`` is an extensible `JSON-RPC `_ client/server library with an intuitive interface 31 | that can be easily extended and integrated in your project without writing a lot of boilerplate code. 32 | 33 | Features: 34 | 35 | - :doc:`framework/library agnostic ` 36 | - :doc:`intuitive interface ` 37 | - :doc:`extensibility ` 38 | - :doc:`synchronous and asynchronous client backends ` 39 | - :doc:`popular frameworks integration ` (aiohttp, flask, aio_pika) 40 | - :doc:`builtin parameter validation ` 41 | - :doc:`pytest integration ` 42 | - :doc:`openapi schema generation support ` 43 | - :doc:`openrpc schema generation support ` 44 | - :doc:`web ui support (SwaggerUI, RapiDoc, ReDoc) ` 45 | 46 | 47 | Extra requirements 48 | ------------------ 49 | 50 | - `aiohttp `_ 51 | - `aio_pika `_ 52 | - `flask `_ 53 | - `pydantic `_ 54 | - `requests `_ 55 | - `httpx `_ 56 | - `openapi-ui-bundles `_ 57 | 58 | 59 | The User Guide 60 | -------------- 61 | 62 | .. toctree:: 63 | :maxdepth: 2 64 | 65 | pjrpc/installation 66 | pjrpc/quickstart 67 | pjrpc/client 68 | pjrpc/server 69 | pjrpc/validation 70 | pjrpc/errors 71 | pjrpc/extending 72 | pjrpc/testing 73 | pjrpc/tracing 74 | pjrpc/retries 75 | pjrpc/specification 76 | pjrpc/webui 77 | pjrpc/examples 78 | 79 | 80 | The API Documentation 81 | --------------------- 82 | 83 | .. toctree:: 84 | :maxdepth: 3 85 | 86 | pjrpc/api/index 87 | 88 | 89 | Development 90 | ----------- 91 | 92 | .. toctree:: 93 | :maxdepth: 2 94 | 95 | pjrpc/development 96 | 97 | 98 | Links 99 | ----- 100 | 101 | - `Source code `_ 102 | 103 | 104 | Indices and tables 105 | ================== 106 | 107 | * :ref:`genindex` 108 | * :ref:`modindex` 109 | * :ref:`search` 110 | -------------------------------------------------------------------------------- /pjrpc/server/specs/extractors/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import itertools as it 3 | from typing import Any, Callable, Iterable, Optional 4 | 5 | from pjrpc.common import UNSET, MaybeSet, UnsetType 6 | from pjrpc.server import exceptions 7 | 8 | __all__ = [ 9 | 'BaseMethodInfoExtractor', 10 | ] 11 | 12 | MethodType = Callable[..., Any] 13 | ExcludeFunc = Callable[[int, str, Optional[type[Any]], Optional[Any]], bool] 14 | 15 | 16 | class BaseMethodInfoExtractor: 17 | """ 18 | Base method schema extractor. 19 | """ 20 | 21 | def extract_params_schema( 22 | self, 23 | method_name: str, 24 | method: MethodType, 25 | ref_template: str, 26 | ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: 27 | """ 28 | Extracts params schema. 29 | """ 30 | 31 | return {}, {} 32 | 33 | def extract_request_schema( 34 | self, 35 | method_name: str, 36 | method: MethodType, 37 | ref_template: str, 38 | ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: 39 | """ 40 | Extracts request schema. 41 | """ 42 | 43 | return {}, {} 44 | 45 | def extract_result_schema( 46 | self, 47 | method_name: str, 48 | method: MethodType, 49 | ref_template: str, 50 | ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: 51 | """ 52 | Extracts result schema. 53 | """ 54 | 55 | return {}, {} 56 | 57 | def extract_response_schema( 58 | self, 59 | method_name: str, 60 | method: MethodType, 61 | ref_template: str, 62 | errors: Optional[Iterable[type[exceptions.TypedError]]] = None, 63 | ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: 64 | """ 65 | Extracts response schema. 66 | """ 67 | 68 | return {}, {} 69 | 70 | def extract_error_response_schema( 71 | self, 72 | method_name: str, 73 | method: MethodType, 74 | ref_template: str, 75 | errors: Optional[Iterable[type[exceptions.TypedError]]] = None, 76 | ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: 77 | """ 78 | Extracts error response schema. 79 | """ 80 | 81 | return {}, {} 82 | 83 | def extract_description(self, method: MethodType) -> MaybeSet[str]: 84 | """ 85 | Extracts method description. 86 | """ 87 | 88 | description: MaybeSet[str] 89 | if method.__doc__: 90 | doc = inspect.cleandoc(method.__doc__) 91 | description = '\n'.join(it.takewhile(lambda line: line, doc.split('\n'))) 92 | else: 93 | description = UNSET 94 | 95 | return description 96 | 97 | def extract_summary(self, method: MethodType) -> MaybeSet[str]: 98 | """ 99 | Extracts method summary. 100 | """ 101 | 102 | description = self.extract_description(method) 103 | 104 | summary: MaybeSet[str] 105 | if not isinstance(description, UnsetType): 106 | summary = description.split('.')[0] 107 | else: 108 | summary = UNSET 109 | 110 | return summary 111 | 112 | def extract_errors(self, method: MethodType) -> MaybeSet[list[type[exceptions.TypedError]]]: 113 | """ 114 | Extracts method errors. 115 | """ 116 | 117 | return UNSET 118 | 119 | def extract_deprecation_status(self, method: MethodType) -> MaybeSet[bool]: 120 | """ 121 | Extracts method deprecation status. 122 | """ 123 | 124 | return UNSET 125 | -------------------------------------------------------------------------------- /pjrpc/server/specs/openapi/ui.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | import pathlib 3 | import re 4 | from typing import Any 5 | 6 | import openapi_ui_bundles 7 | 8 | from pjrpc.server.specs import BaseUI 9 | 10 | 11 | class SwaggerUI(BaseUI): 12 | """ 13 | Swagger UI. 14 | 15 | :param config: documentation configurations 16 | (see https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md). 17 | """ 18 | 19 | def __init__(self, **configs: Any): 20 | self._configs = configs 21 | self._static_folder = pathlib.Path(openapi_ui_bundles.swagger_ui.static_path) 22 | 23 | def get_static_folder(self) -> pathlib.Path: 24 | return self._static_folder 25 | 26 | @ft.lru_cache 27 | def get_index_page(self, spec_url: str) -> str: 28 | index_path = self.get_static_folder() / 'index.html' 29 | index_page = index_path.read_text() 30 | 31 | config = dict(self._configs, **{'url': spec_url, 'dom_id': '#swagger-ui'}) 32 | config_str = ', '.join(f'{param}: "{value}"' for param, value in config.items()) 33 | 34 | return re.sub( 35 | pattern=r'SwaggerUIBundle\({.*?}\)', 36 | repl=f'SwaggerUIBundle({{ {config_str} }})', 37 | string=index_page, 38 | count=1, 39 | flags=re.DOTALL, 40 | ) 41 | 42 | 43 | class RapiDoc(BaseUI): 44 | """ 45 | RapiDoc UI. 46 | 47 | :param config: documentation configurations (see https://mrin9.github.io/RapiDoc/api.html). 48 | Be aware that configuration parameters should be in snake case, 49 | for example: parameter `heading-text` should be passed as `heading_text`) 50 | """ 51 | 52 | def __init__(self, **configs: Any): 53 | self._configs = configs 54 | self._static_folder = pathlib.Path(openapi_ui_bundles.rapidoc.static_path) 55 | 56 | def get_static_folder(self) -> pathlib.Path: 57 | return self._static_folder 58 | 59 | @ft.lru_cache 60 | def get_index_page(self, spec_url: str) -> str: 61 | index_path = pathlib.Path(self.get_static_folder()) / 'index.html' 62 | index_page = index_path.read_text() 63 | 64 | config = dict(self._configs, **{'spec_url': spec_url, 'id': 'thedoc'}) 65 | config_str = ' '.join(f'{param.replace("_", "-")}="{value}"' for param, value in config.items()) 66 | 67 | return re.sub( 68 | pattern='', 69 | repl=f'', 70 | string=index_page, 71 | count=1, 72 | flags=re.DOTALL, 73 | ) 74 | 75 | 76 | class ReDoc(BaseUI): 77 | """ 78 | ReDoc UI. 79 | 80 | :param config: documentation configurations (see https://github.com/Redocly/redoc#configuration). 81 | Be aware that configuration parameters should be in snake case, 82 | for example: parameter `heading-text` should be passed as `heading_text`) 83 | """ 84 | 85 | def __init__(self, **configs: Any): 86 | self._configs = configs 87 | self._static_folder = pathlib.Path(openapi_ui_bundles.redoc.static_path) 88 | 89 | def get_static_folder(self) -> pathlib.Path: 90 | return self._static_folder 91 | 92 | @ft.lru_cache 93 | def get_index_page(self, spec_url: str) -> str: 94 | index_path = pathlib.Path(self.get_static_folder()) / 'index.html' 95 | index_page = index_path.read_text() 96 | 97 | config = dict(self._configs, **{'spec_url': spec_url}) 98 | config_str = ' '.join(f'{param.replace("_", "-")}="{value}"' for param, value in config.items()) 99 | 100 | return re.sub( 101 | pattern='', 102 | repl=f'', 103 | string=index_page, 104 | count=1, 105 | flags=re.DOTALL, 106 | ) 107 | -------------------------------------------------------------------------------- /docs/source/pjrpc/tracing.rst: -------------------------------------------------------------------------------- 1 | .. _tracing: 2 | 3 | Tracing 4 | ======= 5 | 6 | ``pjrpc`` supports client and server metrics collection. 7 | 8 | 9 | client 10 | ------ 11 | 12 | The following example illustrate opentracing integration. 13 | 14 | .. code-block:: python 15 | 16 | from typing import Any, Mapping, Optional 17 | 18 | import opentracing 19 | from opentracing import propagation, tags 20 | 21 | from pjrpc.client import MiddlewareHandler 22 | from pjrpc.client.backend import requests as pjrpc_client 23 | from pjrpc.common import AbstractRequest, AbstractResponse, BatchRequest, Request 24 | 25 | tracer = opentracing.global_tracer() 26 | 27 | 28 | def tracing_middleware( 29 | request: AbstractRequest, 30 | request_kwargs: Mapping[str, Any], 31 | /, 32 | handler: MiddlewareHandler, 33 | ) -> Optional[AbstractResponse]: 34 | if isinstance(request, Request): 35 | span = tracer.start_active_span(f'jsonrpc.{request.method}').span 36 | span.set_tag(tags.COMPONENT, 'pjrpc.client') 37 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_CLIENT) 38 | if http_headers := request_kwargs.get('headers', {}): 39 | tracer.inject( 40 | span_context=span, 41 | format=propagation.Format.HTTP_HEADERS, 42 | carrier=http_headers, 43 | ) 44 | 45 | response = handler(request, request_kwargs) 46 | if response.is_error: 47 | span = tracer.active_span 48 | span.set_tag(tags.ERROR, response.is_error) 49 | span.set_tag('jsonrpc.error_code', response.unwrap_error().code) 50 | span.set_tag('jsonrpc.error_message', response.unwrap_error().message) 51 | 52 | span.finish() 53 | 54 | elif isinstance(request, BatchRequest): 55 | response = handler(request, request_kwargs) 56 | 57 | else: 58 | raise AssertionError("unreachable") 59 | 60 | return response 61 | 62 | 63 | client = pjrpc_client.Client( 64 | 'http://localhost:8080/api/v1', 65 | middlewares=[ 66 | tracing_middleware, 67 | ], 68 | ) 69 | 70 | result = client.proxy.sum(1, 2) 71 | 72 | 73 | 74 | server 75 | ------ 76 | 77 | On the server side you need to implement simple functions (middlewares) and pass them to the JSON-RPC application. 78 | The following example illustrate prometheus metrics collection: 79 | 80 | .. code-block:: python 81 | 82 | import asyncio 83 | 84 | import opentracing 85 | from aiohttp import web 86 | from aiohttp.typedefs import Handler as HttpHandler 87 | from opentracing import tags 88 | 89 | import pjrpc.server 90 | from pjrpc import Request, Response 91 | from pjrpc.server import AsyncHandlerType 92 | from pjrpc.server.integration import aiohttp 93 | 94 | methods = pjrpc.server.MethodRegistry() 95 | 96 | 97 | @methods.add(pass_context='context') 98 | async def sum(context: web.Request, a: int, b: int) -> int: 99 | print("method started") 100 | await asyncio.sleep(1) 101 | print("method finished") 102 | 103 | return a + b 104 | 105 | 106 | async def jsonrpc_tracing_middleware(request: Request, context: web.Request, handler: AsyncHandlerType) -> Response: 107 | tracer = opentracing.global_tracer() 108 | span = tracer.start_span(f'jsonrpc.{request.method}') 109 | 110 | span.set_tag(tags.COMPONENT, 'pjrpc') 111 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_SERVER) 112 | span.set_tag('jsonrpc.version', request.version) 113 | span.set_tag('jsonrpc.id', request.id) 114 | span.set_tag('jsonrpc.method', request.method) 115 | 116 | with tracer.scope_manager.activate(span, finish_on_close=True): 117 | if response := await handler(request, context): 118 | if response.is_error: 119 | span.set_tag('jsonrpc.error_code', response.error.code) 120 | span.set_tag('jsonrpc.error_message', response.error.message) 121 | span.set_tag(tags.ERROR, True) 122 | else: 123 | span.set_tag(tags.ERROR, False) 124 | 125 | return response 126 | 127 | jsonrpc_app = aiohttp.Application( 128 | '/api/v1', 129 | middlewares=[ 130 | jsonrpc_tracing_middleware, 131 | ], 132 | ) 133 | jsonrpc_app.add_methods(methods) 134 | 135 | if __name__ == "__main__": 136 | web.run_app(jsonrpc_app.http_app, host='localhost', port=8080) 137 | -------------------------------------------------------------------------------- /pjrpc/server/validators/pydantic_v1.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Optional 3 | 4 | import pydantic 5 | 6 | from pjrpc.common.typedefs import JsonRpcParamsT 7 | from pjrpc.server.dispatcher import Method 8 | 9 | from . import base 10 | from .base import ExcludeFunc, MethodType 11 | 12 | 13 | class PydanticValidatorFactory(base.BaseValidatorFactory): 14 | """ 15 | Method parameters validator factory based on `pydantic `_ library. 16 | Uses python type annotations for parameters validation. 17 | 18 | :param exclude: a function that decides if the parameters must be excluded 19 | from validation (useful for dependency injection) 20 | """ 21 | 22 | def __init__(self, exclude: Optional[ExcludeFunc] = None, **config_args: Any): 23 | super().__init__(exclude=exclude) 24 | 25 | config_args.setdefault('extra', 'forbid') 26 | self._model_config = config_args 27 | 28 | def __call__(self, method: Method) -> Method: 29 | self.build(method.func) 30 | return method 31 | 32 | def build(self, method: MethodType) -> 'PydanticValidator': 33 | return PydanticValidator(method, self._model_config, self._exclude) 34 | 35 | 36 | class PydanticValidator(base.BaseValidator): 37 | """ 38 | Pydantic method parameters validator based on `pydantic `_ library. 39 | """ 40 | 41 | def __init__( 42 | self, 43 | method: MethodType, 44 | model_config: dict[str, Any], 45 | exclude: Optional[ExcludeFunc] = None, 46 | ): 47 | super().__init__(method, exclude) 48 | self._model_config = model_config 49 | self._params_model = self._build_validation_model(method.__name__) 50 | 51 | def validate_params(self, params: Optional['JsonRpcParamsT']) -> dict[str, Any]: 52 | """ 53 | Validates params against method using ``pydantic`` validator. 54 | 55 | :param params: parameters to be validated 56 | """ 57 | 58 | model_fields: tuple[str, ...] = tuple(self._params_model.__fields__) # type: ignore[arg-type] 59 | 60 | if isinstance(params, dict): 61 | params_dict = params 62 | elif isinstance(params, (list, tuple)): 63 | if len(params) > len(fields := list(model_fields)): 64 | fields.extend((f'params.{n}' for n in range(len(fields), len(params) + 1))) 65 | params_dict = {name: value for name, value in zip(fields, params)} 66 | else: 67 | raise AssertionError("unreachable") 68 | 69 | try: 70 | obj = self._params_model.parse_obj(params_dict) 71 | except pydantic.ValidationError as e: 72 | raise base.ValidationError(*e.errors()) from e 73 | 74 | return {field_name: obj.__dict__[field_name] for field_name in model_fields} 75 | 76 | def _build_validation_model(self, method_name: str) -> type[pydantic.BaseModel]: 77 | schema = self._build_validation_schema(self._signature) 78 | return pydantic.create_model(method_name, **schema, __config__=pydantic.config.get_config(self._model_config)) 79 | 80 | def _build_validation_schema(self, signature: inspect.Signature) -> dict[str, Any]: 81 | """ 82 | Builds pydantic model based validation schema from method signature. 83 | 84 | :param signature: method signature to build schema for 85 | :returns: validation schema 86 | """ 87 | 88 | field_definitions: dict[str, tuple[Any, Any]] = {} 89 | 90 | for param in signature.parameters.values(): 91 | if param.kind is inspect.Parameter.VAR_KEYWORD: 92 | field_definitions[param.name] = ( 93 | Optional[dict[str, param.annotation]] # type: ignore 94 | if param.annotation is not inspect.Parameter.empty else Any, 95 | param.default if param.default is not inspect.Parameter.empty else None, 96 | ) 97 | elif param.kind is inspect.Parameter.VAR_POSITIONAL: 98 | field_definitions[param.name] = ( 99 | Optional[dict[param.annotation]] # type: ignore 100 | if param.annotation is not inspect.Parameter.empty else Any, 101 | param.default if param.default is not inspect.Parameter.empty else None, 102 | ) 103 | else: 104 | field_definitions[param.name] = ( 105 | param.annotation if param.annotation is not inspect.Parameter.empty else Any, 106 | param.default if param.default is not inspect.Parameter.empty else ..., 107 | ) 108 | 109 | return field_definitions 110 | -------------------------------------------------------------------------------- /pjrpc/server/validators/pydantic.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Optional 3 | 4 | import pydantic 5 | 6 | from pjrpc.common.typedefs import JsonRpcParamsT 7 | from pjrpc.server.dispatcher import Method 8 | 9 | from . import base 10 | from .base import ExcludeFunc, MethodType 11 | 12 | 13 | class PydanticValidatorFactory(base.BaseValidatorFactory): 14 | """ 15 | Method parameters validator factory based on `pydantic `_ library. 16 | Uses python type annotations for parameters validation. 17 | 18 | :param exclude: a function that decides if the parameters must be excluded 19 | from validation (useful for dependency injection) 20 | """ 21 | 22 | def __init__(self, exclude: Optional[ExcludeFunc] = None, **config_args: Any): 23 | super().__init__(exclude=exclude) 24 | 25 | config_args.setdefault('extra', 'forbid') 26 | 27 | # https://pydantic-docs.helpmanual.io/usage/model_config/ 28 | self._model_config = pydantic.ConfigDict(**config_args) # type: ignore[typeddict-item] 29 | 30 | def __call__(self, method: Method) -> Method: 31 | self.build(method.func) 32 | return method 33 | 34 | def build(self, method: MethodType) -> 'PydanticValidator': 35 | return PydanticValidator(method, self._model_config, self._exclude) 36 | 37 | 38 | class PydanticValidator(base.BaseValidator): 39 | """ 40 | Pydantic method parameters validator based on `pydantic `_ library. 41 | """ 42 | 43 | def __init__( 44 | self, 45 | method: MethodType, 46 | model_config: pydantic.ConfigDict, 47 | exclude: Optional[ExcludeFunc] = None, 48 | ): 49 | super().__init__(method, exclude) 50 | self._model_config = model_config 51 | self._params_model = self._build_validation_model(method.__name__) 52 | 53 | def validate_params(self, params: Optional['JsonRpcParamsT']) -> dict[str, Any]: 54 | """ 55 | Validates params against method using pydantic validator. 56 | 57 | :param params: parameters to be validated 58 | """ 59 | 60 | if isinstance(params, dict): 61 | params_dict = params 62 | elif isinstance(params, (list, tuple)): 63 | if len(params) > len(fields := list(self._params_model.model_fields)): 64 | fields.extend((f'params.{n}' for n in range(len(fields), len(params) + 1))) 65 | params_dict = {name: value for name, value in zip(fields, params)} 66 | else: 67 | raise AssertionError("unreachable") 68 | 69 | try: 70 | obj = self._params_model.model_validate(params_dict) 71 | except pydantic.ValidationError as e: 72 | raise base.ValidationError(*e.errors()) from e 73 | 74 | return {field_name: obj.__dict__[field_name] for field_name in self._params_model.model_fields} 75 | 76 | def _build_validation_model(self, method_name: str) -> type[pydantic.BaseModel]: 77 | schema = self._build_validation_schema(self._signature) 78 | return pydantic.create_model(method_name, **schema, __config__=self._model_config) 79 | 80 | def _build_validation_schema(self, signature: inspect.Signature) -> dict[str, Any]: 81 | """ 82 | Builds pydantic model based validation schema from method signature. 83 | 84 | :param signature: method signature to build schema for 85 | :returns: validation schema 86 | """ 87 | 88 | field_definitions: dict[str, tuple[Any, Any]] = {} 89 | 90 | for param in signature.parameters.values(): 91 | if param.kind is inspect.Parameter.VAR_KEYWORD: 92 | field_definitions[param.name] = ( 93 | Optional[dict[str, param.annotation]] # type: ignore 94 | if param.annotation is not inspect.Parameter.empty else Any, 95 | param.default if param.default is not inspect.Parameter.empty else None, 96 | ) 97 | elif param.kind is inspect.Parameter.VAR_POSITIONAL: 98 | field_definitions[param.name] = ( 99 | Optional[dict[param.annotation]] # type: ignore 100 | if param.annotation is not inspect.Parameter.empty else Any, 101 | param.default if param.default is not inspect.Parameter.empty else None, 102 | ) 103 | else: 104 | field_definitions[param.name] = ( 105 | param.annotation if param.annotation is not inspect.Parameter.empty else Any, 106 | param.default if param.default is not inspect.Parameter.empty else ..., 107 | ) 108 | 109 | return field_definitions 110 | -------------------------------------------------------------------------------- /pjrpc/server/specs/schemas.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Any, Dict, Iterable, List, Type 3 | 4 | from pjrpc.server.exceptions import TypedError 5 | 6 | REQUEST_SCHEMA: Dict[str, Any] = { 7 | 'title': 'Request', 8 | 'type': 'object', 9 | 'properties': { 10 | 'jsonrpc': { 11 | 'title': 'Version', 12 | 'description': 'JSON-RPC protocol version', 13 | 'type': 'string', 14 | 'enum': ['2.0', '1.0'], 15 | }, 16 | 'id': { 17 | 'title': 'Id', 18 | 'description': 'Request identifier', 19 | 'anyOf': [ 20 | {'type': 'string'}, 21 | {'type': 'integer'}, 22 | {'type': 'null'}, 23 | ], 24 | 'examples': [1], 25 | 'default': None, 26 | }, 27 | 'method': { 28 | 'title': 'Method', 29 | 'description': 'Method name', 30 | 'type': 'string', 31 | }, 32 | 'params': { 33 | 'title': 'Parameters', 34 | 'description': 'Method parameters', 35 | 'type': 'object', 36 | 'properties': {}, 37 | }, 38 | }, 39 | 'required': ['jsonrpc', 'method', 'params'], 40 | 'additionalProperties': False, 41 | } 42 | 43 | RESULT_SCHEMA: Dict[str, Any] = { 44 | 'title': 'Success', 45 | 'type': 'object', 46 | 'properties': { 47 | 'jsonrpc': { 48 | 'title': 'Version', 49 | 'description': 'JSON-RPC protocol version', 50 | 'type': 'string', 51 | 'enum': ['2.0', '1.0'], 52 | }, 53 | 'id': { 54 | 'title': 'Id', 55 | 'description': 'Request identifier', 56 | 'anyOf': [ 57 | {'type': 'string'}, 58 | {'type': 'integer'}, 59 | ], 60 | 'examples': [1], 61 | }, 62 | 'result': {}, 63 | }, 64 | 'required': ['jsonrpc', 'id', 'result'], 65 | 'additionalProperties': False, 66 | } 67 | ERROR_SCHEMA: Dict[str, Any] = { 68 | 'title': 'Error', 69 | 'type': 'object', 70 | 'properties': { 71 | 'jsonrpc': { 72 | 'title': 'Version', 73 | 'description': 'JSON-RPC protocol version', 74 | 'type': 'string', 75 | 'enum': ['2.0', '1.0'], 76 | }, 77 | 'id': { 78 | 'title': 'Id', 79 | 'description': 'Request identifier', 80 | 'anyOf': [ 81 | {'type': 'string'}, 82 | {'type': 'integer'}, 83 | ], 84 | 'examples': [1], 85 | }, 86 | 'error': { 87 | 'type': 'object', 88 | 'properties': { 89 | 'code': { 90 | 'title': 'Code', 91 | 'description': 'Error code', 92 | 'type': 'integer', 93 | }, 94 | 'message': { 95 | 'title': 'Message', 96 | 'description': 'Error message', 97 | 'type': 'string', 98 | }, 99 | 'data': { 100 | 'title': 'Data', 101 | 'description': 'Error additional data', 102 | 'type': 'object', 103 | }, 104 | }, 105 | 'required': ['code', 'message'], 106 | 'additionalProperties': False, 107 | }, 108 | }, 109 | 'required': ['jsonrpc', 'error'], 110 | 'additionalProperties': False, 111 | } 112 | 113 | 114 | def build_request_schema(method_name: str, parameters_schema: Dict[str, Any]) -> Dict[str, Any]: 115 | reqeust_schema = copy.deepcopy(REQUEST_SCHEMA) 116 | 117 | reqeust_schema['properties']['method']['const'] = method_name 118 | reqeust_schema['properties']['params'] = { 119 | 'title': 'Parameters', 120 | 'description': 'Reqeust parameters', 121 | 'type': 'object', 122 | 'properties': parameters_schema, 123 | 'additionalProperties': False, 124 | } 125 | 126 | return reqeust_schema 127 | 128 | 129 | def build_response_schema(result_schema: Dict[str, Any], errors: Iterable[Type[TypedError]]) -> Dict[str, Any]: 130 | response_schema = copy.deepcopy(RESULT_SCHEMA) 131 | response_schema['properties']['result'] = result_schema 132 | 133 | if errors: 134 | error_schemas: List[Dict[str, Any]] = [] 135 | for error in errors: 136 | error_schema = copy.deepcopy(ERROR_SCHEMA) 137 | error_props = error_schema['properties']['error']['properties'] 138 | error_props['code']['const'] = error.CODE 139 | error_props['message']['const'] = error.MESSAGE 140 | error_schemas.append(error_schema) 141 | 142 | response_schema = {'oneOf': [response_schema] + error_schemas} 143 | 144 | return response_schema 145 | -------------------------------------------------------------------------------- /pjrpc/common/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Definition of package exceptions and JSON-RPC protocol errors. 3 | """ 4 | 5 | import dataclasses as dc 6 | from typing import Any, ClassVar, Optional 7 | 8 | from .common import UNSET, MaybeSet 9 | 10 | JsonT = Any 11 | 12 | 13 | class BaseError(Exception): 14 | """ 15 | Base package error. All package errors are inherited from it. 16 | """ 17 | 18 | 19 | class ProtocolError(BaseError): 20 | """ 21 | Raised when JSON-RPC protocol is violated. 22 | """ 23 | 24 | 25 | class IdentityError(ProtocolError): 26 | """ 27 | Raised when a batch requests/responses identifiers are not unique or missing. 28 | """ 29 | 30 | 31 | class DeserializationError(ProtocolError, ValueError): 32 | """ 33 | Request/response deserializatoin error. 34 | Raised when request/response json has incorrect format. 35 | """ 36 | 37 | 38 | @dc.dataclass 39 | class JsonRpcError(BaseError): 40 | """ 41 | `JSON-RPC `_ protocol error. 42 | For more information see `Error object `_. 43 | All JSON-RPC protocol errors are inherited from it. 44 | 45 | :param code: number that indicates the error type 46 | :param message: short description of the error 47 | :param data: value that contains additional information about the error. May be omitted. 48 | """ 49 | 50 | code: int 51 | message: str 52 | data: MaybeSet[JsonT] = dc.field(repr=False, default=UNSET) 53 | 54 | @classmethod 55 | def get_typed_error_by_code(cls, code: int, message: str, data: MaybeSet[JsonT]) -> Optional['JsonRpcError']: 56 | return None 57 | 58 | @classmethod 59 | def from_json(cls, json_data: JsonT) -> 'JsonRpcError': 60 | """ 61 | Deserializes an error from json data. If data format is not correct :py:class:`ValueError` is raised. 62 | 63 | :param json_data: json data the error to be deserialized from 64 | 65 | :returns: deserialized error 66 | :raises: :py:class:`pjrpc.common.exceptions.DeserializationError` if format is incorrect 67 | """ 68 | 69 | try: 70 | if not isinstance(json_data, dict): 71 | raise DeserializationError("data must be of type dict") 72 | 73 | code = json_data['code'] 74 | if not isinstance(code, int): 75 | raise DeserializationError("field 'code' must be of type integer") 76 | 77 | message = json_data['message'] 78 | if not isinstance(message, str): 79 | raise DeserializationError("field 'message' must be of type string") 80 | 81 | data = json_data.get('data', UNSET) 82 | 83 | if typed_error := cls.get_typed_error_by_code(code, message, data): 84 | return typed_error 85 | else: 86 | return cls(code, message, data) 87 | 88 | except KeyError as e: 89 | raise DeserializationError(f"required field {e} not found") from e 90 | 91 | def to_json(self) -> JsonT: 92 | """ 93 | Serializes the error into a dict. 94 | 95 | :returns: serialized error 96 | """ 97 | 98 | json: dict[str, JsonT] = { 99 | 'code': self.code, 100 | 'message': self.message, 101 | } 102 | if self.data is not UNSET: 103 | json.update(data=self.data) 104 | 105 | return json 106 | 107 | def __str__(self) -> str: 108 | return f"({self.code}) {self.message}" 109 | 110 | 111 | class ParseError: 112 | """ 113 | Invalid JSON was received by the server. 114 | An error occurred on the server while parsing the JSON text. 115 | """ 116 | 117 | CODE: ClassVar[int] = -32700 118 | MESSAGE: ClassVar[str] = 'Parse error' 119 | 120 | 121 | class InvalidRequestError: 122 | """ 123 | The JSON sent is not a valid request object. 124 | """ 125 | 126 | CODE: ClassVar[int] = -32600 127 | MESSAGE: ClassVar[str] = 'Invalid Request' 128 | 129 | 130 | class MethodNotFoundError: 131 | """ 132 | The method does not exist / is not available. 133 | """ 134 | 135 | CODE: ClassVar[int] = -32601 136 | MESSAGE: ClassVar[str] = 'Method not found' 137 | 138 | 139 | class InvalidParamsError: 140 | """ 141 | Invalid method parameter(s). 142 | """ 143 | 144 | CODE: ClassVar[int] = -32602 145 | MESSAGE: ClassVar[str] = 'Invalid params' 146 | 147 | 148 | class InternalError: 149 | """ 150 | Internal JSON-RPC error. 151 | """ 152 | 153 | CODE: ClassVar[int] = -32603 154 | MESSAGE: ClassVar[str] = 'Internal error' 155 | 156 | 157 | class ServerError: 158 | """ 159 | Reserved for implementation-defined server-errors. 160 | Codes from -32000 to -32099. 161 | """ 162 | 163 | CODE: ClassVar[int] = -32000 164 | MESSAGE: ClassVar[str] = 'Server error' 165 | -------------------------------------------------------------------------------- /tests/server/test_flask.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import pytest 3 | 4 | from pjrpc.common import BatchRequest, Request, Response 5 | from pjrpc.server import exceptions 6 | from pjrpc.server.dispatcher import MethodRegistry 7 | from pjrpc.server.integration import flask as integration 8 | from tests.common import _ 9 | 10 | 11 | @pytest.fixture 12 | def path(): 13 | return '/test/path' 14 | 15 | 16 | @pytest.fixture 17 | def app(): 18 | return flask.Flask(__name__) 19 | 20 | 21 | @pytest.fixture 22 | def json_rpc(app, path): 23 | json_rpc = integration.JsonRPC(path, http_app=app) 24 | 25 | return json_rpc 26 | 27 | 28 | @pytest.mark.parametrize( 29 | 'request_id, params, result', [ 30 | ( 31 | 1, 32 | (1, 1.1, 'a', {}, False), 33 | [1, 1.1, 'a', {}, False], 34 | ), 35 | ( 36 | 'abc', 37 | {'int': 1, 'float': 1.1, 'str': 'a', 'dict': {}, 'bool': False}, 38 | {'int': 1, 'float': 1.1, 'str': 'a', 'dict': {}, 'bool': False}, 39 | ), 40 | ], 41 | ) 42 | def test_request(app, json_rpc, path, mocker, request_id, params, result): 43 | method_name = 'test_method' 44 | mock = mocker.Mock(name=method_name, return_value=result) 45 | 46 | registry = MethodRegistry() 47 | registry.add_method(mock, method_name) 48 | json_rpc.add_methods(registry) 49 | 50 | with app.test_client() as cli: 51 | raw = cli.post(path, json=Request(method=method_name, params=params, id=request_id).to_json()) 52 | assert raw.status_code == 200 53 | 54 | resp = Response.from_json(raw.json, error_cls=exceptions.JsonRpcError) 55 | 56 | if isinstance(params, dict): 57 | mock.assert_called_once_with(kwargs=params) 58 | else: 59 | mock.assert_called_once_with(args=params) 60 | 61 | assert resp.id == request_id 62 | assert resp.result == result 63 | 64 | 65 | def test_notify(app, json_rpc, path, mocker): 66 | params = [1, 2] 67 | method_name = 'test_method' 68 | mock = mocker.Mock(name=method_name, return_value='result') 69 | 70 | registry = MethodRegistry() 71 | registry.add_method(mock, method_name) 72 | json_rpc.add_methods(registry) 73 | 74 | with app.test_client() as cli: 75 | raw = cli.post(path, json=Request(method=method_name, params=params).to_json()) 76 | assert raw.status_code == 200 77 | assert raw.is_json is False 78 | assert raw.data == b'' 79 | 80 | 81 | def test_errors(app, json_rpc, path, mocker): 82 | request_id = 1 83 | params = (1, 2) 84 | method_name = 'test_method' 85 | 86 | def error_method(*args, **kwargs): 87 | raise exceptions.JsonRpcError(code=1, message='message') 88 | 89 | mock = mocker.Mock(name=method_name, side_effect=error_method) 90 | 91 | registry = MethodRegistry() 92 | registry.add_method(mock, method_name) 93 | json_rpc.add_methods(registry) 94 | 95 | with app.test_client() as cli: 96 | # method not found 97 | raw = cli.post(path, json=Request(method='unknown_method', params=params, id=request_id).to_json()) 98 | assert raw.status_code == 200 99 | 100 | resp = Response.from_json(raw.json, error_cls=exceptions.JsonRpcError) 101 | assert resp.id is request_id 102 | assert resp.is_error is True 103 | assert resp.error == exceptions.MethodNotFoundError(data="method 'unknown_method' not found") 104 | 105 | # customer error 106 | raw = cli.post(path, json=Request(method=method_name, params=params, id=request_id).to_json()) 107 | assert raw.status_code == 200 108 | 109 | resp = Response.from_json(raw.json, error_cls=exceptions.JsonRpcError) 110 | mock.assert_called_once_with(args=params) 111 | assert resp.id == request_id 112 | assert resp.is_error is True 113 | assert resp.error == exceptions.JsonRpcError(code=1, message='message') 114 | 115 | # content type error 116 | raw = cli.post(path, data='') 117 | assert raw.status_code == 415 118 | 119 | # malformed json 120 | raw = cli.post(path, headers={'Content-Type': 'application/json'}, data='') 121 | assert raw.status_code == 200 122 | resp = Response.from_json(raw.json, error_cls=exceptions.JsonRpcError) 123 | assert resp.id is None 124 | assert resp.is_error is True 125 | assert resp.error == exceptions.ParseError(data=_) 126 | 127 | 128 | async def test_http_status(app, path): 129 | expected_http_status = 400 130 | json_rpc = integration.JsonRPC(path, http_app=app, status_by_error=lambda codes: expected_http_status) 131 | json_rpc.add_methods(MethodRegistry()) 132 | 133 | with app.test_client() as cli: 134 | raw = cli.post(path, json=Request(method='unknown_method', id=1).to_json()) 135 | assert raw.status_code == expected_http_status 136 | 137 | raw = cli.post(path, json=BatchRequest(Request(method='unknown_method', id=1)).to_json()) 138 | assert raw.status_code == expected_http_status 139 | -------------------------------------------------------------------------------- /docs/source/pjrpc/errors.rst: -------------------------------------------------------------------------------- 1 | .. _errors: 2 | 3 | Errors 4 | ====== 5 | 6 | 7 | Errors handling 8 | --------------- 9 | 10 | ``pjrpc`` implements all the errors listed in `protocol specification `_: 11 | 12 | .. csv-table:: 13 | :header: "code", "message", "meaning" 14 | :widths: 15, 15, 70 15 | 16 | -32700 , Parse error , Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. 17 | -32700 , Parse error , Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. 18 | -32600 , Invalid Request , The JSON sent is not a valid Request object. 19 | -32601 , Method not found , The method does not exist / is not available. 20 | -32602 , Invalid params , Invalid method parameter(s). 21 | -32603 , Internal error , Internal JSON-RPC error. 22 | -32000 to -32099 , Server error , Reserved for implementation-defined server-errors. 23 | 24 | 25 | Errors can be found in :py:mod:`pjrpc.client.exceptions` module. Having said that error handling 26 | is very simple and "pythonic-way": 27 | 28 | .. code-block:: python 29 | 30 | import pjrpc 31 | from pjrpc.client.backend import requests as pjrpc_client 32 | 33 | client = pjrpc_client.Client('http://localhost/api/v1') 34 | 35 | try: 36 | result = client.proxy.sum(1, 2) 37 | except pjrpc.client.exceptions.MethodNotFound as e: 38 | print(e) 39 | 40 | 41 | Custom errors 42 | ------------- 43 | 44 | Default error list can be easily extended. All you need to create an error class inherited from 45 | :py:class:`pjrpc.client.exceptions.TypedError` and define an error code and a description message. ``pjrpc`` 46 | will be automatically deserializing custom errors for you: 47 | 48 | .. code-block:: python 49 | 50 | import pjrpc 51 | from pjrpc.client.backend import requests as pjrpc_client 52 | 53 | class UserNotFound(pjrpc.client.exceptions.TypedError): 54 | CODE = 1 55 | MESSAGE = 'user not found' 56 | 57 | 58 | client = pjrpc_client.Client('http://localhost/api/v1') 59 | 60 | try: 61 | result = client.proxy.get_user(user_id=1) 62 | except UserNotFound as e: 63 | print(e) 64 | 65 | 66 | Server side 67 | ----------- 68 | 69 | On the server side everything is also pretty straightforward: 70 | 71 | .. code-block:: python 72 | 73 | import uuid 74 | 75 | import flask 76 | 77 | import pjrpc 78 | from pjrpc.server import MethodRegistry 79 | from pjrpc.server.integration import flask as integration 80 | 81 | methods = pjrpc.server.MethodRegistry() 82 | 83 | 84 | class UserNotFound(pjrpc.server.exceptions.TypedError): 85 | CODE = 1 86 | MESSAGE = 'user not found' 87 | 88 | @methods.add() 89 | def add_user(user: dict): 90 | user_id = uuid.uuid4().hex 91 | flask.current_app.users[user_id] = user 92 | 93 | return {'id': user_id, **user} 94 | 95 | @methods.add() 96 | def get_user(self, user_id: str): 97 | user = flask.current_app.users.get(user_id) 98 | if not user: 99 | raise UserNotFound(data=user_id) 100 | 101 | return user 102 | 103 | 104 | json_rpc = integration.JsonRPC('/api/v1') 105 | json_rpc.add_methods(methods) 106 | 107 | json_rpc.http_app.users = {} 108 | 109 | if __name__ == "__main__": 110 | json_rpc.http_app.run(port=80) 111 | 112 | 113 | Independent clients errors 114 | -------------------------- 115 | 116 | Having multiple JSON-RPC services with overlapping error codes is a "real-world" case everyone has ever dialed with. 117 | To handle such situation the error must be marked as base error, the client has an `error_cls` argument 118 | to set a base error class for a particular client: 119 | 120 | .. code-block:: python 121 | 122 | import pjrpc 123 | from pjrpc.client.backend import requests as jrpc_client 124 | 125 | 126 | class ErrorV1(pjrpc.client.exceptions.TypeError, base=True): 127 | pass 128 | 129 | 130 | class PermissionDenied(ErrorV1): 131 | CODE = 1 132 | MESSAGE = 'permission denied' 133 | 134 | 135 | class ErrorV2(pjrpc.client.exceptions.TypeError, base=True): 136 | pass 137 | 138 | 139 | class ResourceNotFound(ErrorV2): 140 | CODE = 1 141 | MESSAGE = 'resource not found' 142 | 143 | 144 | client_v1 = jrpc_client.Client('http://localhost:8080/api/v1', error_cls=ErrorV1) 145 | client_v2 = jrpc_client.Client('http://localhost:8080/api/v2', error_cls=ErrorV2) 146 | 147 | try: 148 | response: pjrpc.Response = client_v1.proxy.add_user(user={}) 149 | except PermissionDenied as e: 150 | print(e) 151 | 152 | try: 153 | response: pjrpc.Response = client_v2.proxy.add_user(user={}) 154 | except ResourceNotFound as e: 155 | print(e) 156 | 157 | The above snippet illustrates two clients receiving the same error code however each one has its own semantic 158 | and therefore its own exception class. Nevertheless clients raise theirs own exceptions for the same error code. 159 | -------------------------------------------------------------------------------- /examples/openapi_flask.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Annotated, Any 3 | 4 | import flask 5 | import flask_cors 6 | import pydantic as pd 7 | 8 | import pjrpc.server.specs.extractors.pydantic 9 | import pjrpc.server.specs.openapi.ui 10 | from pjrpc.server.integration import flask as integration 11 | from pjrpc.server.specs import extractors, openapi 12 | from pjrpc.server.validators import pydantic as validators 13 | 14 | methods = pjrpc.server.MethodRegistry( 15 | validator_factory=validators.PydanticValidatorFactory(), 16 | metadata_processors=[ 17 | openapi.MethodSpecificationGenerator( 18 | extractor=extractors.pydantic.PydanticMethodInfoExtractor(), 19 | ), 20 | ], 21 | ) 22 | 23 | 24 | UserName = Annotated[ 25 | str, 26 | pd.Field(description="User name", examples=["John"]), 27 | ] 28 | 29 | UserSurname = Annotated[ 30 | str, 31 | pd.Field(description="User surname", examples=['Doe']), 32 | ] 33 | 34 | UserAge = Annotated[ 35 | int, 36 | pd.Field(description="User age", examples=[25]), 37 | ] 38 | 39 | UserId = Annotated[ 40 | uuid.UUID, 41 | pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), 42 | ] 43 | 44 | 45 | class UserIn(pd.BaseModel): 46 | """ 47 | User registration data. 48 | """ 49 | 50 | name: UserName 51 | surname: UserSurname 52 | age: UserAge 53 | 54 | 55 | class UserOut(UserIn): 56 | """ 57 | Registered user data. 58 | """ 59 | 60 | id: UserId 61 | 62 | 63 | class AlreadyExistsError(pjrpc.server.exceptions.TypedError): 64 | """ 65 | User already registered error. 66 | """ 67 | 68 | CODE = 2001 69 | MESSAGE = "user already exists" 70 | 71 | 72 | class NotFoundError(pjrpc.server.exceptions.TypedError): 73 | """ 74 | User not found error. 75 | """ 76 | 77 | CODE = 2002 78 | MESSAGE = "user not found" 79 | 80 | 81 | @methods.add( 82 | metadata=[ 83 | openapi.metadata( 84 | summary='Creates a user', 85 | tags=['users'], 86 | errors=[AlreadyExistsError], 87 | ), 88 | ], 89 | ) 90 | def add_user(user: UserIn) -> UserOut: 91 | """ 92 | Creates a user. 93 | 94 | :param object user: user data 95 | :return object: registered user 96 | :raise AlreadyExistsError: user already exists 97 | """ 98 | 99 | for existing_user in flask.current_app.users_db.values(): 100 | if user.name == existing_user.name: 101 | raise AlreadyExistsError() 102 | 103 | user_id = uuid.uuid4() 104 | flask.current_app.users_db[user_id] = user 105 | 106 | return UserOut(id=user_id, **user.model_dump()) 107 | 108 | 109 | @methods.add( 110 | metadata=[ 111 | openapi.metadata( 112 | summary='Returns a user', 113 | tags=['users'], 114 | errors=[NotFoundError], 115 | ), 116 | ], 117 | ) 118 | def get_user(user_id: UserId) -> UserOut: 119 | """ 120 | Returns a user. 121 | 122 | :param object user_id: user id 123 | :return object: registered user 124 | :raise NotFoundError: user not found 125 | """ 126 | 127 | user = flask.current_app.users_db.get(user_id.hex) 128 | if not user: 129 | raise NotFoundError() 130 | 131 | return UserOut(id=user_id, **user.model_dump()) 132 | 133 | 134 | @methods.add( 135 | metadata=[ 136 | openapi.metadata( 137 | summary='Deletes a user', 138 | tags=['users'], 139 | errors=[NotFoundError], 140 | ), 141 | ], 142 | ) 143 | def delete_user(user_id: UserId) -> None: 144 | """ 145 | Deletes a user. 146 | 147 | :param object user_id: user id 148 | :raise NotFoundError: user not found 149 | """ 150 | 151 | user = flask.current_app.users_db.pop(user_id.hex, None) 152 | if not user: 153 | raise NotFoundError() 154 | 155 | 156 | class JSONEncoder(pjrpc.server.JSONEncoder): 157 | def default(self, o: Any) -> Any: 158 | if isinstance(o, pd.BaseModel): 159 | return o.model_dump() 160 | if isinstance(o, uuid.UUID): 161 | return str(o) 162 | 163 | return super().default(o) 164 | 165 | 166 | openapi_spec = openapi.OpenAPI( 167 | info=openapi.Info(version="1.0.0", title="User storage"), 168 | servers=[ 169 | openapi.Server( 170 | url='http://127.0.0.1:8080', 171 | ), 172 | ], 173 | security_schemes=dict( 174 | basicAuth=openapi.SecurityScheme( 175 | type=openapi.SecuritySchemeType.HTTP, 176 | scheme='basic', 177 | ), 178 | ), 179 | security=[ 180 | dict(basicAuth=[]), 181 | ], 182 | ) 183 | 184 | 185 | jsonrpc_v1 = integration.JsonRPC('/api/v1', json_encoder=JSONEncoder) 186 | jsonrpc_v1.add_methods(methods) 187 | jsonrpc_v1.add_spec(openapi_spec, path='openapi.json') 188 | jsonrpc_v1.add_spec_ui('swagger', ui=openapi.ui.SwaggerUI(), spec_url='../openapi.json') 189 | 190 | flask_cors.CORS(jsonrpc_v1.http_app, resources={"/rpc/api/v1/*": {"origins": "*"}}) 191 | jsonrpc_v1.http_app.users_db = {} 192 | 193 | 194 | if __name__ == "__main__": 195 | jsonrpc_v1.http_app.run(port=8080) 196 | -------------------------------------------------------------------------------- /pjrpc/client/backend/requests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing 3 | from typing import Any, Callable, Generator, Iterable, Mapping, MutableMapping, Optional, TypedDict, Union 4 | 5 | import requests.auth 6 | import requests.cookies 7 | 8 | from pjrpc.client import AbstractClient, Middleware, exceptions 9 | from pjrpc.common import AbstractRequest, AbstractResponse, BatchRequest, BatchResponse, JSONEncoder, Request, Response 10 | from pjrpc.common import generators 11 | from pjrpc.common.typedefs import JsonRpcRequestIdT 12 | 13 | 14 | class RequestArgs(TypedDict, total=False): 15 | headers: Mapping[str, Union[str, bytes, None]] 16 | cookies: requests.cookies.RequestsCookieJar 17 | auth: Union[tuple[str, str], requests.auth.AuthBase] 18 | timeout: Union[float, tuple[float, float], tuple[float, None]] 19 | allow_redirects: bool 20 | proxies: MutableMapping[str, str] 21 | hooks: Mapping[str, Union[Iterable[Callable[[requests.Response], Any]], Callable[[requests.Response], Any]]] 22 | verify: Union[bool, str] 23 | cert: Union[str, tuple[str, str]] 24 | 25 | 26 | class Client(AbstractClient): 27 | """ 28 | `Requests `_ library client backend. 29 | 30 | :param url: url to be used as JSON-RPC endpoint. 31 | :param session: custom session to be used instead of :py:class:`requests.Session` 32 | :param id_gen_impl: identifier generator 33 | :param error_cls: JSON-RPC error base class 34 | :param json_loader: json loader 35 | :param json_dumper: json dumper 36 | :param json_encoder: json encoder 37 | :param json_decoder: json decoder 38 | """ 39 | 40 | def __init__( 41 | self, 42 | url: str, 43 | *, 44 | session: Optional[requests.Session] = None, 45 | raise_for_status: bool = True, 46 | id_gen_impl: Callable[..., Generator[JsonRpcRequestIdT, None, None]] = generators.sequential, 47 | error_cls: type[exceptions.JsonRpcError] = exceptions.JsonRpcError, 48 | json_loader: Callable[..., Any] = json.loads, 49 | json_dumper: Callable[..., str] = json.dumps, 50 | json_encoder: type[JSONEncoder] = JSONEncoder, 51 | json_decoder: Optional[json.JSONDecoder] = None, 52 | middlewares: Iterable[Middleware] = (), 53 | ): 54 | super().__init__( 55 | id_gen_impl=id_gen_impl, 56 | error_cls=error_cls, 57 | json_loader=json_loader, 58 | json_dumper=json_dumper, 59 | json_encoder=json_encoder, 60 | json_decoder=json_decoder, 61 | middlewares=middlewares, 62 | ) 63 | self._endpoint = url 64 | self._session = session or requests.Session() 65 | self._owned_session = session is None 66 | self._raise_for_status = raise_for_status 67 | 68 | @typing.overload 69 | def send(self, request: Request, **kwargs: Any) -> Optional[Response]: 70 | ... 71 | 72 | @typing.overload 73 | def send(self, request: BatchRequest, **kwargs: Any) -> Optional[BatchResponse]: 74 | ... 75 | 76 | def send(self, request: AbstractRequest, **kwargs: Any) -> Optional[AbstractResponse]: 77 | """ 78 | Sends a JSON-RPC request. 79 | 80 | :param request: request instance 81 | :param kwargs: additional client request argument 82 | :returns: response instance or None if the request is a notification 83 | """ 84 | 85 | return self._send(request, kwargs) 86 | 87 | def _request( 88 | self, 89 | request_text: str, 90 | is_notification: bool, 91 | request_kwargs: Mapping[str, Any], 92 | ) -> Optional[str]: 93 | """ 94 | Sends a JSON-RPC request. 95 | 96 | :param request_text: request text 97 | :param is_notification: is the request a notification 98 | :returns: response text 99 | """ 100 | 101 | request_kwargs = typing.cast(RequestArgs, request_kwargs) 102 | 103 | request_kwargs['headers'] = headers = dict(request_kwargs.get('headers', {})) 104 | headers['Content-Type'] = self._request_content_type 105 | 106 | resp = self._session.post(self._endpoint, data=request_text, **request_kwargs) 107 | if self._raise_for_status: 108 | resp.raise_for_status() 109 | if is_notification: 110 | return None 111 | 112 | response_text = resp.text 113 | content_type = resp.headers.get('Content-Type', '') 114 | if response_text and content_type.split(';')[0] not in self._response_content_types: 115 | raise exceptions.DeserializationError(f"unexpected response content type: {content_type}") 116 | 117 | return response_text 118 | 119 | def close(self) -> None: 120 | """ 121 | Closes the current http session. 122 | """ 123 | 124 | if self._owned_session: 125 | self._session.close() 126 | 127 | def __enter__(self) -> 'Client': 128 | if self._owned_session: 129 | self._session.__enter__() 130 | 131 | return self 132 | 133 | def __exit__(self, *args: Any) -> None: 134 | if self._owned_session: 135 | self._session.__exit__(*args) 136 | -------------------------------------------------------------------------------- /tests/server/test_werkzeug.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import werkzeug 5 | 6 | from pjrpc.common import Request, Response 7 | from pjrpc.server import exceptions 8 | from pjrpc.server.integration import werkzeug as integration 9 | from tests.common import _ 10 | 11 | 12 | @pytest.fixture 13 | def path(): 14 | return '/test/path' 15 | 16 | 17 | @pytest.fixture 18 | def json_rpc(path): 19 | return integration.JsonRPC(path) 20 | 21 | 22 | @pytest.mark.parametrize( 23 | 'request_id, params, result', [ 24 | ( 25 | 1, 26 | (1, 1.1, 'a', {}, False), 27 | [1, 1.1, 'a', {}, False], 28 | ), 29 | ( 30 | 'abc', 31 | {'int': 1, 'float': 1.1, 'str': 'a', 'dict': {}, 'bool': False}, 32 | {'int': 1, 'float': 1.1, 'str': 'a', 'dict': {}, 'bool': False}, 33 | ), 34 | ], 35 | ) 36 | def test_request(json_rpc, path, mocker, request_id, params, result): 37 | method_name = 'test_method' 38 | mock = mocker.Mock(name=method_name, return_value=result) 39 | 40 | json_rpc.dispatcher.registry.add_method(mock, method_name) 41 | 42 | cli = werkzeug.test.Client(json_rpc) 43 | test_response = cli.post( 44 | path, json=Request(method=method_name, params=params, id=request_id).to_json(), 45 | ) 46 | if type(test_response) is tuple: # werkzeug 1.0 47 | body_iter, code, header = test_response 48 | body = b''.join(body_iter) 49 | else: # werkzeug >= 2.1 50 | body, code = (test_response.data, test_response.status) 51 | assert code == '200 OK' 52 | 53 | resp = Response.from_json(json.loads(body), error_cls=exceptions.JsonRpcError) 54 | 55 | if isinstance(params, dict): 56 | mock.assert_called_once_with(kwargs=params) 57 | else: 58 | mock.assert_called_once_with(args=params) 59 | 60 | assert resp.id == request_id 61 | assert resp.result == result 62 | 63 | 64 | def test_notify(json_rpc, path, mocker): 65 | params = [1, 2] 66 | method_name = 'test_method' 67 | mock = mocker.Mock(name=method_name, return_value='result') 68 | 69 | json_rpc.dispatcher.registry.add_method(mock, method_name) 70 | 71 | cli = werkzeug.test.Client(json_rpc) 72 | test_response = cli.post( 73 | path, json=Request(method=method_name, params=params).to_json(), 74 | ) 75 | if type(test_response) is tuple: # werkzeug 1.0 76 | body_iter, code, header = test_response 77 | body = b''.join(body_iter) 78 | else: # werkzeug >= 2.1 79 | body, code = (test_response.data, test_response.status) 80 | assert code == '200 OK' 81 | assert body == b'' 82 | 83 | 84 | def test_errors(json_rpc, path, mocker): 85 | request_id = 1 86 | params = (1, 2) 87 | method_name = 'test_method' 88 | 89 | def error_method(*args, **kwargs): 90 | raise exceptions.JsonRpcError(code=1, message='message') 91 | 92 | mock = mocker.Mock(name=method_name, side_effect=error_method) 93 | 94 | json_rpc.dispatcher.registry.add_method(mock, method_name) 95 | 96 | cli = werkzeug.test.Client(json_rpc) 97 | # method not found 98 | test_response = cli.post( 99 | path, json=Request(method='unknown_method', params=params, id=request_id).to_json(), 100 | ) 101 | if type(test_response) is tuple: # werkzeug 1.0 102 | body_iter, code, header = test_response 103 | body = b''.join(body_iter) 104 | else: # werkzeug >= 2.1 105 | body, code = (test_response.data, test_response.status) 106 | assert code == '200 OK' 107 | 108 | resp = Response.from_json(json.loads(body), error_cls=exceptions.JsonRpcError) 109 | assert resp.id is request_id 110 | assert resp.is_error is True 111 | assert resp.error == exceptions.MethodNotFoundError(data="method 'unknown_method' not found") 112 | 113 | # customer error 114 | test_response = cli.post( 115 | path, json=Request(method=method_name, params=params, id=request_id).to_json(), 116 | ) 117 | if type(test_response) is tuple: # werkzeug 1.0 118 | body_iter, code, header = test_response 119 | body = b''.join(body_iter) 120 | else: # werkzeug >= 2.1 121 | body, code = (test_response.data, test_response.status) 122 | assert code == '200 OK' 123 | 124 | resp = Response.from_json(json.loads(body), error_cls=exceptions.JsonRpcError) 125 | mock.assert_called_once_with(args=params) 126 | assert resp.id == request_id 127 | assert resp.is_error is True 128 | assert resp.error == exceptions.JsonRpcError(code=1, message='message') 129 | 130 | # malformed json 131 | test_response = cli.post( 132 | path, headers={'Content-Type': 'application/json'}, data='', 133 | ) 134 | if type(test_response) is tuple: # werkzeug 1.0 135 | body_iter, code, header = test_response 136 | body = b''.join(body_iter) 137 | else: 138 | body, code = (test_response.data, test_response.status) 139 | assert code == '200 OK' 140 | resp = Response.from_json(json.loads(body), error_cls=exceptions.JsonRpcError) 141 | assert resp.id is None 142 | assert resp.is_error is True 143 | assert resp.error == exceptions.ParseError(data=_) 144 | -------------------------------------------------------------------------------- /docs/source/pjrpc/client.rst: -------------------------------------------------------------------------------- 1 | .. _client: 2 | 3 | Client 4 | ====== 5 | 6 | 7 | ``pjrpc`` client provides three main method invocation approaches: 8 | 9 | - using handmade :py:class:`pjrpc.common.Request` class object 10 | 11 | .. code-block:: python 12 | 13 | client = Client('http://server/api/v1') 14 | 15 | response: pjrpc.Response = client.send(Request('sum', params=[1, 2], id=1)) 16 | print(f"1 + 2 = {response.result}") 17 | 18 | 19 | - using ``__call__`` method 20 | 21 | .. code-block:: python 22 | 23 | client = Client('http://server/api/v1') 24 | 25 | result = client('sum', a=1, b=2) 26 | print(f"1 + 2 = {result}") 27 | 28 | - using proxy object 29 | 30 | .. code-block:: python 31 | 32 | client = Client('http://server/api/v1') 33 | 34 | result = client.proxy.sum(1, 2) 35 | print(f"1 + 2 = {result}") 36 | 37 | .. code-block:: python 38 | 39 | client = Client('http://server/api/v1') 40 | 41 | result = client.proxy.sum(a=1, b=2) 42 | print(f"1 + 2 = {result}") 43 | 44 | Requests without id in JSON-RPC semantics called notifications. To send a notification to the server 45 | you need to send a request without id: 46 | 47 | .. code-block:: python 48 | 49 | client = Client('http://server/api/v1') 50 | 51 | response: pjrpc.Response = client.send(Request('sum', params=[1, 2])) 52 | 53 | 54 | or use a special method :py:meth:`pjrpc.client.AbstractClient.notify` 55 | 56 | .. code-block:: python 57 | 58 | client = Client('http://server/api/v1') 59 | client.notify('tick') 60 | 61 | 62 | Asynchronous client api looks pretty much the same: 63 | 64 | .. code-block:: python 65 | 66 | client = Client('http://server/api/v1') 67 | 68 | result = await client.proxy.sum(1, 2) 69 | print(f"1 + 2 = {result}") 70 | 71 | 72 | Batch requests 73 | -------------- 74 | 75 | Batch requests also supported. There are several approaches of sending batch requests: 76 | 77 | - using handmade :py:class:`pjrpc.common.Request` class object. The result is a :py:class:`pjrpc.common.BatchResponse` 78 | instance you can iterate over to get all the results or get each one by index: 79 | 80 | .. code-block:: python 81 | 82 | client = pjrpc_client.Client('http://localhost/api/v1') 83 | 84 | with client.batch() as batch: 85 | batch.send(pjrpc.Request('sum', [2, 2], id=1)) 86 | batch.send(pjrpc.Request('sub', [2, 2], id=2)) 87 | batch.send(pjrpc.Request('div', [2, 2], id=3)) 88 | batch.send(pjrpc.Request('mult', [2, 2], id=4)) 89 | 90 | batch_response = batch.get_response() 91 | 92 | print(f"2 + 2 = {batch_response[0].result}") 93 | print(f"2 - 2 = {batch_response[1].result}") 94 | print(f"2 / 2 = {batch_response[2].result}") 95 | print(f"2 * 2 = {batch_response[3].result}") 96 | 97 | 98 | - using ``__call__`` method chain: 99 | 100 | .. code-block:: python 101 | 102 | with client.batch() as batch: 103 | batch('sum', 2, 2) 104 | batch('sub', 2, 2) 105 | batch('div', 2, 2) 106 | batch('mult', 2, 2) 107 | 108 | result = batch.get_results() 109 | 110 | print(f"2 + 2 = {result[0]}") 111 | print(f"2 - 2 = {result[1]}") 112 | print(f"2 / 2 = {result[2]}") 113 | print(f"2 * 2 = {result[3]}") 114 | 115 | 116 | - using proxy chain call: 117 | 118 | .. code-block:: python 119 | 120 | with client.batch() as batch: 121 | batch.proxy.sum(2, 2) 122 | batch.proxy.sub(2, 2) 123 | batch.proxy.div(2, 2) 124 | batch.proxy.mult(2, 2) 125 | 126 | result = batch.get_results() 127 | 128 | print(f"2 + 2 = {result[0]}") 129 | print(f"2 - 2 = {result[1]}") 130 | print(f"2 / 2 = {result[2]}") 131 | print(f"2 * 2 = {result[3]}") 132 | 133 | 134 | Which one to use is up to you but be aware that if any of the requests returns an error the result of the other ones 135 | will be lost. In such case the first approach can be used to iterate over all the responses and get the results of 136 | the succeeded ones like this: 137 | 138 | .. code-block:: python 139 | 140 | client = pjrpc_client.Client('http://localhost/api/v1') 141 | 142 | batch_response = client.send( 143 | pjrpc.BatchRequest( 144 | pjrpc.Request('sum', [2, 2], id=1), 145 | pjrpc.Request('sub', [2, 2], id=2), 146 | pjrpc.Request('div', [2, 2], id=3), 147 | pjrpc.Request('mult', [2, 2], id=4), 148 | ) 149 | ) 150 | 151 | for response in batch_response: 152 | if response.is_success: 153 | print(response.result) 154 | else: 155 | print(response.error) 156 | 157 | 158 | Notifications also supported: 159 | 160 | .. code-block:: python 161 | 162 | client = pjrpc_client.Client('http://localhost/api/v1') 163 | 164 | with client.batch() as batch: 165 | batch.notify('tick') 166 | batch.notify('tack') 167 | batch.notify('tick') 168 | batch.notify('tack') 169 | 170 | 171 | 172 | Id generators 173 | -------------- 174 | 175 | The library request id generator can also be customized. There are four generator types implemented in the library 176 | see :py:mod:`pjrpc.common.generators`. You can implement your own one and pass it to a client by ``id_gen`` 177 | parameter. 178 | -------------------------------------------------------------------------------- /pjrpc/server/integration/aio_pika.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, Callable, Iterable, Optional 4 | 5 | import aio_pika 6 | from yarl import URL 7 | 8 | import pjrpc.server 9 | from pjrpc.server.dispatcher import AsyncExecutor, AsyncMiddlewareType, JSONEncoder 10 | 11 | logger = logging.getLogger(__package__) 12 | 13 | AioPikaDispatcher = pjrpc.server.AsyncDispatcher[aio_pika.abc.AbstractIncomingMessage] 14 | 15 | 16 | class Executor: 17 | """ 18 | `aio_pika `_ based JSON-RPC server. 19 | 20 | :param broker_url: broker connection url 21 | :param request_queue_name: requests queue name 22 | :param response_exchange_name: response exchange name 23 | :param response_routing_key: response routing key 24 | :param prefetch_count: worker prefetch count 25 | """ 26 | 27 | def __init__( 28 | self, 29 | broker_url: URL, 30 | request_queue_name: str, 31 | request_queue_args: Optional[dict[str, Any]] = None, 32 | response_exchange_name: Optional[str] = None, 33 | response_exchange_args: Optional[dict[str, Any]] = None, 34 | response_routing_key: Optional[str] = None, 35 | prefetch_count: int = 0, 36 | executor: Optional[AsyncExecutor] = None, 37 | json_loader: Callable[..., Any] = json.loads, 38 | json_dumper: Callable[..., str] = json.dumps, 39 | json_encoder: type[JSONEncoder] = JSONEncoder, 40 | json_decoder: Optional[type[json.JSONDecoder]] = None, 41 | middlewares: Iterable[AsyncMiddlewareType[aio_pika.abc.AbstractIncomingMessage]] = (), 42 | max_batch_size: Optional[int] = None, 43 | ): 44 | self._broker_url = broker_url 45 | self._request_queue_name = request_queue_name 46 | self._request_queue_args = request_queue_args 47 | self._response_exchange_name = response_exchange_name 48 | self._response_exchange_args = response_exchange_args 49 | self._response_routing_key = response_routing_key 50 | self._prefetch_count = prefetch_count 51 | 52 | self._connection = aio_pika.connection.Connection(broker_url) 53 | self._channel: Optional[aio_pika.abc.AbstractChannel] = None 54 | 55 | self._request_queue: Optional[aio_pika.abc.AbstractQueue] = None 56 | self._response_exchange: Optional[aio_pika.abc.AbstractExchange] = None 57 | self._consumer_tag: Optional[str] = None 58 | self._dispatcher = AioPikaDispatcher( 59 | executor=executor, 60 | json_loader=json_loader, 61 | json_dumper=json_dumper, 62 | json_encoder=json_encoder, 63 | json_decoder=json_decoder, 64 | middlewares=middlewares, 65 | max_batch_size=max_batch_size, 66 | ) 67 | 68 | @property 69 | def dispatcher(self) -> AioPikaDispatcher: 70 | """ 71 | JSON-RPC method dispatcher. 72 | """ 73 | 74 | return self._dispatcher 75 | 76 | async def start(self) -> None: 77 | """ 78 | Starts executor. 79 | """ 80 | 81 | await self._connection.connect() 82 | self._channel = channel = await self._connection.channel() 83 | await channel.set_qos(prefetch_count=self._prefetch_count) 84 | 85 | self._request_queue = await channel.declare_queue( 86 | self._request_queue_name, **(self._request_queue_args or {}), 87 | ) 88 | 89 | if self._response_exchange_name: 90 | self._response_exchange = await channel.declare_exchange( 91 | self._response_exchange_name, **(self._response_exchange_args or {}), 92 | ) 93 | 94 | self._consumer_tag = await self._request_queue.consume(self._rpc_handle) 95 | 96 | async def shutdown(self) -> None: 97 | """ 98 | Stops executor. 99 | """ 100 | 101 | assert self._channel and self._request_queue and self._consumer_tag, "executor not started" 102 | 103 | await self._request_queue.cancel(self._consumer_tag) 104 | await self._channel.close() 105 | await self._connection.close() 106 | 107 | async def _rpc_handle(self, message: aio_pika.abc.AbstractIncomingMessage) -> None: 108 | """ 109 | Handles JSON-RPC request. 110 | 111 | :param message: incoming message 112 | """ 113 | 114 | async with message.process(): 115 | try: 116 | if (response := await self._dispatcher.dispatch(message.body.decode(), context=message)) is not None: 117 | response_text, error_codes = response 118 | 119 | async with self._connection.channel() as channel: 120 | exchange = self._response_exchange or channel.default_exchange 121 | await exchange.publish( 122 | aio_pika.Message( 123 | body=response_text.encode(), 124 | correlation_id=message.correlation_id, 125 | content_type=pjrpc.common.DEFAULT_CONTENT_TYPE, 126 | ), 127 | routing_key=message.reply_to or self._response_routing_key or '', 128 | ) 129 | 130 | except Exception as e: 131 | logger.exception("jsonrpc request handling error: %s", e) 132 | raise 133 | -------------------------------------------------------------------------------- /examples/openrpc_aiohttp.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Annotated, Any 3 | 4 | import aiohttp_cors 5 | import pydantic as pd 6 | from aiohttp import web 7 | 8 | import pjrpc.server.specs.extractors.pydantic 9 | from pjrpc.server.integration import aiohttp as integration 10 | from pjrpc.server.specs import extractors, openrpc 11 | from pjrpc.server.validators import pydantic as validators 12 | 13 | methods = pjrpc.server.MethodRegistry( 14 | validator_factory=validators.PydanticValidatorFactory(exclude=integration.is_aiohttp_request), 15 | metadata_processors=[ 16 | openrpc.MethodSpecificationGenerator( 17 | extractor=extractors.pydantic.PydanticMethodInfoExtractor( 18 | exclude=integration.is_aiohttp_request, 19 | ), 20 | ), 21 | ], 22 | ) 23 | 24 | credentials = {"admin": "admin"} 25 | 26 | 27 | UserName = Annotated[ 28 | str, 29 | pd.Field(description="User name", examples=["John"]), 30 | ] 31 | 32 | UserSurname = Annotated[ 33 | str, 34 | pd.Field(description="User surname", examples=["Doe"]), 35 | ] 36 | 37 | UserAge = Annotated[ 38 | int, 39 | pd.Field(description="User age", examples=[25]), 40 | ] 41 | 42 | UserId = Annotated[ 43 | uuid.UUID, 44 | pd.Field(description="User identifier", examples=["08b02cf9-8e07-4d06-b569-2c24309c1dc1"]), 45 | ] 46 | 47 | 48 | class UserIn(pd.BaseModel, title="User data"): 49 | """ 50 | User registration data. 51 | """ 52 | 53 | name: UserName 54 | surname: UserSurname 55 | age: UserAge 56 | 57 | 58 | class UserOut(UserIn, title="User data"): 59 | """ 60 | Registered user data. 61 | """ 62 | 63 | id: UserId 64 | 65 | 66 | class AlreadyExistsError(pjrpc.server.exceptions.TypedError): 67 | """ 68 | User already registered error. 69 | """ 70 | 71 | CODE = 2001 72 | MESSAGE = "user already exists" 73 | 74 | 75 | class NotFoundError(pjrpc.server.exceptions.TypedError): 76 | """ 77 | User not found error. 78 | """ 79 | 80 | CODE = 2002 81 | MESSAGE = "user not found" 82 | 83 | 84 | @methods.add( 85 | pass_context='request', 86 | metadata=[ 87 | openrpc.metadata( 88 | summary="Creates a user", 89 | tags=['users'], 90 | errors=[AlreadyExistsError], 91 | ), 92 | ], 93 | ) 94 | def add_user(request: web.Request, user: UserIn) -> UserOut: 95 | """ 96 | Creates a user. 97 | 98 | :param request: http request 99 | :param object user: user data 100 | :return object: registered user 101 | :raise AlreadyExistsError: user already exists 102 | """ 103 | 104 | for existing_user in request.config_dict['users'].values(): 105 | if user.name == existing_user.name: 106 | raise AlreadyExistsError() 107 | 108 | user_id = uuid.uuid4() 109 | request.config_dict['users'][user_id] = user 110 | 111 | return UserOut(id=user_id, **user.model_dump()) 112 | 113 | 114 | @methods.add( 115 | pass_context='request', 116 | metadata=[ 117 | openrpc.metadata( 118 | summary="Returns a user", 119 | tags=['users'], 120 | errors=[NotFoundError], 121 | ), 122 | ], 123 | ) 124 | def get_user(request: web.Request, user_id: UserId) -> UserOut: 125 | """ 126 | Returns a user. 127 | 128 | :param request: http request 129 | :param object user_id: user id 130 | :return object: registered user 131 | :raise NotFoundError: user not found 132 | """ 133 | 134 | user = request.config_dict['users'].get(user_id.hex) 135 | if not user: 136 | raise NotFoundError() 137 | 138 | return UserOut(id=user_id, **user.model_dump()) 139 | 140 | 141 | @methods.add( 142 | pass_context='request', 143 | metadata=[ 144 | openrpc.metadata( 145 | summary="Deletes a user", 146 | tags=['users'], 147 | errors=[NotFoundError], 148 | deprecated=True, 149 | ), 150 | ], 151 | ) 152 | def delete_user(request: web.Request, user_id: UserId) -> None: 153 | """ 154 | Deletes a user. 155 | 156 | :param request: http request 157 | :param object user_id: user id 158 | :raise NotFoundError: user not found 159 | """ 160 | 161 | user = request.config_dict['users'].pop(user_id.hex, None) 162 | if not user: 163 | raise NotFoundError() 164 | 165 | 166 | class JSONEncoder(pjrpc.server.JSONEncoder): 167 | def default(self, o: Any) -> Any: 168 | if isinstance(o, pd.BaseModel): 169 | return o.model_dump() 170 | if isinstance(o, uuid.UUID): 171 | return str(o) 172 | 173 | return super().default(o) 174 | 175 | 176 | openrpc_spec = openrpc.OpenRPC( 177 | info=openrpc.Info(version="1.0.0", title="User storage"), 178 | servers=[ 179 | openrpc.Server( 180 | name="api", 181 | url="http://127.0.0.1:8080/myapp/api", 182 | ), 183 | ], 184 | ) 185 | 186 | app = web.Application() 187 | app['users'] = {} 188 | 189 | jsonrpc_app = integration.Application('/api/v1', json_encoder=JSONEncoder) 190 | jsonrpc_app.add_methods(methods) 191 | jsonrpc_app.add_spec(openrpc_spec, path='openrpc.json') 192 | app.add_subapp('/rpc', jsonrpc_app.http_app) 193 | 194 | cors = aiohttp_cors.setup( 195 | app, defaults={ 196 | '*': aiohttp_cors.ResourceOptions( 197 | allow_credentials=True, 198 | expose_headers='*', 199 | allow_headers='*', 200 | ), 201 | }, 202 | ) 203 | for route in list(app.router.routes()): 204 | cors.add(route) 205 | 206 | if __name__ == "__main__": 207 | web.run_app(app, host='localhost', port=8080) 208 | -------------------------------------------------------------------------------- /pjrpc/client/backend/aiohttp.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing 3 | from ssl import SSLContext 4 | from typing import Any, Callable, Generator, Iterable, Mapping, Optional, TypedDict, Union 5 | 6 | from aiohttp import BasicAuth, Fingerprint, client 7 | from aiohttp.typedefs import LooseCookies, LooseHeaders, StrOrURL 8 | from multidict import MultiDict 9 | 10 | from pjrpc.client import AbstractAsyncClient, AsyncMiddleware, exceptions 11 | from pjrpc.common import AbstractRequest, AbstractResponse, BatchRequest, BatchResponse, JSONEncoder, Request, Response 12 | from pjrpc.common import generators 13 | from pjrpc.common.typedefs import JsonRpcRequestIdT 14 | 15 | 16 | class RequestArgs(TypedDict, total=False): 17 | cookies: LooseCookies 18 | headers: LooseHeaders 19 | skip_auto_headers: Iterable[str] 20 | auth: BasicAuth 21 | allow_redirects: bool 22 | max_redirects: int 23 | compress: Union[str, bool] 24 | chunked: bool 25 | expect100: bool 26 | read_until_eof: bool 27 | proxy: StrOrURL 28 | proxy_auth: BasicAuth 29 | timeout: "Union[client.ClientTimeout, None]" 30 | ssl: Union[SSLContext, bool, Fingerprint] 31 | server_hostname: str 32 | proxy_headers: LooseHeaders 33 | trace_request_ctx: Mapping[str, Any] 34 | read_bufsize: int 35 | auto_decompress: bool 36 | max_line_size: int 37 | max_field_size: int 38 | 39 | 40 | class Client(AbstractAsyncClient): 41 | """ 42 | `Aiohttp `_ library client backend. 43 | 44 | :param url: url to be used as JSON-RPC endpoint 45 | :param session: custom session to be used instead of :py:class:`aiohttp.ClientSession` 46 | :param raise_for_status: should `ClientResponse.raise_for_status()` be called automatically 47 | :param id_gen_impl: identifier generator 48 | :param error_cls: JSON-RPC error base class 49 | :param json_loader: json loader 50 | :param json_dumper: json dumper 51 | :param json_encoder: json encoder 52 | :param json_decoder: json decoder 53 | """ 54 | 55 | def __init__( 56 | self, 57 | url: str, 58 | *, 59 | session: Optional[client.ClientSession] = None, 60 | raise_for_status: bool = True, 61 | id_gen_impl: Callable[..., Generator[JsonRpcRequestIdT, None, None]] = generators.sequential, 62 | error_cls: type[exceptions.JsonRpcError] = exceptions.JsonRpcError, 63 | json_loader: Callable[..., Any] = json.loads, 64 | json_dumper: Callable[..., str] = json.dumps, 65 | json_encoder: type[JSONEncoder] = JSONEncoder, 66 | json_decoder: Optional[json.JSONDecoder] = None, 67 | middlewares: Iterable[AsyncMiddleware] = (), 68 | ): 69 | super().__init__( 70 | id_gen_impl=id_gen_impl, 71 | error_cls=error_cls, 72 | json_loader=json_loader, 73 | json_dumper=json_dumper, 74 | json_encoder=json_encoder, 75 | json_decoder=json_decoder, 76 | middlewares=middlewares, 77 | ) 78 | self._endpoint = url 79 | self._session = session or client.ClientSession() 80 | self._owned_session = session is None 81 | self._raise_for_status = raise_for_status 82 | 83 | @typing.overload 84 | async def send(self, request: Request, **kwargs: Any) -> Optional[Response]: 85 | ... 86 | 87 | @typing.overload 88 | async def send(self, request: BatchRequest, **kwargs: Any) -> Optional[BatchResponse]: 89 | ... 90 | 91 | async def send(self, request: AbstractRequest, **kwargs: Any) -> Optional[AbstractResponse]: 92 | """ 93 | Sends a JSON-RPC request. 94 | 95 | :param request: request instance 96 | :param kwargs: additional client request argument 97 | :returns: response instance or None if the request is a notification 98 | """ 99 | 100 | return await self._send(request, kwargs) 101 | 102 | async def _request( 103 | self, 104 | request_text: str, 105 | is_notification: bool, 106 | request_kwargs: Mapping[str, Any], 107 | ) -> Optional[str]: 108 | """ 109 | Makes a JSON-RPC request. 110 | 111 | :param request_text: request text representation 112 | :param is_notification: is the request a notification 113 | :param request_kwargs: additional client request argument 114 | :returns: response text representation or None if the request is a notification 115 | """ 116 | 117 | request_kwargs = typing.cast(RequestArgs, request_kwargs) 118 | 119 | request_kwargs['headers'] = headers = MultiDict(request_kwargs.get('headers', {})) 120 | headers['Content-Type'] = self._request_content_type 121 | 122 | async with self._session.post(self._endpoint, data=request_text, **request_kwargs) as resp: 123 | if self._raise_for_status: 124 | resp.raise_for_status() 125 | response_text = await resp.text() 126 | 127 | if is_notification: 128 | return None 129 | 130 | content_type = resp.headers.get('Content-Type', '') 131 | if response_text and content_type.split(';')[0] not in self._response_content_types: 132 | raise exceptions.DeserializationError(f"unexpected response content type: {content_type}") 133 | 134 | return response_text 135 | 136 | async def close(self) -> None: 137 | """ 138 | Closes current http session. 139 | """ 140 | 141 | if self._owned_session: 142 | await self._session.close() 143 | 144 | async def __aenter__(self) -> 'Client': 145 | if self._owned_session: 146 | await self._session.__aenter__() 147 | 148 | return self 149 | 150 | async def __aexit__(self, *args: Any) -> None: 151 | if self._owned_session: 152 | await self._session.__aexit__(*args) 153 | -------------------------------------------------------------------------------- /docs/source/pjrpc/testing.rst: -------------------------------------------------------------------------------- 1 | .. _testing: 2 | 3 | Testing 4 | ======= 5 | 6 | 7 | pytest 8 | ------ 9 | 10 | ``pjrpc`` implements pytest plugin that simplifies JSON-RPC requests mocking. 11 | To install the plugin add the following line to your pytest configuration: 12 | 13 | .. code-block:: python 14 | 15 | pytest_plugins = ("pjrpc.client.integrations.pytest_aiohttp", ) 16 | 17 | or 18 | 19 | .. code-block:: python 20 | 21 | pytest_plugins = ("pjrpc.client.integrations.pytest_requests", ) 22 | 23 | or export the environment variable ``PYTEST_PLUGINS=pjrpc.client.integrations.pytest_aiohttp``. 24 | For more information `see `_. 25 | 26 | Look at the following test example: 27 | 28 | .. code-block:: python 29 | 30 | from unittest import mock 31 | 32 | import pytest 33 | 34 | import pjrpc 35 | from pjrpc.client.backend import aiohttp as aiohttp_client 36 | from pjrpc.client.integrations.pytest_aiohttp import PjRpcAiohttpMocker 37 | 38 | 39 | async def test_using_fixture(pjrpc_aiohttp_mocker): 40 | client = aiohttp_client.Client('http://localhost/api/v1') 41 | 42 | pjrpc_aiohttp_mocker.add('http://localhost/api/v1', 'sum', result=2) 43 | result = await client.proxy.sum(1, 1) 44 | assert result == 2 45 | 46 | pjrpc_aiohttp_mocker.replace( 47 | 'http://localhost/api/v1', 'sum', error=pjrpc.client.exceptions.JsonRpcError(code=1, message='error', data='oops'), 48 | ) 49 | with pytest.raises(pjrpc.client.exceptions.JsonRpcError) as exc_info: 50 | await client.proxy.sum(a=1, b=1) 51 | 52 | assert exc_info.type is pjrpc.client.exceptions.JsonRpcError 53 | assert exc_info.value.code == 1 54 | assert exc_info.value.message == 'error' 55 | assert exc_info.value.data == 'oops' 56 | 57 | localhost_calls = pjrpc_aiohttp_mocker.calls['http://localhost/api/v1'] 58 | assert localhost_calls[('2.0', 'sum')].call_count == 2 59 | assert localhost_calls[('2.0', 'sum')].mock_calls == [mock.call(1, 1), mock.call(a=1, b=1)] 60 | 61 | 62 | async def test_using_resource_manager(): 63 | client = aiohttp_client.Client('http://localhost/api/v1') 64 | 65 | with PjRpcAiohttpMocker() as mocker: 66 | mocker.add('http://localhost/api/v1', 'div', result=2) 67 | result = await client.proxy.div(4, 2) 68 | assert result == 2 69 | 70 | localhost_calls = mocker.calls['http://localhost/api/v1'] 71 | assert localhost_calls[('2.0', 'div')].mock_calls == [mock.call(4, 2)] 72 | 73 | 74 | 75 | For testing server-side code you should use framework-dependant utils and fixtures. Since ``pjrpc`` can be easily 76 | extended you are free from writing JSON-RPC protocol related code. 77 | 78 | 79 | aiohttp 80 | ------- 81 | 82 | Testing aiohttp server code is very straightforward: 83 | 84 | .. code-block:: python 85 | 86 | import pytest 87 | from aiohttp import web 88 | 89 | import pjrpc.server 90 | from pjrpc.client.backend import aiohttp as async_client 91 | from pjrpc.server.integration import aiohttp as integration 92 | 93 | methods = pjrpc.server.MethodRegistry() 94 | 95 | 96 | @methods.add() 97 | async def div(a: int, b: int) -> float: 98 | return a / b 99 | 100 | 101 | @pytest.fixture 102 | def http_app(): 103 | return web.Application() 104 | 105 | 106 | @pytest.fixture 107 | def jsonrpc_app(http_app): 108 | jsonrpc_app = integration.Application('/api/v1', http_app=http_app) 109 | jsonrpc_app.add_methods(methods) 110 | 111 | return jsonrpc_app 112 | 113 | 114 | async def test_pjrpc_server(aiohttp_client, http_app, jsonrpc_app): 115 | jsonrpc_cli = async_client.Client('/api/v1', session=await aiohttp_client(http_app)) 116 | 117 | result = await jsonrpc_cli.proxy.div(4, 2) 118 | assert result == 2 119 | 120 | result = await jsonrpc_cli.proxy.div(6, 2) 121 | assert result == 3 122 | 123 | 124 | flask 125 | ----- 126 | 127 | For flask it stays the same: 128 | 129 | .. code-block:: python 130 | 131 | import flask.testing 132 | import pytest 133 | import werkzeug.test 134 | 135 | import pjrpc.server 136 | from pjrpc.client.backend import requests as client 137 | from pjrpc.client.integrations.pytest_requests import PjRpcRequestsMocker 138 | from pjrpc.server.integration import flask as integration 139 | 140 | methods = pjrpc.server.MethodRegistry() 141 | 142 | 143 | @methods.add() 144 | def div(a: int, b: int) -> float: 145 | return a / b 146 | 147 | 148 | @pytest.fixture() 149 | def http_app(): 150 | return flask.Flask(__name__) 151 | 152 | 153 | @pytest.fixture 154 | def jsonrpc_app(http_app): 155 | json_rpc = integration.JsonRPC('/api/v1', http_app=http_app) 156 | json_rpc.add_methods(methods) 157 | 158 | return jsonrpc_app 159 | 160 | 161 | class Response(werkzeug.test.Response): 162 | def raise_for_status(self): 163 | if self.status_code >= 400: 164 | raise Exception('client response error') 165 | 166 | @property 167 | def text(self): 168 | return self.data.decode() 169 | 170 | 171 | @pytest.fixture() 172 | def app_client(http_app): 173 | return flask.testing.FlaskClient(http_app, Response) 174 | 175 | 176 | def test_pjrpc_server(http_app, jsonrpc_app, app_client): 177 | with PjRpcRequestsMocker(passthrough=True) as mocker: 178 | jsonrpc_cli = client.Client('/api/v1', session=app_client) 179 | 180 | mocker.add('http://127.0.0.2:8000/api/v1', 'div', result=2) 181 | result = jsonrpc_cli.proxy.div(4, 2) 182 | assert result == 2 183 | 184 | result = jsonrpc_cli.proxy.div(6, 2) 185 | assert result == 3 186 | -------------------------------------------------------------------------------- /tests/common/test_request_v2.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import pjrpc 4 | from pjrpc.common import BatchRequest, Request 5 | 6 | 7 | @pytest.mark.parametrize( 8 | 'id, params', [ 9 | (1, []), 10 | ('1', []), 11 | (None, []), 12 | (1, None), 13 | (1, []), 14 | (1, [1, 2]), 15 | (1, {'a': 1, 'b': 2}), 16 | ], 17 | ) 18 | def test_request_serialization(id, params): 19 | request = Request('method', params, id=id) 20 | 21 | actual_dict = request.to_json() 22 | expected_dict = { 23 | 'jsonrpc': '2.0', 24 | 'method': 'method', 25 | } 26 | if id is not None: 27 | expected_dict.update(id=id) 28 | if params: 29 | expected_dict.update(params=params) 30 | 31 | assert actual_dict == expected_dict 32 | 33 | 34 | @pytest.mark.parametrize( 35 | 'id, params', [ 36 | (1, []), 37 | ('1', []), 38 | (None, []), 39 | (1, None), 40 | (1, []), 41 | (1, [1, 2]), 42 | (1, {'a': 1, 'b': 2}), 43 | ], 44 | ) 45 | def test_request_deserialization(id, params): 46 | data = { 47 | 'jsonrpc': '2.0', 48 | 'id': id, 49 | 'method': 'method', 50 | } 51 | if params: 52 | data.update(params=params) 53 | 54 | request = Request.from_json(data) 55 | 56 | assert request.id == id 57 | assert request.method == 'method' 58 | if params: 59 | assert request.params == params 60 | 61 | 62 | def test_request_properties(): 63 | request = Request('method', {}) 64 | 65 | assert request.is_notification is True 66 | 67 | 68 | def test_request_repr(): 69 | request = Request(method='method', params={'a': 1, 'b': 2}) 70 | assert repr(request) == "Request(method='method', params={'a': 1, 'b': 2}, id=None)" 71 | 72 | request = Request(method='method', params=[1, 2], id=1) 73 | assert repr(request) == "Request(method='method', params=[1, 2], id=1)" 74 | 75 | 76 | def test_request_deserialization_error(): 77 | with pytest.raises(pjrpc.exc.DeserializationError, match="data must be of type dict"): 78 | Request.from_json([]) 79 | 80 | with pytest.raises(pjrpc.exc.DeserializationError, match="required field 'jsonrpc' not found"): 81 | Request.from_json({}) 82 | 83 | with pytest.raises(pjrpc.exc.DeserializationError, match="jsonrpc version '2.1' is not supported"): 84 | Request.from_json({'jsonrpc': '2.1'}) 85 | 86 | with pytest.raises(pjrpc.exc.DeserializationError, match="field 'id' must be of type integer or string"): 87 | Request.from_json({'jsonrpc': '2.0', 'id': {}}) 88 | 89 | with pytest.raises(pjrpc.exc.DeserializationError, match="field 'method' must be of type string"): 90 | Request.from_json({'jsonrpc': '2.0', 'id': 1, 'method': 1}) 91 | 92 | with pytest.raises(pjrpc.exc.DeserializationError, match="field 'params' must be of type list or dict"): 93 | Request.from_json({'jsonrpc': '2.0', 'id': 1, 'method': 'method', 'params': 'params'}) 94 | 95 | 96 | def test_batch_request_serialization(): 97 | request = BatchRequest( 98 | Request('method0', [], id=None), 99 | Request('method1', [1, 2], id=1), 100 | Request('method2', {'a': 1, 'b': 2}, id=None), 101 | ) 102 | 103 | actual_dict = request.to_json() 104 | expected_dict = [ 105 | { 106 | 'jsonrpc': '2.0', 107 | 'method': 'method0', 108 | }, 109 | { 110 | 'jsonrpc': '2.0', 111 | 'id': 1, 112 | 'method': 'method1', 113 | 'params': [1, 2], 114 | }, 115 | { 116 | 'jsonrpc': '2.0', 117 | 'method': 'method2', 118 | 'params': {'a': 1, 'b': 2}, 119 | }, 120 | ] 121 | 122 | assert actual_dict == expected_dict 123 | 124 | 125 | def test_batch_request_deserialization(): 126 | data = [ 127 | { 128 | 'jsonrpc': '2.0', 129 | 'id': None, 130 | 'method': 'method0', 131 | 'params': [], 132 | }, 133 | { 134 | 'jsonrpc': '2.0', 135 | 'id': 1, 136 | 'method': 'method1', 137 | 'params': [1, 2], 138 | }, 139 | { 140 | 'jsonrpc': '2.0', 141 | 'id': None, 142 | 'method': 'method2', 143 | 'params': {'a': 1, 'b': 2}, 144 | }, 145 | ] 146 | 147 | request = BatchRequest.from_json(data) 148 | 149 | assert request[0].id is None 150 | assert request[0].method == 'method0' 151 | assert request[0].params == [] 152 | 153 | assert request[1].id == 1 154 | assert request[1].method == 'method1' 155 | assert request[1].params == [1, 2] 156 | 157 | assert request[2].id is None 158 | assert request[2].method == 'method2' 159 | assert request[2].params == {'a': 1, 'b': 2} 160 | 161 | 162 | def test_batch_request_methods(): 163 | request = BatchRequest( 164 | Request('method1', [1], id=None), 165 | Request('method2', [1], id=None), 166 | Request('method3', [1], id=1), 167 | ) 168 | assert len(request) == 3 169 | assert not request.is_notification 170 | 171 | request = BatchRequest( 172 | Request(id=None, method='method1', params={}), 173 | Request(id=None, method='method2', params={}), 174 | ) 175 | 176 | assert request.is_notification 177 | 178 | 179 | def test_batch_request_repr(): 180 | request = BatchRequest( 181 | Request('method1', [1, 2]), 182 | Request('method2', {'a': 1, 'b': 2}, id='2'), 183 | ) 184 | 185 | assert repr(request) == "BatchRequest(requests=(Request(method='method1', params=[1, 2], id=None), "\ 186 | "Request(method='method2', params={'a': 1, 'b': 2}, id='2')))" 187 | 188 | 189 | def test_batch_request_deserialization_error(): 190 | with pytest.raises(pjrpc.exc.DeserializationError, match="data must be of type list"): 191 | BatchRequest.from_json({}) 192 | --------------------------------------------------------------------------------