├── 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 |
--------------------------------------------------------------------------------