From 945654de02d9baa152a707afe5891acb5869d049 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Thu, 31 Oct 2024 23:11:16 +0500 Subject: [PATCH 1/9] pre-commit hooks upgraded. --- .pre-commit-config.yaml | 16 ++++++++++++---- pjrpc/client/backend/aio_pika.py | 1 - pjrpc/common/v20.py | 8 ++++---- pjrpc/server/integration/aio_pika.py | 8 ++++---- pjrpc/server/integration/flask.py | 2 +- pjrpc/server/integration/starlette.py | 15 +++------------ pjrpc/server/specs/openapi.py | 6 +++--- pyproject.toml | 8 ++++++-- pytest.ini | 4 ++++ 9 files changed, 37 insertions(+), 31 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d94e92..3614d36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ default_stages: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: check-yaml - id: check-toml @@ -39,11 +39,11 @@ repos: args: - --diff - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 + rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort name: fix import order @@ -63,7 +63,7 @@ repos: - --multi-line=9 - --project=pjrpc - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.7.1 + rev: v1.13.0 hooks: - id: mypy stages: @@ -75,4 +75,12 @@ repos: - aiohttp>=3.7 - httpx>=0.23.0 - pydantic>=2.0 + - types-jsonschema>=3.0,<4.0 - types-requests>=2.0 + - aio-pika>=8.0 + - docstring_parser>=0.8 + - werkzeug>=2.0 + - pytest>=7.4.0 + - starlette>=0.25.0 + - flask>=2.0.0 + - openapi-ui-bundles>=0.3.0 diff --git a/pjrpc/client/backend/aio_pika.py b/pjrpc/client/backend/aio_pika.py index 7a77875..f3915c9 100644 --- a/pjrpc/client/backend/aio_pika.py +++ b/pjrpc/client/backend/aio_pika.py @@ -90,7 +90,6 @@ async def close(self) -> None: self._channel = None if self._connection: await self._connection.close() - self._connection = None for future in self._futures.values(): if future.done(): diff --git a/pjrpc/common/v20.py b/pjrpc/common/v20.py index 23ce3f4..f3ec439 100644 --- a/pjrpc/common/v20.py +++ b/pjrpc/common/v20.py @@ -6,7 +6,7 @@ import abc import itertools as it import operator as op -from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Type +from typing import Any, ClassVar, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Type from pjrpc.common.typedefs import Json, JsonRpcParams, JsonRpcRequestId @@ -115,7 +115,7 @@ class Response(AbstractResponse): :param error: response error """ - version = '2.0' + version: ClassVar[str] = '2.0' @classmethod def from_json(cls, json_data: Json, error_cls: Type[JsonRpcError] = JsonRpcError) -> 'Response': @@ -281,7 +281,7 @@ class Request(AbstractRequest): :param id: request identifier """ - version = '2.0' + version: ClassVar[str] = '2.0' @classmethod def from_json(cls, json_data: Json) -> 'Request': @@ -607,7 +607,7 @@ class BatchRequest(AbstractRequest): :param strict: if ``True`` checks response identifier uniqueness """ - version = '2.0' + version: ClassVar[str] = '2.0' @classmethod def from_json(cls, data: Json) -> 'BatchRequest': diff --git a/pjrpc/server/integration/aio_pika.py b/pjrpc/server/integration/aio_pika.py index 7e6e1c0..4d6acd6 100644 --- a/pjrpc/server/integration/aio_pika.py +++ b/pjrpc/server/integration/aio_pika.py @@ -25,10 +25,10 @@ def __init__( self, broker_url: URL, rx_queue_name: str, - tx_exchange_name: str = None, - tx_routing_key: str = None, + tx_exchange_name: Optional[str] = None, + tx_routing_key: Optional[str] = None, prefetch_count: int = 0, - **kwargs: Any + **kwargs: Any, ): self._broker_url = broker_url self._rx_queue_name = rx_queue_name @@ -56,7 +56,7 @@ def dispatcher(self) -> pjrpc.server.AsyncDispatcher: async def start( self, queue_args: Optional[Dict[str, Any]] = None, - exchange_args: Optional[Dict[str, Any]] = None + exchange_args: Optional[Dict[str, Any]] = None, ) -> None: """ Starts executor. diff --git a/pjrpc/server/integration/flask.py b/pjrpc/server/integration/flask.py index 3eab204..2e7e48b 100644 --- a/pjrpc/server/integration/flask.py +++ b/pjrpc/server/integration/flask.py @@ -146,7 +146,7 @@ def _rpc_handle(self, dispatcher: pjrpc.server.Dispatcher) -> flask.Response: raise exceptions.UnsupportedMediaType() try: - flask.request.encoding_errors = 'strict' + flask.request.encoding_errors = 'strict' # type: ignore[attr-defined] request_text = flask.request.get_data(as_text=True) except UnicodeDecodeError as e: raise exceptions.BadRequest() from e diff --git a/pjrpc/server/integration/starlette.py b/pjrpc/server/integration/starlette.py index 1edad88..4cdcdc1 100644 --- a/pjrpc/server/integration/starlette.py +++ b/pjrpc/server/integration/starlette.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Dict, Mapping, Optional, cast +from typing import Any, Dict, Mapping, Optional from starlette import exceptions, routing from starlette.applications import Starlette @@ -13,18 +13,9 @@ from starlette.staticfiles import StaticFiles import pjrpc -from pjrpc.common.typedefs import Func from pjrpc.server import specs, utils -def async_partial(func: Func, *bound_args: Any, **bound_kwargs: Any) -> Func: - @ft.wraps(func) - async def wrapped(*args: Any, **kwargs: Any) -> Any: - return await func(*bound_args, *args, **bound_kwargs, **kwargs) - - return cast(Func, wrapped) - - class Application: """ `aiohttp `_ based JSON-RPC server. @@ -47,7 +38,7 @@ def __init__( self._dispatcher = pjrpc.server.AsyncDispatcher(**kwargs) self._endpoints: Dict[str, pjrpc.server.AsyncDispatcher] = {'': self._dispatcher} - self._app.add_route(self._path, async_partial(self._rpc_handle, dispatcher=self._dispatcher), methods=['POST']) + self._app.add_route(self._path, ft.partial(self._rpc_handle, dispatcher=self._dispatcher), methods=['POST']) if self._spec: self._app.add_route(utils.join_path(self._path, self._spec.path), self._generate_spec, methods=['GET']) @@ -99,7 +90,7 @@ def add_endpoint(self, prefix: str, **kwargs: Any) -> pjrpc.server.AsyncDispatch self._app.add_route( utils.join_path(self._path, prefix), - async_partial(self._rpc_handle, dispatcher=self._dispatcher), + ft.partial(self._rpc_handle, dispatcher=self._dispatcher), methods=['POST'], ) diff --git a/pjrpc/server/specs/openapi.py b/pjrpc/server/specs/openapi.py index 3d8972f..999a538 100644 --- a/pjrpc/server/specs/openapi.py +++ b/pjrpc/server/specs/openapi.py @@ -923,7 +923,7 @@ def __init__(self, **configs: Any): self._configs = configs def get_static_folder(self) -> str: - return self._bundle.swagger_ui.static_path + return str(self._bundle.swagger_ui.static_path) @ft.lru_cache(maxsize=10) def get_index_page(self, spec_url: str) -> str: @@ -964,7 +964,7 @@ def __init__(self, **configs: Any): self._configs = configs def get_static_folder(self) -> str: - return self._bundle.static_path + return str(self._bundle.static_path) @ft.lru_cache(maxsize=10) def get_index_page(self, spec_url: str) -> str: @@ -1005,7 +1005,7 @@ def __init__(self, **configs: Any): self._configs = configs def get_static_folder(self) -> str: - return self._bundle.static_path + return str(self._bundle.static_path) @ft.lru_cache(maxsize=10) def get_index_page(self, spec_url: str) -> str: diff --git a/pyproject.toml b/pyproject.toml index 9fe673e..5128626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ aiofiles = { version = ">=0.7", optional = true } aiohttp = { version = ">=3.7", optional = true } django = { version = ">=3.0", optional = true } docstring-parser = { version = ">=0.8", optional = true } -flask = { version = ">=1.1.3", optional = true } +flask = { version = ">=2.0.0", optional = true } httpx = { version = ">=0.23.0", optional = true } jsonschema = {version = ">=3.0,<4.0", optional = true} kombu = { version = ">=5.1", optional = true } @@ -110,5 +110,9 @@ warn_unused_ignores = true [[tool.mypy.overrides]] -module = "aio_pika.*" +module = "django.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "kombu.*" ignore_missing_imports = true diff --git a/pytest.ini b/pytest.ini index 4088045..bf665e8 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,6 @@ [pytest] asyncio_mode=auto + +filterwarnings = + ignore::DeprecationWarning + default:::pjrpc From 0b9bfe2c3b7ab4f0afbbbd1c17c19cff52508743 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Thu, 31 Oct 2024 20:47:09 +0500 Subject: [PATCH 2/9] Added raise_for_status flag for http clients. --- pjrpc/client/backend/aiohttp.py | 5 ++++- pjrpc/client/backend/httpx.py | 18 ++++++++++++++---- pjrpc/client/backend/requests.py | 12 ++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/pjrpc/client/backend/aiohttp.py b/pjrpc/client/backend/aiohttp.py index 96caa38..4db4473 100644 --- a/pjrpc/client/backend/aiohttp.py +++ b/pjrpc/client/backend/aiohttp.py @@ -19,11 +19,13 @@ def __init__( self, url: str, session_args: Optional[Dict[str, Any]] = None, session: Optional[client.ClientSession] = None, + raise_for_status: bool = True, **kwargs: Any, ): super().__init__(**kwargs) self._endpoint = url self._session = session or client.ClientSession(**(session_args or {})) + self._raise_for_status = raise_for_status async def _request(self, request_text: str, is_notification: bool = False, **kwargs: Any) -> Optional[str]: """ @@ -38,7 +40,8 @@ async def _request(self, request_text: str, is_notification: bool = False, **kwa headers['Content-Type'] = pjrpc.common.DEFAULT_CONTENT_TYPE async with self._session.post(self._endpoint, data=request_text, **kwargs) as resp: - resp.raise_for_status() + if self._raise_for_status: + resp.raise_for_status() response_text = await resp.text() if is_notification: diff --git a/pjrpc/client/backend/httpx.py b/pjrpc/client/backend/httpx.py index f80b48b..3cecd67 100644 --- a/pjrpc/client/backend/httpx.py +++ b/pjrpc/client/backend/httpx.py @@ -15,10 +15,11 @@ class Client(AbstractClient): :param kwargs: parameters to be passed to :py:class:`pjrpc.client.AbstractClient` """ - def __init__(self, url: str, client: Optional[httpx.Client] = None, **kwargs: Any): + def __init__(self, url: str, client: Optional[httpx.Client] = None, raise_for_status: bool = True, **kwargs: Any): super().__init__(**kwargs) self._endpoint = url self._client = client or httpx.Client() + self._raise_for_status = raise_for_status def _request(self, request_text: str, is_notification: bool = False, **kwargs: Any) -> Optional[str]: """ @@ -33,7 +34,8 @@ def _request(self, request_text: str, is_notification: bool = False, **kwargs: A headers['Content-Type'] = pjrpc.common.DEFAULT_CONTENT_TYPE resp = self._client.post(self._endpoint, content=request_text, **kwargs) - resp.raise_for_status() + if self._raise_for_status: + resp.raise_for_status() if is_notification: return None @@ -68,10 +70,17 @@ class AsyncClient(AbstractAsyncClient): :param session: custom session to be used instead of :py:class:`aiohttp.ClientSession` """ - def __init__(self, url: str, client: Optional[httpx.AsyncClient] = None, **kwargs: Any): + def __init__( + self, + url: str, + client: Optional[httpx.AsyncClient] = None, + raise_for_status: bool = True, + **kwargs: Any, + ): super().__init__(**kwargs) self._endpoint = url self._client = client or httpx.AsyncClient() + self._raise_for_status = raise_for_status async def _request(self, request_text: str, is_notification: bool = False, **kwargs: Any) -> Optional[str]: """ @@ -86,7 +95,8 @@ async def _request(self, request_text: str, is_notification: bool = False, **kwa headers['Content-Type'] = pjrpc.common.DEFAULT_CONTENT_TYPE resp = await self._client.post(self._endpoint, content=request_text, **kwargs) - resp.raise_for_status() + if self._raise_for_status: + resp.raise_for_status() response_buff: List[str] = [] async for chunk in resp.aiter_text(): diff --git a/pjrpc/client/backend/requests.py b/pjrpc/client/backend/requests.py index bc69251..180b856 100644 --- a/pjrpc/client/backend/requests.py +++ b/pjrpc/client/backend/requests.py @@ -15,10 +15,17 @@ class Client(AbstractClient): :param kwargs: parameters to be passed to :py:class:`pjrpc.client.AbstractClient` """ - def __init__(self, url: str, session: Optional[requests.Session] = None, **kwargs: Any): + def __init__( + self, + url: str, + session: Optional[requests.Session] = None, + raise_for_status: bool = True, + **kwargs: Any, + ): super().__init__(**kwargs) self._endpoint = url self._session = session or requests.Session() + self._raise_for_status = raise_for_status def _request(self, request_text: str, is_notification: bool = False, **kwargs: Any) -> Optional[str]: """ @@ -33,7 +40,8 @@ def _request(self, request_text: str, is_notification: bool = False, **kwargs: A headers['Content-Type'] = pjrpc.common.DEFAULT_CONTENT_TYPE resp = self._session.post(self._endpoint, data=request_text, **kwargs) - resp.raise_for_status() + if self._raise_for_status: + resp.raise_for_status() if is_notification: return None From b44b5e5b69ac84b2356af9948671446042e983a9 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Thu, 31 Oct 2024 21:20:38 +0500 Subject: [PATCH 3/9] Error codes are returned from dispatcher. --- docs/source/pjrpc/extending.rst | 5 ++- examples/httpserver.py | 5 ++- pjrpc/server/dispatcher.py | 17 +++++--- pjrpc/server/integration/aio_pika.py | 5 ++- pjrpc/server/integration/aiohttp.py | 5 ++- pjrpc/server/integration/django/sites.py | 5 ++- pjrpc/server/integration/flask.py | 5 ++- pjrpc/server/integration/kombu.py | 5 ++- pjrpc/server/integration/starlette.py | 5 ++- pjrpc/server/integration/werkzeug.py | 5 ++- tests/server/test_dispatcher.py | 51 ++++++++++++++++-------- tests/server/test_error_handlers.py | 6 ++- tests/server/test_middleware.py | 6 ++- 13 files changed, 81 insertions(+), 44 deletions(-) diff --git a/docs/source/pjrpc/extending.rst b/docs/source/pjrpc/extending.rst index e6b7244..dc8a572 100644 --- a/docs/source/pjrpc/extending.rst +++ b/docs/source/pjrpc/extending.rst @@ -30,11 +30,12 @@ an JSON-RPC server implementation based on :py:mod:`http.server` standard python self.send_error(http.HTTPStatus.BAD_REQUEST) return - response_text = self.server.dispatcher.dispatch(request_text, context=self) - if response_text is None: + response = self.server.dispatcher.dispatch(request_text, context=self) + if response is None: self.send_response_only(http.HTTPStatus.OK) self.end_headers() else: + response_text, error_codes = response self.send_response(http.HTTPStatus.OK) self.send_header("Content-type", pjrpc.common.DEFAULT_CONTENT_TYPE) self.end_headers() diff --git a/examples/httpserver.py b/examples/httpserver.py index 889e039..b5ed0f6 100644 --- a/examples/httpserver.py +++ b/examples/httpserver.py @@ -28,11 +28,12 @@ def do_POST(self): self.send_error(http.HTTPStatus.BAD_REQUEST) return - response_text = self.server.dispatcher.dispatch(request_text, context=self) - if response_text is None: + response = self.server.dispatcher.dispatch(request_text, context=self) + if response is None: self.send_response_only(http.HTTPStatus.OK) self.end_headers() else: + response_text, error_codes = response self.send_response(http.HTTPStatus.OK) self.send_header("Content-type", pjrpc.common.DEFAULT_CONTENT_TYPE) self.end_headers() diff --git a/pjrpc/server/dispatcher.py b/pjrpc/server/dispatcher.py index df8f5c6..627b23f 100644 --- a/pjrpc/server/dispatcher.py +++ b/pjrpc/server/dispatcher.py @@ -4,7 +4,7 @@ import json import logging from typing import Any, Awaitable, Callable, Dict, Generator, ItemsView, Iterable, Iterator, KeysView, List, Optional -from typing import Type, Union, ValuesView, cast +from typing import Tuple, Type, Union, ValuesView, cast import pjrpc from pjrpc.common import UNSET, AbstractResponse, BatchRequest, BatchResponse, MaybeSet, Request, Response, UnsetType @@ -20,6 +20,13 @@ default_validator = validators.base.BaseValidator() +def extract_error_codes(response: AbstractResponse) -> Tuple[int, ...]: + if isinstance(response, BatchResponse): + return (response.error.code,) if response.error else tuple(r.error.code if r.error else 0 for r in response) + else: + return (response.error.code if response.error else 0,) + + class Method: """ JSON-RPC method wrapper. Stores method itself and some metainformation. @@ -372,7 +379,7 @@ def __init__( error_handlers=error_handlers, ) - def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional[str]: + def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional[Tuple[str, Tuple[int, ...]]]: """ Deserializes request, dispatches it to the required method and serializes the result. @@ -413,7 +420,7 @@ def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional response_text = self._json_dumper(response.to_json(), cls=self._json_encoder) logger.getChild('response').debug("response sent: %s", response_text) - return response_text + return response_text, extract_error_codes(response) return None @@ -508,7 +515,7 @@ def __init__( error_handlers=error_handlers, ) - async def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional[str]: + async def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional[Tuple[str, Tuple[int, ...]]]: """ Deserializes request, dispatches it to the required method and serializes the result. @@ -550,7 +557,7 @@ async def dispatch(self, request_text: str, context: Optional[Any] = None) -> Op response_text = self._json_dumper(response.to_json(), cls=self._json_encoder) logger.getChild('response').debug("response sent: %s", response_text) - return response_text + return response_text, extract_error_codes(response) return None diff --git a/pjrpc/server/integration/aio_pika.py b/pjrpc/server/integration/aio_pika.py index 4d6acd6..95eaf69 100644 --- a/pjrpc/server/integration/aio_pika.py +++ b/pjrpc/server/integration/aio_pika.py @@ -95,9 +95,10 @@ async def _rpc_handle(self, message: aio_pika.abc.AbstractIncomingMessage) -> No try: reply_to = message.reply_to - response_text = await self._dispatcher.dispatch(message.body.decode(), context=message) + response = await self._dispatcher.dispatch(message.body.decode(), context=message) - if response_text is not None: + if response is not None: + response_text, error_codes = response if self._tx_routing_key: routing_key = self._tx_routing_key elif reply_to: diff --git a/pjrpc/server/integration/aiohttp.py b/pjrpc/server/integration/aiohttp.py index 9d32f37..20b592e 100644 --- a/pjrpc/server/integration/aiohttp.py +++ b/pjrpc/server/integration/aiohttp.py @@ -139,8 +139,9 @@ async def _rpc_handle(self, http_request: web.Request, dispatcher: pjrpc.server. except UnicodeDecodeError as e: raise web.HTTPBadRequest() from e - response_text = await dispatcher.dispatch(request_text, context=http_request) - if response_text is None: + response = await dispatcher.dispatch(request_text, context=http_request) + if response is None: return web.Response() else: + response_text, error_codes = response return web.json_response(text=response_text) diff --git a/pjrpc/server/integration/django/sites.py b/pjrpc/server/integration/django/sites.py index 08dc36c..1bd3a28 100644 --- a/pjrpc/server/integration/django/sites.py +++ b/pjrpc/server/integration/django/sites.py @@ -106,10 +106,11 @@ def _rpc_handle(self, request: HttpRequest) -> HttpResponse: except UnicodeDecodeError: return HttpResponseBadRequest() - response_text = self._dispatcher.dispatch(request_text, context=request) - if response_text is None: + response = self._dispatcher.dispatch(request_text, context=request) + if response is None: return HttpResponse() else: + response_text, error_codes = response return HttpResponse(response_text, content_type=pjrpc.common.DEFAULT_CONTENT_TYPE) diff --git a/pjrpc/server/integration/flask.py b/pjrpc/server/integration/flask.py index 2e7e48b..8385264 100644 --- a/pjrpc/server/integration/flask.py +++ b/pjrpc/server/integration/flask.py @@ -151,8 +151,9 @@ def _rpc_handle(self, dispatcher: pjrpc.server.Dispatcher) -> flask.Response: except UnicodeDecodeError as e: raise exceptions.BadRequest() from e - response_text = dispatcher.dispatch(request_text) - if response_text is None: + response = dispatcher.dispatch(request_text) + if response is None: return current_app.response_class() else: + response_text, error_codes = response return current_app.response_class(response_text, mimetype=pjrpc.common.DEFAULT_CONTENT_TYPE) diff --git a/pjrpc/server/integration/kombu.py b/pjrpc/server/integration/kombu.py index dbacfed..ba3f001 100644 --- a/pjrpc/server/integration/kombu.py +++ b/pjrpc/server/integration/kombu.py @@ -72,9 +72,10 @@ def _rpc_handle(self, message: kombu.Message) -> None: try: reply_to = message.properties.get('reply_to') - response_text = self._dispatcher.dispatch(message.body, context=message) + response = self._dispatcher.dispatch(message.body, context=message) - if response_text is not None: + if response is not None: + response_text, error_codes = response if reply_to is None: logger.warning("property 'reply_to' is missing") else: diff --git a/pjrpc/server/integration/starlette.py b/pjrpc/server/integration/starlette.py index 4cdcdc1..51bac3a 100644 --- a/pjrpc/server/integration/starlette.py +++ b/pjrpc/server/integration/starlette.py @@ -137,8 +137,9 @@ async def _rpc_handle(self, http_request: Request, dispatcher: pjrpc.server.Asyn except UnicodeDecodeError as e: raise exceptions.HTTPException(400) from e - response_text = await dispatcher.dispatch(request_text, context=http_request) - if response_text is None: + response = await dispatcher.dispatch(request_text, context=http_request) + if response is None: return Response() else: + response_text, error_codes = response return Response(content=response_text, media_type=pjrpc.common.DEFAULT_CONTENT_TYPE) diff --git a/pjrpc/server/integration/werkzeug.py b/pjrpc/server/integration/werkzeug.py index 6c548ee..c099e35 100644 --- a/pjrpc/server/integration/werkzeug.py +++ b/pjrpc/server/integration/werkzeug.py @@ -50,8 +50,9 @@ def _rpc_handle(self, request: werkzeug.Request) -> werkzeug.Response: except UnicodeDecodeError as e: raise exceptions.BadRequest() from e - response_text = self._dispatcher.dispatch(request_text, context=request) - if response_text is None: + response = self._dispatcher.dispatch(request_text, context=request) + if response is None: return werkzeug.Response() else: + response_text, error_codes = response return werkzeug.Response(response_text, mimetype=pjrpc.common.DEFAULT_CONTENT_TYPE) diff --git a/tests/server/test_dispatcher.py b/tests/server/test_dispatcher.py index 334634d..a5da137 100644 --- a/tests/server/test_dispatcher.py +++ b/tests/server/test_dispatcher.py @@ -151,7 +151,8 @@ def test_dispatcher(): 'method': 'method', }) - assert json.loads(disp.dispatch(request)) == { + response, error_codes = disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': 1, 'error': { @@ -181,7 +182,8 @@ def method1(param): 'params': ['param1'], }) - assert json.loads(disp.dispatch(request)) == { + response, error_codes = disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': 1, 'result': 'param1', @@ -198,7 +200,8 @@ def method2(): 'method': 'method2', }) - assert json.loads(disp.dispatch(request)) == { + response, error_codes = disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': 1, 'error': { @@ -227,7 +230,8 @@ def method2(): }, ]) - assert json.loads(disp.dispatch(request)) == [ + response, error_codes = disp.dispatch(request) + assert json.loads(response) == [ { 'jsonrpc': '2.0', 'id': 1, @@ -248,7 +252,8 @@ def method2(): def test_dispatcher_errors(): disp = dispatcher.Dispatcher() - assert json.loads(disp.dispatch('')) == { + response, error_codes = disp.dispatch('') + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -258,7 +263,8 @@ def test_dispatcher_errors(): }, } - assert json.loads(disp.dispatch('{}')) == { + response, error_codes = disp.dispatch('{}') + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -268,7 +274,8 @@ def test_dispatcher_errors(): }, } - assert json.loads(disp.dispatch('[]')) == { + response, error_codes = disp.dispatch('[]') + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -291,7 +298,8 @@ def test_dispatcher_errors(): }, ]) - assert json.loads(disp.dispatch(request)) == { + response, error_codes = disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -301,7 +309,8 @@ def test_dispatcher_errors(): }, } - assert json.loads(disp.dispatch(request)) == { + response, error_codes = disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -321,7 +330,8 @@ async def test_async_dispatcher(): 'method': 'method', }) - assert json.loads(await disp.dispatch(request)) == { + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': 1, 'error': { @@ -351,7 +361,8 @@ async def method1(param): 'params': ['param1'], }) - assert json.loads(await disp.dispatch(request)) == { + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': 1, 'result': 'param1', @@ -368,7 +379,8 @@ async def method2(): 'method': 'method2', }) - assert json.loads(await disp.dispatch(request)) == { + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': 1, 'error': { @@ -397,7 +409,8 @@ async def method2(): }, ]) - assert json.loads(await disp.dispatch(request)) == [ + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == [ { 'jsonrpc': '2.0', 'id': 1, @@ -418,7 +431,8 @@ async def method2(): async def test_async_dispatcher_errors(): disp = dispatcher.AsyncDispatcher() - assert json.loads(await disp.dispatch('')) == { + response, error_codes = await disp.dispatch('') + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -428,7 +442,8 @@ async def test_async_dispatcher_errors(): }, } - assert json.loads(await disp.dispatch('{}')) == { + response, error_codes = await disp.dispatch('{}') + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -451,7 +466,8 @@ async def test_async_dispatcher_errors(): }, ]) - assert json.loads(await disp.dispatch(request)) == { + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { @@ -461,7 +477,8 @@ async def test_async_dispatcher_errors(): }, } - assert json.loads(await disp.dispatch(request)) == { + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == { 'jsonrpc': '2.0', 'id': None, 'error': { diff --git a/tests/server/test_error_handlers.py b/tests/server/test_error_handlers.py index 40099ae..9edf3fb 100644 --- a/tests/server/test_error_handlers.py +++ b/tests/server/test_error_handlers.py @@ -39,9 +39,10 @@ def test_32603_error_handler(request, context, error): ) request_text = json.dumps(test_request.to_json()) - response_text = dispatcher.dispatch(request_text, test_context) + response_text, error_codes = dispatcher.dispatch(request_text, test_context) actual_response = pjrpc.common.Response.from_json(json.loads(response_text)) assert actual_response == expected_response + assert error_codes == (-32601,) assert handler_call_order == [test_any_error_handler, test_32601_error_handler] @@ -81,8 +82,9 @@ async def test_32603_error_handler(request, context, error): ) request_text = json.dumps(test_request.to_json()) - response_text = await dispatcher.dispatch(request_text, test_context) + response_text, error_codes = await dispatcher.dispatch(request_text, test_context) actual_response = pjrpc.common.Response.from_json(json.loads(response_text)) assert actual_response == expected_response + assert error_codes == (-32601,) assert handler_call_order == [test_any_error_handler, test_32601_error_handler] diff --git a/tests/server/test_middleware.py b/tests/server/test_middleware.py index 0ee793b..dde35fe 100644 --- a/tests/server/test_middleware.py +++ b/tests/server/test_middleware.py @@ -34,9 +34,10 @@ def test_middleware2(request, context, handler): dispatcher.add(test_method, 'test_method', 'context') request_text = json.dumps(test_request.to_json()) - response_text = dispatcher.dispatch(request_text, test_context) + response_text, error_codes = dispatcher.dispatch(request_text, test_context) actual_response = pjrpc.common.Response.from_json(json.loads(response_text)) assert actual_response == test_response + assert error_codes == (0,) assert middleware_call_order == [test_middleware1, test_middleware2] @@ -71,8 +72,9 @@ async def test_middleware2(request, context, handler): dispatcher.add(test_method, 'test_method', 'context') request_text = json.dumps(test_request.to_json()) - response_text = await dispatcher.dispatch(request_text, test_context) + response_text, error_codes = await dispatcher.dispatch(request_text, test_context) actual_response = pjrpc.common.Response.from_json(json.loads(response_text)) assert actual_response == test_response + assert error_codes == (0,) assert middleware_call_order == [test_middleware1, test_middleware2] From 968ad379f7f9ea4ff1d76433397d65817d3efc71 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Fri, 1 Nov 2024 00:02:22 +0500 Subject: [PATCH 4/9] custom http server response status added. --- pjrpc/server/integration/aiohttp.py | 10 +++++++--- pjrpc/server/integration/flask.py | 17 ++++++++++++++--- pjrpc/server/integration/starlette.py | 16 ++++++++++++---- tests/server/test_aiohttp.py | 13 +++++++++++++ tests/server/test_flask.py | 13 +++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/pjrpc/server/integration/aiohttp.py b/pjrpc/server/integration/aiohttp.py index 20b592e..c4cc5fd 100644 --- a/pjrpc/server/integration/aiohttp.py +++ b/pjrpc/server/integration/aiohttp.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Dict, Mapping, Optional +from typing import Any, Callable, Dict, Mapping, Optional, Tuple import aiohttp.web from aiohttp import web @@ -18,7 +18,9 @@ class Application: `aiohttp `_ based JSON-RPC server. :param path: JSON-RPC handler base path - :param app_args: arguments to be passed to :py:class:`aiohttp.web.Application` + :param spec: api specification instance + :param app: aiohttp application instance + :param status_by_error: a function returns http status code by json-rpc error codes, 200 for all errors by default :param kwargs: arguments to be passed to the dispatcher :py:class:`pjrpc.server.AsyncDispatcher` """ @@ -27,11 +29,13 @@ def __init__( path: str = '', spec: Optional[specs.Specification] = None, app: Optional[web.Application] = None, + status_by_error: Callable[[Tuple[int, ...]], int] = lambda codes: 200, **kwargs: Any, ): self._path = path.rstrip('/') self._spec = spec self._app = app or web.Application() + self._status_by_error = status_by_error self._dispatcher = pjrpc.server.AsyncDispatcher(**kwargs) self._endpoints: Dict[str, pjrpc.server.AsyncDispatcher] = {'': self._dispatcher} @@ -144,4 +148,4 @@ async def _rpc_handle(self, http_request: web.Request, dispatcher: pjrpc.server. return web.Response() else: response_text, error_codes = response - return web.json_response(text=response_text) + return web.json_response(status=self._status_by_error(error_codes), text=response_text) diff --git a/pjrpc/server/integration/flask.py b/pjrpc/server/integration/flask.py index 8385264..66e4811 100644 --- a/pjrpc/server/integration/flask.py +++ b/pjrpc/server/integration/flask.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Dict, Optional, Union +from typing import Any, Callable, Dict, Optional, Tuple, Union import flask from flask import current_app @@ -23,9 +23,16 @@ class JsonRPC: :param kwargs: arguments to be passed to the dispatcher :py:class:`pjrpc.server.Dispatcher` """ - def __init__(self, path: str, spec: Optional[specs.Specification] = None, **kwargs: Any): + def __init__( + self, + path: str, + spec: Optional[specs.Specification] = None, + status_by_error: Callable[[Tuple[int, ...]], int] = lambda codes: 200, + **kwargs: Any, + ): self._path = path.rstrip('/') self._spec = spec + self._status_by_error = status_by_error kwargs.setdefault('json_loader', flask.json.loads) kwargs.setdefault('json_dumper', flask.json.dumps) @@ -156,4 +163,8 @@ def _rpc_handle(self, dispatcher: pjrpc.server.Dispatcher) -> flask.Response: return current_app.response_class() else: response_text, error_codes = response - return current_app.response_class(response_text, mimetype=pjrpc.common.DEFAULT_CONTENT_TYPE) + return current_app.response_class( + response_text, + status=self._status_by_error(error_codes), + mimetype=pjrpc.common.DEFAULT_CONTENT_TYPE, + ) diff --git a/pjrpc/server/integration/starlette.py b/pjrpc/server/integration/starlette.py index 51bac3a..6183dc3 100644 --- a/pjrpc/server/integration/starlette.py +++ b/pjrpc/server/integration/starlette.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Dict, Mapping, Optional +from typing import Any, Callable, Dict, Mapping, Optional, Tuple from starlette import exceptions, routing from starlette.applications import Starlette @@ -18,10 +18,12 @@ class Application: """ - `aiohttp `_ based JSON-RPC server. + `starlette `_ based JSON-RPC server. :param path: JSON-RPC handler base path - :param app_args: arguments to be passed to :py:class:`aiohttp.web.Application` + :param spec: api specification instance + :param app: starlette application instance + :param status_by_error: a function returns http status code by json-rpc error codes, 200 for all errors by default :param kwargs: arguments to be passed to the dispatcher :py:class:`pjrpc.server.AsyncDispatcher` """ @@ -30,11 +32,13 @@ def __init__( path: str = '', spec: Optional[specs.Specification] = None, app: Optional[Starlette] = None, + status_by_error: Callable[[Tuple[int, ...]], int] = lambda codes: 200, **kwargs: Any, ): self._path = path.rstrip('/') self._spec = spec self._app = app or Starlette() + self._status_by_error = status_by_error self._dispatcher = pjrpc.server.AsyncDispatcher(**kwargs) self._endpoints: Dict[str, pjrpc.server.AsyncDispatcher] = {'': self._dispatcher} @@ -142,4 +146,8 @@ async def _rpc_handle(self, http_request: Request, dispatcher: pjrpc.server.Asyn return Response() else: response_text, error_codes = response - return Response(content=response_text, media_type=pjrpc.common.DEFAULT_CONTENT_TYPE) + return Response( + status_code=self._status_by_error(error_codes), + content=response_text, + media_type=pjrpc.common.DEFAULT_CONTENT_TYPE, + ) diff --git a/tests/server/test_aiohttp.py b/tests/server/test_aiohttp.py index f36790e..a7faff4 100644 --- a/tests/server/test_aiohttp.py +++ b/tests/server/test_aiohttp.py @@ -151,3 +151,16 @@ async def test_context(json_rpc, path, mocker, aiohttp_client): context, kwargs = call_args['request'], call_args['kwargs'] assert isinstance(context, web.Request) assert kwargs == params + + +async def test_http_status(json_rpc, path, aiohttp_client): + expected_http_status = 400 + json_rpc = integration.Application(path, status_by_error=lambda codes: expected_http_status) + + cli = await aiohttp_client(json_rpc.app) + raw = await cli.post(path, json=v20.Request(method='unknown_method', id=1).to_json()) + assert raw.status == expected_http_status + + cli = await aiohttp_client(json_rpc.app) + raw = await cli.post(path, json=v20.BatchRequest(v20.Request(method='unknown_method', id=1)).to_json()) + assert raw.status == expected_http_status diff --git a/tests/server/test_flask.py b/tests/server/test_flask.py index 78255ba..5861127 100644 --- a/tests/server/test_flask.py +++ b/tests/server/test_flask.py @@ -121,3 +121,16 @@ def error_method(*args, **kwargs): # decoding error raw = cli.post(path, headers={'Content-Type': 'application/json'}, data=b'\xff') assert raw.status_code == 400 + + +async def test_http_status(app, path): + expected_http_status = 400 + json_rpc = integration.JsonRPC(path, status_by_error=lambda codes: expected_http_status) + json_rpc.init_app(app) + + with app.test_client() as cli: + raw = cli.post(path, json=v20.Request(method='unknown_method', id=1).to_json()) + assert raw.status_code == expected_http_status + + raw = cli.post(path, json=v20.BatchRequest(v20.Request(method='unknown_method', id=1)).to_json()) + assert raw.status_code == expected_http_status From e1673bfff4014a6f695ce3f12ab0aee3375a3fc2 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Sun, 3 Nov 2024 19:47:39 +0500 Subject: [PATCH 5/9] batch size validation support added. --- pjrpc/server/dispatcher.py | 44 ++++++++++++++++-------- tests/server/test_dispatcher.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/pjrpc/server/dispatcher.py b/pjrpc/server/dispatcher.py index 627b23f..fd8f299 100644 --- a/pjrpc/server/dispatcher.py +++ b/pjrpc/server/dispatcher.py @@ -365,6 +365,7 @@ def __init__( json_decoder: Optional[Type[json.JSONDecoder]] = None, middlewares: Iterable['MiddlewareType'] = (), error_handlers: Dict[Union[None, int, Exception], List['ErrorHandlerType']] = {}, + max_batch_size: Optional[int] = None, ): super().__init__( request_class=request_class, @@ -378,6 +379,7 @@ def __init__( middlewares=middlewares, error_handlers=error_handlers, ) + self._max_batch_size = max_batch_size def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional[Tuple[str, Tuple[int, ...]]]: """ @@ -407,12 +409,18 @@ def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional else: if isinstance(request, BatchRequest): - response = self._batch_response( - *( - resp for resp in (self._handle_request(request, context) for request in request) - if not isinstance(resp, UnsetType) - ), - ) + if self._max_batch_size and len(request) > self._max_batch_size: + response = self._response_class( + id=None, + error=pjrpc.exceptions.InvalidRequestError(data="batch too large"), + ) + else: + response = self._batch_response( + *( + resp for resp in (self._handle_request(request, context) for request in request) + if not isinstance(resp, UnsetType) + ), + ) else: response = self._handle_request(request, context) @@ -501,6 +509,8 @@ def __init__( json_decoder: Optional[Type[json.JSONDecoder]] = None, middlewares: Iterable['AsyncMiddlewareType'] = (), error_handlers: Dict[Union[None, int, Exception], List['AsyncErrorHandlerType']] = {}, + max_batch_size: Optional[int] = None, + concurrent_batch: bool = True, ): super().__init__( request_class=request_class, @@ -514,6 +524,8 @@ def __init__( middlewares=middlewares, error_handlers=error_handlers, ) + self._max_batch_size = max_batch_size + self._concurrent_batch = concurrent_batch async def dispatch(self, request_text: str, context: Optional[Any] = None) -> Optional[Tuple[str, Tuple[int, ...]]]: """ @@ -543,13 +555,19 @@ async def dispatch(self, request_text: str, context: Optional[Any] = None) -> Op else: if isinstance(request, BatchRequest): - response = self._batch_response( - *( - resp - for resp in await asyncio.gather(*(self._handle_request(req, context) for req in request)) - if resp - ), - ) + if self._max_batch_size and len(request) > self._max_batch_size: + response = self._response_class( + id=None, + error=pjrpc.exceptions.InvalidRequestError(data="batch too large"), + ) + else: + response = self._batch_response( + *( + resp + for resp in await asyncio.gather(*(self._handle_request(req, context) for req in request)) + if resp + ), + ) else: response = await self._handle_request(request, context) diff --git a/tests/server/test_dispatcher.py b/tests/server/test_dispatcher.py index a5da137..be13dad 100644 --- a/tests/server/test_dispatcher.py +++ b/tests/server/test_dispatcher.py @@ -320,6 +320,35 @@ def test_dispatcher_errors(): }, } +def test_dispatcher_batch_too_large_errors(): + disp = dispatcher.Dispatcher(max_batch_size=1) + + request = json.dumps( + [ + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'method', + }, + { + 'jsonrpc': '2.0', + 'id': 2, + 'method': 'method', + }, + ] + ) + + response, error_codes = disp.dispatch(request) + assert json.loads(response) == { + 'jsonrpc': '2.0', + 'id': None, + 'error': { + 'code': -32600, + 'message': 'Invalid Request', + 'data': "batch too large", + }, + } + async def test_async_dispatcher(): disp = dispatcher.AsyncDispatcher() @@ -487,3 +516,33 @@ async def test_async_dispatcher_errors(): 'data': 'request id duplicates found: 1', }, } + + +async def test_async_dispatcher_batch_too_large_errors(): + disp = dispatcher.AsyncDispatcher(max_batch_size=1) + + request = json.dumps( + [ + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'method', + }, + { + 'jsonrpc': '2.0', + 'id': 2, + 'method': 'method', + }, + ] + ) + + response, error_codes = await disp.dispatch(request) + assert json.loads(response) == { + 'jsonrpc': '2.0', + 'id': None, + 'error': { + 'code': -32600, + 'message': 'Invalid Request', + 'data': "batch too large", + }, + } From 2468adf729f11a4f9c5134f1d95dcb0a39de880a Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Fri, 1 Nov 2024 10:42:02 +0500 Subject: [PATCH 6/9] openapi 3.0 support added. --- examples/openapi_aiohttp.py | 135 +- examples/openapi_aiohttp_subendpoints.py | 168 +- examples/openrpc_aiohttp.py | 221 ++ pjrpc/common/exceptions.py | 24 +- pjrpc/server/integration/aiohttp.py | 60 +- pjrpc/server/integration/django/sites.py | 2 +- pjrpc/server/integration/flask.py | 66 +- pjrpc/server/integration/starlette.py | 55 +- pjrpc/server/specs/__init__.py | 2 - pjrpc/server/specs/extractors/__init__.py | 160 +- pjrpc/server/specs/extractors/docstring.py | 96 +- pjrpc/server/specs/extractors/pydantic.py | 315 ++- pjrpc/server/specs/openapi.py | 852 ++++---- pjrpc/server/specs/openrpc.py | 411 ++-- pjrpc/server/specs/schemas.py | 147 ++ pyproject.toml | 3 + tests/server/conftest.py | 2 +- tests/server/resources/oas-3.0-meta.yaml | 1031 ++++++++++ tests/server/resources/oas-3.1-meta.yaml | 1440 +++++++++++++ tests/server/resources/openapi-1.json | 1241 ----------- tests/server/resources/openrpc-1.3.2.json | 932 +++++++++ tests/server/resources/openrpc-1.json | 189 -- tests/server/test_dispatcher.py | 5 +- tests/server/test_openapi.py | 2162 +++++++++++++++++++- tests/server/test_openrpc.py | 945 ++++++++- 25 files changed, 8058 insertions(+), 2606 deletions(-) create mode 100644 examples/openrpc_aiohttp.py create mode 100644 pjrpc/server/specs/schemas.py create mode 100644 tests/server/resources/oas-3.0-meta.yaml create mode 100644 tests/server/resources/oas-3.1-meta.yaml delete mode 100644 tests/server/resources/openapi-1.json create mode 100644 tests/server/resources/openrpc-1.3.2.json delete mode 100644 tests/server/resources/openrpc-1.json diff --git a/examples/openapi_aiohttp.py b/examples/openapi_aiohttp.py index 2538c0e..6f6fd7f 100644 --- a/examples/openapi_aiohttp.py +++ b/examples/openapi_aiohttp.py @@ -1,8 +1,8 @@ import uuid -from typing import Any +from typing import Annotated, Any import aiohttp_cors -import pydantic +import pydantic as pd from aiohttp import helpers, web import pjrpc.server.specs.extractors.docstring @@ -20,8 +20,8 @@ class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) @@ -41,14 +41,35 @@ async def _rpc_handle(self, http_request: web.Request, dispatcher: pjrpc.server. return await super()._rpc_handle(http_request=http_request, dispatcher=dispatcher) -class UserIn(pydantic.BaseModel): +UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), +] + +UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), +] + +UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[36]), +] + +UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["226a2c23-c98b-4729-b398-0dae550e99ff"]), +] + + +class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -56,7 +77,7 @@ class UserOut(UserIn): Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -78,26 +99,28 @@ class NotFoundError(pjrpc.exc.JsonRpcError): @specs.annotate( - tags=['users'], - errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], + params_schema={'user': {'schema': {'type': 'object'}}}, + result_schema={'type': 'string'}, + # tags=['users'], + # errors=[AlreadyExistsError], + # examples=[ + # specs.MethodExample( + # summary="Simple example", + # params=dict( + # user={ + # 'name': 'John', + # 'surname': 'Doe', + # 'age': 25, + # }, + # ), + # result={ + # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', + # 'name': 'John', + # 'surname': 'Doe', + # 'age': 25, + # }, + # ), + # ], ) @methods.add(context='request') @validator.validate @@ -118,30 +141,30 @@ def add_user(request: web.Request, user: UserIn) -> UserOut: user_id = uuid.uuid4().hex request.config_dict['users'][user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], + # examples=[ + # specs.MethodExample( + # summary='Simple example', + # params=dict( + # user_id='c47726c6-a232-45f1-944f-60b98966ff1b', + # ), + # result={ + # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', + # 'name': 'John', + # 'surname': 'Doe', + # 'age': 25, + # }, + # ), + # ], ) @methods.add(context='request') @validator.validate -def get_user(request: web.Request, user_id: uuid.UUID) -> UserOut: +def get_user(request: web.Request, user_id: UserId) -> UserOut: """ Returns a user. @@ -161,19 +184,19 @@ def get_user(request: web.Request, user_id: uuid.UUID) -> UserOut: @specs.annotate( tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result=None, - ), - ], + # examples=[ + # specs.MethodExample( + # summary='Simple example', + # params=dict( + # user_id='c47726c6-a232-45f1-944f-60b98966ff1b', + # ), + # result=None, + # ), + # ], ) @methods.add(context='request') @validator.validate -def delete_user(request: web.Request, user_id: uuid.UUID) -> None: +def delete_user(request: web.Request, user_id: UserId) -> None: """ Deletes a user. @@ -210,7 +233,7 @@ def delete_user(request: web.Request, user_id: uuid.UUID) -> None: dict(basicAuth=[]), ], schema_extractors=[ - extractors.docstring.DocstringSchemaExtractor(), + # extractors.docstring.DocstringSchemaExtractor(), extractors.pydantic.PydanticSchemaExtractor(), ], ui=specs.SwaggerUI(), diff --git a/examples/openapi_aiohttp_subendpoints.py b/examples/openapi_aiohttp_subendpoints.py index a24ada3..1e375fc 100644 --- a/examples/openapi_aiohttp_subendpoints.py +++ b/examples/openapi_aiohttp_subendpoints.py @@ -1,12 +1,13 @@ import uuid -from typing import Any +from typing import Annotated, Any import aiohttp_cors -import pydantic +import pydantic as pd from aiohttp import web import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic +from pjrpc.common.exceptions import MethodNotFoundError from pjrpc.server.integration import aiohttp as integration from pjrpc.server.specs import extractors from pjrpc.server.specs import openapi as specs @@ -19,22 +20,43 @@ class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) -class UserIn(pydantic.BaseModel): +UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), +] + +UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), +] + +UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[36]), +] + +UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["226a2c23-c98b-4729-b398-0dae550e99ff"]), +] + + +class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -42,7 +64,7 @@ class UserOut(UserIn): Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -55,26 +77,26 @@ class AlreadyExistsError(pjrpc.exc.JsonRpcError): @specs.annotate( - tags=['users'], + # tags=['users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], + # examples=[ + # specs.MethodExample( + # summary="Simple example", + # params=dict( + # user={ + # 'name': 'John', + # 'surname': 'Doe', + # 'age': 25, + # }, + # ), + # result={ + # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', + # 'name': 'John', + # 'surname': 'Doe', + # 'age': 25, + # }, + # ), + # ], ) @user_methods.add(context='request') @validator.validate @@ -91,16 +113,32 @@ def add_user(request: web.Request, user: UserIn) -> UserOut: user_id = uuid.uuid4().hex request.config_dict['users'][user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) + + +PostTitle = Annotated[ + str, + pd.Field(description="Post title", examples=["About me"]), +] +PostContent = Annotated[ + str, + pd.Field(description="Post content", examples=['Software engineer']), +] -class PostIn(pydantic.BaseModel): +PostId = Annotated[ + uuid.UUID, + pd.Field(description="Post identifier", examples=["226a2c23-c98b-4729-b398-0dae550e99ff"]), +] + + +class PostIn(pd.BaseModel): """ User registration data. """ - title: str - content: str + title: PostTitle + content: PostContent class PostOut(PostIn): @@ -108,28 +146,28 @@ class PostOut(PostIn): Registered user data. """ - id: uuid.UUID + id: PostId @specs.annotate( - tags=['posts'], + # tags=['posts'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - post={ - 'title': 'Super post', - 'content': 'My first post', - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'title': 'Super post', - 'content': 'My first post', - }, - ), - ], + # examples=[ + # specs.MethodExample( + # summary="Simple example", + # params=dict( + # post={ + # 'title': 'Super post', + # 'content': 'My first post', + # }, + # ), + # result={ + # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', + # 'title': 'Super post', + # 'content': 'My first post', + # }, + # ), + # ], ) @post_methods.add(context='request') @validator.validate @@ -148,9 +186,15 @@ def add_post(request: web.Request, post: PostIn) -> PostOut: return PostOut(id=post_id, **post.dict()) +error_http_status_map = { + AlreadyExistsError.code: 400, + MethodNotFoundError.code: 404, +} + jsonrpc_app = integration.Application( '/api/v1', json_encoder=JSONEncoder, + status_by_error=lambda codes: 200 if len(codes) != 1 else error_http_status_map.get(codes[0], 200), spec=specs.OpenAPI( info=specs.Info(version="1.0.0", title="User storage"), servers=[ @@ -168,20 +212,34 @@ def add_post(request: web.Request, post: PostIn) -> PostOut: dict(basicAuth=[]), ], schema_extractors=[ - extractors.docstring.DocstringSchemaExtractor(), extractors.pydantic.PydanticSchemaExtractor(), ], - ui=specs.SwaggerUI(), + # ui=specs.SwaggerUI(), # ui=specs.RapiDoc(), - # ui=specs.ReDoc(), + ui=specs.ReDoc(hide_schema_titles=True), + error_http_status_map=error_http_status_map, ), ) jsonrpc_app.app['users'] = {} jsonrpc_app.app['posts'] = {} -jsonrpc_app.add_endpoint('/users', json_encoder=JSONEncoder).add_methods(user_methods) -jsonrpc_app.add_endpoint('/posts', json_encoder=JSONEncoder).add_methods(post_methods) +jsonrpc_app.add_endpoint( + '/users', + json_encoder=JSONEncoder, + spec_params=dict( + method_schema_extra={'tags': ['users']}, + component_name_prefix='V1', + ), +).add_methods(user_methods) +jsonrpc_app.add_endpoint( + '/posts', + json_encoder=JSONEncoder, + spec_params=dict( + method_schema_extra={'tags': ['posts']}, + component_name_prefix='V1', + ), +).add_methods(post_methods) cors = aiohttp_cors.setup( diff --git a/examples/openrpc_aiohttp.py b/examples/openrpc_aiohttp.py new file mode 100644 index 0000000..e34e47b --- /dev/null +++ b/examples/openrpc_aiohttp.py @@ -0,0 +1,221 @@ +import uuid +from typing import Annotated, Any + +import aiohttp_cors +import pydantic as pd +from aiohttp import web + +import pjrpc.server.specs.extractors.docstring +import pjrpc.server.specs.extractors.pydantic +from pjrpc.server.integration import aiohttp as integration +from pjrpc.server.specs import extractors +from pjrpc.server.specs import openrpc as specs +from pjrpc.server.validators import pydantic as validators + +methods = pjrpc.server.MethodRegistry() +validator = validators.PydanticValidator() + +credentials = {"admin": "admin"} + + +class JSONEncoder(pjrpc.JSONEncoder): + def default(self, o: Any) -> Any: + if isinstance(o, pd.BaseModel): + return o.model_dump() + if isinstance(o, uuid.UUID): + return str(o) + + return super().default(o) + + +UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), +] + +UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=["Doe"]), +] + +UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[25]), +] + +UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["08b02cf9-8e07-4d06-b569-2c24309c1dc1"]), +] + + +class UserIn(pd.BaseModel, title="User data"): + """ + User registration data. + """ + + name: UserName + surname: UserSurname + age: UserAge + + +class UserOut(UserIn, title="User data"): + """ + Registered user data. + """ + + id: UserId + + +class AlreadyExistsError(pjrpc.exc.JsonRpcError): + """ + User already registered error. + """ + + code = 2001 + message = "user already exists" + + +class NotFoundError(pjrpc.exc.JsonRpcError): + """ + User not found error. + """ + + code = 2002 + message = "user not found" + + +@specs.annotate( + summary="Creates a user", + tags=['users'], + errors=[AlreadyExistsError], +) +@methods.add(context='request') +@validator.validate +def add_user(request: web.Request, user: UserIn) -> UserOut: + """ + Creates a user. + + :param request: http request + :param object user: user data + :return object: registered user + :raise AlreadyExistsError: user already exists + """ + + for existing_user in request.config_dict['users'].values(): + if user.name == existing_user.name: + raise AlreadyExistsError() + + user_id = uuid.uuid4().hex + request.config_dict['users'][user_id] = user + + return UserOut(id=user_id, **user.dict()) + + +@specs.annotate( + summary="Returns a user", + tags=['users'], + errors=[NotFoundError], + examples=[ + specs.MethodExample( + name="Simple", + params=[ + specs.ExampleObject( + name="user_id", + value="49b2ee18-6fd2-4840-bd27-a208117fca41", + ), + ], + result=specs.ExampleObject( + name="result", + value={"name": "Alex", "surname": "Jones", "age": 34, "id": "49b2ee18-6fd2-4840-bd27-a208117fca41"}, + ), + summary="Simple example", + ), + ], +) +@methods.add(context='request') +@validator.validate +def get_user(request: web.Request, user_id: UserId) -> UserOut: + """ + Returns a user. + + :param request: http request + :param object user_id: user id + :return object: registered user + :raise NotFoundError: user not found + """ + + user = request.config_dict['users'].get(user_id.hex) + if not user: + raise NotFoundError() + + return UserOut(id=user_id, **user.dict()) + + +@specs.annotate( + summary="Deletes a user", + tags=['users'], + errors=[NotFoundError], + deprecated=True, +) +@methods.add(context='request') +@validator.validate +def delete_user(request: web.Request, user_id: UserId) -> None: + """ + Deletes a user. + + :param request: http request + :param object user_id: user id + :raise NotFoundError: user not found + """ + + user = request.config_dict['users'].pop(user_id.hex, None) + if not user: + raise NotFoundError() + + +app = web.Application() +app['users'] = {} + +jsonrpc_app = integration.Application( + '/api', + json_encoder=JSONEncoder, + spec=specs.OpenRPC( + info=specs.Info(version="1.0.0", title="User storage"), + servers=[ + specs.Server( + name="api", + url="http://127.0.0.1:8080/myapp/api", + ), + ], + schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), + ), +) +jsonrpc_app.add_endpoint( + '/v1', + spec_params=dict( + servers=[ + specs.Server( + name="v1", + url="http://127.0.0.1:8080/myapp/api/v1", + ), + ], + tags=['v1'], + ), +).add_methods(methods) +app.add_subapp('/myapp', jsonrpc_app.app) + +cors = aiohttp_cors.setup( + app, defaults={ + '*': aiohttp_cors.ResourceOptions( + allow_credentials=True, + expose_headers='*', + allow_headers='*', + ), + }, +) +for route in list(app.router.routes()): + cors.add(route) + +if __name__ == "__main__": + web.run_app(app, host='localhost', port=8080) diff --git a/pjrpc/common/exceptions.py b/pjrpc/common/exceptions.py index 1a6d016..44babab 100644 --- a/pjrpc/common/exceptions.py +++ b/pjrpc/common/exceptions.py @@ -150,8 +150,8 @@ class ParseError(ClientError): An error occurred on the server while parsing the JSON text. """ - code = -32700 - message = 'Parse error' + code: int = -32700 + message: str = 'Parse error' class InvalidRequestError(ClientError): @@ -159,8 +159,8 @@ class InvalidRequestError(ClientError): The JSON sent is not a valid request object. """ - code = -32600 - message = 'Invalid Request' + code: int = -32600 + message: str = 'Invalid Request' class MethodNotFoundError(ClientError): @@ -168,8 +168,8 @@ class MethodNotFoundError(ClientError): The method does not exist / is not available. """ - code = -32601 - message = 'Method not found' + code: int = -32601 + message: str = 'Method not found' class InvalidParamsError(ClientError): @@ -177,8 +177,8 @@ class InvalidParamsError(ClientError): Invalid method parameter(s). """ - code = -32602 - message = 'Invalid params' + code: int = -32602 + message: str = 'Invalid params' class InternalError(JsonRpcError): @@ -186,8 +186,8 @@ class InternalError(JsonRpcError): Internal JSON-RPC error. """ - code = -32603 - message = 'Internal error' + code: int = -32603 + message: str = 'Internal error' class ServerError(JsonRpcError): @@ -196,5 +196,5 @@ class ServerError(JsonRpcError): Codes from -32000 to -32099. """ - code = -32000 - message = 'Server error' + code: int = -32000 + message: str = 'Server error' diff --git a/pjrpc/server/integration/aiohttp.py b/pjrpc/server/integration/aiohttp.py index c4cc5fd..ac6fe8f 100644 --- a/pjrpc/server/integration/aiohttp.py +++ b/pjrpc/server/integration/aiohttp.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Callable, Dict, Mapping, Optional, Tuple +from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple import aiohttp.web from aiohttp import web @@ -28,29 +28,33 @@ def __init__( self, path: str = '', spec: Optional[specs.Specification] = None, + specs: Iterable[specs.Specification] = (), app: Optional[web.Application] = None, status_by_error: Callable[[Tuple[int, ...]], int] = lambda codes: 200, **kwargs: Any, ): - self._path = path.rstrip('/') - self._spec = spec + self._path = path = path.rstrip('/') + self._specs = ([spec] if spec else []) + list(specs) self._app = app or web.Application() self._status_by_error = status_by_error self._dispatcher = pjrpc.server.AsyncDispatcher(**kwargs) self._endpoints: Dict[str, pjrpc.server.AsyncDispatcher] = {'': self._dispatcher} - self._app.router.add_post(self._path, ft.partial(self._rpc_handle, dispatcher=self._dispatcher)) + self._app.router.add_post(path, ft.partial(self._rpc_handle, dispatcher=self._dispatcher)) - if self._spec: - self._app.router.add_get(utils.join_path(self._path, self._spec.path), self._generate_spec) + for spec in self._specs: + self._app.router.add_get( + utils.join_path(path, spec.path), + ft.partial(self._generate_spec, spec=spec), + ) - if self._spec.ui and self._spec.ui_path: + if spec.ui and spec.ui_path: ui_app = web.Application() - ui_app.router.add_get('/', self._ui_index_page) - ui_app.router.add_get('/index.html', self._ui_index_page) - ui_app.router.add_static('/', self._spec.ui.get_static_folder()) + ui_app.router.add_get('/', ft.partial(self._ui_index_page, spec=spec)) + ui_app.router.add_get('/index.html', ft.partial(self._ui_index_page, spec=spec)) + ui_app.router.add_static('/', spec.ui.get_static_folder()) - self._app.add_subapp(utils.join_path(self._path, self._spec.ui_path), ui_app) + self._app.add_subapp(utils.join_path(path, spec.ui_path), ui_app) @property def app(self) -> web.Application: @@ -76,6 +80,18 @@ def endpoints(self) -> Mapping[str, pjrpc.server.AsyncDispatcher]: return self._endpoints + def add_subapp(self, prefix: str, subapp: 'Application') -> None: + """ + Adds sub-application. + + :param prefix: sub-application prefix + :param subapp: sub-application to be added + """ + + prefix = prefix.rstrip('/') + self._endpoints[prefix] = subapp.dispatcher + self._app.add_subapp(utils.join_path(self._path, prefix), subapp.app) + def add_endpoint( self, prefix: str, @@ -106,24 +122,24 @@ def add_endpoint( return dispatcher - async def _generate_spec(self, request: web.Request) -> web.Response: - assert self._spec is not None, "spec is not set" - - endpoint_path = utils.remove_suffix(request.path, suffix=self._spec.path) - + def generate_spec(self, spec: specs.Specification, path: str = '') -> Dict[str, Any]: methods = {path: dispatcher.registry.values() for path, dispatcher in self._endpoints.items()} - schema = self._spec.schema(path=endpoint_path, methods_map=methods) + return spec.schema(path=path, methods_map=methods) + + async def _generate_spec(self, request: web.Request, spec: specs.Specification) -> web.Response: + endpoint_path = utils.remove_suffix(request.path, suffix=spec.path) + schema = self.generate_spec(path=endpoint_path, spec=spec) return web.json_response(text=json.dumps(schema, cls=specs.JSONEncoder)) - async def _ui_index_page(self, request: web.Request) -> web.Response: - assert self._spec is not None and self._spec.ui is not None, "spec is not set" + async def _ui_index_page(self, request: web.Request, spec: specs.Specification) -> web.Response: + assert spec.ui is not None, "spec is not set" - app_path = request.path.rsplit(self._spec.ui_path, maxsplit=1)[0] - spec_full_path = utils.join_path(app_path, self._spec.path) + app_path = request.path.rsplit(spec.ui_path, maxsplit=1)[0] + spec_full_path = utils.join_path(app_path, spec.path) return web.Response( - text=self._spec.ui.get_index_page(spec_url=spec_full_path), + text=spec.ui.get_index_page(spec_url=spec_full_path), content_type='text/html', ) diff --git a/pjrpc/server/integration/django/sites.py b/pjrpc/server/integration/django/sites.py index 1bd3a28..faad532 100644 --- a/pjrpc/server/integration/django/sites.py +++ b/pjrpc/server/integration/django/sites.py @@ -72,7 +72,7 @@ def _generate_spec(self, request: HttpRequest) -> HttpResponse: assert self._spec is not None, "spec is not set" endpoint_path = utils.remove_suffix(request.path, suffix=self._spec.path) - schema = self._spec.schema(path=endpoint_path, methods=self._dispatcher.registry.values()) + schema = self._spec.schema(path=endpoint_path, methods_map={'': self._dispatcher.registry.values()}) return HttpResponse( json.dumps(schema, cls=specs.JSONEncoder), diff --git a/pjrpc/server/integration/flask.py b/pjrpc/server/integration/flask.py index 66e4811..6653dc2 100644 --- a/pjrpc/server/integration/flask.py +++ b/pjrpc/server/integration/flask.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Callable, Dict, Optional, Tuple, Union +from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Union import flask from flask import current_app @@ -27,11 +27,12 @@ def __init__( self, path: str, spec: Optional[specs.Specification] = None, + specs: Iterable[specs.Specification] = (), status_by_error: Callable[[Tuple[int, ...]], int] = lambda codes: 200, **kwargs: Any, ): self._path = path.rstrip('/') - self._spec = spec + self._specs = ([spec] if spec else []) + list(specs) self._status_by_error = status_by_error kwargs.setdefault('json_loader', flask.json.loads) @@ -101,46 +102,63 @@ def init_app(self, app: Union[flask.Flask, flask.Blueprint]) -> None: if blueprint: app.register_blueprint(blueprint) - if self._spec: + for spec in self._specs: app.add_url_rule( - utils.join_path(self._path, self._spec.path), + utils.join_path(self._path, spec.path), methods=['GET'], - view_func=self._generate_spec, + endpoint=self._generate_spec.__name__, + view_func=ft.partial(self._generate_spec, spec=spec), ) - if self._spec.ui and self._spec.ui_path: - path = utils.join_path(self._path, self._spec.ui_path) - app.add_url_rule(f'{path}/', methods=['GET'], view_func=self._ui_index_page) - app.add_url_rule(f'{path}/index.html', methods=['GET'], view_func=self._ui_index_page) - app.add_url_rule(f'{path}/', methods=['GET'], view_func=self._ui_static) - - def _generate_spec(self) -> flask.Response: - assert self._spec is not None, "spec is not set" - - endpoint_path = utils.remove_suffix(flask.request.path, suffix=self._spec.path) + if spec.ui and spec.ui_path: + path = utils.join_path(self._path, spec.ui_path) + app.add_url_rule( + f'{path}/', + methods=['GET'], + endpoint=self._ui_index_page.__name__, + view_func=ft.partial(self._ui_index_page, spec=spec), + ) + app.add_url_rule( + f'{path}/index.html', + methods=['GET'], + endpoint=f'{self._ui_index_page.__name__}-index', + view_func=ft.partial(self._ui_index_page, spec=spec), + ) + app.add_url_rule( + f'{path}/', + methods=['GET'], + endpoint=self._ui_static.__name__, + view_func=ft.partial(self._ui_static, spec=spec), + ) + + def generate_spec(self, spec: specs.Specification, path: str = '') -> Dict[str, Any]: methods = {path: dispatcher.registry.values() for path, dispatcher in self._endpoints.items()} - schema = self._spec.schema(path=endpoint_path, methods_map=methods) + return spec.schema(path=path, methods_map=methods) + + def _generate_spec(self, spec: specs.Specification) -> flask.Response: + endpoint_path = utils.remove_suffix(flask.request.path, suffix=spec.path) + schema = self.generate_spec(spec, path=endpoint_path) return current_app.response_class( json.dumps(schema, cls=specs.JSONEncoder), mimetype=pjrpc.common.DEFAULT_CONTENT_TYPE, ) - def _ui_index_page(self) -> flask.Response: - assert self._spec is not None and self._spec.ui is not None, "spec is not set" + def _ui_index_page(self, spec: specs.Specification) -> flask.Response: + assert spec.ui is not None, "spec is not set" - app_path = flask.request.path.rsplit(self._spec.ui_path, maxsplit=1)[0] - spec_full_path = utils.join_path(app_path, self._spec.path) + app_path = flask.request.path.rsplit(spec.ui_path, maxsplit=1)[0] + spec_full_path = utils.join_path(app_path, spec.path) return current_app.response_class( - response=self._spec.ui.get_index_page(spec_url=spec_full_path), + response=spec.ui.get_index_page(spec_url=spec_full_path), content_type='text/html', ) - def _ui_static(self, filename: str) -> flask.Response: - assert self._spec is not None and self._spec.ui is not None, "spec is not set" + def _ui_static(self, filename: str, spec: specs.Specification) -> flask.Response: + assert spec.ui is not None, "spec is not set" - return flask.send_from_directory(self._spec.ui.get_static_folder(), filename) + return flask.send_from_directory(spec.ui.get_static_folder(), filename) def _rpc_handle(self, dispatcher: pjrpc.server.Dispatcher) -> flask.Response: """ diff --git a/pjrpc/server/integration/starlette.py b/pjrpc/server/integration/starlette.py index 6183dc3..16d8f92 100644 --- a/pjrpc/server/integration/starlette.py +++ b/pjrpc/server/integration/starlette.py @@ -4,7 +4,7 @@ import functools as ft import json -from typing import Any, Callable, Dict, Mapping, Optional, Tuple +from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple from starlette import exceptions, routing from starlette.applications import Starlette @@ -31,29 +31,38 @@ def __init__( self, path: str = '', spec: Optional[specs.Specification] = None, + specs: Iterable[specs.Specification] = (), app: Optional[Starlette] = None, status_by_error: Callable[[Tuple[int, ...]], int] = lambda codes: 200, **kwargs: Any, ): - self._path = path.rstrip('/') - self._spec = spec + self._path = path = path.rstrip('/') + self._specs = ([spec] if spec else []) + list(specs) self._app = app or Starlette() self._status_by_error = status_by_error self._dispatcher = pjrpc.server.AsyncDispatcher(**kwargs) self._endpoints: Dict[str, pjrpc.server.AsyncDispatcher] = {'': self._dispatcher} - self._app.add_route(self._path, ft.partial(self._rpc_handle, dispatcher=self._dispatcher), methods=['POST']) + self._app.add_route(path, ft.partial(self._rpc_handle, dispatcher=self._dispatcher), methods=['POST']) - if self._spec: - self._app.add_route(utils.join_path(self._path, self._spec.path), self._generate_spec, methods=['GET']) + for spec in self._specs: + self._app.add_route( + utils.join_path(path, spec.path), + ft.partial(self._generate_spec, spec=spec), + methods=['GET'], + ) - if self._spec.ui and self._spec.ui_path: - ui_path = utils.join_path(self._path, self._spec.ui_path) + if spec.ui and spec.ui_path: + ui_path = utils.join_path(path, spec.ui_path) - self._app.add_route(utils.join_path(ui_path, '/'), self._ui_index_page) - self._app.add_route(utils.join_path(ui_path, 'index.html'), self._ui_index_page) + self._app.add_route( + utils.join_path(ui_path, '/'), ft.partial(self._ui_index_page, spec=spec), + ) + self._app.add_route( + utils.join_path(ui_path, 'index.html'), ft.partial(self._ui_index_page, spec=spec), + ) self._app.routes.append( - routing.Mount(ui_path, app=StaticFiles(directory=str(self._spec.ui.get_static_folder()))), + routing.Mount(ui_path, app=StaticFiles(directory=str(spec.ui.get_static_folder()))), ) @property @@ -94,33 +103,33 @@ def add_endpoint(self, prefix: str, **kwargs: Any) -> pjrpc.server.AsyncDispatch self._app.add_route( utils.join_path(self._path, prefix), - ft.partial(self._rpc_handle, dispatcher=self._dispatcher), + ft.partial(self._rpc_handle, dispatcher=dispatcher), methods=['POST'], ) return dispatcher - async def _generate_spec(self, request: Request) -> Response: - assert self._spec is not None, "spec is not set" - - endpoint_path = utils.remove_suffix(request.url.path, suffix=self._spec.path) - + def generate_spec(self, spec: specs.Specification, path: str = '') -> Dict[str, Any]: methods = {path: dispatcher.registry.values() for path, dispatcher in self._endpoints.items()} - schema = self._spec.schema(path=endpoint_path, methods_map=methods) + return spec.schema(path=path, methods_map=methods) + + async def _generate_spec(self, request: Request, spec: specs.Specification) -> Response: + endpoint_path = utils.remove_suffix(request.url.path, suffix=spec.path) + schema = self.generate_spec(path=endpoint_path, spec=spec) return Response( content=json.dumps(schema, cls=specs.JSONEncoder), media_type='application/json', ) - async def _ui_index_page(self, request: Request) -> Response: - assert self._spec is not None and self._spec.ui is not None, "spec is not set" + async def _ui_index_page(self, request: Request, spec: specs.Specification) -> Response: + assert spec.ui is not None, "spec is not set" - app_path = request.url.path.rsplit(self._spec.ui_path, maxsplit=1)[0] - spec_full_path = utils.join_path(app_path, self._spec.path) + app_path = request.url.path.rsplit(spec.ui_path, maxsplit=1)[0] + spec_full_path = utils.join_path(app_path, spec.path) return Response( - content=self._spec.ui.get_index_page(spec_url=spec_full_path), + content=spec.ui.get_index_page(spec_url=spec_full_path), media_type='text/html', ) diff --git a/pjrpc/server/specs/__init__.py b/pjrpc/server/specs/__init__.py index df5626e..7688dab 100644 --- a/pjrpc/server/specs/__init__.py +++ b/pjrpc/server/specs/__init__.py @@ -80,14 +80,12 @@ def ui_path(self) -> Optional[str]: def schema( self, path: str, - methods: Iterable[Method] = (), methods_map: Mapping[str, Iterable[Method]] = {}, ) -> Dict[str, Any]: """ Returns specification schema. :param path: methods endpoint path - :param methods: methods list the specification is generated for :param methods_map: methods map the specification is generated for. Each item is a mapping from a prefix to methods on which the methods will be served """ diff --git a/pjrpc/server/specs/extractors/__init__.py b/pjrpc/server/specs/extractors/__init__.py index d8dc695..aa920a6 100644 --- a/pjrpc/server/specs/extractors/__init__.py +++ b/pjrpc/server/specs/extractors/__init__.py @@ -1,98 +1,80 @@ -import dataclasses as dc import inspect import itertools as it -from typing import Any, Dict, Iterable, List, Optional, Type +from typing import Any, Dict, Iterable, List, Optional, Tuple, Type from pjrpc.common import UNSET, MaybeSet, UnsetType from pjrpc.common.exceptions import JsonRpcError from pjrpc.common.typedefs import MethodType -@dc.dataclass(frozen=True) -class Schema: - """ - Method parameter/result schema. - """ - - schema: Dict[str, Any] - required: bool = True - summary: MaybeSet[str] = UNSET - description: MaybeSet[str] = UNSET - deprecated: MaybeSet[bool] = UNSET - definitions: MaybeSet[Dict[str, Any]] = UNSET - - -@dc.dataclass(frozen=True) -class Example: - """ - Method usage example. - """ - - params: Dict[str, Any] - result: Any - version: str = '2.0' - summary: MaybeSet[str] = UNSET - description: MaybeSet[str] = UNSET - - -@dc.dataclass(frozen=True) -class ErrorExample: - """ - Method error example. - """ - - code: int - message: str - data: MaybeSet[Optional[Any]] = UNSET - summary: MaybeSet[str] = UNSET - description: MaybeSet[str] = UNSET - - -@dc.dataclass(frozen=True) -class Tag: +class BaseSchemaExtractor: """ - A list of method tags. + Base method schema extractor. """ - name: str - description: MaybeSet[str] = UNSET - externalDocs: MaybeSet[str] = UNSET + def extract_params_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + exclude: Iterable[str] = (), + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + """ + Extracts params schema. + """ + return {}, {} -@dc.dataclass(frozen=True) -class Error: - """ - Defines an application level error. - """ + def extract_request_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + exclude: Iterable[str] = (), + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + """ + Extracts request schema. + """ - code: int - message: str - data: MaybeSet[Dict[str, Any]] = UNSET - data_required: MaybeSet[bool] = UNSET - title: MaybeSet[str] = UNSET - description: MaybeSet[str] = UNSET - deprecated: MaybeSet[bool] = UNSET - definitions: MaybeSet[Dict[str, Any]] = UNSET + return {}, {} + def extract_result_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + """ + Extracts result schema. + """ -class BaseSchemaExtractor: - """ - Base method schema extractor. - """ + return {}, {} - def extract_params_schema(self, method: MethodType, exclude: Iterable[str] = ()) -> Dict[str, Schema]: + def extract_response_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + errors: Optional[Iterable[Type[JsonRpcError]]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: """ - Extracts method parameters schema. + Extracts response schema. """ - return {} + return {}, {} - def extract_result_schema(self, method: MethodType) -> Schema: + def extract_error_response_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + errors: Optional[Iterable[Type[JsonRpcError]]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: """ - Extracts method result schema. + Extracts error response schema. """ - return Schema(schema={}) + return {}, {} def extract_description(self, method: MethodType) -> MaybeSet[str]: """ @@ -123,45 +105,13 @@ def extract_summary(self, method: MethodType) -> MaybeSet[str]: return summary - def extract_errors_schema( - self, - method: MethodType, - errors: Optional[Iterable[Type[JsonRpcError]]] = None, - ) -> MaybeSet[List[Error]]: + def extract_errors(self, method: MethodType) -> MaybeSet[List[Type[JsonRpcError]]]: """ - Extracts method errors schema. + Extracts method errors. """ return UNSET - def extract_tags(self, method: MethodType) -> MaybeSet[List[Tag]]: - """ - Extracts method tags. - """ - - return UNSET - - def extract_examples(self, method: MethodType) -> MaybeSet[List[Example]]: - """ - Extracts method usage examples. - """ - - return UNSET - - def extract_error_examples( - self, - method: MethodType, - errors: Optional[Iterable[Type[JsonRpcError]]] = None, - ) -> MaybeSet[List[ErrorExample]]: - """ - Extracts method error examples. - """ - - return [ - ErrorExample(code=error.code, message=error.message, summary=error.message) - for error in errors - ] if errors else UNSET - def extract_deprecation_status(self, method: MethodType) -> MaybeSet[bool]: """ Extracts method deprecation status. diff --git a/pjrpc/server/specs/extractors/docstring.py b/pjrpc/server/specs/extractors/docstring.py index f0162e6..2439348 100644 --- a/pjrpc/server/specs/extractors/docstring.py +++ b/pjrpc/server/specs/extractors/docstring.py @@ -1,10 +1,11 @@ -from typing import Dict, Iterable, List, Optional, Type +from typing import Any, Dict, Iterable, List, Optional, Tuple, Type import docstring_parser from pjrpc.common import UNSET, MaybeSet, exceptions from pjrpc.common.typedefs import MethodType -from pjrpc.server.specs.extractors import BaseSchemaExtractor, Error, Example, JsonRpcError, Schema, Tag +from pjrpc.server.specs.extractors import BaseSchemaExtractor, JsonRpcError +from pjrpc.server.specs.schemas import build_request_schema, build_response_schema class DocstringSchemaExtractor(BaseSchemaExtractor): @@ -12,7 +13,13 @@ class DocstringSchemaExtractor(BaseSchemaExtractor): docstring method specification generator. """ - def extract_params_schema(self, method: MethodType, exclude: Iterable[str] = ()) -> Dict[str, Schema]: + def extract_params_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + exclude: Iterable[str] = (), + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: exclude = set(exclude) parameters_schema = {} @@ -22,36 +29,58 @@ def extract_params_schema(self, method: MethodType, exclude: Iterable[str] = ()) if param.arg_name in exclude: continue - parameters_schema[param.arg_name] = Schema( - schema={'type': param.type_name}, - required=not param.is_optional, - summary=param.description.split('.')[0] if param.description is not None else UNSET, - description=param.description if param.description is not None else UNSET, - ) + parameters_schema[param.arg_name] = { + 'title': param.arg_name.capitalize(), + 'description': param.description if param.description is not None else UNSET, + 'type': param.type_name, + } + + return parameters_schema, {} + + def extract_request_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + exclude: Iterable[str] = (), + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + exclude = set(exclude) + parameters_schema, components = self.extract_params_schema(method_name, method, ref_template, exclude) - return parameters_schema + return build_request_schema(method_name, parameters_schema), components - def extract_result_schema(self, method: MethodType) -> Schema: - result_schema = Schema(schema={}) + def extract_result_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + result_schema = {} if method.__doc__: doc = docstring_parser.parse(method.__doc__) if doc and doc.returns: - result_schema = Schema( - schema={'type': doc.returns.type_name}, - required=True, - summary=doc.returns.description.split('.')[0] if doc.returns.description is not None else UNSET, - description=doc.returns.description if doc.returns.description is not None else UNSET, - ) - - return result_schema - - def extract_errors_schema( - self, - method: MethodType, - errors: Optional[Iterable[Type[JsonRpcError]]] = None, - ) -> MaybeSet[List[Error]]: - errors_schema = [] + result_schema = { + 'type': doc.returns.type_name, + 'title': 'Result', + 'description': doc.returns.description if doc.returns.description is not None else UNSET, + } + + return result_schema, {} + + def extract_response_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + errors: Optional[Iterable[Type[JsonRpcError]]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + result_schema, components = self.extract_result_schema(method_name, method, ref_template) + + return build_response_schema(result_schema, errors or []), components + + def extract_errors(self, method: MethodType) -> MaybeSet[List[Type[JsonRpcError]]]: + errors_schema: List[Type[JsonRpcError]] = [] if method.__doc__: error_map = { @@ -63,12 +92,7 @@ def extract_errors_schema( for error in doc.raises: error_cls = error_map.get(error.type_name or '') if error_cls: - errors_schema.append( - Error( - code=error_cls.code, - message=error_cls.message, - ), - ) + errors_schema.append(error_cls) return errors_schema or UNSET @@ -90,12 +114,6 @@ def extract_summary(self, method: MethodType) -> MaybeSet[str]: return description - def extract_tags(self, method: MethodType) -> MaybeSet[List[Tag]]: - return UNSET - - def extract_examples(self, method: MethodType) -> MaybeSet[List[Example]]: - return UNSET - def extract_deprecation_status(self, method: MethodType) -> MaybeSet[bool]: is_deprecated: MaybeSet[bool] if method.__doc__: diff --git a/pjrpc/server/specs/extractors/pydantic.py b/pjrpc/server/specs/extractors/pydantic.py index cf35981..9d0e289 100644 --- a/pjrpc/server/specs/extractors/pydantic.py +++ b/pjrpc/server/specs/extractors/pydantic.py @@ -1,24 +1,224 @@ import inspect -from typing import Any, Dict, Iterable, List, Optional, Type +from typing import Any, Dict, Generic, Iterable, Literal, Optional, Tuple, Type, TypeVar, Union import pydantic as pd -from pjrpc.common import UNSET, MaybeSet -from pjrpc.common.exceptions import JsonRpcError +from pjrpc.common import exceptions from pjrpc.common.typedefs import MethodType -from pjrpc.server.specs.extractors import BaseSchemaExtractor, Error, Schema +from pjrpc.server.specs.extractors import BaseSchemaExtractor + + +def to_camel(string: str) -> str: + return ''.join(word.capitalize() for word in string.split('_')) + + +MethodT = TypeVar('MethodT', bound=str) +ParamsT = TypeVar('ParamsT', bound=pd.BaseModel, covariant=True) + + +class JsonRpcRequest( + pd.BaseModel, Generic[MethodT, ParamsT], + title="Request", + extra='forbid', strict=True, +): + jsonrpc: Literal['2.0', '1.0'] = pd.Field(title="Version", description="JSON-RPC protocol version") + id: Optional[Union[str, int]] = pd.Field(None, description="Request identifier", examples=[1]) + method: MethodT = pd.Field(description="Method name") + params: ParamsT = pd.Field(description="Method parameters") + + +RequestT = TypeVar('RequestT', bound=Union[JsonRpcRequest[Any, Any]]) + + +class JsonRpcRequestWrapper(pd.RootModel[RequestT], Generic[RequestT], title="Request"): + pass + + +ResultT = TypeVar('ResultT', covariant=True) + + +class JsonRpcResponseSuccess( + pd.BaseModel, Generic[ResultT], + title='Success', + extra='forbid', strict=True, +): + jsonrpc: Literal['2.0', '1.0'] = pd.Field(title="Version", description="JSON-RPC protocol version") + id: Union[str, int] = pd.Field(description="Request identifier", examples=[1]) + result: ResultT = pd.Field(description="Method execution result") + + +ErrorCodeT = TypeVar('ErrorCodeT', bound=int) +ErrorDataT = TypeVar('ErrorDataT') + + +class JsonRpcError( + pd.BaseModel, Generic[ErrorCodeT, ErrorDataT], + title="Error", + extra='forbid', strict=True, +): + code: ErrorCodeT = pd.Field(description="Error code") + message: str = pd.Field(description="Error message") + data: ErrorDataT = pd.Field(description="Error additional data") + + +JsonRpcErrorT = TypeVar('JsonRpcErrorT', bound=JsonRpcError[Any, Any], covariant=True) + + +class JsonRpcResponseError( + pd.BaseModel, Generic[JsonRpcErrorT], + title="ResponseError", + extra='forbid', strict=True, +): + jsonrpc: Literal['1.0', '2.0'] = pd.Field(title="Version", description="JSON-RPC protocol version") + id: Union[str, int] = pd.Field(description="Request identifier", examples=[1]) + error: JsonRpcErrorT = pd.Field(description="Request error") + + +ResponseT = TypeVar('ResponseT', bound=Union[JsonRpcResponseSuccess[Any], JsonRpcResponseError[Any]]) + + +class JsonRpcResponseWrapper(pd.RootModel[ResponseT], Generic[ResponseT], title="Response"): + pass class PydanticSchemaExtractor(BaseSchemaExtractor): """ Pydantic method specification extractor. - """ - def __init__(self, ref_template: str = '#/components/schemas/{model}'): - self._ref_template = ref_template + :param config_args: model configuration parameters + """ - def extract_params_schema(self, method: MethodType, exclude: Iterable[str] = ()) -> Dict[str, Schema]: + def __init__(self, **config_args: Any): + self._config_args = config_args + + def extract_params_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + exclude: Iterable[str] = (), + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + params_model = self._build_params_model(method_name, method, exclude) + params_schema = params_model.model_json_schema(ref_template=ref_template) + + return params_schema, params_schema.pop('$defs', {}) + + def extract_request_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + exclude: Iterable[str] = (), + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: exclude = set(exclude) + + params_model = self._build_params_model(method_name, method, exclude) + request_model = pd.create_model( + f'{to_camel(method_name)}Request', + __base__=JsonRpcRequestWrapper[ + JsonRpcRequest[Literal[method_name], params_model], # type: ignore[valid-type] + ], + __cls_kwargs__=self._config_args, + ) + request_schema = request_model.model_json_schema(ref_template=ref_template) + + return request_schema, request_schema.pop('$defs', {}) + + def extract_result_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + result_model = self._build_result_model(method_name, method) + result_schema = result_model.model_json_schema(ref_template=ref_template) + + return result_schema, result_schema.pop('$defs', {}) + + def extract_response_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + errors: Optional[Iterable[Type[exceptions.JsonRpcError]]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + return_model = self._build_result_model(method_name, method) + + response_model: Type[pd.BaseModel] + error_models = tuple( + pd.create_model( + error.__name__, + __base__=JsonRpcResponseError[JsonRpcError[Literal[error.code], Any]], # type: ignore[name-defined] + __cls_kwargs__=dict( + self._config_args, + title=error.__name__, + json_schema_extra=dict(description=f'**{error.code}** {error.message}'), + ), + ) for error in errors or [] + ) + + response_model = pd.create_model( + f'{to_camel(method_name)}Response', + __base__=JsonRpcResponseWrapper[ + Union[(JsonRpcResponseSuccess[return_model], *error_models)], # type: ignore[valid-type] + ], + ) + response_schema = response_model.model_json_schema(ref_template=ref_template) + if error_models: + response_schema['description'] = '\n'.join( + f'* {error.model_json_schema().get("description", error.__name__)}' + for error in error_models + ) + + return response_schema, response_schema.pop('$defs', {}) + + def extract_error_response_schema( + self, + method_name: str, + method: MethodType, + ref_template: str, + errors: Optional[Iterable[Type[exceptions.JsonRpcError]]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]]]: + error_models = tuple( + pd.create_model( + error.__name__, + __base__=JsonRpcResponseError[JsonRpcError[Literal[error.code], Any]], # type: ignore[name-defined] + __cls_kwargs__=dict( + self._config_args, + title=error.__name__, + json_schema_extra=dict(description=f'**{error.code}** {error.message}'), + ), + ) for error in errors or [] + ) + if len(error_models) == 1: + response_model = pd.create_model( + f'{to_camel(method_name)}Response', + __base__=JsonRpcResponseWrapper[Union[error_models]], # type: ignore[valid-type] + ) + response_schema = response_model.model_json_schema(ref_template=ref_template) + else: + response_model = pd.create_model( + f'{to_camel(method_name)}Response', + __base__=JsonRpcResponseWrapper[Union[error_models]], # type: ignore[valid-type] + ) + response_schema = response_model.model_json_schema(ref_template=ref_template) + + if error_models: + response_schema['description'] = '\n'.join( + f'* {error.model_json_schema().get("description", error.__name__)}' + for error in error_models + ) + else: + response_schema['description'] = 'Error' + + return response_schema, response_schema.pop('$defs', {}) + + def _build_params_model( + self, + method_name: str, + method: MethodType, + exclude: Iterable[str] = (), + ) -> Type[pd.BaseModel]: signature = inspect.signature(method) field_definitions: Dict[str, Any] = {} @@ -32,25 +232,13 @@ def extract_params_schema(self, method: MethodType, exclude: Iterable[str] = ()) param.default if param.default is not inspect.Parameter.empty else ..., ) - params_model = pd.create_model('RequestModel', **field_definitions) - model_schema = params_model.model_json_schema(ref_template=self._ref_template) - - parameters_schema = {} - for param_name, param_schema in model_schema['properties'].items(): - required = param_name in model_schema.get('required', []) - - parameters_schema[param_name] = Schema( - schema=param_schema, - summary=param_schema.get('title', UNSET), - description=param_schema.get('description', UNSET), - deprecated=param_schema.get('deprecated', UNSET), - required=required, - definitions=model_schema.get('$defs'), - ) - - return parameters_schema + return pd.create_model( + f'{to_camel(method_name)}Parameters', + **field_definitions, + __cls_kwargs__=dict(self._config_args, extra='forbid'), + ) - def extract_result_schema(self, method: MethodType) -> Schema: + def _build_result_model(self, method_name: str, method: MethodType) -> Type[pd.BaseModel]: result = inspect.signature(method) if result.return_annotation is inspect.Parameter.empty: @@ -60,75 +248,8 @@ def extract_result_schema(self, method: MethodType) -> Schema: else: return_annotation = result.return_annotation - result_model = pd.create_model('ResultModel', result=(return_annotation, ...)) - model_schema = result_model.model_json_schema(ref_template=self._ref_template) - - result_schema = model_schema['properties']['result'] - required = 'result' in model_schema.get('required', []) - if not required: - result_schema['nullable'] = 'true' - - result_schema = Schema( - schema=result_schema, - summary=result_schema.get('title', UNSET), - description=result_schema.get('description', UNSET), - deprecated=result_schema.get('deprecated', UNSET), - required=required, - definitions=model_schema.get('$defs', UNSET), + return pd.create_model( + f'{to_camel(method_name)}Result', + __base__=pd.RootModel[return_annotation], # type: ignore[valid-type] + __cls_kwargs__=dict(self._config_args), ) - - return result_schema - - def extract_errors_schema( - self, - method: MethodType, - errors: Optional[Iterable[Type[JsonRpcError]]] = None, - ) -> MaybeSet[List[Error]]: - if errors: - errors_schema = [] - for error in errors: - field_definitions: Dict[str, Any] = {} - for field_name, annotation in self._get_annotations(error).items(): - if field_name.startswith('_'): - continue - - field_definitions[field_name] = (annotation, getattr(error, field_name, ...)) - - result_model = pd.create_model(error.message, **field_definitions) - model_schema = result_model.model_json_schema(ref_template=self._ref_template) - - data_schema = model_schema['properties'].get('data', UNSET) - required = 'data' in model_schema.get('required', []) - - errors_schema.append( - Error( - code=error.code, - message=error.message, - data=data_schema, - data_required=required, - title=error.message, - description=inspect.cleandoc(error.__doc__) if error.__doc__ is not None else UNSET, - deprecated=model_schema.get('deprecated', UNSET), - definitions=model_schema.get('$defs'), - ), - ) - return errors_schema - - else: - return UNSET - - @staticmethod - def _extract_field_schema(model_schema: Dict[str, Any], field_name: str) -> Dict[str, Any]: - field_schema = model_schema['properties'][field_name] - if '$ref' in field_schema: - field_schema = model_schema['definitions'][field_schema['$ref']] - - return field_schema - - @staticmethod - def _get_annotations(cls: Type[Any]) -> Dict[str, Any]: - annotations: Dict[str, Any] = {} - for patent in cls.mro(): - annotations.update(**getattr(patent, '__annotations__', {})) - - return annotations diff --git a/pjrpc/server/specs/openapi.py b/pjrpc/server/specs/openapi.py index 999a538..4a43ac3 100644 --- a/pjrpc/server/specs/openapi.py +++ b/pjrpc/server/specs/openapi.py @@ -7,100 +7,21 @@ except ImportError: raise AssertionError("python 3.7 or later is required") -import copy import enum import functools as ft import pathlib import re -from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type +from collections import defaultdict +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, TypedDict, Union from pjrpc.common import UNSET, MaybeSet, UnsetType, exceptions from pjrpc.common.typedefs import Func from pjrpc.server import Method, utils +from pjrpc.server.specs.schemas import build_request_schema, build_response_schema from . import BaseUI, Specification, extractors -from .extractors import Error, ErrorExample, Schema - -RESULT_SCHEMA: Dict[str, Any] = { - 'type': 'object', - 'properties': { - 'jsonrpc': { - 'type': 'string', - 'enum': ['2.0', '1.0'], - }, - 'id': { - 'anyOf': [ - {'type': 'string'}, - {'type': 'number'}, - ], - }, - 'result': {}, - }, - 'required': ['jsonrpc', 'id', 'result'], - 'title': 'Success', - 'description': 'JSON-RPC success', -} - -ERROR_SCHEMA: Dict[str, Any] = { - 'type': 'object', - 'properties': { - 'jsonrpc': { - 'type': 'string', - 'enum': ['2.0', '1.0'], - }, - 'id': { - 'anyOf': [ - {'type': 'string'}, - {'type': 'number'}, - ], - }, - 'error': { - 'type': 'object', - 'properties': { - 'code': {'type': 'integer'}, - 'message': {'type': 'string'}, - 'data': {'type': 'object'}, - }, - 'required': ['code', 'message'], - }, - }, - 'required': ['jsonrpc', 'error'], - 'title': 'Error', - 'description': 'JSON-RPC error', -} - -RESPONSE_SCHEMA: Dict[str, Any] = { - 'oneOf': [RESULT_SCHEMA, ERROR_SCHEMA], -} - -RESULT_SCHEMA_IDX = 0 -ERROR_SCHEMA_IDX = 1 - -REQUEST_SCHEMA: Dict[str, Any] = { - 'type': 'object', - 'properties': { - 'jsonrpc': { - 'type': 'string', - 'enum': ['2.0', '1.0'], - }, - 'id': { - 'anyOf': [ - {'type': 'string'}, - {'type': 'number'}, - ], - }, - 'method': { - 'type': 'string', - }, - 'params': { - 'type': 'object', - 'properties': {}, - }, - }, - 'required': ['jsonrpc', 'method'], -} - -JSONRPC_HTTP_CODE = '200' + +HTTP_DEFAULT_STATUS = 200 JSONRPC_MEDIATYPE = 'application/json' @@ -113,7 +34,29 @@ def drop_unset(obj: Any) -> Any: return obj -@dc.dataclass(frozen=True) +JsonSchema = Dict[str, Any] + + +@dc.dataclass +class Reference: + """ + A simple object to allow referencing other components in the OpenAPI document, internally and externally. + + :param ref: the reference identifier. + :param summary: a short summary which by default SHOULD override that of the referenced component. + :param description: a description which by default SHOULD override that of the referenced component + """ + + ref: str + summary: MaybeSet[str] = UNSET + description: MaybeSet[str] = UNSET + + def __post_init__(self) -> None: + self.__dict__['$ref'] = self.__dict__['ref'] + self.__dataclass_fields__['ref'].name = '$ref' # noqa + + +@dc.dataclass class Contact: """ Contact information for the exposed API. @@ -128,27 +71,30 @@ class Contact: email: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class License: """ License information for the exposed API. :param name: the license name used for the API + :param identifier: an SPDX license expression for the API :param url: a URL to the license used for the API """ name: str + identifier: MaybeSet[str] = UNSET url: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Info: """ Metadata about the API. :param title: the title of the application :param version: the version of the OpenAPI document - :param description: a short description of the application + :param summary: a short summary of the API. + :param description: a description of the application :param contact: the contact information for the exposed API :param license: the license information for the exposed API :param termsOfService: a URL to the Terms of Service for the API @@ -156,13 +102,14 @@ class Info: title: str version: str + summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET contact: MaybeSet[Contact] = UNSET license: MaybeSet[License] = UNSET termsOfService: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class ServerVariable: """ An object representing a Server Variable for server URL template substitution. @@ -177,13 +124,15 @@ class ServerVariable: description: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Server: """ Connectivity information of a target server. :param url: a URL to the target host :param description: an optional string describing the host designated by the URL + :param variables: a map between a variable name and its value. + The value is used for substitution in the server's URL template. """ url: str @@ -191,7 +140,29 @@ class Server: variables: MaybeSet[Dict[str, ServerVariable]] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass +class Link: + """ + The Link object represents a possible design-time link for a response. + + :param operationRef: a relative or absolute URI reference to an OAS operation + :param operationId: the name of an existing, resolvable OAS operation, as defined with a unique operationId + :param parameters: a map representing parameters to pass to an operation as specified with operationId + or identified via operationRef + :param requestBody: a literal value or {expression} to use as a request body when calling the target operation + :param description: a description of the link + :param server: a server object to be used by the target operation. + """ + + operationRef: MaybeSet[str] = UNSET + operationId: MaybeSet[str] = UNSET + parameters: MaybeSet[Dict[str, Any]] = UNSET + requestBody: MaybeSet[Any] = UNSET + description: MaybeSet[str] = UNSET + server: MaybeSet[Server] = UNSET + + +@dc.dataclass class ExternalDocumentation: """ Allows referencing an external resource for extended documentation. @@ -204,7 +175,7 @@ class ExternalDocumentation: description: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Tag: """ A list of tags for API documentation control. @@ -241,7 +212,7 @@ class ApiKeyLocation(str, enum.Enum): COOKIE = 'cookie' -@dc.dataclass(frozen=True) +@dc.dataclass class OAuthFlow: """ Configuration details for a supported OAuth Flow. @@ -258,7 +229,7 @@ class OAuthFlow: refreshUrl: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class OAuthFlows: """ Configuration of the supported OAuth Flows. @@ -275,7 +246,7 @@ class OAuthFlows: authorizationCode: MaybeSet[OAuthFlow] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class SecurityScheme: """ Defines a security scheme that can be used by the operations. @@ -291,7 +262,7 @@ class SecurityScheme: """ type: SecuritySchemeType - scheme: str + scheme: MaybeSet[str] = UNSET name: MaybeSet[str] = UNSET location: MaybeSet[ApiKeyLocation] = UNSET # `in` field bearerFormat: MaybeSet[str] = UNSET @@ -305,20 +276,7 @@ def __post_init__(self) -> None: self.__dataclass_fields__['location'].name = 'in' # noqa -@dc.dataclass(frozen=False) -class Components: - """ - Holds a set of reusable objects for different aspects of the OAS. - - :param securitySchemes: an object to hold reusable Security Scheme Objects - :param schemas: the definition of input and output data types - """ - - securitySchemes: MaybeSet[Dict[str, SecurityScheme]] = UNSET - schemas: Dict[str, Dict[str, Any]] = dc.field(default_factory=dict) - - -@dc.dataclass(frozen=True) +@dc.dataclass class MethodExample: """ Method usage example. @@ -337,7 +295,7 @@ class MethodExample: description: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class ExampleObject: """ Method usage example. @@ -348,51 +306,31 @@ class ExampleObject: :param externalValue: a URL that points to the literal example """ - value: Any + value: MaybeSet[Any] = UNSET summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET externalValue: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) -class MediaType: - """ - Each Media Type Object provides schema and examples for the media type identified by its key. - - :param schema: the schema defining the content. - :param example: example of the media type - """ - - schema: Dict[str, Any] - examples: MaybeSet[Dict[str, ExampleObject]] = UNSET - - -@dc.dataclass(frozen=True) -class Response: - """ - A container for the expected responses of an operation. - - :param description: a short description of the response - :param content: a map containing descriptions of potential response payloads - """ - - description: str - content: MaybeSet[Dict[str, MediaType]] = UNSET - - -@dc.dataclass(frozen=True) -class RequestBody: +@dc.dataclass +class Encoding: """ - Describes a single request body. + A single encoding definition applied to a single schema property. - :param content: the content of the request body - :param required: determines if the request body is required in the request - :param description: a brief description of the request body + :param contentType: the Content-Type for encoding a specific property + :param headers: a map allowing additional information to be provided as headers + :param style: describes how a specific property value will be serialized depending on its type + :param explode: when this is true, property values of type array or object generate separate parameters + for each value of the array, or key-value-pair of the map + :param allowReserved: determines whether the parameter value SHOULD allow reserved characters, + as defined by RFC3986 to be included without percent-encoding """ - content: Dict[str, MediaType] - required: MaybeSet[bool] = UNSET - description: MaybeSet[str] = UNSET + contentType: MaybeSet[str] = UNSET + headers: MaybeSet[Dict[str, Union['Header', Reference]]] = UNSET + style: MaybeSet[str] = UNSET + explode: MaybeSet[bool] = UNSET + allowReserved: MaybeSet[bool] = UNSET class ParameterLocation(str, enum.Enum): @@ -420,7 +358,37 @@ class StyleType(str, enum.Enum): DEEP_OBJECT = 'deepObject' # provides a simple way of rendering nested objects using form parameters -@dc.dataclass(frozen=True) +@dc.dataclass +class MediaType: + """ + Each Media Type Object provides schema and examples for the media type identified by its key. + + :param schema: the schema defining the content. + :param example: example of the media type + """ + + schema: MaybeSet[Dict[str, Any]] = UNSET + example: MaybeSet[Any] = UNSET + examples: MaybeSet[Dict[str, ExampleObject]] = UNSET + encoding: MaybeSet[Dict[str, Encoding]] = UNSET + + +@dc.dataclass +class RequestBody: + """ + Describes a single request body. + + :param content: the content of the request body + :param required: determines if the request body is required in the request + :param description: a brief description of the request body + """ + + content: Dict[str, MediaType] + required: MaybeSet[bool] = UNSET + description: MaybeSet[str] = UNSET + + +@dc.dataclass class Parameter: """ Describes a single operation parameter. @@ -450,7 +418,8 @@ class Parameter: style: MaybeSet[StyleType] = UNSET explode: MaybeSet[bool] = UNSET allowReserved: MaybeSet[bool] = UNSET - schema: MaybeSet[Dict[str, Any]] = UNSET + schema: MaybeSet[JsonSchema] = UNSET + example: MaybeSet[Any] = UNSET examples: MaybeSet[Dict[str, ExampleObject]] = UNSET content: MaybeSet[Dict[str, MediaType]] = UNSET @@ -460,7 +429,25 @@ def __post_init__(self) -> None: self.__dataclass_fields__['location'].name = 'in' # noqa -@dc.dataclass(frozen=True) +Header = Parameter + + +@dc.dataclass +class Response: + """ + A container for the expected responses of an operation. + + :param description: a short description of the response + :param content: a map containing descriptions of potential response payloads + """ + + description: str + headers: MaybeSet[Dict[str, Union[Header, Reference]]] = UNSET + content: MaybeSet[Dict[str, MediaType]] = UNSET + links: MaybeSet[Dict[str, Union[Link, Reference]]] = UNSET + + +@dc.dataclass class Operation: """ Describes a single API operation on a path. @@ -476,19 +463,20 @@ class Operation: :param security: a declaration of which security mechanisms can be used for this operation """ - responses: Dict[str, Response] - requestBody: MaybeSet[RequestBody] = UNSET + responses: MaybeSet[Dict[str, Union[Response, Reference]]] = UNSET + requestBody: MaybeSet[Union[RequestBody, Reference]] = UNSET tags: MaybeSet[List[str]] = UNSET summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET externalDocs: MaybeSet[ExternalDocumentation] = UNSET + operationId: MaybeSet[str] = UNSET deprecated: MaybeSet[bool] = UNSET servers: MaybeSet[List[Server]] = UNSET security: MaybeSet[List[Dict[str, List[str]]]] = UNSET - parameters: MaybeSet[List[Parameter]] = UNSET + parameters: MaybeSet[List[Union[Parameter, Reference]]] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Path: """ Describes the interface for the given method name. @@ -496,6 +484,7 @@ class Path: :param summary: an optional, string summary, intended to apply to all operations in this path :param description: an optional, string description, intended to apply to all operations in this path :param servers: an alternative server array to service all operations in this path + :param parameters: a list of parameters that are applicable for all the operations described under this path """ get: MaybeSet[Operation] = UNSET @@ -509,32 +498,67 @@ class Path: summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET servers: MaybeSet[List[Server]] = UNSET + parameters: MaybeSet[Union[Parameter, Reference]] = UNSET + + +@dc.dataclass +class Components: + """ + Holds a set of reusable objects for different aspects of the OAS. + + :param securitySchemes: an object to hold reusable Security Scheme Objects + :param schemas: the definition of input and output data types + """ + + schemas: MaybeSet[Dict[str, JsonSchema]] = UNSET + responses: MaybeSet[Dict[str, Union[Response, Reference]]] = UNSET + parameters: MaybeSet[Dict[str, Union[Parameter, Reference]]] = UNSET + examples: MaybeSet[Dict[str, Union[ExampleObject, Reference]]] = UNSET + requestBodies: MaybeSet[Dict[str, Union[RequestBody, Reference]]] = UNSET + headers: MaybeSet[Dict[str, Union[Header, Reference]]] = UNSET + securitySchemes: MaybeSet[Dict[str, Union[SecurityScheme, Reference]]] = UNSET + links: MaybeSet[Dict[str, Dict[str, Union[Link, Reference]]]] = UNSET + pathItems: MaybeSet[Dict[str, Union[Path, Reference]]] = UNSET + + +class OpenApiMeta(TypedDict): + params_schema: MaybeSet[Dict[str, JsonSchema]] + result_schema: MaybeSet[JsonSchema] + errors: MaybeSet[List[Type[exceptions.JsonRpcError]]] + examples: MaybeSet[List[MethodExample]] + tags: MaybeSet[List[Tag]] + summary: MaybeSet[str] + description: MaybeSet[str] + external_docs: MaybeSet[ExternalDocumentation] + deprecated: MaybeSet[bool] + security: MaybeSet[List[Dict[str, List[str]]]] + parameters: MaybeSet[List[Parameter]] + servers: MaybeSet[List[Server]] + component_name_prefix: Optional[str] def annotate( - params_schema: MaybeSet[Dict[str, Schema]] = UNSET, - result_schema: MaybeSet[Schema] = UNSET, - errors_schema: MaybeSet[List[Error]] = UNSET, + params_schema: MaybeSet[Dict[str, JsonSchema]] = UNSET, + result_schema: MaybeSet[JsonSchema] = UNSET, errors: MaybeSet[List[Type[exceptions.JsonRpcError]]] = UNSET, examples: MaybeSet[List[MethodExample]] = UNSET, - error_examples: MaybeSet[List[ErrorExample]] = UNSET, - tags: MaybeSet[List[str]] = UNSET, + tags: MaybeSet[List[Union[str, Tag]]] = UNSET, summary: MaybeSet[str] = UNSET, description: MaybeSet[str] = UNSET, external_docs: MaybeSet[ExternalDocumentation] = UNSET, deprecated: MaybeSet[bool] = UNSET, security: MaybeSet[List[Dict[str, List[str]]]] = UNSET, parameters: MaybeSet[List[Parameter]] = UNSET, + servers: MaybeSet[List[Server]] = UNSET, + component_name_prefix: Optional[str] = None, ) -> Callable[[Func], Func]: """ Adds Open Api specification annotation to the method. :param params_schema: method parameters JSON schema :param result_schema: method result JSON schema - :param errors_schema: method errors schema :param errors: method errors :param examples: method usage examples - :param error_examples: method error examples :param tags: a list of tags for method documentation control :param summary: a short summary of what the method does :param description: a verbose explanation of the method behavior @@ -542,27 +566,29 @@ def annotate( :param deprecated: declares this method to be deprecated :param security: a declaration of which security mechanisms can be used for the method :param parameters: a list of parameters that are applicable for the method + :param servers: a list of connectivity information of a target server. + :param component_name_prefix: components name prefix. """ def decorator(method: Func) -> Func: - utils.set_meta( - method, - openapi_spec=dict( - params_schema=params_schema, - result_schema=result_schema, - errors_schema=errors_schema, - errors=errors, - examples=examples, - error_examples=error_examples, - tags=[Tag(name=tag) for tag in tags] if not isinstance(tags, UnsetType) else UNSET, - summary=summary, - description=description, - external_docs=external_docs, - deprecated=deprecated, - security=security, - parameters=parameters, - ), + meta: OpenApiMeta = dict( + params_schema=params_schema, + result_schema=result_schema, + errors=errors, + examples=examples, + tags=[ + tag if isinstance(tag, Tag) else Tag(name=tag) for tag in tags + ] if not isinstance(tags, UnsetType) else UNSET, + summary=summary, + description=description, + external_docs=external_docs, + deprecated=deprecated, + security=security, + parameters=parameters, + servers=servers, + component_name_prefix=component_name_prefix, ) + utils.set_meta(method, openapi_spec=meta) return method @@ -578,6 +604,7 @@ class OpenAPI(Specification): :param servers: an array of Server Objects, which provide connectivity information to a target server :param external_docs: additional external documentation :param openapi: the semantic version number of the OpenAPI Specification version that the OpenAPI document uses + :param json_schema_dialect: the default value for the $schema keyword within Schema Objects :param tags: a list of tags used by the specification with additional metadata :param security: a declaration of which security mechanisms can be used across the API :param schema_extractor: method specification extractor @@ -596,7 +623,8 @@ class OpenAPI(Specification): externalDocs: MaybeSet[ExternalDocumentation] = UNSET tags: MaybeSet[List[Tag]] = UNSET security: MaybeSet[List[Dict[str, List[str]]]] = UNSET - openapi: str = '3.0.0' + openapi: str = '3.1.0' + jsonSchemaDialect: MaybeSet[str] = UNSET def __init__( self, @@ -606,12 +634,14 @@ def __init__( external_docs: MaybeSet[ExternalDocumentation] = UNSET, tags: MaybeSet[List[Tag]] = UNSET, security: MaybeSet[List[Dict[str, List[str]]]] = UNSET, - security_schemes: MaybeSet[Dict[str, SecurityScheme]] = UNSET, - openapi: str = '3.0.0', + security_schemes: MaybeSet[Dict[str, Union[SecurityScheme, Reference]]] = UNSET, + openapi: str = '3.1.0', + json_schema_dialect: MaybeSet[str] = UNSET, schema_extractor: Optional[extractors.BaseSchemaExtractor] = None, schema_extractors: Iterable[extractors.BaseSchemaExtractor] = (), ui: Optional[BaseUI] = None, ui_path: str = '/ui/', + error_http_status_map: Dict[int, int] = {}, ): super().__init__(path, ui=ui, ui_path=ui_path) @@ -621,120 +651,49 @@ def __init__( self.tags = tags self.security = security self.openapi = openapi + self.jsonSchemaDialect = json_schema_dialect self.paths: Dict[str, Path] = {} self.components = Components(securitySchemes=security_schemes) + self._error_http_status_map = error_http_status_map self._schema_extractors = list(schema_extractors) or [schema_extractor or extractors.BaseSchemaExtractor()] def schema( self, path: str, - methods: Iterable[Method] = (), methods_map: Mapping[str, Iterable[Method]] = {}, + component_name_prefix: str = '', ) -> Dict[str, Any]: - methods_list: List[Tuple[str, Method]] = [] - methods_list.extend((path, method) for method in methods) - methods_list.extend( + methods_list = [ (utils.join_path(path, prefix), method) for prefix, methods in methods_map.items() for method in methods - ) + ] for prefix, method in methods_list: method_meta = utils.get_meta(method.method) - annotated_spec = method_meta.get('openapi_spec', {}) - - specs: List[Dict[str, Any]] = [ - dict( - params_schema=annotated_spec.get('params_schema', UNSET), - result_schema=annotated_spec.get('result_schema', UNSET), - errors_schema=annotated_spec.get('errors_schema', UNSET), - deprecated=annotated_spec.get('deprecated', UNSET), - description=annotated_spec.get('description', UNSET), - summary=annotated_spec.get('summary', UNSET), - tags=annotated_spec.get('tags', UNSET), - examples=annotated_spec.get('examples', UNSET), - error_examples=annotated_spec.get('error_examples', UNSET), - external_docs=annotated_spec.get('external_docs', UNSET), - security=annotated_spec.get('security', UNSET), - parameters=annotated_spec.get('parameters', UNSET), - ), - ] - for schema_extractor in self._schema_extractors: - specs.append( - dict( - params_schema=schema_extractor.extract_params_schema( - method.method, - exclude=[method.context] if method.context else [], - ), - result_schema=schema_extractor.extract_result_schema(method.method), - errors_schema=schema_extractor.extract_errors_schema( - method.method, annotated_spec.get('errors') or [], - ), - deprecated=schema_extractor.extract_deprecation_status(method.method), - description=schema_extractor.extract_description(method.method), - summary=schema_extractor.extract_summary(method.method), - tags=schema_extractor.extract_tags(method.method), - examples=schema_extractor.extract_examples(method.method), - error_examples=schema_extractor.extract_error_examples( - method.method, annotated_spec.get('errors') or [], - ), - ), - ) - - method_spec = self._merge_specs(specs) - request_schema = self._build_request_schema(method.name, method_spec) - response_schema = self._build_response_schema(method_spec) - - request_examples: Dict[str, Any] = { - example.summary or f'Example#{i}': ExampleObject( - summary=example.summary, - description=example.description, - value=dict( - jsonrpc=example.version, - id=1, - method=method.name, - params=example.params, - ), - ) for i, example in enumerate(method_spec.get('examples') or []) - } - - response_success_examples: Dict[str, Any] = { - example.summary or f'Example#{i}': ExampleObject( - summary=example.summary, - description=example.description, - value=dict( - jsonrpc=example.version, - id=1, - result=example.result, - ), - ) for i, example in enumerate(method_spec.get('examples') or []) - } - - response_error_examples: Dict[str, Any] = { - example.message or f'Error#{i}': ExampleObject( - summary=example.summary, - description=example.description, - value=dict( - jsonrpc='2.0', - id=1, - error=dict( - code=example.code, - message=example.message, - data=example.data, - ), - ), - ) for i, example in enumerate( - utils.unique( - [ - ErrorExample(code=error.code, message=error.message) - for error in method_spec.get('errors') or [] - ], - method_spec.get('error_examples') or [], - key=lambda item: item.code, - ), - ) - } + annotated_spec: OpenApiMeta = method_meta.get('openapi_spec', {}) + + component_name_prefix = annotated_spec.get('component_name_prefix') or component_name_prefix + status_errors_map = self._extract_errors(method) + default_status_errors = status_errors_map.pop(HTTP_DEFAULT_STATUS, []) + + errors_schema = self._extract_errors_schema(method, status_errors_map, component_name_prefix) + + request_schema = self._extract_request_schema(method, component_name_prefix) + response_schema = self._extract_response_schema(method, default_status_errors, component_name_prefix) + + summary, description = self._extract_description(method) + tags = self._extract_tags(method) + servers = self._extract_servers(method) + parameters = self._extract_parameters(method) + security = self._extract_security(method) + deprecated = self._extract_deprecated(method) + external_docs = self._extract_external_docs(method) + + request_examples, response_success_examples = self._build_examples( + method, annotated_spec.get('examples', UNSET) or [], + ) self.paths[f'{prefix}#{method.name}'] = Path( post=Operation( @@ -749,157 +708,236 @@ def schema( required=True, ), responses={ - JSONRPC_HTTP_CODE: Response( - description='JSON-RPC Response', - content={ - JSONRPC_MEDIATYPE: MediaType( - schema=response_schema, - examples={**response_success_examples, **response_error_examples} or UNSET, - ), - }, - ), + **{ + str(HTTP_DEFAULT_STATUS): Response( + description=(response_schema or {}).get('description', 'JSON-RPC Response'), + content={ + JSONRPC_MEDIATYPE: MediaType( + schema=response_schema, + examples=response_success_examples or UNSET, + ), + }, + ), + }, + **{ + str(status): Response( + description=error_schema.get('description', 'JSON-RPC Error'), + content={ + JSONRPC_MEDIATYPE: MediaType( + schema=error_schema, + ), + }, + ) + for status, error_schema in errors_schema.items() + }, }, - tags=[tag.name for tag in method_spec.get('tags') or []], - summary=method_spec['summary'], - description=method_spec['description'], - deprecated=method_spec['deprecated'], - externalDocs=method_spec.get('external_docs', UNSET), - security=method_spec.get('security', UNSET), - parameters=method_spec.get('parameters', UNSET), + tags=[tag.name for tag in tags] or UNSET, + summary=summary, + description=description, + deprecated=deprecated, + externalDocs=external_docs, + security=security or UNSET, + parameters=list(parameters) or UNSET, + servers=servers or UNSET, ), ) return drop_unset(dc.asdict(self)) - def _merge_specs(self, specs: List[Dict[str, Any]]) -> Dict[str, Any]: - specs = reversed(specs) - result: Dict[str, Any] = next(specs, {}) - - for spec in specs: - if spec.get('errors_schema', UNSET) is not UNSET: - schema = {schema.code: schema for schema in result.get('errors_schema') or []} - schema.update({schema.code: schema for schema in spec['errors_schema']}) - result['errors_schema'] = list(schema.values()) - - if spec.get('result_schema', UNSET) is not UNSET: - if result.get('result_schema', UNSET) is UNSET: - result['result_schema'] = spec['result_schema'] - else: - cur_schema = result['result_schema'] - new_schema = spec['result_schema'] - result['result_schema'] = Schema( - new_schema.schema or cur_schema.schema, - new_schema.required if new_schema.required is not UNSET else cur_schema.required, - new_schema.summary or cur_schema.summary, - new_schema.description or cur_schema.description, - new_schema.deprecated if new_schema.deprecated is not UNSET else cur_schema.deprecated, - new_schema.definitions or cur_schema.definitions, - ) - - if spec.get('params_schema', UNSET) is not UNSET: - if result.get('params_schema', UNSET) is UNSET: - result['params_schema'] = spec['params_schema'] - else: - for param, schema1 in spec['params_schema'].items(): - schema2 = result['params_schema'].get(param) - if schema2 is None: - result['params_schema'][param] = schema1 - else: - result['params_schema'][param] = Schema( - schema1.schema or schema2.schema, - schema1.required if schema1.required is not UNSET else schema2.required, - schema1.summary or schema2.summary, - schema1.description or schema2.description, - schema1.deprecated if schema1.deprecated is not UNSET else schema2.deprecated, - schema1.definitions or schema2.definitions, - ) + def _extract_errors(self, method: Method) -> Dict[int, List[Type[exceptions.JsonRpcError]]]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) - if spec.get('summary', UNSET) is not UNSET: - result['summary'] = spec['summary'] + errors = annotations.get('errors', UNSET) or [] + for schema_extractor in self._schema_extractors: + errors.extend(schema_extractor.extract_errors(method.method) or []) - if spec.get('description', UNSET) is not UNSET: - result['description'] = spec['description'] + unique_errors = list({error.code: error for error in errors}.values()) + status_error_map: Dict[int, List[Type[exceptions.JsonRpcError]]] = defaultdict(list) + for error in unique_errors: + http_status = self._error_http_status_map.get(error.code, HTTP_DEFAULT_STATUS) + status_error_map[http_status].append(error) - if spec.get('tags', UNSET) is not UNSET: - result['tags'] = spec['tags'] + (result.get('tags') or []) + return status_error_map - if spec.get('deprecated', UNSET) is not UNSET: - result['deprecated'] = spec['deprecated'] + def _extract_errors_schema( + self, + method: Method, + status_errors_map: Dict[int, List[Type[exceptions.JsonRpcError]]], + component_name_prefix: str, + ) -> Dict[int, Dict[str, Any]]: + status_error_schema_map: Dict[int, Dict[str, Any]] = {} - if spec.get('examples', UNSET) is not UNSET: - result['examples'] = spec['examples'] + (result.get('examples') or []) + for status, errors in status_errors_map.items(): + for schema_extractor in self._schema_extractors: + if result := schema_extractor.extract_error_response_schema( + method.name, + method.method, + ref_template=f'#/components/schemas/{component_name_prefix}{{model}}', + errors=errors, + ): + schema, components = result + if components: + self.components.schemas = schemas = self.components.schemas or {} + schemas.update({ + f"{component_name_prefix}{name}": component + for name, component in components.items() + }) + status_error_schema_map[status] = schema + break + + return status_error_schema_map + + def _extract_request_schema(self, method: Method, component_name_prefix: str) -> MaybeSet[Dict[str, Any]]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) + + request_schema: MaybeSet[Dict[str, Any]] = UNSET + if params_schema := annotations.get('params_schema', UNSET): + request_schema = build_request_schema(method.name, params_schema) + else: + for schema_extractor in self._schema_extractors: + if result := schema_extractor.extract_request_schema( + method.name, + method.method, + ref_template=f'#/components/schemas/{component_name_prefix}{{model}}', + exclude=[method.context] if method.context else [], + ): + request_schema, components = result + if components: + self.components.schemas = schemas = self.components.schemas or {} + schemas.update({ + f"{component_name_prefix}{name}": component + for name, component in components.items() + }) + break - if spec.get('error_examples', UNSET) is not UNSET: - result['error_examples'] = spec['error_examples'] + (result.get('error_examples') or []) + return request_schema - if spec.get('external_docs', UNSET) is not UNSET: - result['external_docs'] = spec['external_docs'] + def _extract_response_schema( + self, + method: Method, + errors: List[Type[exceptions.JsonRpcError]], + component_name_prefix: str, + ) -> MaybeSet[Dict[str, Any]]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) + + response_schema: MaybeSet[Dict[str, Any]] = UNSET + if result_schema := annotations.get('result_schema', UNSET): + response_schema = build_response_schema(result_schema, errors=errors) + else: + for schema_extractor in self._schema_extractors: + if result := schema_extractor.extract_response_schema( + method.name, + method.method, + ref_template=f'#/components/schemas/{component_name_prefix}{{model}}', + errors=errors, + ): + response_schema, components = result + if components: + self.components.schemas = schemas = self.components.schemas or {} + schemas.update({ + f"{component_name_prefix}{name}": component + for name, component in components.items() + }) + break - if spec.get('security', UNSET) is not UNSET: - result['security'] = spec['security'] + (result.get('security') or []) + return response_schema - if spec.get('parameters', UNSET) is not UNSET: - result['parameters'] = spec['parameters'] + (result.get('parameters') or []) + def _extract_description(self, method: Method) -> Tuple[MaybeSet[str], MaybeSet[str]]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) - return result + summary = annotations.get('summary', UNSET) + description = annotations.get('description', UNSET) - def _build_request_schema(self, method_name: str, method_spec: Dict[str, Any]) -> Dict[str, Any]: - request_schema: Dict[str, Any] = copy.deepcopy(REQUEST_SCHEMA) - request_schema['properties']['method']['enum'] = [method_name] + for schema_extractor in self._schema_extractors: + if not summary: + summary = schema_extractor.extract_summary(method.method) + if not description: + description = schema_extractor.extract_description(method.method) - for param_name, param_schema in method_spec['params_schema'].items(): - schema = request_schema['properties']['params']['properties'][param_name] = param_schema.schema.copy() - schema.update({ - 'title': param_schema.summary, - 'description': param_schema.description, - 'deprecated': param_schema.deprecated, - }) + return summary, description - if param_schema.required: - required_params = request_schema['properties']['params'].setdefault('required', []) - required_params.append(param_name) + def _extract_tags(self, method: Method) -> List[Tag]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) - if param_schema.definitions: - self.components.schemas.update(param_schema.definitions) + tags = annotations.get('tags', UNSET) or [] - return request_schema + return tags - def _build_response_schema(self, method_spec: Dict[str, Any]) -> Dict[str, Any]: - response_schema: Dict[str, Any] = copy.deepcopy(RESPONSE_SCHEMA) - result_schema = method_spec['result_schema'] - - response_schema['oneOf'][RESULT_SCHEMA_IDX]['properties']['result'] = result_schema.schema - if result_schema.definitions: - self.components.schemas.update(result_schema.definitions) - - if method_spec['errors_schema']: - errors_schema: List[Dict[str, Any]] = [] - response_schema['oneOf'][ERROR_SCHEMA_IDX]['properties']['error'] = {'oneOf': errors_schema} - - for schema in utils.unique( - [ - Error(code=error.code, message=error.message) - for error in method_spec.get('errors') or [] - ], - method_spec['errors_schema'], - key=lambda item: item.code, - ): - errors_schema.append({ - 'type': 'object', - 'properties': { - 'code': {'type': 'integer', 'enum': [schema.code]}, - 'message': {'type': 'string'}, - 'data': schema.data, - }, - 'required': ['code', 'message'] + ['data'] if schema.data_required else [], - 'deprecated': schema.deprecated, - 'title': schema.title or schema.message, - 'description': schema.description, - }) - if schema.definitions: - self.components.schemas.update(schema.definitions) + def _extract_servers(self, method: Method) -> List[Server]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) - return response_schema + servers = annotations.get('servers', UNSET) or [] + + return servers + + def _extract_parameters(self, method: Method) -> List[Parameter]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) + + parameters = annotations.get('parameters', UNSET) or [] + + return parameters + + def _extract_security(self, method: Method) -> List[Dict[str, List[str]]]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) + + security = annotations.get('security', UNSET) or [] + + return security + + def _extract_deprecated(self, method: Method) -> MaybeSet[bool]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) + + deprecated = annotations.get('deprecated', UNSET) + + for schema_extractor in self._schema_extractors: + deprecated = deprecated or schema_extractor.extract_deprecation_status(method.method) + + return deprecated + + def _extract_external_docs(self, method: Method) -> MaybeSet[ExternalDocumentation]: + method_meta = utils.get_meta(method.method) + annotations: OpenApiMeta = method_meta.get('openapi_spec', {}) + + external_docs = annotations.get('external_docs', UNSET) + + return external_docs + + def _build_examples(self, method: Method, examples: List[MethodExample]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + request_examples: Dict[str, Any] = { + example.summary or f'Example#{i}': ExampleObject( + summary=example.summary, + description=example.description, + value={ + 'jsonrpc': example.version, + 'id': 1, + 'method': method.name, + 'params': example.params, + }, + ) for i, example in enumerate(examples) + } + + response_examples: Dict[str, Any] = { + example.summary or f'Example#{i}': ExampleObject( + summary=example.summary, + description=example.description, + value={ + 'jsonrpc': example.version, + 'id': 1, + 'result': example.result, + }, + ) for i, example in enumerate(examples) + } + + return request_examples, response_examples class SwaggerUI(BaseUI): diff --git a/pjrpc/server/specs/openrpc.py b/pjrpc/server/specs/openrpc.py index 43a4dba..6b4eef9 100644 --- a/pjrpc/server/specs/openrpc.py +++ b/pjrpc/server/specs/openrpc.py @@ -8,8 +8,7 @@ except ImportError: raise AssertionError("python 3.7 or later is required") -import itertools as it -from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Type, Union +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, TypedDict, Union from pjrpc.common import UNSET, MaybeSet, UnsetType, exceptions from pjrpc.common.typedefs import Func @@ -18,9 +17,36 @@ from . import Specification, extractors Json = Union[str, int, float, dict, bool, list, tuple, set, None] # type: ignore[type-arg] +JsonSchema = Dict[str, Any] -@dc.dataclass(frozen=True) +def remove_prefix(s: str, prefix: str) -> str: + return s[len(prefix):] if s.startswith(prefix) else s + + +def follow_ref(schema: Dict[str, Any], components: Dict[str, Dict[str, Any]], ref_prefix: str) -> Dict[str, Any]: + if '$ref' in schema: + ref = remove_prefix(schema['$ref'], ref_prefix) + schema = components[ref] + + return schema + + +@dc.dataclass +class Reference: + """ + A simple object to allow referencing other components in the specification, internally and externally. + :param ref: the reference identifier. + """ + + ref: str + + def __post_init__(self) -> None: + self.__dict__['$ref'] = self.__dict__['ref'] + self.__dataclass_fields__['ref'].name = '$ref' # noqa + + +@dc.dataclass class Contact: """ Contact information for the exposed API. @@ -35,7 +61,7 @@ class Contact: email: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class License: """ License information for the exposed API. @@ -48,7 +74,7 @@ class License: url: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Info: """ Metadata about the API. @@ -69,24 +95,42 @@ class Info: termsOfService: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass +class ServerVariable: + """ + An object representing a Server Variable for server URL template substitution. + + :param default: The default value to use for substitution. + :param enum: An enumeration of string values to be used if the substitution options are from a limited set. + :param description: An optional description for the server variable. + """ + + default: str + enum: MaybeSet[List[str]] = UNSET + description: MaybeSet[str] = UNSET + + +@dc.dataclass class Server: """ Connectivity information of a target server. :param name: a name to be used as the canonical name for the server. - :param url: a URL to the target host + :param url: a URL to the target host. This URL supports Server Variables. :param summary: a short summary of what the server is :param description: an optional string describing the host designated by the URL + :param variables: A map between a variable name and its value. + The value is passed into the Runtime Expression to produce a server URL. """ name: str url: str summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET + variables: MaybeSet[Dict[str, ServerVariable]] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class ExternalDocumentation: """ Allows referencing an external resource for extended documentation. @@ -99,25 +143,23 @@ class ExternalDocumentation: description: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Tag: """ A list of tags for API documentation control. Tags can be used for logical grouping of methods by resources or any other qualifier. :param name: the name of the tag - :param summary: a short summary of the tag :param description: a verbose explanation for the tag :param externalDocs: additional external documentation for this tag """ name: str - summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET externalDocs: MaybeSet[ExternalDocumentation] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class ExampleObject: """ The ExampleObject object is an object the defines an example. @@ -126,15 +168,17 @@ class ExampleObject: :param name: canonical name of the example :param summary: short description for the example :param description: a verbose explanation of the example + :param externalValue: a URL that points to the literal example. """ - value: Json + value: Any name: str summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET + externalValue: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class MethodExample: """ The example Pairing object consists of a set of example params and result. @@ -147,13 +191,13 @@ class MethodExample: """ name: str - params: List[ExampleObject] - result: ExampleObject + params: List[Union[ExampleObject, Reference]] + result: Union[ExampleObject, Reference] summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class ContentDescriptor: """ Content Descriptors are objects that describe content. @@ -169,14 +213,14 @@ class ContentDescriptor: """ name: str - schema: Dict[str, Any] + schema: JsonSchema summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET required: MaybeSet[bool] = UNSET deprecated: MaybeSet[bool] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Error: """ Defines an application level error. @@ -188,7 +232,7 @@ class Error: code: int message: str - data: MaybeSet[Dict[str, Any]] = UNSET + data: MaybeSet[Any] = UNSET class ParamStructure(str, enum.Enum): @@ -201,7 +245,28 @@ class ParamStructure(str, enum.Enum): EITHER = 'either' -@dc.dataclass(frozen=True) +@dc.dataclass +class Link: + """ + :param name: Canonical name of the link. + :param description: A description of the link. + :param summary: Short description for the link. + :param method: The name of an existing, resolvable OpenRPC method, as defined with a unique method. + :param params: A map representing parameters to pass to a method as specified with method. + The key is the parameter name to be used, whereas the value can be a constant or a runtime + expression to be evaluated and passed to the linked method. + :param server: A server object to be used by the target method. + """ + + name: str + description: MaybeSet[str] = UNSET + summary: MaybeSet[str] = UNSET + method: MaybeSet[str] = UNSET + params: MaybeSet[Any] = UNSET + server: MaybeSet[Server] = UNSET + + +@dc.dataclass class MethodInfo: """ Describes the interface for the given method name. @@ -221,39 +286,67 @@ class MethodInfo: """ name: str - params: List[Union[ContentDescriptor, Dict[str, Any]]] - result: Union[ContentDescriptor, Dict[str, Any]] - errors: MaybeSet[List[Error]] = UNSET + params: List[Union[ContentDescriptor, Reference]] + result: Union[ContentDescriptor, Reference] + errors: MaybeSet[List[Union[Error, Reference]]] = UNSET + links: MaybeSet[List[Union[Link, Reference]]] = UNSET paramStructure: MaybeSet[ParamStructure] = UNSET - examples: MaybeSet[List[MethodExample]] = UNSET + examples: MaybeSet[List[Union[MethodExample, Reference]]] = UNSET summary: MaybeSet[str] = UNSET description: MaybeSet[str] = UNSET - tags: MaybeSet[List[Tag]] = UNSET + tags: MaybeSet[List[Union[Tag, Reference]]] = UNSET deprecated: MaybeSet[bool] = UNSET externalDocs: MaybeSet[ExternalDocumentation] = UNSET servers: MaybeSet[List[Server]] = UNSET -@dc.dataclass(frozen=True) +@dc.dataclass class Components: """ Set of reusable objects for different aspects of the OpenRPC. + :param contentDescriptors: reusable Schema Objects :param schemas: reusable Schema Objects + :param examples: reusable Schema Objects + :param links: reusable Schema Objects + :param errors: reusable Schema Objects + :param examplePairingObjects: reusable Schema Objects + :param tags: reusable Schema Objects """ - schemas: Dict[str, Any] = dc.field(default_factory=dict) + contentDescriptors: MaybeSet[Dict[str, ContentDescriptor]] = UNSET + schemas: MaybeSet[Dict[str, JsonSchema]] = UNSET + examples: MaybeSet[Dict[str, ExampleObject]] = UNSET + links: MaybeSet[Dict[str, Link]] = UNSET + errors: MaybeSet[Dict[str, Error]] = UNSET + examplePairingObjects: MaybeSet[Dict[str, MethodExample]] = UNSET + tags: MaybeSet[Dict[str, Tag]] = UNSET + + +class OpenRpcMeta(TypedDict): + params_schema: MaybeSet[List[ContentDescriptor]] + result_schema: MaybeSet[ContentDescriptor] + errors: MaybeSet[List[Error]] + examples: MaybeSet[List[Union[MethodExample, Reference]]] + tags: MaybeSet[List[Union[Tag, Reference]]] + summary: MaybeSet[str] + description: MaybeSet[str] + deprecated: MaybeSet[bool] + external_docs: MaybeSet[ExternalDocumentation] + servers: MaybeSet[List[Server]] def annotate( params_schema: MaybeSet[List[ContentDescriptor]] = UNSET, result_schema: MaybeSet[ContentDescriptor] = UNSET, errors: MaybeSet[List[Union[Error, Type[exceptions.JsonRpcError]]]] = UNSET, - examples: MaybeSet[List[MethodExample]] = UNSET, + examples: MaybeSet[List[Union[MethodExample, Reference]]] = UNSET, summary: MaybeSet[str] = UNSET, description: MaybeSet[str] = UNSET, tags: MaybeSet[List[Union[Tag, str]]] = UNSET, deprecated: MaybeSet[bool] = UNSET, + external_docs: MaybeSet[ExternalDocumentation] = UNSET, + servers: MaybeSet[List[Server]] = UNSET, ) -> Callable[[Func], Func]: """ Adds JSON-RPC method to the API specification. @@ -266,28 +359,30 @@ def annotate( :param description: a verbose explanation of the method behavior :param tags: a list of tags for API documentation control :param deprecated: declares this method to be deprecated + :param external_docs: additional external documentation for the method + :param servers: an alternative servers array to service this method """ def decorator(method: Func) -> Func: - utils.set_meta( - method, - openrpc_spec=dict( - params_schema=params_schema, - result_schema=result_schema, - errors=[ - error if isinstance(error, Error) else Error(code=error.code, message=error.message) - for error in errors - ] if errors else UNSET, - examples=examples, - tags=[ - Tag(name=tag) if isinstance(tag, str) else tag - for tag in tags - ] if tags else UNSET, - summary=summary, - description=description, - deprecated=deprecated, - ), + meta: OpenRpcMeta = dict( + params_schema=params_schema, + result_schema=result_schema, + errors=[ + error if isinstance(error, Error) else Error(code=error.code, message=error.message) + for error in errors + ] if errors else UNSET, + examples=examples, + tags=[ + Tag(name=tag) if isinstance(tag, str) else tag + for tag in tags + ] if tags else UNSET, + summary=summary, + description=description, + deprecated=deprecated, + external_docs=external_docs, + servers=servers, ) + utils.set_meta(method, openrpc_spec=meta) return method @@ -299,7 +394,7 @@ class OpenRPC(Specification): """ OpenRPC Specification. - :param info: specification information + :param info: provides metadata about the API :param path: specification url path :param servers: connectivity information :param external_docs: additional external documentation @@ -309,10 +404,10 @@ class OpenRPC(Specification): info: Info components: Components - methods: List[MethodInfo] = dc.field(default_factory=list) + methods: List[Union[MethodInfo, Reference]] = dc.field(default_factory=list) servers: MaybeSet[List[Server]] = UNSET externalDocs: MaybeSet[ExternalDocumentation] = UNSET - openrpc: str = '1.0.0' + openrpc: str = '1.3.2' def __init__( self, @@ -320,7 +415,7 @@ def __init__( path: str = '/openrpc.json', servers: MaybeSet[List[Server]] = UNSET, external_docs: MaybeSet[ExternalDocumentation] = UNSET, - openrpc: str = '1.0.0', + openrpc: str = '1.3.2', schema_extractor: Optional[extractors.BaseSchemaExtractor] = None, ): super().__init__(path) @@ -337,91 +432,157 @@ def __init__( def schema( self, path: str, - methods: Iterable[Method] = (), methods_map: Mapping[str, Iterable[Method]] = {}, ) -> Dict[str, Any]: - for method in it.chain(methods, methods_map.get('', [])): - method_name = method.name - - method_meta = utils.get_meta(method.method) - annotated_spec = method_meta.get('openrpc_spec', {}) - - params_schema = self._schema_extractor.extract_params_schema( - method.method, - exclude=[method.context] if method.context else [], - ) - result_schema = self._schema_extractor.extract_result_schema(method.method) - extracted_spec: Dict[str, Any] = dict( - params_schema=[ - ContentDescriptor( - name=name, - schema=schema.schema, - summary=schema.summary, - description=schema.description, - required=schema.required, - deprecated=schema.deprecated, - ) for name, schema in params_schema.items() - ], - result_schema=ContentDescriptor( - name='result', - schema=result_schema.schema, - summary=result_schema.summary, - description=result_schema.description, - required=result_schema.required, - deprecated=result_schema.deprecated, - ), - errors=self._schema_extractor.extract_errors_schema(method.method), - deprecated=self._schema_extractor.extract_deprecation_status(method.method), - description=self._schema_extractor.extract_description(method.method), - summary=self._schema_extractor.extract_summary(method.method), - tags=self._schema_extractor.extract_tags(method.method), - examples=[ - MethodExample( - name=example.summary or f'Example#{i}', - params=[ - ExampleObject( - value=param_value, - name=param_name, - ) - for param_name, param_value in example.params.items() - ], - result=ExampleObject( - name='result', - value=example.result, - ), - summary=example.summary, - description=example.description, - ) - for i, example in enumerate(self._schema_extractor.extract_examples(method.method) or []) - ], - ) - method_spec: Dict[str, Any] = extracted_spec.copy() - method_spec.update((k, v) for k, v in annotated_spec.items() if v is not UNSET) + self.components.schemas = {} + + for method in methods_map.get('', []): + summary, description = self._extract_description(method) + params_schema = self._extract_params_schema(method) + result_schema = self._extract_result_schema(method) + errors = self._extract_errors(method) + deprecated = self._extract_deprecated(method) + tags = self._extract_tags(method) + external_docs = self._extract_external_docs(method) + servers = self._extract_servers(method) + examples = self._extract_examples(method) self.methods.append( MethodInfo( - name=method_name, - params=method_spec['params_schema'], - result=method_spec['result_schema'], - errors=method_spec['errors'], - examples=method_spec['examples'], - summary=method_spec['summary'], - description=method_spec['description'], - tags=method_spec['tags'], - deprecated=method_spec['deprecated'], + name=method.name, + params=list(params_schema), + result=result_schema, + errors=list(errors) if errors else UNSET, + examples=examples or UNSET, + summary=summary, + description=description, + tags=tags or UNSET, + deprecated=deprecated, + servers=servers or UNSET, + externalDocs=external_docs or UNSET, ), ) - for param_schema in params_schema.values(): - if not isinstance(param_schema.definitions, UnsetType): - self.components.schemas.update(param_schema.definitions) - - if not isinstance(result_schema.definitions, UnsetType): - self.components.schemas.update(result_schema.definitions) - return dc.asdict( self, dict_factory=lambda iterable: dict( filter(lambda item: not isinstance(item[1], UnsetType), iterable), ), ) + + def _extract_params_schema(self, method: Method) -> List[ContentDescriptor]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + if not (params_descriptors := annotations.get('params_schema')): + request_ref_prefix = '#/components/schemas/' + params_schema, components = self._schema_extractor.extract_params_schema( + method.name, + method.method, + ref_template=f'{request_ref_prefix}{{model}}', + exclude=[method.context] if method.context else [], + ) + params_descriptors = [ + ContentDescriptor( + name=name, + schema=schema, + summary=schema.get('title', UNSET), + description=schema.get('description', UNSET), + required=name in params_schema.get('required', []), + deprecated=schema.get('deprecated', UNSET), + ) for name, schema in params_schema['properties'].items() + ] + + self.components.schemas = schemas = self.components.schemas or {} + schemas.update(components) + + return params_descriptors + + def _extract_result_schema(self, method: Method) -> ContentDescriptor: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + if not (result_descriptor := annotations.get('result_schema')): + response_ref_prefix = '#/components/schemas/' + result_schema, components = self._schema_extractor.extract_result_schema( + method.name, + method.method, + ref_template=f'{response_ref_prefix}{{model}}', + ) + result_descriptor = ContentDescriptor( + name='result', + schema=result_schema, + summary=result_schema.get('title', UNSET), + description=result_schema.get('description', UNSET), + required='result' in result_schema.get('required', []), + deprecated=result_schema.get('deprecated', UNSET), + ) + self.components.schemas = schemas = self.components.schemas or {} + schemas.update(components) + + return result_descriptor + + def _extract_errors(self, method: Method) -> MaybeSet[List[Error]]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + errors = annotations.get('errors', UNSET) or [] + errors.extend([ + Error(code=error.code, message=error.message) + for error in self._schema_extractor.extract_errors(method.method) or [] + ]) + + unique_errors = list({error.code: error for error in errors}.values()) + + return unique_errors or UNSET + + def _extract_description(self, method: Method) -> Tuple[MaybeSet[str], MaybeSet[str]]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + summary = annotations.get('summary', UNSET) or self._schema_extractor.extract_summary(method.method) + description = annotations.get('description', UNSET) or self._schema_extractor.extract_description(method.method) + + return summary, description + + def _extract_tags(self, method: Method) -> MaybeSet[List[Union[Tag, Reference]]]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + tags = annotations.get('tags', UNSET) or [] + + return tags or UNSET + + def _extract_servers(self, method: Method) -> MaybeSet[List[Server]]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + servers = annotations.get('servers', UNSET) or [] + + return servers or UNSET + + def _extract_deprecated(self, method: Method) -> MaybeSet[bool]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + deprecated = annotations.get('deprecated', UNSET) + if deprecated is UNSET: + deprecated = self._schema_extractor.extract_deprecation_status(method.method) + + return deprecated + + def _extract_external_docs(self, method: Method) -> MaybeSet[ExternalDocumentation]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + external_docs = annotations.get('external_docs', UNSET) + + return external_docs + + def _extract_examples(self, method: Method) -> MaybeSet[List[Union[MethodExample, Reference]]]: + method_meta = utils.get_meta(method.method) + annotations: OpenRpcMeta = method_meta.get('openrpc_spec', {}) + + examples = annotations.get('examples', UNSET) + + return examples diff --git a/pjrpc/server/specs/schemas.py b/pjrpc/server/specs/schemas.py new file mode 100644 index 0000000..719fae8 --- /dev/null +++ b/pjrpc/server/specs/schemas.py @@ -0,0 +1,147 @@ +import copy +from typing import Any, Dict, Iterable, List, Type + +from pjrpc.common.exceptions import JsonRpcError + +REQUEST_SCHEMA: Dict[str, Any] = { + 'title': 'Request', + 'type': 'object', + 'properties': { + 'jsonrpc': { + 'title': 'Version', + 'description': 'JSON-RPC protocol version', + 'type': 'string', + 'enum': ['2.0', '1.0'], + }, + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + {'type': 'null'}, + ], + 'examples': [1], + 'default': None, + }, + 'method': { + 'title': 'Method', + 'description': 'Method name', + 'type': 'string', + }, + 'params': { + 'title': 'Parameters', + 'description': 'Method parameters', + 'type': 'object', + 'properties': {}, + }, + }, + 'required': ['jsonrpc', 'method', 'params'], + 'additionalProperties': False, +} + +RESULT_SCHEMA: Dict[str, Any] = { + 'title': 'Success', + 'type': 'object', + 'properties': { + 'jsonrpc': { + 'title': 'Version', + 'description': 'JSON-RPC protocol version', + 'type': 'string', + 'enum': ['2.0', '1.0'], + }, + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'examples': [1], + }, + 'result': {}, + }, + 'required': ['jsonrpc', 'id', 'result'], + 'additionalProperties': False, +} +ERROR_SCHEMA: Dict[str, Any] = { + 'title': 'Error', + 'type': 'object', + 'properties': { + 'jsonrpc': { + 'title': 'Version', + 'description': 'JSON-RPC protocol version', + 'type': 'string', + 'enum': ['2.0', '1.0'], + }, + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'examples': [1], + }, + 'error': { + 'type': 'object', + 'properties': { + 'code': { + 'title': 'Code', + 'description': 'Error code', + 'type': 'integer', + }, + 'message': { + 'title': 'Message', + 'description': 'Error message', + 'type': 'string', + }, + 'data': { + 'title': 'Data', + 'description': 'Error additional data', + 'type': 'object', + }, + }, + 'required': ['code', 'message'], + 'additionalProperties': False, + }, + }, + 'required': ['jsonrpc', 'error'], + 'additionalProperties': False, +} + + +def build_request_schema(method_name: str, parameters_schema: Dict[str, Any]) -> Dict[str, Any]: + reqeust_schema = copy.deepcopy(REQUEST_SCHEMA) + + reqeust_schema['properties']['method']['const'] = method_name + reqeust_schema['properties']['method']['enum'] = [method_name] + reqeust_schema['properties']['params'] = { + 'title': 'Parameters', + 'description': 'Reqeust parameters', + 'type': 'object', + 'properties': parameters_schema, + 'additionalProperties': False, + } + + return reqeust_schema + + +def build_response_schema(result_schema: Dict[str, Any], errors: Iterable[Type[JsonRpcError]]) -> Dict[str, Any]: + response_schema = copy.deepcopy(RESULT_SCHEMA) + response_schema['properties']['result'] = result_schema + + if errors: + error_schemas: List[Dict[str, Any]] = [] + for error in errors: + error_schema = copy.deepcopy(ERROR_SCHEMA) + error_props = error_schema['properties']['error']['properties'] + error_props['code']['const'] = error.code + error_props['code']['enum'] = [error.code] + error_props['message']['const'] = error.message + error_props['message']['enum'] = [error.message] + error_schemas.append(error_schema) + + response_schema = {'oneOf': [response_schema] + error_schemas} + + return response_schema diff --git a/pyproject.toml b/pyproject.toml index 5128626..bb18293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,9 @@ responses = "^0.23.3" respx = "^0.20.2" mypy = "^1.7.1" pre-commit = "~3.2.0" +types-jsonschema = "^4.23.0.20240813" +types-requests = "^2.32.0.20241016" +deepdiff = "^8.0.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/server/conftest.py b/tests/server/conftest.py index 31d4693..679eef3 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -15,7 +15,7 @@ def dyn_method(request): return context['dynamic_method'] -@pytest.fixture +@pytest.fixture(scope='session') def resources(): def getter(name: str, loader: Optional[Callable] = None) -> str: resource_file = THIS_DIR / 'resources' / name diff --git a/tests/server/resources/oas-3.0-meta.yaml b/tests/server/resources/oas-3.0-meta.yaml new file mode 100644 index 0000000..acaf506 --- /dev/null +++ b/tests/server/resources/oas-3.0-meta.yaml @@ -0,0 +1,1031 @@ +id: https://spec.openapis.org/oas/3.0/schema/WORK-IN-PROGRESS +$schema: http://json-schema.org/draft-04/schema# +description: The description of OpenAPI v3.0.x Documents +type: object +required: + - openapi + - info + - paths +properties: + openapi: + type: string + pattern: ^3\.0\.\d(-.+)?$ + info: + $ref: '#/definitions/Info' + externalDocs: + $ref: '#/definitions/ExternalDocumentation' + servers: + type: array + items: + $ref: '#/definitions/Server' + security: + type: array + items: + $ref: '#/definitions/SecurityRequirement' + tags: + type: array + items: + $ref: '#/definitions/Tag' + uniqueItems: true + paths: + $ref: '#/definitions/Paths' + components: + $ref: '#/definitions/Components' +patternProperties: + '^x-': {} +additionalProperties: false +definitions: + Reference: + type: object + required: + - $ref + patternProperties: + '^\$ref$': + type: string + format: uri-reference + Info: + type: object + required: + - title + - version + properties: + title: + type: string + description: + type: string + termsOfService: + type: string + format: uri-reference + contact: + $ref: '#/definitions/Contact' + license: + $ref: '#/definitions/License' + version: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + + Contact: + type: object + properties: + name: + type: string + url: + type: string + format: uri-reference + email: + type: string + format: email + patternProperties: + '^x-': {} + additionalProperties: false + + License: + type: object + required: + - name + properties: + name: + type: string + url: + type: string + format: uri-reference + patternProperties: + '^x-': {} + additionalProperties: false + + Server: + type: object + required: + - url + properties: + url: + type: string + description: + type: string + variables: + type: object + additionalProperties: + $ref: '#/definitions/ServerVariable' + patternProperties: + '^x-': {} + additionalProperties: false + + ServerVariable: + type: object + required: + - default + properties: + enum: + type: array + items: + type: string + default: + type: string + description: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + Components: + type: object + properties: + schemas: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + responses: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/Response' + parameters: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/Parameter' + examples: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/Example' + requestBodies: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/RequestBody' + headers: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/Header' + securitySchemes: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/SecurityScheme' + links: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/Link' + callbacks: + type: object + patternProperties: + '^[a-zA-Z0-9\.\-_]+$': + oneOf: + - $ref: '#/definitions/Reference' + - $ref: '#/definitions/Callback' + patternProperties: + '^x-': {} + additionalProperties: false + + Schema: + type: object + properties: + title: + type: string + multipleOf: + type: number + minimum: 0 + exclusiveMinimum: true + maximum: + type: number + exclusiveMaximum: + type: boolean + default: false + minimum: + type: number + exclusiveMinimum: + type: boolean + default: false + maxLength: + type: integer + minimum: 0 + minLength: + type: integer + minimum: 0 + default: 0 + pattern: + type: string + format: regex + maxItems: + type: integer + minimum: 0 + minItems: + type: integer + minimum: 0 + default: 0 + uniqueItems: + type: boolean + default: false + maxProperties: + type: integer + minimum: 0 + minProperties: + type: integer + minimum: 0 + default: 0 + required: + type: array + items: + type: string + minItems: 1 + uniqueItems: true + enum: + type: array + items: {} + minItems: 1 + uniqueItems: false + type: + type: string + enum: + - array + - boolean + - integer + - number + - object + - string + not: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + allOf: + type: array + items: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + oneOf: + type: array + items: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + anyOf: + type: array + items: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + items: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + properties: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + additionalProperties: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + - type: boolean + default: true + description: + type: string + format: + type: string + default: {} + nullable: + type: boolean + default: false + discriminator: + $ref: '#/definitions/Discriminator' + readOnly: + type: boolean + default: false + writeOnly: + type: boolean + default: false + example: {} + externalDocs: + $ref: '#/definitions/ExternalDocumentation' + deprecated: + type: boolean + default: false + xml: + $ref: '#/definitions/XML' + patternProperties: + '^x-': {} + additionalProperties: false + + Discriminator: + type: object + required: + - propertyName + properties: + propertyName: + type: string + mapping: + type: object + additionalProperties: + type: string + + XML: + type: object + properties: + name: + type: string + namespace: + type: string + format: uri + prefix: + type: string + attribute: + type: boolean + default: false + wrapped: + type: boolean + default: false + patternProperties: + '^x-': {} + additionalProperties: false + + Response: + type: object + required: + - description + properties: + description: + type: string + headers: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Header' + - $ref: '#/definitions/Reference' + content: + type: object + additionalProperties: + $ref: '#/definitions/MediaType' + links: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Link' + - $ref: '#/definitions/Reference' + patternProperties: + '^x-': {} + additionalProperties: false + + MediaType: + type: object + properties: + schema: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + example: {} + examples: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Example' + - $ref: '#/definitions/Reference' + encoding: + type: object + additionalProperties: + $ref: '#/definitions/Encoding' + patternProperties: + '^x-': {} + additionalProperties: false + allOf: + - $ref: '#/definitions/ExampleXORExamples' + + Example: + type: object + properties: + summary: + type: string + description: + type: string + value: {} + externalValue: + type: string + format: uri-reference + patternProperties: + '^x-': {} + additionalProperties: false + + Header: + type: object + properties: + description: + type: string + required: + type: boolean + default: false + deprecated: + type: boolean + default: false + allowEmptyValue: + type: boolean + default: false + style: + type: string + enum: + - simple + default: simple + explode: + type: boolean + allowReserved: + type: boolean + default: false + schema: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + content: + type: object + additionalProperties: + $ref: '#/definitions/MediaType' + minProperties: 1 + maxProperties: 1 + example: {} + examples: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Example' + - $ref: '#/definitions/Reference' + patternProperties: + '^x-': {} + additionalProperties: false + allOf: + - $ref: '#/definitions/ExampleXORExamples' + - $ref: '#/definitions/SchemaXORContent' + + Paths: + type: object + patternProperties: + '^\/': + $ref: '#/definitions/PathItem' + '^x-': {} + additionalProperties: false + + PathItem: + type: object + properties: + $ref: + type: string + summary: + type: string + description: + type: string + get: + $ref: '#/definitions/Operation' + put: + $ref: '#/definitions/Operation' + post: + $ref: '#/definitions/Operation' + delete: + $ref: '#/definitions/Operation' + options: + $ref: '#/definitions/Operation' + head: + $ref: '#/definitions/Operation' + patch: + $ref: '#/definitions/Operation' + trace: + $ref: '#/definitions/Operation' + servers: + type: array + items: + $ref: '#/definitions/Server' + parameters: + type: array + items: + oneOf: + - $ref: '#/definitions/Parameter' + - $ref: '#/definitions/Reference' + uniqueItems: true + patternProperties: + '^x-': {} + additionalProperties: false + + Operation: + type: object + required: + - responses + properties: + tags: + type: array + items: + type: string + summary: + type: string + description: + type: string + externalDocs: + $ref: '#/definitions/ExternalDocumentation' + operationId: + type: string + parameters: + type: array + items: + oneOf: + - $ref: '#/definitions/Parameter' + - $ref: '#/definitions/Reference' + uniqueItems: true + requestBody: + oneOf: + - $ref: '#/definitions/RequestBody' + - $ref: '#/definitions/Reference' + responses: + $ref: '#/definitions/Responses' + callbacks: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Callback' + - $ref: '#/definitions/Reference' + deprecated: + type: boolean + default: false + security: + type: array + items: + $ref: '#/definitions/SecurityRequirement' + servers: + type: array + items: + $ref: '#/definitions/Server' + patternProperties: + '^x-': {} + additionalProperties: false + + Responses: + type: object + properties: + default: + oneOf: + - $ref: '#/definitions/Response' + - $ref: '#/definitions/Reference' + patternProperties: + '^[1-5](?:\d{2}|XX)$': + oneOf: + - $ref: '#/definitions/Response' + - $ref: '#/definitions/Reference' + '^x-': {} + minProperties: 1 + additionalProperties: false + + + SecurityRequirement: + type: object + additionalProperties: + type: array + items: + type: string + + Tag: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + externalDocs: + $ref: '#/definitions/ExternalDocumentation' + patternProperties: + '^x-': {} + additionalProperties: false + + ExternalDocumentation: + type: object + required: + - url + properties: + description: + type: string + url: + type: string + format: uri-reference + patternProperties: + '^x-': {} + additionalProperties: false + + ExampleXORExamples: + description: Example and examples are mutually exclusive + not: + required: [example, examples] + + SchemaXORContent: + description: Schema and content are mutually exclusive, at least one is required + not: + required: [schema, content] + oneOf: + - required: [schema] + - required: [content] + description: Some properties are not allowed if content is present + allOf: + - not: + required: [style] + - not: + required: [explode] + - not: + required: [allowReserved] + - not: + required: [example] + - not: + required: [examples] + + Parameter: + type: object + properties: + name: + type: string + in: + type: string + description: + type: string + required: + type: boolean + default: false + deprecated: + type: boolean + default: false + allowEmptyValue: + type: boolean + default: false + style: + type: string + explode: + type: boolean + allowReserved: + type: boolean + default: false + schema: + oneOf: + - $ref: '#/definitions/Schema' + - $ref: '#/definitions/Reference' + content: + type: object + additionalProperties: + $ref: '#/definitions/MediaType' + minProperties: 1 + maxProperties: 1 + example: {} + examples: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Example' + - $ref: '#/definitions/Reference' + patternProperties: + '^x-': {} + additionalProperties: false + required: + - name + - in + allOf: + - $ref: '#/definitions/ExampleXORExamples' + - $ref: '#/definitions/SchemaXORContent' + oneOf: + - $ref: '#/definitions/PathParameter' + - $ref: '#/definitions/QueryParameter' + - $ref: '#/definitions/HeaderParameter' + - $ref: '#/definitions/CookieParameter' + + PathParameter: + description: Parameter in path + required: + - required + properties: + in: + enum: [path] + style: + enum: [matrix, label, simple] + default: simple + required: + enum: [true] + + QueryParameter: + description: Parameter in query + properties: + in: + enum: [query] + style: + enum: [form, spaceDelimited, pipeDelimited, deepObject] + default: form + + HeaderParameter: + description: Parameter in header + properties: + in: + enum: [header] + style: + enum: [simple] + default: simple + + CookieParameter: + description: Parameter in cookie + properties: + in: + enum: [cookie] + style: + enum: [form] + default: form + + RequestBody: + type: object + required: + - content + properties: + description: + type: string + content: + type: object + additionalProperties: + $ref: '#/definitions/MediaType' + required: + type: boolean + default: false + patternProperties: + '^x-': {} + additionalProperties: false + + SecurityScheme: + oneOf: + - $ref: '#/definitions/APIKeySecurityScheme' + - $ref: '#/definitions/HTTPSecurityScheme' + - $ref: '#/definitions/OAuth2SecurityScheme' + - $ref: '#/definitions/OpenIdConnectSecurityScheme' + + APIKeySecurityScheme: + type: object + required: + - type + - name + - in + properties: + type: + type: string + enum: + - apiKey + name: + type: string + in: + type: string + enum: + - header + - query + - cookie + description: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + HTTPSecurityScheme: + type: object + required: + - scheme + - type + properties: + scheme: + type: string + bearerFormat: + type: string + description: + type: string + type: + type: string + enum: + - http + patternProperties: + '^x-': {} + additionalProperties: false + oneOf: + - description: Bearer + properties: + scheme: + type: string + pattern: ^[Bb][Ee][Aa][Rr][Ee][Rr]$ + + - description: Non Bearer + not: + required: [bearerFormat] + properties: + scheme: + not: + type: string + pattern: ^[Bb][Ee][Aa][Rr][Ee][Rr]$ + + OAuth2SecurityScheme: + type: object + required: + - type + - flows + properties: + type: + type: string + enum: + - oauth2 + flows: + $ref: '#/definitions/OAuthFlows' + description: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + OpenIdConnectSecurityScheme: + type: object + required: + - type + - openIdConnectUrl + properties: + type: + type: string + enum: + - openIdConnect + openIdConnectUrl: + type: string + format: uri-reference + description: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + OAuthFlows: + type: object + properties: + implicit: + $ref: '#/definitions/ImplicitOAuthFlow' + password: + $ref: '#/definitions/PasswordOAuthFlow' + clientCredentials: + $ref: '#/definitions/ClientCredentialsFlow' + authorizationCode: + $ref: '#/definitions/AuthorizationCodeOAuthFlow' + patternProperties: + '^x-': {} + additionalProperties: false + + ImplicitOAuthFlow: + type: object + required: + - authorizationUrl + - scopes + properties: + authorizationUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + type: object + additionalProperties: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + PasswordOAuthFlow: + type: object + required: + - tokenUrl + - scopes + properties: + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + type: object + additionalProperties: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + ClientCredentialsFlow: + type: object + required: + - tokenUrl + - scopes + properties: + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + type: object + additionalProperties: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + AuthorizationCodeOAuthFlow: + type: object + required: + - authorizationUrl + - tokenUrl + - scopes + properties: + authorizationUrl: + type: string + format: uri-reference + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + type: object + additionalProperties: + type: string + patternProperties: + '^x-': {} + additionalProperties: false + + Link: + type: object + properties: + operationId: + type: string + operationRef: + type: string + format: uri-reference + parameters: + type: object + additionalProperties: {} + requestBody: {} + description: + type: string + server: + $ref: '#/definitions/Server' + patternProperties: + '^x-': {} + additionalProperties: false + not: + description: Operation Id and Operation Ref are mutually exclusive + required: [operationId, operationRef] + + Callback: + type: object + additionalProperties: + $ref: '#/definitions/PathItem' + patternProperties: + '^x-': {} + + Encoding: + type: object + properties: + contentType: + type: string + headers: + type: object + additionalProperties: + oneOf: + - $ref: '#/definitions/Header' + - $ref: '#/definitions/Reference' + style: + type: string + enum: + - form + - spaceDelimited + - pipeDelimited + - deepObject + explode: + type: boolean + allowReserved: + type: boolean + default: false + patternProperties: + '^x-': {} + additionalProperties: false diff --git a/tests/server/resources/oas-3.1-meta.yaml b/tests/server/resources/oas-3.1-meta.yaml new file mode 100644 index 0000000..468bc7e --- /dev/null +++ b/tests/server/resources/oas-3.1-meta.yaml @@ -0,0 +1,1440 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/schema/2022-10-07", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI v3.1.x documents without schema validation, as defined by https://spec.openapis.org/oas/v3.1.0", + "type": "object", + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.1\\.\\d+(-.+)?$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "format": "uri", + "default": "https://spec.openapis.org/oas/3.1/dialect/base" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [ + { + "url": "/" + } + ] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "components": { + "$ref": "#/$defs/components" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/tag" + } + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "openapi", + "info" + ], + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "components" + ] + }, + { + "required": [ + "webhooks" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri" + }, + "contact": { + "$ref": "#/$defs/contact" + }, + "license": { + "$ref": "#/$defs/license" + }, + "version": { + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "contact": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name" + ], + "dependentSchemas": { + "identifier": { + "not": { + "required": [ + "url" + ] + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#server-object", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server-variable" + } + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server-variable": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#server-variable-object", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "default" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "components": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#components-object", + "type": "object", + "properties": { + "schemas": { + "type": "object", + "additionalProperties": { + "$dynamicRef": "#meta" + } + }, + "responses": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/response-or-reference" + } + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + }, + "requestBodies": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/request-body-or-reference" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "securitySchemes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/security-scheme-or-reference" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "pathItems": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + } + }, + "patternProperties": { + "^(schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems)$": { + "$comment": "Enumerating all of the property names in the regex above is necessary for unevaluatedProperties to work as expected", + "propertyNames": { + "pattern": "^[a-zA-Z0-9._-]+$" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "paths": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#path-item-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "get": { + "$ref": "#/$defs/operation" + }, + "put": { + "$ref": "#/$defs/operation" + }, + "post": { + "$ref": "#/$defs/operation" + }, + "delete": { + "$ref": "#/$defs/operation" + }, + "options": { + "$ref": "#/$defs/operation" + }, + "head": { + "$ref": "#/$defs/operation" + }, + "patch": { + "$ref": "#/$defs/operation" + }, + "trace": { + "$ref": "#/$defs/operation" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/path-item" + } + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#operation-object", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "requestBody": { + "$ref": "#/$defs/request-body-or-reference" + }, + "responses": { + "$ref": "#/$defs/responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "external-documentation": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "path", + "cookie" + ] + }, + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": [ + "name", + "in" + ], + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "if": { + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + }, + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-path" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-header" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-query" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-cookie" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-form" + } + ], + "$defs": { + "styles-for-path": { + "if": { + "properties": { + "in": { + "const": "path" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "name": { + "pattern": "[^/#?]+$" + }, + "style": { + "default": "simple", + "enum": [ + "matrix", + "label", + "simple" + ] + }, + "required": { + "const": true + } + }, + "required": [ + "required" + ] + } + }, + "styles-for-header": { + "if": { + "properties": { + "in": { + "const": "header" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + } + } + } + }, + "styles-for-query": { + "if": { + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + } + } + }, + "styles-for-cookie": { + "if": { + "properties": { + "in": { + "const": "cookie" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "form", + "const": "form" + } + } + } + }, + "styles-for-form": { + "if": { + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameter-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/parameter" + } + }, + "request-body": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#request-body-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "content": { + "$ref": "#/$defs/content" + }, + "required": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "content" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/request-body" + } + }, + "content": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type" + }, + "propertyNames": { + "format": "media-range" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#media-type-object", + "type": "object", + "properties": { + "schema": { + "$dynamicRef": "#meta" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/examples" + } + ], + "unevaluatedProperties": false + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string", + "format": "media-range" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/encoding/$defs/explode-default" + } + ], + "unevaluatedProperties": false, + "$defs": { + "explode-default": { + "if": { + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#responses-object", + "type": "object", + "properties": { + "default": { + "$ref": "#/$defs/response-or-reference" + } + }, + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": { + "$ref": "#/$defs/response-or-reference" + } + }, + "minProperties": 1, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "response": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#response-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "content": { + "$ref": "#/$defs/content" + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + } + }, + "required": [ + "description" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "response-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/response" + } + }, + "callbacks": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string", + "format": "uri" + } + }, + "not": { + "required": [ + "value", + "externalValue" + ] + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "example-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/example" + } + }, + "link": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#link-object", + "type": "object", + "properties": { + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/map-of-strings" + }, + "requestBody": true, + "description": { + "type": "string" + }, + "body": { + "$ref": "#/$defs/server" + } + }, + "oneOf": [ + { + "required": [ + "operationRef" + ] + }, + { + "required": [ + "operationId" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "link-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/link" + } + }, + "header": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#header-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + }, + "explode": { + "default": false, + "type": "boolean" + } + }, + "$ref": "#/$defs/examples" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "header-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/header" + } + }, + "tag": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#reference-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "unevaluatedProperties": false + }, + "schema": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#schema-object", + "$dynamicAnchor": "meta", + "type": [ + "object", + "boolean" + ] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": [ + "apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect" + ] + }, + "description": { + "type": "string" + } + }, + "required": [ + "type" + ], + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-apikey" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http-bearer" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oauth2" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oidc" + } + ], + "unevaluatedProperties": false, + "$defs": { + "type-apikey": { + "if": { + "properties": { + "type": { + "const": "apiKey" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "cookie" + ] + } + }, + "required": [ + "name", + "in" + ] + } + }, + "type-http": { + "if": { + "properties": { + "type": { + "const": "http" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "scheme": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + } + }, + "type-http-bearer": { + "if": { + "properties": { + "type": { + "const": "http" + }, + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + }, + "required": [ + "type", + "scheme" + ] + }, + "then": { + "properties": { + "bearerFormat": { + "type": "string" + } + } + } + }, + "type-oauth2": { + "if": { + "properties": { + "type": { + "const": "oauth2" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + } + }, + "required": [ + "flows" + ] + } + }, + "type-oidc": { + "if": { + "properties": { + "type": { + "const": "openIdConnect" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "openIdConnectUrl": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "openIdConnectUrl" + ] + } + } + } + }, + "security-scheme-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/security-scheme" + } + }, + "oauth-flows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/$defs/oauth-flows/$defs/implicit" + }, + "password": { + "$ref": "#/$defs/oauth-flows/$defs/password" + }, + "clientCredentials": { + "$ref": "#/$defs/oauth-flows/$defs/client-credentials" + }, + "authorizationCode": { + "$ref": "#/$defs/oauth-flows/$defs/authorization-code" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "client-credentials": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "authorization-code": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#specification-extensions", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/tests/server/resources/openapi-1.json b/tests/server/resources/openapi-1.json deleted file mode 100644 index 0d4deb5..0000000 --- a/tests/server/resources/openapi-1.json +++ /dev/null @@ -1,1241 +0,0 @@ -{ - "info": { - "title": "Test tittle", - "version": "1.0.0", - "description": "test api", - "contact": { - "name": "owner", - "email": "test@gmal.com" - }, - "license": { - "name": "MIT" - } - }, - "paths": { - "/test/path#method1": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "title": "Result", - "type": "integer" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "integer", - "enum": [ - 2001 - ] - }, - "message": { - "type": "string" - } - }, - "required": [], - "title": "test error 1" - }, - { - "type": "object", - "properties": { - "code": { - "type": "integer", - "enum": [ - 2002 - ] - }, - "message": { - "type": "string" - } - }, - "required": [], - "title": "test error 2" - } - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - }, - "examples": { - "example 1 summary": { - "value": { - "jsonrpc": "2.0", - "id": 1, - "result": 1 - }, - "summary": "example 1 summary", - "description": "example 1 description" - }, - "test error 2": { - "value": { - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": 2002, - "message": "test error 2" - } - }, - "summary": "test error 2 summary" - }, - "test error 1": { - "value": { - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": 2001, - "message": "test error 1" - } - }, - "summary": "test error 1" - } - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method1" - ] - }, - "params": { - "type": "object", - "properties": { - "param1": { - "title": "Param1", - "type": "integer" - }, - "param2": { - "$ref": "#/components/schemas/Model" - }, - "param3": { - "title": "Param3" - } - }, - "required": [ - "param1", - "param2", - "param3" - ] - } - }, - "required": [ - "jsonrpc", - "method" - ] - }, - "examples": { - "example 1 summary": { - "value": { - "jsonrpc": "2.0", - "id": 1, - "method": "method1", - "params": { - "param1": 1, - "param2": { - "field1": "field", - "field2": 2 - } - } - }, - "summary": "example 1 summary", - "description": "example 1 description" - } - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [ - "tag1", - "tag2" - ], - "summary": "method1 summary", - "description": "method1 description", - "externalDocs": { - "url": "http://doc.info#method1" - }, - "deprecated": true, - "security": [ - { - "basicAuth": [] - } - ] - } - }, - "/test/path#method2": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "$ref": "#/components/schemas/ResultModel" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - }, - "required": [ - "code", - "message" - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method2" - ] - }, - "params": { - "type": "object", - "properties": { - "param1": { - "title": "Param1", - "type": "integer" - }, - "param2": { - "title": "Param2" - } - }, - "required": [ - "param1", - "param2" - ] - } - }, - "required": [ - "jsonrpc", - "method" - ] - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [] - } - }, - "/test/path#method3": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Result" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - }, - "required": [ - "code", - "message" - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method3" - ] - }, - "params": { - "type": "object", - "properties": { - "param1": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Param1" - }, - "param2": { - "default": 1, - "title": "Param2", - "type": "integer" - } - } - } - }, - "required": [ - "jsonrpc", - "method" - ] - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [] - } - }, - "/test/path#method4": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "type": "number" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - }, - "required": [ - "code", - "message" - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method4" - ] - }, - "params": { - "type": "object", - "properties": { - "param1": { - "type": "number", - "title": "param1 summary", - "description": "param1 description" - } - }, - "required": [ - "param1" - ] - } - }, - "required": [ - "jsonrpc", - "method" - ] - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [] - } - }, - "/test/path#method5": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "title": "Result", - "type": "null" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - }, - "required": [ - "code", - "message" - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method5" - ] - }, - "params": { - "type": "object", - "properties": {} - } - }, - "required": [ - "jsonrpc", - "method" - ] - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [] - } - }, - "/test/path#method6": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "title": "Result" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - }, - "required": [ - "code", - "message" - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method6" - ] - }, - "params": { - "type": "object", - "properties": {} - } - }, - "required": [ - "jsonrpc", - "method" - ] - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [] - } - }, - "/test/path#method7": { - "post": { - "responses": { - "200": { - "description": "JSON-RPC Response", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "result": { - "title": "Result" - } - }, - "required": [ - "jsonrpc", - "id", - "result" - ], - "title": "Success", - "description": "JSON-RPC success" - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - }, - "required": [ - "code", - "message" - ] - } - }, - "required": [ - "jsonrpc", - "error" - ], - "title": "Error", - "description": "JSON-RPC error" - } - ] - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "enum": [ - "2.0", - "1.0" - ] - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "method": { - "type": "string", - "enum": [ - "method7" - ] - }, - "params": { - "type": "object", - "properties": { - "param1": { - "type": null, - "title": "param1 summary", - "description": "param1 summary. param1 description." - } - }, - "required": [ - "param1" - ] - } - }, - "required": [ - "jsonrpc", - "method" - ] - } - } - }, - "required": true, - "description": "JSON-RPC Request" - }, - "tags": [], - "summary": "Method 7.", - "description": "Method 7 description.", - "deprecated": false - } - } - }, - "servers": [ - { - "url": "http://test-server", - "description": "test server" - } - ], - "externalDocs": { - "url": "http://external-doc.com" - }, - "tags": [ - { - "name": "test-tag" - } - ], - "security": [ - { - "basicAuth": [] - } - ], - "components": { - "securitySchemes": { - "basicAuth": { - "type": "http", - "scheme": "basic" - } - }, - "schemas": { - "SubModel": { - "title": "SubModel", - "type": "object", - "properties": { - "field1": { - "title": "Field1", - "type": "string" - } - }, - "required": [ - "field1" - ] - }, - "Model": { - "title": "Model", - "type": "object", - "properties": { - "field1": { - "title": "Field1", - "type": "string" - }, - "field2": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Field2", - "default": 1 - }, - "field3": { - "$ref": "#/components/schemas/SubModel" - } - }, - "required": [ - "field1", - "field3" - ] - }, - "ResultModel": { - "properties": { - "field1": { - "title": "Field1", - "type": "string" - } - }, - "required": [ - "field1" - ], - "title": "ResultModel", - "type": "object" - } - } - }, - "openapi": "3.0.0" -} diff --git a/tests/server/resources/openrpc-1.3.2.json b/tests/server/resources/openrpc-1.3.2.json new file mode 100644 index 0000000..121d888 --- /dev/null +++ b/tests/server/resources/openrpc-1.3.2.json @@ -0,0 +1,932 @@ +{ + "$schema": "https://meta.json-schema.tools/", + "title": "openrpcDocument", + "type": "object", + "required": [ + "info", + "methods", + "openrpc" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "openrpc": { + "title": "openrpc", + "type": "string", + "enum": ["1.3.2"] + }, + "info": { + "$ref": "#/definitions/infoObject" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocumentationObject" + }, + "servers": { + "title": "servers", + "type": "array", + "additionalItems": false, + "items": { + "$ref": "#/definitions/serverObject" + } + }, + "methods": { + "title": "methods", + "type": "array", + "additionalItems": false, + "items": { + "title": "methodOrReference", + "oneOf": [ + { + "$ref": "#/definitions/methodObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "components": { + "title": "components", + "type": "object", + "properties": { + "schemas": { + "title": "schemaComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/JSONSchema" + } + } + }, + "links": { + "title": "linkComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/linkObject" + } + } + }, + "errors": { + "title": "errorComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/errorObject" + } + } + }, + "examples": { + "title": "exampleComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/exampleObject" + } + } + }, + "examplePairings": { + "title": "examplePairingComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/examplePairingObject" + } + } + }, + "contentDescriptors": { + "title": "contentDescriptorComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/contentDescriptorObject" + } + } + }, + "tags": { + "title": "tagComponents", + "type": "object", + "patternProperties": { + "[0-z]+": { + "$ref": "#/definitions/tagObject" + } + } + } + } + }, + "$schema": { + "title": "metaSchema", + "description": "JSON Schema URI (used by some editors)", + "type": "string", + "default": "https://meta.open-rpc.org/" + } + }, + "definitions": { + "JSONSchema": { + "title": "JSONSchema", + "default": {}, + "oneOf": [ + { + "$ref": "#/definitions/JSONSchemaObject" + }, + { + "$ref": "#/definitions/JSONSchemaBoolean" + } + ] + }, + "JSONSchemaBoolean": { + "title": "JSONSchemaBoolean", + "description": "Always valid if true. Never valid if false. Is constant.", + "type": "boolean" + }, + "JSONSchemaObject": { + "title": "JSONSchemaObject", + "type": "object", + "properties": { + "$id": { + "title": "$id", + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "title": "$schema", + "type": "string", + "format": "uri" + }, + "$ref": { + "title": "$ref", + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "title": "$comment", + "type": "string" + }, + "title": { + "title": "title", + "type": "string" + }, + "description": { + "title": "description", + "type": "string" + }, + "default": true, + "readOnly": { + "title": "readOnly", + "type": "boolean", + "default": false + }, + "examples": { + "title": "examples", + "type": "array", + "items": true + }, + "multipleOf": { + "title": "multipleOf", + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "title": "maximum", + "type": "number" + }, + "exclusiveMaximum": { + "title": "exclusiveMaximum", + "type": "number" + }, + "minimum": { + "title": "minimum", + "type": "number" + }, + "exclusiveMinimum": { + "title": "exclusiveMinimum", + "type": "number" + }, + "maxLength": { + "$ref": "#/definitions/nonNegativeInteger" + }, + "minLength": { + "$ref": "#/definitions/nonNegativeIntegerDefault0" + }, + "pattern": { + "title": "pattern", + "type": "string", + "format": "regex" + }, + "additionalItems": { + "$ref": "#/definitions/JSONSchema" + }, + "items": { + "title": "items", + "anyOf": [{ + "$ref": "#/definitions/JSONSchema" + }, { + "$ref": "#/definitions/schemaArray" + }], + "default": true + }, + "maxItems": { + "$ref": "#/definitions/nonNegativeInteger" + }, + "minItems": { + "$ref": "#/definitions/nonNegativeIntegerDefault0" + }, + "uniqueItems": { + "title": "uniqueItems", + "type": "boolean", + "default": false + }, + "contains": { + "$ref": "#/definitions/JSONSchema" + }, + "maxProperties": { + "$ref": "#/definitions/nonNegativeInteger" + }, + "minProperties": { + "$ref": "#/definitions/nonNegativeIntegerDefault0" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "additionalProperties": { + "$ref": "#/definitions/JSONSchema" + }, + "definitions": { + "title": "definitions", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/JSONSchema" + }, + "default": {} + }, + "properties": { + "title": "properties", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/JSONSchema" + }, + "default": {} + }, + "patternProperties": { + "title": "patternProperties", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/JSONSchema" + }, + "propertyNames": { + "title": "propertyNames", + "format": "regex" + }, + "default": {} + }, + "dependencies": { + "title": "dependencies", + "type": "object", + "additionalProperties": { + "title": "dependenciesSet", + "anyOf": [{ + "$ref": "#/definitions/JSONSchema" + }, { + "$ref": "#/definitions/stringArray" + }] + } + }, + "propertyNames": { + "$ref": "#/definitions/JSONSchema" + }, + "const": true, + "enum": { + "title": "enum", + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "title": "type", + "anyOf": [{ + "$ref": "#/definitions/simpleTypes" + }, { + "title": "arrayOfSimpleTypes", + "type": "array", + "items": { + "$ref": "#/definitions/simpleTypes" + }, + "minItems": 1, + "uniqueItems": true + }] + }, + "format": { + "title": "format", + "type": "string" + }, + "contentMediaType": { + "title": "contentMediaType", + "type": "string" + }, + "contentEncoding": { + "title": "contentEncoding", + "type": "string" + }, + "if": { + "$ref": "#/definitions/JSONSchema" + }, + "then": { + "$ref": "#/definitions/JSONSchema" + }, + "else": { + "$ref": "#/definitions/JSONSchema" + }, + "allOf": { + "$ref": "#/definitions/schemaArray" + }, + "anyOf": { + "$ref": "#/definitions/schemaArray" + }, + "oneOf": { + "$ref": "#/definitions/schemaArray" + }, + "not": { + "$ref": "#/definitions/JSONSchema" + } + } + }, + "schemaArray": { + "title": "schemaArray", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/JSONSchema" + } + }, + "nonNegativeInteger": { + "title": "nonNegativeInteger", + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "title": "nonNegativeIntegerDefaultZero", + "type": "integer", + "minimum": 0, + "default": 0 + }, + "simpleTypes": { + "title": "simpleTypes", + "type": "string", + "enum": ["array", "boolean", "integer", "null", "number", "object", "string"] + }, + "stringArray": { + "title": "stringArray", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [] + }, + "specificationExtension": { + "title": "specificationExtension" + }, + "referenceObject": { + "title": "referenceObject", + "type": "object", + "additionalProperties": false, + "required": [ + "$ref" + ], + "properties": { + "$ref": { + "$ref": "#/definitions/JSONSchemaObject/properties/$ref" + } + } + }, + "errorObject": { + "title": "errorObject", + "type": "object", + "description": "Defines an application level error.", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "title": "errorObjectCode", + "description": "A Number that indicates the error type that occurred. This MUST be an integer. The error codes from and including -32768 to -32000 are reserved for pre-defined errors. These pre-defined errors SHOULD be assumed to be returned from any JSON-RPC api.", + "type": "integer" + }, + "message": { + "title": "errorObjectMessage", + "description": "A String providing a short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + }, + "data": { + "title": "errorObjectData", + "description": "A Primitive or Structured value that contains additional information about the error. This may be omitted. The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.)." + } + } + }, + "licenseObject": { + "title": "licenseObject", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "title": "licenseObjectName", + "type": "string" + }, + "url": { + "title": "licenseObjectUrl", + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "contactObject": { + "title": "contactObject", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "title": "contactObjectName", + "type": "string" + }, + "email": { + "title": "contactObjectEmail", + "type": "string" + }, + "url": { + "title": "contactObjectUrl", + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "infoObject": { + "title": "infoObject", + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "version" + ], + "properties": { + "title": { + "title": "infoObjectProperties", + "type": "string" + }, + "description": { + "title": "infoObjectDescription", + "type": "string" + }, + "termsOfService": { + "title": "infoObjectTermsOfService", + "type": "string", + "format": "uri" + }, + "version": { + "title": "infoObjectVersion", + "type": "string" + }, + "contact": { + "$ref": "#/definitions/contactObject" + }, + "license": { + "$ref": "#/definitions/licenseObject" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "serverObject": { + "title": "serverObject", + "type": "object", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "url": { + "title": "serverObjectUrl", + "type": "string", + "format": "uri" + }, + "name": { + "title": "serverObjectName", + "type": "string" + }, + "description": { + "title": "serverObjectDescription", + "type": "string" + }, + "summary": { + "title": "serverObjectSummary", + "type": "string" + }, + "variables": { + "title": "serverObjectVariables", + "type": "object", + "patternProperties": { + "[0-z]+": { + "title": "serverObjectVariable", + "type": "object", + "required": [ + "default" + ], + "properties": { + "default": { + "title": "serverObjectVariableDefault", + "type": "string" + }, + "description": { + "title": "serverObjectVariableDescription", + "type": "string" + }, + "enum": { + "title": "serverObjectVariableEnum", + "type": "array", + "items": { + "title": "serverObjectVariableEnumItem", + "type": "string" + } + } + } + } + } + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "linkObject": { + "title": "linkObject", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "title": "linkObjectName", + "type": "string", + "minLength": 1 + }, + "summary": { + "title": "linkObjectSummary", + "type": "string" + }, + "method": { + "title": "linkObjectMethod", + "type": "string" + }, + "description": { + "title": "linkObjectDescription", + "type": "string" + }, + "params": { + "title": "linkObjectParams" + }, + "server": { + "title": "linkObjectServer", + "$ref": "#/definitions/serverObject" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "externalDocumentationObject": { + "title": "externalDocumentationObject", + "type": "object", + "additionalProperties": false, + "description": "information about external documentation", + "required": [ + "url" + ], + "properties": { + "description": { + "title": "externalDocumentationObjectDescription", + "type": "string" + }, + "url": { + "title": "externalDocumentationObjectUrl", + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "methodObject": { + "title": "methodObject", + "type": "object", + "required": [ + "name", + "params" + ], + "additionalProperties": false, + "properties": { + "name": { + "title": "methodObjectName", + "description": "The cannonical name for the method. The name MUST be unique within the methods array.", + "type": "string", + "minLength": 1 + }, + "description": { + "title": "methodObjectDescription", + "description": "A verbose explanation of the method behavior. GitHub Flavored Markdown syntax MAY be used for rich text representation.", + "type": "string" + }, + "summary": { + "title": "methodObjectSummary", + "description": "A short summary of what the method does.", + "type": "string" + }, + "servers": { + "title": "servers", + "type": "array", + "additionalItems": false, + "items": { + "$ref": "#/definitions/serverObject" + } + }, + "tags": { + "title": "methodObjectTags", + "type": "array", + "items": { + "title": "tagOrReference", + "oneOf": [ + { + "$ref": "#/definitions/tagObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "paramStructure": { + "title": "methodObjectParamStructure", + "type": "string", + "description": "Format the server expects the params. Defaults to 'either'.", + "enum": [ + "by-position", + "by-name", + "either" + ], + "default": "either" + }, + "params": { + "title": "methodObjectParams", + "type": "array", + "items": { + "title": "contentDescriptorOrReference", + "oneOf": [ + { + "$ref": "#/definitions/contentDescriptorObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "result": { + "title": "methodObjectResult", + "oneOf": [ + { + "$ref": "#/definitions/contentDescriptorObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + }, + "errors": { + "title": "methodObjectErrors", + "description": "Defines an application level error.", + "type": "array", + "items": { + "title": "errorOrReference", + "oneOf": [ + { + "$ref": "#/definitions/errorObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "links": { + "title": "methodObjectLinks", + "type": "array", + "items": { + "title": "linkOrReference", + "oneOf": [ + { + "$ref": "#/definitions/linkObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "examples": { + "title": "methodObjectExamples", + "type": "array", + "items": { + "title": "examplePairingOrReference", + "oneOf": [ + { + "$ref": "#/definitions/examplePairingObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "deprecated": { + "title": "methodObjectDeprecated", + "type": "boolean", + "default": false + }, + "externalDocs": { + "$ref": "#/definitions/externalDocumentationObject" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "tagObject": { + "title": "tagObject", + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "title": "tagObjectName", + "type": "string", + "minLength": 1 + }, + "description": { + "title": "tagObjectDescription", + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocumentationObject" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "exampleObject": { + "title": "exampleObject", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "summary": { + "title": "exampleObjectSummary", + "type": "string" + }, + "value": { + "title": "exampleObjectValue" + }, + "description": { + "title": "exampleObjectDescription", + "type": "string" + }, + "name": { + "title": "exampleObjectName", + "type": "string", + "minLength": 1 + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "examplePairingObject": { + "title": "examplePairingObject", + "type": "object", + "required": [ + "name", + "params" + ], + "properties": { + "name": { + "title": "examplePairingObjectName", + "type": "string", + "minLength": 1 + }, + "description": { + "title": "examplePairingObjectDescription", + "type": "string" + }, + "params": { + "title": "examplePairingObjectParams", + "type": "array", + "items": { + "title": "exampleOrReference", + "oneOf": [ + { + "$ref": "#/definitions/exampleObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + }, + "result": { + "title": "examplePairingObjectResult", + "oneOf": [ + { + "$ref": "#/definitions/exampleObject" + }, + { + "$ref": "#/definitions/referenceObject" + } + ] + } + } + }, + "contentDescriptorObject": { + "title": "contentDescriptorObject", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "schema" + ], + "properties": { + "name": { + "title": "contentDescriptorObjectName", + "type": "string", + "minLength": 1 + }, + "description": { + "title": "contentDescriptorObjectDescription", + "type": "string" + }, + "summary": { + "title": "contentDescriptorObjectSummary", + "type": "string" + }, + "schema": { + "$ref": "#/definitions/JSONSchema" + }, + "required": { + "title": "contentDescriptorObjectRequired", + "type": "boolean", + "default": false + }, + "deprecated": { + "title": "contentDescriptorObjectDeprecated", + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/specificationExtension" + } + } + } + } +} diff --git a/tests/server/resources/openrpc-1.json b/tests/server/resources/openrpc-1.json deleted file mode 100644 index 3764f14..0000000 --- a/tests/server/resources/openrpc-1.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "info": { - "title": "Test tittle", - "version": "1.0.0", - "description": "test api", - "contact": { - "name": "owner", - "email": "test@gmal.com" - }, - "license": { - "name": "MIT" - } - }, - "methods": [ - { - "name": "method1", - "params": [ - { - "name": "param1", - "schema": { - "type": "integer" - }, - "summary": "param1 summary", - "description": "param1 summary.\ndescription.", - "required": true - }, - { - "name": "param2", - "schema": { - "type": "object" - }, - "summary": "param2 summary", - "description": "param2 summary.\ndescription.", - "required": true - } - ], - "result": { - "name": "result", - "schema": { - "type": "integer" - }, - "summary": "result summary", - "description": "result summary.\ndescription.", - "required": true - }, - "errors": [ - { - "code": 2001, - "message": "test error 1" - }, - { - "code": 2002, - "message": "test error 2" - } - ], - "examples": [ - { - "name": "Simple example", - "params": [ - { - "value": 1, - "name": "param1", - "description": "param1 description" - }, - { - "value": { - "field1": "field", - "field2": 2 - }, - "name": "param2", - "description": "param2 description" - } - ], - "result": { - "value": 1, - "name": "result", - "description": "result description" - }, - "summary": "example 1 summary", - "description": "example 1 description" - } - ], - "summary": "method1 summary", - "description": "method1 description", - "tags": [ - { - "name": "tag1" - }, - { - "name": "tag2" - } - ], - "deprecated": true - }, - { - "name": "method2", - "params": [ - { - "name": "param1", - "schema": { - "type": "integer" - }, - "summary": "param1 summary", - "description": "param1 summary.", - "required": false - }, - { - "name": "param2", - "schema": { - "type": "number" - }, - "summary": "param2 summary", - "description": "param2 summary.", - "required": false - } - ], - "result": { - "name": "result", - "schema": { - "type": "string" - }, - "summary": "result summary", - "description": "result summary.", - "required": true - }, - "examples": [], - "summary": "Method2.", - "description": "Description.", - "deprecated": false - }, - { - "name": "method3", - "params": [ - { - "name": "param1", - "schema": { - "type": "number" - }, - "summary": "param1 summary", - "description": "param1 description" - } - ], - "result": { - "name": "result", - "schema": { - "type": "number" - }, - "summary": "result summary", - "description": "result description" - }, - "examples": [] - }, - { - "name": "method4", - "params": [], - "result": { - "name": "result", - "schema": {}, - "required": true - }, - "examples": [] - }, - { - "name": "method5", - "params": [], - "result": { - "name": "result", - "schema": {}, - "required": true - }, - "examples": [] - } - ], - "servers": [ - { - "name": "test server", - "url": "http://test-server", - "summary": "test server summary", - "description": "test server description" - } - ], - "externalDocs": { - "url": "http://external-doc.com" - }, - "openrpc": "1.0.0", - "components": { - "schemas": {} - } -} diff --git a/tests/server/test_dispatcher.py b/tests/server/test_dispatcher.py index be13dad..257dd96 100644 --- a/tests/server/test_dispatcher.py +++ b/tests/server/test_dispatcher.py @@ -320,6 +320,7 @@ def test_dispatcher_errors(): }, } + def test_dispatcher_batch_too_large_errors(): disp = dispatcher.Dispatcher(max_batch_size=1) @@ -335,7 +336,7 @@ def test_dispatcher_batch_too_large_errors(): 'id': 2, 'method': 'method', }, - ] + ], ) response, error_codes = disp.dispatch(request) @@ -533,7 +534,7 @@ async def test_async_dispatcher_batch_too_large_errors(): 'id': 2, 'method': 'method', }, - ] + ], ) response, error_codes = await disp.dispatch(request) diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index d46487a..0dcd8f0 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -1,159 +1,2113 @@ -import json -from typing import Optional +from typing import Any, Dict, Optional -import pydantic +import jsonschema +import pydantic as pd +import pytest +import yaml +from deepdiff.diff import DeepDiff -import pjrpc.common.exceptions -import pjrpc.server.specs.extractors.docstring -import pjrpc.server.specs.extractors.pydantic -from pjrpc.server import Method -from pjrpc.server.specs import extractors -from pjrpc.server.specs import openapi as specs +from pjrpc.common import exceptions +from pjrpc.server.dispatcher import Method +from pjrpc.server.specs import openapi +from pjrpc.server.specs.extractors.docstring import DocstringSchemaExtractor +from pjrpc.server.specs.extractors.pydantic import PydanticSchemaExtractor +from pjrpc.server.specs.openapi import ApiKeyLocation, Contact, ExampleObject, ExternalDocumentation, Info, License +from pjrpc.server.specs.openapi import MethodExample, OpenAPI, Parameter, ParameterLocation, SecurityScheme +from pjrpc.server.specs.openapi import SecuritySchemeType, Server, ServerVariable, StyleType, Tag -def test_openapi_schema_generator(resources): - spec = specs.OpenAPI( - info=specs.Info( - version='1.0.0', - title='Test tittle', - description='test api', - contact=specs.Contact(name='owner', email='test@gmal.com'), - license=specs.License(name='MIT'), +def jsonrpc_request_schema(method_name: str, params_schema: Dict[str, Any]) -> Dict[str, Any]: + return { + 'title': 'Request', + 'type': 'object', + 'properties': { + 'jsonrpc': { + 'title': 'Version', + 'description': 'JSON-RPC protocol version', + 'type': 'string', + 'enum': ['2.0', '1.0'], + }, + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + {'type': 'null'}, + ], + 'default': None, + 'examples': [1], + }, + 'method': { + 'title': 'Method', + 'description': 'Method name', + 'type': 'string', + 'const': method_name, + 'enum': [method_name], + }, + 'params': params_schema, + }, + 'required': ['jsonrpc', 'method', 'params'], + 'additionalProperties': False, + } + + +def jsonrpc_response_schema(result_schema: Dict[str, Any]) -> Dict[str, Any]: + return { + 'title': 'Success', + 'type': 'object', + 'properties': { + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'examples': [1], + }, + 'jsonrpc': { + 'title': 'Version', + 'description': 'JSON-RPC protocol version', + 'type': 'string', + 'enum': ['2.0', '1.0'], + }, + 'result': result_schema, + }, + 'required': ['jsonrpc', 'id', 'result'], + 'additionalProperties': False, + } + + +def jsonrpc_error_schema(title: str, description: str, error_schema: Dict[str, Any]) -> Dict[str, Any]: + return { + 'title': title, + 'description': description, + 'type': 'object', + 'properties': { + 'jsonrpc': { + 'description': 'JSON-RPC protocol version', + 'enum': ['1.0', '2.0'], + 'title': 'Version', + 'type': 'string', + }, + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'examples': [1], + }, + 'error': error_schema, + }, + 'required': ['jsonrpc', 'id', 'error'], + 'additionalProperties': False, + } + + +def error_response_component(title: str, description: str, error_schema: Dict[str, Any]) -> Dict[str, Any]: + return { + 'title': title, + 'description': description, + 'type': 'object', + 'properties': { + 'jsonrpc': { + 'title': 'Version', + 'description': 'JSON-RPC protocol version', + 'type': 'string', + 'enum': ['1.0', '2.0'], + }, + 'id': { + 'title': 'Id', + 'description': 'Request identifier', + 'anyOf': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'examples': [1], + }, + 'error': error_schema, + }, + 'required': ['jsonrpc', 'id', 'error'], + 'additionalProperties': False, + } + + +def request_body_schema(schema: Dict[str, Any], description: str = 'JSON-RPC Request') -> Dict[str, Any]: + return { + 'description': description, + 'content': { + 'application/json': { + 'schema': schema, + }, + }, + 'required': True, + } + + +def response_schema(schema: Dict[str, Any], description: str = 'JSON-RPC Response') -> Dict[str, Any]: + return { + 'description': description, + 'content': { + 'application/json': { + 'schema': schema, + }, + }, + } + + +def error_component(code: int) -> Dict[str, Any]: + return { + 'title': 'Error', + 'type': 'object', + 'properties': { + 'code': { + 'title': 'Code', + 'description': 'Error code', + 'type': 'integer', + 'const': code, + 'enum': [code], + }, + 'data': { + 'title': 'Data', + 'description': 'Error additional data', + }, + 'message': { + 'title': 'Message', + 'description': 'Error message', + 'type': 'string', + }, + }, + 'required': ['code', 'message', 'data'], + 'additionalProperties': False, + } + + +@pytest.fixture(scope='session') +def oas31_meta(resources): + return resources('oas-3.1-meta.yaml', loader=yaml.unsafe_load) + + +def test_info_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + summary='api summary', + version='1.0', + description='api description', + contact=Contact( + name='contact name', + url='http://contact.com', + email='contact@mail.com', + ), + license=License( + name='license name', + identifier='licence id', + url='http://license.com', + ), + termsOfService='http://term-of-services.com', + ), + json_schema_dialect='dialect', + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, oas31_meta) + + expected_schema = { + 'openapi': '3.1.0', + 'jsonSchemaDialect': 'dialect', + 'info': { + 'contact': { + 'email': 'contact@mail.com', + 'name': 'contact name', + 'url': 'http://contact.com', + }, + 'description': 'api description', + 'license': { + 'name': 'license name', + 'url': 'http://license.com', + 'identifier': 'licence id', + }, + 'termsOfService': 'http://term-of-services.com', + 'title': 'api title', + 'summary': 'api summary', + 'version': '1.0', + }, + 'paths': {}, + 'components': {}, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_servers_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', ), - external_docs=specs.ExternalDocumentation(url='http://external-doc.com'), servers=[ - specs.Server( - url='http://test-server', - description='test server', + Server( + url='http://server.com', + description='server description', + variables={ + 'name1': ServerVariable(default='var1', enum=['var1', 'var2'], description='var description'), + }, ), ], + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, oas31_meta) + + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'servers': [{ + 'description': 'server description', + 'url': 'http://server.com', + 'variables': { + 'name1': { + 'default': 'var1', + 'description': 'var description', + 'enum': ['var1', 'var2'], + }, + }, + }], + 'paths': {}, + 'components': {}, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_external_docs_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + external_docs=ExternalDocumentation( + url="http://ex-doc.com", + description="ext doc description", + ), + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, oas31_meta) + + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'externalDocs': { + 'description': 'ext doc description', + 'url': 'http://ex-doc.com', + }, + 'paths': {}, + 'components': {}, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_tags_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), tags=[ - specs.Tag(name='test-tag'), + Tag( + name="tag1", + description="tag1 description", + externalDocs=ExternalDocumentation( + url="http://tag-ext-doc.com", + description="tag ext doc description", + ), + ), + ], + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, oas31_meta) + + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'tags': [{ + 'description': 'tag1 description', + 'externalDocs': { + 'description': 'tag ext doc description', + 'url': 'http://tag-ext-doc.com', + }, + 'name': 'tag1', + }], + 'paths': {}, + 'components': {}, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_security_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + security=[ + {'jwt': []}, ], - security_schemes=dict( - basicAuth=specs.SecurityScheme( - type=specs.SecuritySchemeType.HTTP, + security_schemes={ + 'basic': SecurityScheme( + type=SecuritySchemeType.HTTP, scheme='basic', ), + 'jwt': SecurityScheme( + type=SecuritySchemeType.HTTP, + scheme='bearer', + bearerFormat='JWT', + description='JWT security schema', + ), + 'key': SecurityScheme( + type=SecuritySchemeType.APIKEY, + name='api-key', + location=ApiKeyLocation.HEADER, + ), + }, + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, oas31_meta) + + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'security': [{ + 'jwt': [], + }], + 'paths': {}, + 'components': { + 'securitySchemes': { + 'basic': { + 'scheme': 'basic', + 'type': 'http', + }, + 'jwt': { + 'bearerFormat': 'JWT', + 'description': 'JWT security schema', + 'scheme': 'bearer', + 'type': 'http', + }, + 'key': { + 'in': 'header', + 'name': 'api-key', + 'type': 'apiKey', + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_path_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', ), - security=[ - dict(basicAuth=[]), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + def test_method(): + pass + + actual_schema = spec.schema( + path='/path', + methods_map={ + '/sub': [Method(test_method)], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/path/sub#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethodParameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_multipath_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), ], + ) + + def test_method1() -> None: + pass + + def test_method2() -> int: + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '/sub1': [ + Method(test_method1), + ], + 'sub2': [ + Method(test_method2), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/sub1#test_method1': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method1___TestMethod1Parameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethod1Result_', + }, + ), + }, + }, + }, + '/sub2#test_method2': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method2___TestMethod2Parameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethod2Result_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method1___TestMethod1Parameters_': jsonrpc_request_schema( + method_name='test_method1', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethod1Parameters', + }, + ), + 'TestMethod1Parameters': { + 'title': 'TestMethod1Parameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethod1Result_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethod1Result', + }, + ), + 'TestMethod1Result': { + 'title': 'TestMethod1Result', + 'type': 'null', + }, + 'JsonRpcRequest_Literal__test_method2___TestMethod2Parameters_': jsonrpc_request_schema( + method_name='test_method2', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethod2Parameters', + }, + ), + 'TestMethod2Parameters': { + 'title': 'TestMethod2Parameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethod2Result_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethod2Result', + }, + ), + 'TestMethod2Result': { + 'title': 'TestMethod2Result', + 'type': 'integer', + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_custom_method_name_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, schema_extractors=[ - extractors.docstring.DocstringSchemaExtractor(), - extractors.pydantic.PydanticSchemaExtractor(), + PydanticSchemaExtractor(), ], ) - class SubModel(pydantic.BaseModel): - field1: str + def test_method(): + pass - class Model(pydantic.BaseModel): - field1: str - field2: Optional[int] = 1 - field3: SubModel + actual_schema = spec.schema( + path='/', + methods_map={ + '/sub': [ + Method(test_method, name='custom_method_name'), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/sub#custom_method_name': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__custom_method_name___CustomMethodNameParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_CustomMethodNameResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__custom_method_name___CustomMethodNameParameters_': jsonrpc_request_schema( + method_name='custom_method_name', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/CustomMethodNameParameters', + }, + ), + 'CustomMethodNameParameters': { + 'title': 'CustomMethodNameParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_CustomMethodNameResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/CustomMethodNameResult', + }, + ), + 'CustomMethodNameResult': { + 'title': 'CustomMethodNameResult', + }, + }, + }, + } - class ResultModel(pydantic.BaseModel): - field1: str + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) - class TestError(pjrpc.common.exceptions.JsonRpcError): - code = 2001 - message = 'test error 1' - @specs.annotate( - errors=[ - TestError, +def test_method_context_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), ], - errors_schema=[ - specs.Error(code=2002, message='test error 2'), + ) + + def test_method(ctx): + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '/sub': [ + Method(test_method, context='ctx'), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/sub#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethodParameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_request_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), ], - examples=[ - specs.MethodExample( - params=dict( - param1=1, - param2={'field1': 'field', 'field2': 2}, - ), - result=1, - version='2.0', - summary='example 1 summary', - description='example 1 description', + ) + + class Model(pd.BaseModel): + field1: str = pd.Field(title='field1 title') + field2: str = pd.Field(description='field2 description') + + def test_method( + param1, + param2: int, + param3: Model, + param4: float = 1.1, + param5: Optional[str] = None, + param6: bool = pd.Field(description="param6 description"), + ) -> None: + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '/': [Method(test_method)], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethodParameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': { + 'param1': { + 'title': 'Param1', + }, + 'param2': { + 'title': 'Param2', + 'type': 'integer', + }, + 'param3': { + '$ref': '#/components/schemas/Model', + }, + 'param4': { + 'title': 'Param4', + 'type': 'number', + 'default': 1.1, + }, + 'param5': { + 'title': 'Param5', + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + 'default': None, + }, + 'param6': { + 'title': 'Param6', + 'description': 'param6 description', + 'type': 'boolean', + }, + }, + 'required': ['param1', 'param2', 'param3', 'param6'], + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + 'type': 'null', + }, + 'Model': { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'field1': { + 'title': 'field1 title', + 'type': 'string', + }, + 'field2': { + 'title': 'Field2', + 'description': 'field2 description', + 'type': 'string', + }, + }, + 'required': ['field1', 'field2'], + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_response_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + class Model(pd.BaseModel): + field1: str = pd.Field(title='field1 title') + field2: str = pd.Field(description='field2 description') + + def test_method1() -> None: + pass + + def test_method2() -> int: + pass + + def test_method3() -> Optional[str]: + pass + + def test_method4() -> Model: + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '/': [ + Method(test_method1), + Method(test_method2), + Method(test_method3), + Method(test_method4), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/#test_method1': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method1___TestMethod1Parameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethod1Result_', + }, + ), + }, + }, + }, + '/#test_method2': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method2___TestMethod2Parameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethod2Result_', + }, + ), + }, + }, + }, + '/#test_method3': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method3___TestMethod3Parameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethod3Result_', + }, + ), + }, + }, + }, + '/#test_method4': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method4___TestMethod4Parameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethod4Result_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method1___TestMethod1Parameters_': jsonrpc_request_schema( + method_name='test_method1', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethod1Parameters', + }, + ), + 'TestMethod1Parameters': { + 'title': 'TestMethod1Parameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethod1Result_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethod1Result', + }, + ), + 'TestMethod1Result': { + 'title': 'TestMethod1Result', + 'type': 'null', + }, + 'JsonRpcRequest_Literal__test_method2___TestMethod2Parameters_': jsonrpc_request_schema( + method_name='test_method2', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethod2Parameters', + }, + ), + 'TestMethod2Parameters': { + 'title': 'TestMethod2Parameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethod2Result_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethod2Result', + }, + ), + 'TestMethod2Result': { + 'title': 'TestMethod2Result', + 'type': 'integer', + }, + 'JsonRpcRequest_Literal__test_method3___TestMethod3Parameters_': jsonrpc_request_schema( + method_name='test_method3', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethod3Parameters', + }, + ), + 'TestMethod3Parameters': { + 'title': 'TestMethod3Parameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethod3Result_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethod3Result', + }, + ), + 'TestMethod3Result': { + 'title': 'TestMethod3Result', + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + }, + 'JsonRpcRequest_Literal__test_method4___TestMethod4Parameters_': jsonrpc_request_schema( + method_name='test_method4', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethod4Parameters', + }, + ), + 'TestMethod4Parameters': { + 'title': 'TestMethod4Parameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethod4Result_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethod4Result', + }, + ), + 'TestMethod4Result': { + 'title': 'TestMethod4Result', + '$ref': '#/components/schemas/Model', + }, + 'Model': { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'field1': { + 'title': 'field1 title', + 'type': 'string', + }, + 'field2': { + 'title': 'Field2', + 'description': 'field2 description', + 'type': 'string', + }, + }, + 'required': ['field1', 'field2'], + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_parameters_annotation_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + @openapi.annotate( + parameters=[ + Parameter( + name="param name", + location=ParameterLocation.HEADER, + description="param description", + required=True, + deprecated=False, + allowEmptyValue=True, + style=StyleType.SIMPLE, + explode=False, + allowReserved=True, + schema={'schema key': 'schema value'}, + example="param example", + examples={ + 'example1': ExampleObject( + value="param value", + summary="param summary", + description="param description", + ), + }, ), ], - error_examples=[ - specs.ErrorExample(code=2002, message='test error 2', summary='test error 2 summary'), + ) + def test_method(): + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '/path': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/path#test_method': { + 'post': { + 'parameters': [ + { + 'name': 'param name', + 'description': 'param description', + 'in': 'header', + 'style': 'simple', + 'example': 'param example', + 'examples': { + 'example1': { + 'description': 'param description', + 'summary': 'param summary', + 'value': 'param value', + }, + }, + 'schema': { + 'schema key': 'schema value', + }, + 'explode': False, + 'allowEmptyValue': True, + 'allowReserved': True, + 'deprecated': False, + 'required': True, + }, + ], + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethodParameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_description_annotation_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), ], - tags=['tag1', 'tag2'], - summary='method1 summary', - description='method1 description', - external_docs=specs.ExternalDocumentation(url='http://doc.info#method1'), + ) + + @openapi.annotate( + summary='method summary', + description='method description', + tags=['tag1', Tag(name="tag2", description="tag2 description")], + external_docs=ExternalDocumentation(url="http://ext-doc.com", description="ext doc description"), deprecated=True, security=[ - dict(basicAuth=[]), + {'basic': []}, + ], + servers=[ + Server(url="http://server.com", description="server description"), ], ) - def method1(ctx, param1: int, param2: Model, param3) -> int: + def test_method(): pass - def method2(param1: int, param2) -> ResultModel: - pass + actual_schema = spec.schema( + path='/', + methods_map={ + '/path': [ + Method(test_method), + ], + }, + ) - def method3(param1: Optional[float] = None, param2: int = 1) -> Optional[str]: - pass + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/path#test_method': { + 'post': { + 'summary': 'method summary', + 'description': 'method description', + 'tags': ['tag1', 'tag2'], + 'externalDocs': { + 'description': 'ext doc description', + 'url': 'http://ext-doc.com', + }, + 'deprecated': True, + 'security': [{ + 'basic': [], + }], + 'servers': [ + {'url': 'http://server.com', 'description': 'server description'}, + ], + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethodParameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + }, + }, + } - @specs.annotate( - params_schema={ - 'param1': specs.Schema( - schema={'type': 'number'}, - summary='param1 summary', - description='param1 description', + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_examples_annotation_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + @openapi.annotate( + examples=[ + MethodExample( + params={"param1": "value1", "param2": 2}, + result="method result", + summary="example summary", + description="example description", ), + ], + ) + def test_method(): + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '/path': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/path#test_method': { + 'post': { + 'requestBody': { + 'description': 'JSON-RPC Request', + 'content': { + 'application/json': { + 'schema': { + 'title': 'Request', + '$ref': '#/components/schemas/' + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + 'examples': { + 'example summary': { + 'summary': 'example summary', + 'description': 'example description', + 'value': { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'test_method', + 'params': { + 'param1': 'value1', + 'param2': 2, + }, + }, + }, + }, + }, + }, + 'required': True, + }, + 'responses': { + '200': { + 'description': 'JSON-RPC Response', + 'content': { + 'application/json': { + 'schema': { + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + 'examples': { + 'example summary': { + 'summary': 'example summary', + 'description': 'example description', + 'value': { + 'jsonrpc': '2.0', + 'id': 1, + 'result': 'method result', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/TestMethodParameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + }, }, - result_schema=specs.Schema( - schema={'type': 'number'}, - summary='result summary', - description='result description', + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_schema_annotation_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + @openapi.annotate( + params_schema={ + 'param1': {'type': 'string'}, + 'param2': {'type': 'number'}, + }, + result_schema={ + 'type': 'string', + }, ) - def method4(param1: int) -> int: + def test_method(): pass - def method5(*args, **kwargs) -> None: + actual_schema = spec.schema( + path='/', + methods_map={ + '/path': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'openapi': '3.1.0', + 'paths': { + '/path#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema=jsonrpc_request_schema( + 'test_method', + params_schema={ + 'title': 'Parameters', + 'description': 'Reqeust parameters', + 'type': 'object', + 'properties': { + 'param1': { + 'type': 'string', + }, + 'param2': { + 'type': 'number', + }, + }, + 'additionalProperties': False, + }, + ), + ), + 'responses': { + '200': response_schema( + schema=jsonrpc_response_schema( + result_schema={ + 'type': 'string', + }, + ), + ), + }, + }, + }, + }, + 'components': {}, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_errors_annotation_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + @openapi.annotate( + errors=[ + exceptions.MethodNotFoundError, + exceptions.InvalidParamsError, + ], + ) + def test_method(): pass - def method6(): + actual_schema = spec.schema( + path='/', + methods_map={ + '/path': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/path#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema={ + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + 'title': 'Request', + }, + ), + 'responses': { + '200': response_schema( + description='* **-32601** Method not found\n* **-32602** Invalid params', + schema={ + 'title': 'Response', + 'description': '* **-32601** Method not found\n* **-32602** Invalid params', + 'anyOf': [ + {'$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_'}, + {'$ref': '#/components/schemas/MethodNotFoundError'}, + {'$ref': '#/components/schemas/InvalidParamsError'}, + ], + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + '$ref': '#/components/schemas/TestMethodParameters', + 'description': 'Method parameters', + }, + ), + 'TestMethodParameters': { + 'title': 'TestMethodParameters', + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + 'MethodNotFoundError': error_response_component( + title='MethodNotFoundError', + description='**-32601** Method not found', + error_schema={ + 'description': 'Request error', + '$ref': '#/components/schemas/JsonRpcError_Literal_-32601__Any_', + }, + ), + 'JsonRpcError_Literal_-32601__Any_': error_component(code=-32601), + 'InvalidParamsError': error_response_component( + title='InvalidParamsError', + description='**-32602** Invalid params', + error_schema={ + 'description': 'Request error', + '$ref': '#/components/schemas/JsonRpcError_Literal_-32602__Any_', + }, + ), + 'JsonRpcError_Literal_-32602__Any_': error_component(code=-32602), + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_errors_annotation_with_status_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={ + exceptions.MethodNotFoundError.code: 404, + exceptions.InternalError.code: 500, + exceptions.ServerError.code: 500, + }, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + @openapi.annotate( + errors=[ + exceptions.MethodNotFoundError, + exceptions.InternalError, + exceptions.ServerError, + ], + ) + def test_method(): pass - def method7(param1): - """ - Method 7. + actual_schema = spec.schema( + path='/', + methods_map={ + '/path': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/path#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/JsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/JsonRpcResponseSuccess_TestMethodResult_', + }, + ), + '404': response_schema( + description='* **-32601** Method not found', + schema={ + '$ref': '#/components/schemas/MethodNotFoundError', + 'title': 'Response', + 'description': '* **-32601** Method not found', + }, + ), + '500': response_schema( + description='* **-32603** Internal error\n* **-32000** Server error', + schema={ + 'title': 'Response', + 'description': '* **-32603** Internal error\n* **-32000** Server error', + 'anyOf': [ + {'$ref': '#/components/schemas/InternalError'}, + {'$ref': '#/components/schemas/ServerError'}, + ], + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'JsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + '$ref': '#/components/schemas/TestMethodParameters', + 'description': 'Method parameters', + }, + ), + 'TestMethodParameters': { + 'properties': {}, + 'title': 'TestMethodParameters', + 'type': 'object', + 'additionalProperties': False, + }, + 'JsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/TestMethodResult', + }, + ), + 'TestMethodResult': { + 'title': 'TestMethodResult', + }, + 'ServerError': error_response_component( + title='ServerError', + description='**-32000** Server error', + error_schema={ + 'description': 'Request error', + '$ref': '#/components/schemas/JsonRpcError_Literal_-32000__Any_', + }, + ), + 'JsonRpcError_Literal_-32000__Any_': error_component(code=-32000), + 'MethodNotFoundError': error_response_component( + title='MethodNotFoundError', + description='**-32601** Method not found', + error_schema={ + 'description': 'Request error', + '$ref': '#/components/schemas/JsonRpcError_Literal_-32601__Any_', + }, + ), + 'JsonRpcError_Literal_-32601__Any_': error_component(code=-32601), + 'InternalError': error_response_component( + title='InternalError', + description='**-32603** Internal error', + error_schema={ + 'description': 'Request error', + '$ref': '#/components/schemas/JsonRpcError_Literal_-32603__Any_', + }, + ), + 'JsonRpcError_Literal_-32603__Any_': error_component(code=-32603), + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) - Method 7 description. - :param param1: param1 summary. param1 description. +def test_method_request_docstring_schema(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractor=DocstringSchemaExtractor(), + ) + + def test_method( + param1, + param2: int, + param3: float = 1.1, + param4: str = '', + ) -> int: """ + Test method title. + Test method description. - method1 = Method(method1, 'method1', 'ctx') - method2 = Method(method2, 'method2') - method3 = Method(method3, 'method3') - method4 = Method(method4, 'method4') - method5 = Method(method5, 'method5') - method6 = Method(method6, 'method6') - method7 = Method(method7, 'method7') + :param any param1: Param1 description. + :param int param2: Param2 description. + :param float param3: Param3 description. + :param str param4: Param4 description. + :return int: Result description. + """ - actual_schema = spec.schema('/test/path', methods=[method1, method2, method3, method4, method5, method6, method7]) + actual_schema = spec.schema( + path='/', + methods_map={ + '/': [Method(test_method)], + }, + ) + + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/#test_method': { + 'post': { + 'summary': 'Test method title.', + 'description': 'Test method description.', + 'requestBody': request_body_schema( + schema=jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'title': 'Parameters', + 'description': 'Reqeust parameters', + 'type': 'object', + 'properties': { + 'param1': { + 'title': 'Param1', + 'description': 'Param1 description.', + 'type': 'any', + }, + 'param2': { + 'title': 'Param2', + 'description': 'Param2 description.', + 'type': 'int', + }, + 'param3': { + 'title': 'Param3', + 'description': 'Param3 description.', + 'type': 'float', + }, + 'param4': { + 'title': 'Param4', + 'description': 'Param4 description.', + 'type': 'str', + }, + }, + 'additionalProperties': False, + }, + ), + ), + 'responses': { + '200': response_schema( + schema=jsonrpc_response_schema( + result_schema={ + 'title': 'Result', + 'description': 'Result description.', + 'type': 'int', + }, + ), + ), + }, + 'deprecated': False, + }, + }, + }, + 'components': {}, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_component_name_prefix(resources, oas31_meta): + spec = OpenAPI( + info=Info( + title='api title', + version='1.0', + ), + error_http_status_map={}, + schema_extractors=[ + PydanticSchemaExtractor(), + ], + ) + + class Model1(pd.BaseModel): + field1: str = pd.Field(title='field1 title') + field2: str = pd.Field(description='field2 description') - assert actual_schema == resources('openapi-1.json', loader=json.loads) + class Model2(pd.BaseModel): + field1: str = pd.Field(title='field1 title') + field2: str = pd.Field(description='field2 description') + def test_method(param1: Model1) -> Model2: + pass -def test_ui(): - swagger_ui = specs.SwaggerUI() - swagger_ui.get_index_page('/path') - swagger_ui.get_static_folder() + actual_schema = spec.schema( + path='/', + methods_map={ + '/': [Method(test_method)], + }, + component_name_prefix='Prefix', + ) - swagger_ui = specs.ReDoc() - swagger_ui.get_index_page('/path') - swagger_ui.get_static_folder() + jsonschema.validate(actual_schema, oas31_meta) + expected_schema = { + 'openapi': '3.1.0', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'paths': { + '/#test_method': { + 'post': { + 'requestBody': request_body_schema( + schema={ + 'title': 'Request', + '$ref': '#/components/schemas/' + 'PrefixJsonRpcRequest_Literal__test_method___TestMethodParameters_', + }, + ), + 'responses': { + '200': response_schema( + schema={ + 'title': 'Response', + '$ref': '#/components/schemas/PrefixJsonRpcResponseSuccess_TestMethodResult_', + }, + ), + }, + }, + }, + }, + 'components': { + 'schemas': { + 'PrefixJsonRpcRequest_Literal__test_method___TestMethodParameters_': jsonrpc_request_schema( + method_name='test_method', + params_schema={ + 'description': 'Method parameters', + '$ref': '#/components/schemas/PrefixTestMethodParameters', + }, + ), + 'PrefixTestMethodParameters': { + 'additionalProperties': False, + 'properties': { + 'param1': { + '$ref': '#/components/schemas/PrefixModel1', + }, + }, + 'required': ['param1'], + 'title': 'TestMethodParameters', + 'type': 'object', + }, + 'PrefixJsonRpcResponseSuccess_TestMethodResult_': jsonrpc_response_schema( + result_schema={ + 'description': 'Method execution result', + '$ref': '#/components/schemas/PrefixTestMethodResult', + }, + ), + 'PrefixTestMethodResult': { + 'title': 'TestMethodResult', + '$ref': '#/components/schemas/PrefixModel2', + }, + 'PrefixModel1': { + 'title': 'Model1', + 'type': 'object', + 'properties': { + 'field1': { + 'title': 'field1 title', + 'type': 'string', + }, + 'field2': { + 'title': 'Field2', + 'description': 'field2 description', + 'type': 'string', + }, + }, + 'required': ['field1', 'field2'], + }, + 'PrefixModel2': { + 'title': 'Model2', + 'type': 'object', + 'properties': { + 'field1': { + 'type': 'string', + 'title': 'field1 title', + }, + 'field2': { + 'title': 'Field2', + 'description': 'field2 description', + 'type': 'string', + }, + }, + 'required': ['field1', 'field2'], + }, + }, + }, + } - swagger_ui = specs.RapiDoc() - swagger_ui.get_index_page('/path') - swagger_ui.get_static_folder() + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) diff --git a/tests/server/test_openrpc.py b/tests/server/test_openrpc.py index 6d2d1dd..585cace 100644 --- a/tests/server/test_openrpc.py +++ b/tests/server/test_openrpc.py @@ -1,140 +1,883 @@ import json from typing import Optional -import pydantic - -import pjrpc.common.exceptions -import pjrpc.server.specs.extractors.docstring -from pjrpc.server import Method -from pjrpc.server.specs import extractors -from pjrpc.server.specs import openrpc as specs - - -def test_openapi_schema_generator(resources): - spec = specs.OpenRPC( - info=specs.Info( - version='1.0.0', - title='Test tittle', - description='test api', - contact=specs.Contact(name='owner', email='test@gmal.com'), - license=specs.License(name='MIT'), +import jsonschema +import pydantic as pd +import pytest +from deepdiff.diff import DeepDiff + +from pjrpc.common import exceptions +from pjrpc.server.dispatcher import Method +from pjrpc.server.specs import openrpc +from pjrpc.server.specs.extractors.pydantic import PydanticSchemaExtractor +from pjrpc.server.specs.openrpc import Contact, ContentDescriptor, ExampleObject, ExternalDocumentation, Info, License +from pjrpc.server.specs.openrpc import MethodExample, OpenRPC, Server, ServerVariable, Tag + + +@pytest.fixture(scope='session') +def openrpc13_meta(resources): + return resources('openrpc-1.3.2.json', loader=json.loads) + + +def test_info_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + description='api description', + contact=Contact( + name='contact name', + url='http://contact.com', + email='contact@mail.com', + ), + license=License( + name='license name', + url='http://license.com', + ), + termsOfService='http://term-of-services.com', + ), + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, openrpc13_meta) + + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'version': '1.0', + 'title': 'api title', + 'description': 'api description', + 'contact': { + 'name': 'contact name', + 'url': 'http://contact.com', + 'email': 'contact@mail.com', + }, + 'license': { + 'name': 'license name', + 'url': 'http://license.com', + }, + 'termsOfService': 'http://term-of-services.com', + }, + 'methods': [], + 'components': { + 'schemas': {}, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_servers_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', ), - external_docs=specs.ExternalDocumentation(url='http://external-doc.com'), servers=[ - specs.Server( - url='http://test-server', - name='test server', - summary='test server summary', - description='test server description', + Server( + name='server name', + url='http://server.com', + summary='server summary', + description='server description', + variables={ + 'name1': ServerVariable(default='var1', enum=['var1', 'var2'], description='var description'), + }, ), ], - schema_extractor=extractors.docstring.DocstringSchemaExtractor(), ) - class SubModel(pydantic.BaseModel): - field1: str + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, openrpc13_meta) - class Model(pydantic.BaseModel): - field1: str - field2: int - field3: SubModel + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [], + 'servers': [ + { + 'name': 'server name', + 'summary': 'server summary', + 'description': 'server description', + 'url': 'http://server.com', + 'variables': { + 'name1': { + 'default': 'var1', + 'enum': ['var1', 'var2'], + 'description': 'var description', + }, + }, + }, + ], + 'components': { + 'schemas': {}, + }, + } - class TestError(pjrpc.common.exceptions.JsonRpcError): - code = 2001 - message = 'test error 1' + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) - @specs.annotate( - errors=[ - TestError, - specs.Error(code=2002, message='test error 2'), + +def test_external_docs_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + external_docs=ExternalDocumentation( + url="http://ex-doc.com", + description="ext doc description", + ), + ) + + actual_schema = spec.schema(path='/') + jsonschema.validate(actual_schema, openrpc13_meta) + + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'version': '1.0', + 'title': 'api title', + }, + 'methods': [], + 'externalDocs': { + 'url': 'http://ex-doc.com', + 'description': 'ext doc description', + }, + 'components': { + 'schemas': {}, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_custom_method_name_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + def test_method(): + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method, name='custom_method_name'), + ], + }, + ) + + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'custom_method_name', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'CustomMethodNameResult', + 'schema': { + 'title': 'CustomMethodNameResult', + }, + 'required': False, + }, + }, + ], + 'components': { + 'schemas': {}, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_context_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + def test_method(ctx): + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method, context='ctx'), + ], + }, + ) + + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethodResult', + 'schema': { + 'title': 'TestMethodResult', + }, + 'required': False, + }, + }, + ], + 'components': { + 'schemas': {}, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_request_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + class Model(pd.BaseModel): + field1: str = pd.Field(title='field1 title') + field2: str = pd.Field(description='field2 description') + + def test_method( + param1, + param2: int, + param3: Model, + param4: float = 1.1, + param5: Optional[str] = None, + param6: bool = pd.Field(description="param6 description"), + ) -> None: + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '': [Method(test_method)], + }, + ) + + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method', + 'params': [ + { + 'name': 'param1', + 'summary': 'Param1', + 'schema': { + 'title': 'Param1', + }, + 'required': True, + }, + { + 'name': 'param2', + 'summary': 'Param2', + 'schema': { + 'title': 'Param2', + 'type': 'integer', + }, + 'required': True, + }, + { + 'name': 'param3', + 'schema': { + '$ref': '#/components/schemas/Model', + }, + 'required': True, + }, + { + 'name': 'param4', + 'summary': 'Param4', + 'schema': { + 'title': 'Param4', + 'type': 'number', + 'default': 1.1, + }, + 'required': False, + }, + { + 'name': 'param5', + 'summary': 'Param5', + 'schema': { + 'title': 'Param5', + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + 'default': None, + }, + 'required': False, + }, + { + 'name': 'param6', + 'summary': 'Param6', + 'description': 'param6 description', + 'schema': { + 'title': 'Param6', + 'description': 'param6 description', + 'type': 'boolean', + }, + 'required': True, + }, + ], + 'result': { + 'name': 'result', + 'schema': { + 'title': 'TestMethodResult', + 'type': 'null', + }, + 'summary': 'TestMethodResult', + 'required': False, + }, + }, + ], + 'components': { + 'schemas': { + 'Model': { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'field1': { + 'title': 'field1 title', + 'type': 'string', + }, + 'field2': { + 'title': 'Field2', + 'description': 'field2 description', + 'type': 'string', + }, + }, + 'required': ['field1', 'field2'], + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_response_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + class Model(pd.BaseModel): + field1: str = pd.Field(title='field1 title') + field2: str = pd.Field(description='field2 description') + + def test_method1() -> None: + pass + + def test_method2() -> int: + pass + + def test_method3() -> Optional[str]: + pass + + def test_method4() -> Model: + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method1), + Method(test_method2), + Method(test_method3), + Method(test_method4), + ], + }, + ) + + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method1', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethod1Result', + 'schema': { + 'title': 'TestMethod1Result', + 'type': 'null', + }, + 'required': False, + }, + }, + { + 'name': 'test_method2', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethod2Result', + 'schema': { + 'title': 'TestMethod2Result', + 'type': 'integer', + }, + 'required': False, + }, + }, + { + 'name': 'test_method3', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethod3Result', + 'schema': { + 'title': 'TestMethod3Result', + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + }, + 'required': False, + }, + }, + { + 'name': 'test_method4', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethod4Result', + 'schema': { + 'title': 'TestMethod4Result', + '$ref': '#/components/schemas/Model', + }, + 'required': False, + }, + }, + ], + 'components': { + 'schemas': { + 'Model': { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'field1': { + 'title': 'field1 title', + 'type': 'string', + }, + 'field2': { + 'title': 'Field2', + 'description': 'field2 description', + 'type': 'string', + }, + }, + 'required': ['field1', 'field2'], + }, + }, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_description_annotation_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + @openrpc.annotate( + summary='method summary', + description='method description', + tags=[ + 'tag1', + Tag( + name="tag2", + description="tag2 description", + externalDocs=ExternalDocumentation(url="http://tag-ext-doc.com", description="tag doc description"), + ), + ], + external_docs=ExternalDocumentation(url="http://ext-doc.com", description="ext doc description"), + deprecated=True, + servers=[ + Server( + name="server name", + summary="server summary", + description="server description", + url="http://server.com", + variables={ + 'name1': ServerVariable(default='var1', enum=['var1', 'var2'], description='var description'), + }, + ), + ], + ) + def test_method(): + pass + + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method', + 'summary': 'method summary', + 'description': 'method description', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethodResult', + 'schema': { + 'title': 'TestMethodResult', + }, + 'required': False, + }, + 'tags': [ + { + 'name': 'tag1', + }, + { + 'name': 'tag2', + 'description': 'tag2 description', + 'externalDocs': { + 'description': 'tag doc description', + 'url': 'http://tag-ext-doc.com', + }, + }, + ], + 'deprecated': True, + 'externalDocs': { + 'description': 'ext doc description', + 'url': 'http://ext-doc.com', + }, + 'servers': [ + { + 'name': 'server name', + 'summary': 'server summary', + 'description': 'server description', + 'url': 'http://server.com', + 'variables': { + 'name1': { + 'description': 'var description', + 'default': 'var1', + 'enum': ['var1', 'var2'], + }, + }, + }, + ], + }, ], + 'components': { + 'schemas': {}, + }, + } + + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_examples_annotation_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + @openrpc.annotate( examples=[ - specs.MethodExample( - name='Simple example', + MethodExample( + name='example name', params=[ - specs.ExampleObject( - name='param1', - value=1, - description='param1 description', - ), - specs.ExampleObject( - name='param2', - value={'field1': 'field', 'field2': 2}, - description='param2 description', + ExampleObject( + value={"param1": "value1", "param2": 2}, + name="param name", + summary="param summary", + description="param description", + externalValue="http://param.com", ), ], - result=specs.ExampleObject( - name='result', - value=1, - description='result description', + result=ExampleObject( + value="method result", + name="result name", + summary="result summary", + description="result description", + externalValue="http://result.com", ), - summary='example 1 summary', - description='example 1 description', + summary="example summary", + description="example description", ), ], - tags=['tag1', 'tag2'], - summary='method1 summary', - description='method1 description', - deprecated=True, ) - def method1(ctx, param1: int, param2: Model) -> int: - """ - Method1. + def test_method(): + pass - Description + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method), + ], + }, + ) - :param ctx: context - :param integer param1: param1 summary. - description. - :param object param2: param2 summary. - description. - :return integer: result summary. - description. - :raises TestError: test error - """ + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethodResult', + 'schema': { + 'title': 'TestMethodResult', + }, + 'required': False, + }, + 'examples': [ + { + 'name': 'example name', + 'summary': 'example summary', + 'description': 'example description', + 'params': [ + { + 'name': 'param name', + 'summary': 'param summary', + 'description': 'param description', + 'value': { + 'param1': 'value1', + 'param2': 2, + }, + 'externalValue': 'http://param.com', + }, + ], + 'result': { + 'name': 'result name', + 'summary': 'result summary', + 'description': 'result description', + 'value': 'method result', + 'externalValue': 'http://result.com', + }, + }, + ], + }, + ], + 'components': { + 'schemas': {}, + }, + } - def method2(param1: int = 1, param2: Optional[float] = None) -> Optional[str]: - """ - Method2. + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) - Description. - :param integer? param1: param1 summary. - :param number? param2: param2 summary. - :return string: result summary. - """ +def test_method_schema_annotation_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) - @specs.annotate( + @openrpc.annotate( params_schema=[ - specs.ContentDescriptor( - name='param1', - schema={'type': 'number'}, - summary='param1 summary', - description='param1 description', + ContentDescriptor( + name="params name", + schema={ + 'param1': {'type': 'string'}, + 'param2': {'type': 'number'}, + }, + summary="params summary", + description="params description", + required=True, + deprecated=True, ), ], - result_schema=specs.ContentDescriptor( - name='result', - schema={'type': 'number'}, - summary='result summary', - description='result description', + result_schema=ContentDescriptor( + name="result name", + schema={'type': 'string'}, + summary="result summary", + description="result description", + required=True, + deprecated=True, ), ) - def method3(param1: int) -> Model: + def test_method(): pass - def method4(*args, **kwargs) -> None: - pass + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method), + ], + }, + ) + + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method', + 'params': [ + { + 'name': 'params name', + 'summary': 'params summary', + 'description': 'params description', + 'schema': { + 'param1': { + 'type': 'string', + }, + 'param2': { + 'type': 'number', + }, + }, + 'required': True, + 'deprecated': True, + }, + ], + 'result': { + 'name': 'result name', + 'summary': 'result summary', + 'description': 'result description', + 'schema': { + 'type': 'string', + }, + 'required': True, + 'deprecated': True, + }, + }, + ], + 'components': { + 'schemas': {}, + }, + } - def method5(): + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) + + +def test_method_errors_annotation_schema(resources, openrpc13_meta): + spec = OpenRPC( + info=Info( + title='api title', + version='1.0', + ), + schema_extractor=PydanticSchemaExtractor(), + ) + + @openrpc.annotate( + errors=[ + exceptions.MethodNotFoundError, + exceptions.InvalidParamsError, + ], + ) + def test_method(): pass - method1 = Method(method1, 'method1', 'ctx') - method2 = Method(method2, 'method2') - method3 = Method(method3, 'method3') - method4 = Method(method4, 'method4') - method5 = Method(method5, 'method5') + actual_schema = spec.schema( + path='/', + methods_map={ + '': [ + Method(test_method), + ], + }, + ) - actual_schema = spec.schema('/test/path', methods=[method1, method2, method3, method4, method5]) + jsonschema.validate(actual_schema, openrpc13_meta) + expected_schema = { + 'openrpc': '1.3.2', + 'info': { + 'title': 'api title', + 'version': '1.0', + }, + 'methods': [ + { + 'name': 'test_method', + 'params': [], + 'result': { + 'name': 'result', + 'summary': 'TestMethodResult', + 'schema': { + 'title': 'TestMethodResult', + }, + 'required': False, + }, + 'errors': [ + { + 'code': -32601, + 'message': 'Method not found', + }, + { + 'code': -32602, + 'message': 'Invalid params', + }, + ], + }, + ], + 'components': { + 'schemas': {}, + }, + } - assert actual_schema == resources('openrpc-1.json', loader=json.loads) + assert not DeepDiff(expected_schema, actual_schema, use_enum_value=True) From 379c09141e9bfcc78d5c78cac6463c3e78da14b2 Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Tue, 12 Nov 2024 01:03:26 +0500 Subject: [PATCH 7/9] examples fixed. --- README.rst | 99 ++++++--------- docs/source/pjrpc/quickstart.rst | 105 +++++++--------- docs/source/pjrpc/webui.rst | 105 +++++++--------- examples/django/mysite/jsonrpc/__init__.py | 2 +- examples/django/mysite/jsonrpc/posts.py | 4 +- examples/django/mysite/jsonrpc/users.py | 4 +- examples/openapi_aiohttp.py | 60 ++------- examples/openapi_aiohttp_subendpoints.py | 136 +++++++-------------- examples/openapi_flask.py | 99 ++++++--------- examples/openapi_flask_subendpoints.py | 134 ++++++++++---------- examples/openrpc_aiohttp.py | 34 +----- examples/openrpc_flask.py | 115 ++++++----------- examples/pydantic_validator.py | 2 +- 13 files changed, 326 insertions(+), 573 deletions(-) diff --git a/README.rst b/README.rst index 60173d3..1142b7d 100644 --- a/README.rst +++ b/README.rst @@ -454,19 +454,19 @@ and Swagger UI web tool with basic auth: .. code-block:: python import uuid - from typing import Any, Optional + from typing import Annotated, Any, Optional import flask - import flask_httpauth - import pydantic import flask_cors + import flask_httpauth + import pydantic as pd from werkzeug import security import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import flask as integration + from pjrpc.server.specs import extractors + from pjrpc.server.specs import openapi as specs from pjrpc.server.validators import pydantic as validators - from pjrpc.server.specs import extractors, openapi as specs - app = flask.Flask('myapp') flask_cors.CORS(app, resources={"/myapp/api/v1/*": {"origins": "*"}}) @@ -492,22 +492,43 @@ and Swagger UI web tool with basic auth: class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) - class UserIn(pydantic.BaseModel): + UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), + ] + + UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), + ] + + UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[25]), + ] + + UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), + ] + + + class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -515,7 +536,7 @@ and Swagger UI web tool with basic auth: Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -537,26 +558,9 @@ and Swagger UI web tool with basic auth: @specs.annotate( + summary='Creates a user', tags=['users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate @@ -576,30 +580,17 @@ and Swagger UI web tool with basic auth: user_id = uuid.uuid4().hex flask.current_app.users_db[user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Returns a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate - def get_user(user_id: uuid.UUID) -> UserOut: + def get_user(user_id: UserId) -> UserOut: """ Returns a user. @@ -612,25 +603,17 @@ and Swagger UI web tool with basic auth: if not user: raise NotFoundError() - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Deletes a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result=None, - ), - ], ) @methods.add @validator.validate - def delete_user(user_id: uuid.UUID) -> None: + def delete_user(user_id: UserId) -> None: """ Deletes a user. @@ -664,8 +647,6 @@ and Swagger UI web tool with basic auth: ], schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ui=specs.SwaggerUI(), - # ui=specs.RapiDoc(), - # ui=specs.ReDoc(), ), ) json_rpc.dispatcher.add_methods(methods) diff --git a/docs/source/pjrpc/quickstart.rst b/docs/source/pjrpc/quickstart.rst index de64b8a..cce27f5 100644 --- a/docs/source/pjrpc/quickstart.rst +++ b/docs/source/pjrpc/quickstart.rst @@ -384,19 +384,19 @@ Swagger UI web tool with basic auth: .. code-block:: python import uuid - from typing import Any, Optional + from typing import Annotated, Any, Optional import flask - import flask_httpauth - import pydantic import flask_cors + import flask_httpauth + import pydantic as pd from werkzeug import security import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import flask as integration + from pjrpc.server.specs import extractors + from pjrpc.server.specs import openapi as specs from pjrpc.server.validators import pydantic as validators - from pjrpc.server.specs import extractors, openapi as specs - app = flask.Flask('myapp') flask_cors.CORS(app, resources={"/myapp/api/v1/*": {"origins": "*"}}) @@ -422,22 +422,43 @@ Swagger UI web tool with basic auth: class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) - class UserIn(pydantic.BaseModel): + UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), + ] + + UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), + ] + + UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[25]), + ] + + UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), + ] + + + class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -445,7 +466,7 @@ Swagger UI web tool with basic auth: Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -467,26 +488,9 @@ Swagger UI web tool with basic auth: @specs.annotate( + summary='Creates a user', tags=['users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate @@ -506,30 +510,17 @@ Swagger UI web tool with basic auth: user_id = uuid.uuid4().hex flask.current_app.users_db[user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Returns a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate - def get_user(user_id: uuid.UUID) -> UserOut: + def get_user(user_id: UserId) -> UserOut: """ Returns a user. @@ -538,29 +529,21 @@ Swagger UI web tool with basic auth: :raise NotFoundError: user not found """ - user = flask.current_app.users_db.get(user_id) + user = flask.current_app.users_db.get(user_id.hex) if not user: raise NotFoundError() - return UserOut(**user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Deletes a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result=None, - ), - ], ) @methods.add @validator.validate - def delete_user(user_id: uuid.UUID) -> None: + def delete_user(user_id: UserId) -> None: """ Deletes a user. @@ -568,7 +551,7 @@ Swagger UI web tool with basic auth: :raise NotFoundError: user not found """ - user = flask.current_app.users_db.pop(user_id, None) + user = flask.current_app.users_db.pop(user_id.hex, None) if not user: raise NotFoundError() @@ -590,12 +573,10 @@ Swagger UI web tool with basic auth: ), ), security=[ - dict(basicAuth=[]) + dict(basicAuth=[]), ], schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ui=specs.SwaggerUI(), - # ui=specs.RapiDoc(), - # ui=specs.ReDoc(), ), ) json_rpc.dispatcher.add_methods(methods) diff --git a/docs/source/pjrpc/webui.rst b/docs/source/pjrpc/webui.rst index f4d3ccd..54264c3 100644 --- a/docs/source/pjrpc/webui.rst +++ b/docs/source/pjrpc/webui.rst @@ -54,19 +54,19 @@ using flask web framework: .. code-block:: python import uuid - from typing import Any, Optional + from typing import Annotated, Any, Optional import flask - import flask_httpauth - import pydantic import flask_cors + import flask_httpauth + import pydantic as pd from werkzeug import security import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import flask as integration + from pjrpc.server.specs import extractors + from pjrpc.server.specs import openapi as specs from pjrpc.server.validators import pydantic as validators - from pjrpc.server.specs import extractors, openapi as specs - app = flask.Flask('myapp') flask_cors.CORS(app, resources={"/myapp/api/v1/*": {"origins": "*"}}) @@ -92,22 +92,43 @@ using flask web framework: class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) - class UserIn(pydantic.BaseModel): + UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), + ] + + UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), + ] + + UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[25]), + ] + + UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), + ] + + + class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -115,7 +136,7 @@ using flask web framework: Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -137,26 +158,9 @@ using flask web framework: @specs.annotate( + summary='Creates a user', tags=['users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate @@ -176,30 +180,17 @@ using flask web framework: user_id = uuid.uuid4().hex flask.current_app.users_db[user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Returns a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate - def get_user(user_id: uuid.UUID) -> UserOut: + def get_user(user_id: UserId) -> UserOut: """ Returns a user. @@ -208,29 +199,21 @@ using flask web framework: :raise NotFoundError: user not found """ - user = flask.current_app.users_db.get(user_id) + user = flask.current_app.users_db.get(user_id.hex) if not user: raise NotFoundError() - return UserOut(**user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Deletes a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result=None, - ), - ], ) @methods.add @validator.validate - def delete_user(user_id: uuid.UUID) -> None: + def delete_user(user_id: UserId) -> None: """ Deletes a user. @@ -238,7 +221,7 @@ using flask web framework: :raise NotFoundError: user not found """ - user = flask.current_app.users_db.pop(user_id, None) + user = flask.current_app.users_db.pop(user_id.hex, None) if not user: raise NotFoundError() @@ -260,12 +243,10 @@ using flask web framework: ), ), security=[ - dict(basicAuth=[]) + dict(basicAuth=[]), ], schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ui=specs.SwaggerUI(), - # ui=specs.RapiDoc(), - # ui=specs.ReDoc(), ), ) json_rpc.dispatcher.add_methods(methods) diff --git a/examples/django/mysite/jsonrpc/__init__.py b/examples/django/mysite/jsonrpc/__init__.py index 942e930..9739aec 100644 --- a/examples/django/mysite/jsonrpc/__init__.py +++ b/examples/django/mysite/jsonrpc/__init__.py @@ -12,7 +12,7 @@ class JSONEncoder(pjrpc.server.JSONEncoder): def default(self, o: Any) -> Any: if isinstance(o, pydantic.BaseModel): - return o.dict() + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) diff --git a/examples/django/mysite/jsonrpc/posts.py b/examples/django/mysite/jsonrpc/posts.py index e77998e..cb3b2fe 100644 --- a/examples/django/mysite/jsonrpc/posts.py +++ b/examples/django/mysite/jsonrpc/posts.py @@ -83,7 +83,7 @@ def add_post(request: HttpRequest, post: PostIn) -> PostOut: post_id = uuid.uuid4().hex db['posts'][post_id] = post - return PostOut(id=post_id, **post.dict()) + return PostOut(id=post_id, **post.model_dump()) @specs.annotate( @@ -119,7 +119,7 @@ def get_post(request: HttpRequest, post_id: uuid.UUID) -> PostOut: if not post: raise NotFoundError() - return PostOut(**post.dict()) + return PostOut(**post.model_dump()) @specs.annotate( diff --git a/examples/django/mysite/jsonrpc/users.py b/examples/django/mysite/jsonrpc/users.py index cf81c21..ea6535b 100644 --- a/examples/django/mysite/jsonrpc/users.py +++ b/examples/django/mysite/jsonrpc/users.py @@ -86,7 +86,7 @@ def add_user(request: HttpRequest, user: UserIn) -> UserOut: user_id = uuid.uuid4().hex db['users'][user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( @@ -123,7 +123,7 @@ def get_user(request: HttpRequest, user_id: uuid.UUID) -> UserOut: if not user: raise NotFoundError() - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( diff --git a/examples/openapi_aiohttp.py b/examples/openapi_aiohttp.py index 6f6fd7f..0f4be8f 100644 --- a/examples/openapi_aiohttp.py +++ b/examples/openapi_aiohttp.py @@ -5,7 +5,6 @@ import pydantic as pd from aiohttp import helpers, web -import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import aiohttp as integration from pjrpc.server.specs import extractors @@ -99,28 +98,9 @@ class NotFoundError(pjrpc.exc.JsonRpcError): @specs.annotate( - params_schema={'user': {'schema': {'type': 'object'}}}, - result_schema={'type': 'string'}, - # tags=['users'], - # errors=[AlreadyExistsError], - # examples=[ - # specs.MethodExample( - # summary="Simple example", - # params=dict( - # user={ - # 'name': 'John', - # 'surname': 'Doe', - # 'age': 25, - # }, - # ), - # result={ - # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - # 'name': 'John', - # 'surname': 'Doe', - # 'age': 25, - # }, - # ), - # ], + summary='Creates a user', + tags=['users'], + errors=[AlreadyExistsError], ) @methods.add(context='request') @validator.validate @@ -145,22 +125,9 @@ def add_user(request: web.Request, user: UserIn) -> UserOut: @specs.annotate( + summary='Returns a user', tags=['users'], errors=[NotFoundError], - # examples=[ - # specs.MethodExample( - # summary='Simple example', - # params=dict( - # user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - # ), - # result={ - # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - # 'name': 'John', - # 'surname': 'Doe', - # 'age': 25, - # }, - # ), - # ], ) @methods.add(context='request') @validator.validate @@ -178,21 +145,13 @@ def get_user(request: web.Request, user_id: UserId) -> UserOut: if not user: raise NotFoundError() - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Deletes a user', tags=['users'], errors=[NotFoundError], - # examples=[ - # specs.MethodExample( - # summary='Simple example', - # params=dict( - # user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - # ), - # result=None, - # ), - # ], ) @methods.add(context='request') @validator.validate @@ -232,13 +191,8 @@ def delete_user(request: web.Request, user_id: UserId) -> None: security=[ dict(basicAuth=[]), ], - schema_extractors=[ - # extractors.docstring.DocstringSchemaExtractor(), - extractors.pydantic.PydanticSchemaExtractor(), - ], + schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ui=specs.SwaggerUI(), - # ui=specs.RapiDoc(), - # ui=specs.ReDoc(), ), ) jsonrpc_app.dispatcher.add_methods(methods) diff --git a/examples/openapi_aiohttp_subendpoints.py b/examples/openapi_aiohttp_subendpoints.py index 1e375fc..9968b3d 100644 --- a/examples/openapi_aiohttp_subendpoints.py +++ b/examples/openapi_aiohttp_subendpoints.py @@ -5,7 +5,6 @@ import pydantic as pd from aiohttp import web -import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic from pjrpc.common.exceptions import MethodNotFoundError from pjrpc.server.integration import aiohttp as integration @@ -13,8 +12,8 @@ from pjrpc.server.specs import openapi as specs from pjrpc.server.validators import pydantic as validators -user_methods = pjrpc.server.MethodRegistry() -post_methods = pjrpc.server.MethodRegistry() +user_methods_v1 = pjrpc.server.MethodRegistry() +user_methods_v2 = pjrpc.server.MethodRegistry() validator = validators.PydanticValidator() @@ -40,12 +39,12 @@ def default(self, o: Any) -> Any: UserAge = Annotated[ int, - pd.Field(description="User age", examples=[36]), + pd.Field(description="User age", examples=[25]), ] UserId = Annotated[ uuid.UUID, - pd.Field(description="User identifier", examples=["226a2c23-c98b-4729-b398-0dae550e99ff"]), + pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), ] @@ -77,28 +76,12 @@ class AlreadyExistsError(pjrpc.exc.JsonRpcError): @specs.annotate( - # tags=['users'], - errors=[AlreadyExistsError], - # examples=[ - # specs.MethodExample( - # summary="Simple example", - # params=dict( - # user={ - # 'name': 'John', - # 'surname': 'Doe', - # 'age': 25, - # }, - # ), - # result={ - # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - # 'name': 'John', - # 'surname': 'Doe', - # 'age': 25, - # }, - # ), - # ], + tags=['v1', 'users'], + errors=[ + AlreadyExistsError, + ], ) -@user_methods.add(context='request') +@user_methods_v1.add(context='request') @validator.validate def add_user(request: web.Request, user: UserIn) -> UserOut: """ @@ -116,74 +99,53 @@ def add_user(request: web.Request, user: UserIn) -> UserOut: return UserOut(id=user_id, **user.model_dump()) -PostTitle = Annotated[ +UserAddress = Annotated[ str, - pd.Field(description="Post title", examples=["About me"]), + pd.Field(description="User address", examples=["Brownsville, Texas, United States"]), ] -PostContent = Annotated[ - str, - pd.Field(description="Post content", examples=['Software engineer']), -] -PostId = Annotated[ - uuid.UUID, - pd.Field(description="Post identifier", examples=["226a2c23-c98b-4729-b398-0dae550e99ff"]), -] - - -class PostIn(pd.BaseModel): +class UserInV2(UserIn): """ User registration data. """ - title: PostTitle - content: PostContent + name: UserName + surname: UserSurname + age: UserAge + address: UserAddress -class PostOut(PostIn): +class UserOutV2(UserInV2): """ Registered user data. """ - id: PostId + id: UserId @specs.annotate( - # tags=['posts'], - errors=[AlreadyExistsError], - # examples=[ - # specs.MethodExample( - # summary="Simple example", - # params=dict( - # post={ - # 'title': 'Super post', - # 'content': 'My first post', - # }, - # ), - # result={ - # 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - # 'title': 'Super post', - # 'content': 'My first post', - # }, - # ), - # ], + tags=['v2', 'users'], + errors=[ + AlreadyExistsError, + ], ) -@post_methods.add(context='request') +@user_methods_v2.add(context='request', name='add_user') @validator.validate -def add_post(request: web.Request, post: PostIn) -> PostOut: +def add_user_v2(request: web.Request, user: UserInV2) -> UserOutV2: """ - Creates a post. + Creates a user. :param request: http request - :param object post: post data - :return object: created post + :param object user: user data + :return object: registered user + :raise AlreadyExistsError: user already exists """ - post_id = uuid.uuid4().hex - request.config_dict['posts'][post_id] = post + user_id = uuid.uuid4().hex + request.config_dict['users'][user_id] = user - return PostOut(id=post_id, **post.dict()) + return UserOutV2(id=user_id, **user.model_dump()) error_http_status_map = { @@ -192,7 +154,7 @@ def add_post(request: web.Request, post: PostIn) -> PostOut: } jsonrpc_app = integration.Application( - '/api/v1', + '/api', json_encoder=JSONEncoder, status_by_error=lambda codes: 200 if len(codes) != 1 else error_http_status_map.get(codes[0], 200), spec=specs.OpenAPI( @@ -211,12 +173,8 @@ def add_post(request: web.Request, post: PostIn) -> PostOut: security=[ dict(basicAuth=[]), ], - schema_extractors=[ - extractors.pydantic.PydanticSchemaExtractor(), - ], - # ui=specs.SwaggerUI(), - # ui=specs.RapiDoc(), - ui=specs.ReDoc(hide_schema_titles=True), + schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), + ui=specs.SwaggerUI(), error_http_status_map=error_http_status_map, ), ) @@ -224,22 +182,20 @@ def add_post(request: web.Request, post: PostIn) -> PostOut: jsonrpc_app.app['users'] = {} jsonrpc_app.app['posts'] = {} -jsonrpc_app.add_endpoint( - '/users', +jsonrpc_app_v1 = integration.Application( json_encoder=JSONEncoder, - spec_params=dict( - method_schema_extra={'tags': ['users']}, - component_name_prefix='V1', - ), -).add_methods(user_methods) -jsonrpc_app.add_endpoint( - '/posts', + status_by_error=lambda codes: 200 if len(codes) != 1 else error_http_status_map.get(codes[0], 200), +) +jsonrpc_app_v1.dispatcher.add_methods(user_methods_v1) + +jsonrpc_app_v2 = integration.Application( json_encoder=JSONEncoder, - spec_params=dict( - method_schema_extra={'tags': ['posts']}, - component_name_prefix='V1', - ), -).add_methods(post_methods) + status_by_error=lambda codes: 200 if len(codes) != 1 else error_http_status_map.get(codes[0], 200), +) +jsonrpc_app_v2.dispatcher.add_methods(user_methods_v2) + +jsonrpc_app.add_subapp('/v1', jsonrpc_app_v1) +jsonrpc_app.add_subapp('/v2', jsonrpc_app_v2) cors = aiohttp_cors.setup( diff --git a/examples/openapi_flask.py b/examples/openapi_flask.py index 8ffa95a..96a6022 100644 --- a/examples/openapi_flask.py +++ b/examples/openapi_flask.py @@ -1,13 +1,12 @@ import uuid -from typing import Any, Optional +from typing import Annotated, Any, Optional import flask import flask_cors import flask_httpauth -import pydantic +import pydantic as pd from werkzeug import security -import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import flask as integration from pjrpc.server.specs import extractors @@ -38,22 +37,43 @@ def _rpc_handle(self, dispatcher: pjrpc.server.Dispatcher) -> flask.Response: class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) -class UserIn(pydantic.BaseModel): +UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), +] + +UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), +] + +UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[25]), +] + +UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), +] + + +class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -61,7 +81,7 @@ class UserOut(UserIn): Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -83,26 +103,9 @@ class NotFoundError(pjrpc.exc.JsonRpcError): @specs.annotate( + summary='Creates a user', tags=['users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate @@ -122,30 +125,17 @@ def add_user(user: UserIn) -> UserOut: user_id = uuid.uuid4().hex flask.current_app.users_db[user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Returns a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) @methods.add @validator.validate -def get_user(user_id: uuid.UUID) -> UserOut: +def get_user(user_id: UserId) -> UserOut: """ Returns a user. @@ -158,25 +148,17 @@ def get_user(user_id: uuid.UUID) -> UserOut: if not user: raise NotFoundError() - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Deletes a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - summary='Simple example', - params=dict( - user_id='c47726c6-a232-45f1-944f-60b98966ff1b', - ), - result=None, - ), - ], ) @methods.add @validator.validate -def delete_user(user_id: uuid.UUID) -> None: +def delete_user(user_id: UserId) -> None: """ Deletes a user. @@ -208,13 +190,8 @@ def delete_user(user_id: uuid.UUID) -> None: security=[ dict(basicAuth=[]), ], - schema_extractors=[ - extractors.docstring.DocstringSchemaExtractor(), - extractors.pydantic.PydanticSchemaExtractor(), - ], + schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ui=specs.SwaggerUI(), - # ui=specs.RapiDoc(), - # ui=specs.ReDoc(), ), ) json_rpc.dispatcher.add_methods(methods) diff --git a/examples/openapi_flask_subendpoints.py b/examples/openapi_flask_subendpoints.py index 80ff726..25c701a 100644 --- a/examples/openapi_flask_subendpoints.py +++ b/examples/openapi_flask_subendpoints.py @@ -1,10 +1,9 @@ import uuid -from typing import Any +from typing import Annotated, Any import flask -import pydantic +import pydantic as pd -import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import flask as integration from pjrpc.server.specs import extractors @@ -13,29 +12,50 @@ app = flask.Flask('myapp') -user_methods = pjrpc.server.MethodRegistry() -post_methods = pjrpc.server.MethodRegistry() +user_methods_v1 = pjrpc.server.MethodRegistry() +user_methods_v2 = pjrpc.server.MethodRegistry() validator = validators.PydanticValidator() class JSONEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) -class UserIn(pydantic.BaseModel): +UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), +] + +UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), +] + +UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[36]), +] + +UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["226a2c23-c98b-4729-b398-0dae550e99ff"]), +] + + +class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -43,7 +63,7 @@ class UserOut(UserIn): Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -56,28 +76,10 @@ class AlreadyExistsError(pjrpc.exc.JsonRpcError): @specs.annotate( - tags=['users'], + tags=['v1', 'users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - user={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], ) -@user_methods.add +@user_methods_v1.add @validator.validate def add_user(user: UserIn) -> UserOut: """ @@ -91,64 +93,57 @@ def add_user(user: UserIn) -> UserOut: user_id = uuid.uuid4().hex flask.current_app.db['users'][user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) + + +UserAddress = Annotated[ + str, + pd.Field(description="User address", examples=["Brownsville, Texas, United States"]), +] -class PostIn(pydantic.BaseModel): +class UserInV2(pd.BaseModel): """ User registration data. """ - title: str - content: str + name: UserName + surname: UserSurname + age: UserAge + address: UserAddress -class PostOut(PostIn): +class UserOutV2(UserInV2): """ Registered user data. """ - id: uuid.UUID + id: UserId @specs.annotate( - tags=['posts'], + tags=['v2', 'users'], errors=[AlreadyExistsError], - examples=[ - specs.MethodExample( - summary="Simple example", - params=dict( - post={ - 'title': 'Super post', - 'content': 'My first post', - }, - ), - result={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'title': 'Super post', - 'content': 'My first post', - }, - ), - ], ) -@post_methods.add +@user_methods_v2.add(name='add_user') @validator.validate -def add_post(post: PostIn) -> PostOut: +def add_user_v2(user: UserInV2) -> UserOutV2: """ - Creates a post. + Creates a user. - :param object post: post data - :return object: created post + :param object user: user data + :return object: registered user + :raise AlreadyExistsError: user already exists """ - post_id = uuid.uuid4().hex - flask.current_app.db['posts'][post_id] = post + user_id = uuid.uuid4().hex + flask.current_app.db['users'][user_id] = user - return PostOut(id=post_id, **post.dict()) + return UserOutV2(id=user_id, **user.model_dump()) json_rpc = integration.JsonRPC( - '/api/v1', + '/api', json_encoder=JSONEncoder, spec=specs.OpenAPI( info=specs.Info(version="1.0.0", title="User storage"), @@ -166,17 +161,14 @@ def add_post(post: PostIn) -> PostOut: security=[ dict(basicAuth=[]), ], - schema_extractors=[ - extractors.docstring.DocstringSchemaExtractor(), - extractors.pydantic.PydanticSchemaExtractor(), - ], + schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ui=specs.SwaggerUI(), ), ) -app.db = {'users': {}, 'posts': {}} +app.db = {'users': {}} -json_rpc.add_endpoint('/users', json_encoder=JSONEncoder).add_methods(user_methods) -json_rpc.add_endpoint('/posts', json_encoder=JSONEncoder).add_methods(post_methods) +json_rpc.add_endpoint('/v1', json_encoder=JSONEncoder).add_methods(user_methods_v1) +json_rpc.add_endpoint('/v2', json_encoder=JSONEncoder).add_methods(user_methods_v2) json_rpc.init_app(app) if __name__ == "__main__": diff --git a/examples/openrpc_aiohttp.py b/examples/openrpc_aiohttp.py index e34e47b..f3e09ca 100644 --- a/examples/openrpc_aiohttp.py +++ b/examples/openrpc_aiohttp.py @@ -5,7 +5,6 @@ import pydantic as pd from aiohttp import web -import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import aiohttp as integration from pjrpc.server.specs import extractors @@ -109,29 +108,13 @@ def add_user(request: web.Request, user: UserIn) -> UserOut: user_id = uuid.uuid4().hex request.config_dict['users'][user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( summary="Returns a user", tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - name="Simple", - params=[ - specs.ExampleObject( - name="user_id", - value="49b2ee18-6fd2-4840-bd27-a208117fca41", - ), - ], - result=specs.ExampleObject( - name="result", - value={"name": "Alex", "surname": "Jones", "age": 34, "id": "49b2ee18-6fd2-4840-bd27-a208117fca41"}, - ), - summary="Simple example", - ), - ], ) @methods.add(context='request') @validator.validate @@ -149,7 +132,7 @@ def get_user(request: web.Request, user_id: UserId) -> UserOut: if not user: raise NotFoundError() - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( @@ -191,18 +174,7 @@ def delete_user(request: web.Request, user_id: UserId) -> None: schema_extractor=extractors.pydantic.PydanticSchemaExtractor(), ), ) -jsonrpc_app.add_endpoint( - '/v1', - spec_params=dict( - servers=[ - specs.Server( - name="v1", - url="http://127.0.0.1:8080/myapp/api/v1", - ), - ], - tags=['v1'], - ), -).add_methods(methods) +jsonrpc_app.add_endpoint('/v1').add_methods(methods) app.add_subapp('/myapp', jsonrpc_app.app) cors = aiohttp_cors.setup( diff --git a/examples/openrpc_flask.py b/examples/openrpc_flask.py index dca5f0d..42734ae 100644 --- a/examples/openrpc_flask.py +++ b/examples/openrpc_flask.py @@ -1,11 +1,10 @@ import uuid -from typing import Any +from typing import Annotated, Any import flask -import pydantic +import pydantic as pd from flask_cors import CORS -import pjrpc.server.specs.extractors.docstring import pjrpc.server.specs.extractors.pydantic from pjrpc.server.integration import flask as integration from pjrpc.server.specs import extractors @@ -21,22 +20,43 @@ class JsonEncoder(pjrpc.JSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, pydantic.BaseModel): - return o.dict() + if isinstance(o, pd.BaseModel): + return o.model_dump() if isinstance(o, uuid.UUID): return str(o) return super().default(o) -class UserIn(pydantic.BaseModel): +UserName = Annotated[ + str, + pd.Field(description="User name", examples=["John"]), +] + +UserSurname = Annotated[ + str, + pd.Field(description="User surname", examples=['Doe']), +] + +UserAge = Annotated[ + int, + pd.Field(description="User age", examples=[25]), +] + +UserId = Annotated[ + uuid.UUID, + pd.Field(description="User identifier", examples=["c47726c6-a232-45f1-944f-60b98966ff1b"]), +] + + +class UserIn(pd.BaseModel): """ User registration data. """ - name: str - surname: str - age: int + name: UserName + surname: UserSurname + age: UserAge class UserOut(UserIn): @@ -44,7 +64,7 @@ class UserOut(UserIn): Registered user data. """ - id: uuid.UUID + id: UserId class AlreadyExistsError(pjrpc.exc.JsonRpcError): @@ -66,32 +86,9 @@ class NotFoundError(pjrpc.exc.JsonRpcError): @specs.annotate( + summary='Adds a new user', errors=[AlreadyExistsError], tags=['users'], - examples=[ - specs.MethodExample( - name='Simple user', - params=[ - specs.ExampleObject( - name='user', - value={ - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ], - result=specs.ExampleObject( - name='result', - value={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ), - ], ) @methods.add @validator.validate @@ -111,38 +108,17 @@ def add_user(user: UserIn) -> UserOut: user_id = uuid.uuid4().hex flask.current_app.users_db[user_id] = user - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Returns a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - name='Simple example', - params=[ - specs.ExampleObject( - name='user', - value={ - 'user_id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - }, - ), - ], - result=specs.ExampleObject( - name="result", - value={ - 'id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - 'name': 'John', - 'surname': 'Doe', - 'age': 25, - }, - ), - ), - ], ) @methods.add @validator.validate -def get_user(user_id: uuid.UUID) -> UserOut: +def get_user(user_id: UserId) -> UserOut: """ Returns a user. @@ -155,34 +131,17 @@ def get_user(user_id: uuid.UUID) -> UserOut: if not user: raise NotFoundError() - return UserOut(id=user_id, **user.dict()) + return UserOut(id=user_id, **user.model_dump()) @specs.annotate( + summary='Deletes a user', tags=['users'], errors=[NotFoundError], - examples=[ - specs.MethodExample( - name='Simple example', - summary='Simple example', - params=[ - specs.ExampleObject( - name='user', - value={ - 'user_id': 'c47726c6-a232-45f1-944f-60b98966ff1b', - }, - ), - ], - result=specs.ExampleObject( - name="result", - value=None, - ), - ), - ], ) @methods.add @validator.validate -def delete_user(user_id: uuid.UUID) -> None: +def delete_user(user_id: UserId) -> None: """ Deletes a user. diff --git a/examples/pydantic_validator.py b/examples/pydantic_validator.py index ea689d8..b608afd 100644 --- a/examples/pydantic_validator.py +++ b/examples/pydantic_validator.py @@ -36,7 +36,7 @@ async def add_user(request: web.Request, user: User): user_id = uuid.uuid4() request.app['users'][user_id] = user - return {'id': user_id, **user.dict()} + return {'id': user_id, **user.model_dump()} class JSONEncoder(pjrpc.server.JSONEncoder): From 715f82a375bd35f92cc34bb5ae6193a9c2b4811d Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Tue, 12 Nov 2024 01:03:56 +0500 Subject: [PATCH 8/9] python 3.13 support added. --- .github/workflows/test.yml | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb000a5..1b36397 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index bb18293..bb6dd94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,16 +27,16 @@ classifiers = [ "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] [tool.poetry.dependencies] -python = ">=3.8,<4.0" +python = ">=3.9,<4.0" aio-pika = { version = ">=8.0", optional = true } aiofiles = { version = ">=0.7", optional = true } aiohttp = { version = ">=3.7", optional = true } From 5f7adf485bad5e6ed1c290ea900db7f2f91774db Mon Sep 17 00:00:00 2001 From: Dmitry Pershin Date: Tue, 12 Nov 2024 01:14:13 +0500 Subject: [PATCH 9/9] bump version 1.10.0 --- CHANGELOG.rst | 10 ++++++++++ pjrpc/__about__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d72a168..1be8fd7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog ========= +1.10.0 (2024-11-12) +------------------ + +- openapi 3.x support added. +- batch size validation support added. +- custom http server response status added. +- raise_for_status flag added for http clients. +- python 3.12, 3.13 support added. + + 1.9.0 (2024-04-22) ------------------ diff --git a/pjrpc/__about__.py b/pjrpc/__about__.py index 99f6ad7..2428073 100644 --- a/pjrpc/__about__.py +++ b/pjrpc/__about__.py @@ -2,7 +2,7 @@ __description__ = 'Extensible JSON-RPC library' __url__ = 'https://github.com/dapper91/pjrpc' -__version__ = '1.9.0' +__version__ = '1.10.0' __author__ = 'Dmitry Pershin' __email__ = 'dapper1291@gmail.com' diff --git a/pyproject.toml b/pyproject.toml index bb6dd94..0ead851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pjrpc" -version = "1.9.0" +version = "1.10.0" description = "Extensible JSON-RPC library" authors = ["Dmitry Pershin "] license = "Unlicense"