diff --git a/.github/workflows/on_update.yml b/.github/workflows/on_update.yml index 7fdf4eec..ae6fe18d 100644 --- a/.github/workflows/on_update.yml +++ b/.github/workflows/on_update.yml @@ -79,7 +79,7 @@ jobs: - name: Run tox if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.12' }} run: | - tox -e py,py-async,style,typing-mypy,security-safety,security-bandit,docs -p all + tox -e py,py-async,style,typing-mypy,typing-pyright,security-safety,security-bandit,docs -p all - name: Run tox (Pytype) if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.11' }} run: | diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 1c244513..f0749ad6 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -34,7 +34,7 @@ jobs: - name: Run tox if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.12' }} run: | - tox -e py,py-async,style,typing-mypy,security-safety,security-bandit,docs -p all + tox -e py,py-async,style,typing-mypy,typing-pyright,security-safety,security-bandit,docs -p all - name: Run tox (Pytype) if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.11' }} run: | diff --git a/Makefile b/Makefile index 16ace947..680820f5 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,10 @@ clean: @find . -name ".coverage" | xargs rm -rf @rm -rf .coverage coverage.* .eggs/ .mypy_cache/ .pytype/ .ruff_cache/ .pytest_cache/ .tox/ src/Flask_JSONRPC.egg-info/ htmlcov/ junit/ htmldoc/ build/ dist/ wheelhouse/ +style: + @ruff check . + @ruff format . + test: clean @python -m pip install --upgrade tox @python -m tox diff --git a/examples/openrpc/README.rst b/examples/openrpc/README.rst index a6451d66..6924814c 100644 --- a/examples/openrpc/README.rst +++ b/examples/openrpc/README.rst @@ -17,7 +17,7 @@ Testing your service 2. Testing :: - $ curl 'http://localhost:5000/api' -X POST \ + $ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \ --data-raw '{ "jsonrpc": "2.0", "method": "Petstore.create_pet", @@ -40,7 +40,7 @@ Testing your service :: - $ curl 'http://localhost:5000/api' -X POST \ + $ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \ --data-raw '{ "jsonrpc": "2.0", "method": "Petstore.get_pets", @@ -77,7 +77,7 @@ Testing your service :: - $ curl 'http://localhost:5000/api' -X POST \ + $ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \ --data-raw '{ "jsonrpc": "2.0", "method": "Petstore.get_pet_by_id", diff --git a/examples/pydantic/README.rst b/examples/pydantic/README.rst new file mode 100644 index 00000000..f56edb9b --- /dev/null +++ b/examples/pydantic/README.rst @@ -0,0 +1,117 @@ +petstore +======= + +A petstore application. + + +Testing your service +******************** + +1. Running + +:: + + $ python petstore.py + * Running on http://0.0.0.0:5000/ + +2. Testing + +:: + $ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \ + --data-raw '{ + "jsonrpc": "2.0", + "method": "Petstore.create_pet", + "params": { + "pet": { + "name": "Jhon", + "tag": "cat" + } + }, + "id": "1c7fb3b2-7a87-4cf7-8e28-aafc33dae71d" + }' + { + "id": "1c7fb3b2-7a87-4cf7-8e28-aafc33dae71d", + "jsonrpc": "2.0",P + "result": { + "id": 32, + "name": "Jhon", + "tag": "cat" + } + } + + + +:: + $ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \ + --data-raw '{ + "jsonrpc": "2.0", + "method": "Petstore.get_pets", + "params": {}, + "id": "16ebeed1-748c-4983-ba19-2848692c873a" + }' + { + "id": "16ebeed1-748c-4983-ba19-2848692c873a", + "jsonrpc": "2.0", + "result": [ + { + "id": 1, + "name": "Bob", + "tag": "dog" + }, + { + "id": 2, + "name": "Eve", + "tag": "cat" + }, + { + "id": 3, + "name": "Alice", + "tag": "bird" + }, + { + "id": 32, + "name": "Jhon", + "tag": "cat" + } + ] + } + + + +:: + $ curl 'http://localhost:5000/api' -X POST -H "Content-Type: application/json; indent=4" \ + --data-raw '{ + "jsonrpc": "2.0", + "method": "Petstore.get_pet_by_id", + "params": { + "id": 32 + }, + "id": "5dfbd1c0-6919-4ce2-a05e-0b4a4aa2aeb2" + }' + { + "id": "5dfbd1c0-6919-4ce2-a05e-0b4a4aa2aeb2", + "jsonrpc": "2.0", + "result": { + "id": 32, + "name": "Jhon", + "tag": "cat" + } + } + + + +:: + $ curl 'http://localhost:5000/api' -X POST -H 'Content-Type: application/json;charset=utf-8' \ + --data-raw '{ + "jsonrpc": "2.0", + "method": "Petstore.delete_pet_by_id", + "params": { + "id": 32 + }, + "id": "706cf9c3-5b5d-4288-8555-a67c8b5de481" + }' + { + "id": "706cf9c3-5b5d-4288-8555-a67c8b5de481", + "jsonrpc": "2.0", + "result": null + } diff --git a/examples/pydantic/run.py b/examples/pydantic/run.py new file mode 100755 index 00000000..b01ec297 --- /dev/null +++ b/examples/pydantic/run.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# isort:skip_file +import os +import sys +import typing as t +import random + +from flask import Flask + +try: + from flask_jsonrpc import JSONRPC +except ModuleNotFoundError: + project_dir, project_module_name = os.path.split(os.path.dirname(os.path.realpath(__file__))) + flask_jsonrpc_project_dir = os.path.join(project_dir, os.pardir, 'src') + if os.path.exists(flask_jsonrpc_project_dir) and flask_jsonrpc_project_dir not in sys.path: + sys.path.append(flask_jsonrpc_project_dir) + + from flask_jsonrpc import JSONRPC + +from pydantic import BaseModel + +from flask_jsonrpc.contrib.openrpc import OpenRPC +from flask_jsonrpc.contrib.openrpc import typing as st + +app = Flask('openrpc') +jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) +openrpc = OpenRPC( + app, + jsonrpc, + openrpc_schema=st.OpenRPCSchema( + openrpc='1.0.0-rc1', + info=st.Info( + version='1.0.0', + title='Petstore Expanded', + description=( + 'A sample API that uses a petstore as an example to ' + 'demonstrate features in the OpenRPC specification' + ), + terms_of_service='https://open-rpc.org', + contact=st.Contact(name='OpenRPC Team', email='doesntexist@open-rpc.org', url='https://open-rpc.org'), + license=st.License(name='Apache 2.0', url='https://www.apache.org/licenses/LICENSE-2.0.html'), + ), + servers=[st.Server(url='http://petstore.open-rpc.org')], + components=st.Components( + schemas={ + 'Pet': st.Schema( + all_of=[ + st.Schema(ref='#/components/schemas/NewPet'), + st.Schema(required=['id'], properties={'id': st.Schema(type=st.SchemaDataType.INTEGER)}), + ] + ), + 'NewPet': st.Schema( + type=st.SchemaDataType.OBJECT, + required=['name'], + properties={ + 'name': st.Schema(type=st.SchemaDataType.STRING), + 'tag': st.Schema(type=st.SchemaDataType.STRING), + }, + ), + } + ), + external_docs=st.ExternalDocumentation( + url='https://github.com/open-rpc/examples/blob/master/service-descriptions/petstore-expanded-openrpc.json' + ), + ), +) + + +class NewPet(BaseModel): + name: str + tag: str + + +class Pet(NewPet): + id: int + + +PETS = [Pet(id=1, name='Bob', tag='dog'), Pet(id=2, name='Eve', tag='cat'), Pet(id=3, name='Alice', tag='bird')] + + +@openrpc.extend_schema( + name='Petstore.get_pets', + description=( + 'Returns all pets from the system that the user has access to\n' + 'Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem ' + 'sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio ' + 'lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ' + 'ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer ' + 'at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie ' + 'imperdiet. Vivamus id aliquam diam.' + ), + params=[ + st.ContentDescriptor( + name='tags', + description='tags to filter by', + schema=st.Schema(type=st.SchemaDataType.ARRAY, items=st.Schema(type=st.SchemaDataType.STRING)), + ), + st.ContentDescriptor( + name='limit', + description='maximum number of results to return', + schema=st.Schema(type=st.SchemaDataType.INTEGER), + ), + ], + result=st.ContentDescriptor( + name='pet', + description='pet response', + schema=st.Schema(type=st.SchemaDataType.ARRAY, items=st.Schema(ref='#/components/schemas/Pet')), + ), +) +@jsonrpc.method('Petstore.get_pets') +def get_pets(tags: t.Optional[t.List[str]] = None, limit: t.Optional[int] = None) -> t.List[Pet]: + pets = PETS + if tags is not None: + pets = [pet for pet in pets if pet.tag in tags] + if limit is not None: + pets = pets[:limit] + return pets + + +@openrpc.extend_schema( + name='Petstore.create_pet', + description='Creates a new pet in the store. Duplicates are allowed', + params=[ + st.ContentDescriptor( + name='newPet', description='Pet to add to the store.', schema=st.Schema(ref='#/components/schemas/NewPet') + ) + ], + result=st.ContentDescriptor( + name='pet', description='the newly created pet', schema=st.Schema(ref='#/components/schemas/Pet') + ), +) +@jsonrpc.method('Petstore.create_pet') +def create_pet(pet: NewPet) -> Pet: + pet = Pet(id=random.randint(4, 100), name=pet.name, tag=pet.tag) + PETS.append(pet) + return pet + + +@openrpc.extend_schema( + name='Petstore.get_pet_by_id', + description='Returns a user based on a single ID, if the user does not have access to the pet', + params=[ + st.ContentDescriptor( + name='id', description='ID of pet to fetch', required=True, schema=st.Schema(type=st.SchemaDataType.INTEGER) + ) + ], + result=st.ContentDescriptor( + name='pet', description='pet response', schema=st.Schema(ref='#/components/schemas/Pet') + ), +) +@jsonrpc.method('Petstore.get_pet_by_id') +def get_pet_by_id(id: int) -> t.Optional[Pet]: + pet = [pet for pet in PETS if pet.id == id] + return None if len(pet) == 0 else pet[0] + + +@openrpc.extend_schema( + name='Petstore.delete_pet_by_id', + description='deletes a single pet based on the ID supplied', + params=[ + st.ContentDescriptor( + name='id', + description='ID of pet to delete', + required=True, + schema=st.Schema(type=st.SchemaDataType.INTEGER), + ) + ], + result=st.ContentDescriptor(name='pet', description='pet deleted', schema=st.Schema()), +) +@jsonrpc.method('Petstore.delete_pet_by_id') +def delete_pet_by_id(id: int) -> None: + global PETS + PETS = [pet for pet in PETS if pet.id != id] # noqa: F823, F841 + + +if __name__ == '__main__': + app.run(host='0.0.0.0', debug=True) diff --git a/pyproject.toml b/pyproject.toml index 50461a6e..fd6bf612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Flask-JSONRPC" -version = "4.0.0a0" +version = "4.0.0a1" description = "Adds JSONRPC support to Flask." readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE.txt"} @@ -30,7 +30,7 @@ dependencies = [ "typeguard==2.13.3", "typing_extensions>=4.3.0", "typing_inspect==0.9.0", - "dacite==1.8.1", + "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", ] [project.optional-dependencies] @@ -214,14 +214,18 @@ exclude_lines = [ ] [tool.mypy] +plugins = ["pydantic.mypy"] files = ["src/flask_jsonrpc"] python_version = "3.12" +pretty = true +strict = true check_untyped_defs = true ignore_errors = false ignore_missing_imports = false show_error_codes = true -pretty = true -strict = true +disallow_any_generics = true +no_implicit_reexport = true +disallow_untyped_defs = true [[tool.mypy.overrides]] module = [ @@ -232,7 +236,17 @@ module = [ ] ignore_missing_imports = true +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + [tool.pytype] inputs = ["src/flask_jsonrpc"] python_version = "3.11" disable = ["invalid-annotation"] + +[tool.pyright] +pythonVersion = "3.12" +include = ["src/flask_jsonrpc"] +typeCheckingMode = "basic" diff --git a/requirements/base.txt b/requirements/base.txt index f71b2be9..04f680ca 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,6 +8,6 @@ typeguard==2.13.3 # https://github.com/agronholm/typeguard typing_extensions>=4.3.0 # https://github.com/python/typing/blob/master/typing_extensions/README.rst typing_inspect==0.9.0 # https://github.com/ilevkivskyi/typing_inspect -# OpenRPC +# Data types # ------------------------------------------------------------------------------ -dacite==1.8.1 # https://github.com/konradhalas/dacite +pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4 # https://github.com/pydantic/pydantic diff --git a/requirements/typing.txt b/requirements/typing.txt index 3d0b6b30..461f89ce 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -4,3 +4,4 @@ mypy==1.11.2;python_version>="3.11" # https://github.com/python/mypy pytype==2024.1.5;python_version>="3.11" and python_version<"3.12" # https://github.com/google/pytype types_setuptools==75.1.0.20240917 # https://github.com/python/typeshed typeguard==2.13.3 # https://github.com/agronholm/typeguard +pyright==1.1.380;python_version>="3.11" # https://github.com/microsoft/pyright diff --git a/setup.py b/setup.py index 3cf9ac21..90e50cb3 100755 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ def find_python_files(path: pathlib.Path) -> t.List[pathlib.Path]: '--strict', '--check-untyped-defs', '--ignore-missing-imports', + '--disallow-untyped-defs', '--disable-error-code', 'unused-ignore', '--disable-error-code', diff --git a/src/flask_jsonrpc/app.py b/src/flask_jsonrpc/app.py index aa32e101..9ef4d3a2 100644 --- a/src/flask_jsonrpc/app.py +++ b/src/flask_jsonrpc/app.py @@ -143,6 +143,6 @@ def init_browse_app(self: Self, app: Flask, path: t.Optional[str] = None, base_u def register_browse(self: Self, jsonrpc_app: t.Union['JSONRPC', 'JSONRPCBlueprint']) -> None: if not self.jsonrpc_browse: raise RuntimeError( - 'You need to init the Browse app before register the Site, see JSONRPC.init_browse_app(...)' + 'you need to init the Browse app before register the Site, see JSONRPC.init_browse_app(...)' ) self.jsonrpc_browse.register_jsonrpc_site(jsonrpc_app.get_jsonrpc_site()) diff --git a/src/flask_jsonrpc/contrib/browse/__init__.py b/src/flask_jsonrpc/contrib/browse/__init__.py index bc84c523..6a26d392 100644 --- a/src/flask_jsonrpc/contrib/browse/__init__.py +++ b/src/flask_jsonrpc/contrib/browse/__init__.py @@ -27,9 +27,10 @@ import typing as t from collections import ChainMap -from flask import Blueprint, jsonify, request, render_template +from flask import Blueprint, request, render_template from flask_jsonrpc.helpers import urn +from flask_jsonrpc.encoders import jsonify, serializable # Python 3.11+ try: @@ -56,7 +57,7 @@ def __init__( self.init_app(app) def _service_methods_desc(self: Self) -> t.Dict[str, 'ServiceMethodDescribe']: - return dict(ChainMap(*[site.describe()['methods'] for site in self.jsonrpc_sites])) + return dict(ChainMap(*[site.describe().methods for site in self.jsonrpc_sites])) def init_app(self: Self, app: 'Flask') -> None: name = urn('browse', app.name, self.url_prefix) @@ -79,7 +80,7 @@ def vf_index(self: Self) -> str: server_urls: t.Dict[str, str] = {} service_describes = [site.describe() for site in self.jsonrpc_sites] for service_describe in service_describes: - server_urls.update({name: service_describe['servers'][0]['url'] for name in service_describe['methods']}) + server_urls.update({name: service_describe.servers[0].url for name in service_describe.methods}) url_prefix = f"{request.script_root}{request.path.rstrip('/')}" return render_template('browse/index.html', url_prefix=url_prefix, server_urls=server_urls) @@ -92,14 +93,16 @@ def vf_json_packages(self: Self) -> 'ft.ResponseReturnValue': if package.startswith('rpc.'): continue package_name = package.split('.')[0] - packages_tree.setdefault(package_name, []).append({'name': package, **service_methods[package]}) + packages_tree.setdefault(package_name, []).append( + {'name': package, **serializable(service_methods[package])} + ) return jsonify(packages_tree) def vf_json_method(self: Self, method_name: str) -> 'ft.ResponseReturnValue': service_procedures = self._service_methods_desc() if method_name not in service_procedures: return jsonify({'message': 'Not found'}), 404 - return jsonify({'name': method_name, **service_procedures[method_name]}) + return jsonify({'name': method_name, **serializable(service_procedures[method_name])}) def vf_partials_dashboard(self: Self) -> str: return render_template('browse/partials/dashboard.html') diff --git a/src/flask_jsonrpc/contrib/openrpc/helpers.py b/src/flask_jsonrpc/contrib/openrpc/helpers.py deleted file mode 100644 index e92c3d45..00000000 --- a/src/flask_jsonrpc/contrib/openrpc/helpers.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# * Neither the name of the Cenobit Technologies nor the names of -# its contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -import typing as t -from dataclasses import fields, is_dataclass - -from dacite import from_dict - -from .typing import Method, OpenRPCSchema - - -def _dataclass_to_dict(obj: t.Any) -> t.Dict[str, t.Any]: # noqa: ANN401 - data: t.Dict[str, t.Any] = {} - for field in fields(obj): - field_metadata = field.metadata - field_name = field_metadata.get('field_name', field.name) - field_value = getattr(obj, field.name) - - if field_value is None: - continue - - if is_dataclass(field_value): - data[field_name] = _dataclass_to_dict(field_value) - continue - - if isinstance(field_value, list): - col_items = [] - for item in field_value: - item_value = _dataclass_to_dict(item) if is_dataclass(item) else item - col_items.append(item_value) - data[field_name] = col_items - continue - - if isinstance(field_value, dict): - map_items: t.Dict[str, t.Any] = {} - for k, v in field_value.items(): - map_items[k] = _dataclass_to_dict(v) if is_dataclass(v) else v - data[field_name] = map_items - continue - - decode = field_metadata.get('decode') - if decode is not None: - data[field_name] = decode(field_value) - continue - - data[field_name] = field_value - return data - - -def openrpc_schema_to_dict(openrpc_schema: OpenRPCSchema) -> t.Dict[str, t.Any]: - """Return the fields of the OpenRPCSchema dataclass instance as a - new dictionary mapping field names to field values. - """ - return _dataclass_to_dict(openrpc_schema) - - -def openrpc_method_schema_from_dict(data: t.Dict[str, t.Any]) -> Method: - """Create a Method data class instance from a dictionary.""" - return from_dict(data_class=Method, data=data) diff --git a/src/flask_jsonrpc/contrib/openrpc/methods.py b/src/flask_jsonrpc/contrib/openrpc/methods.py index 1b2bcce1..126f990c 100644 --- a/src/flask_jsonrpc/contrib/openrpc/methods.py +++ b/src/flask_jsonrpc/contrib/openrpc/methods.py @@ -27,9 +27,10 @@ import typing as t from collections import OrderedDict +from flask_jsonrpc.encoders import serializable + from . import typing as st from .utils import MethodExtendSchema, extend_schema -from .helpers import openrpc_schema_to_dict, openrpc_method_schema_from_dict # Python 3.9+ try: @@ -46,7 +47,7 @@ def _openrpc_discover_method( jsonrpc_sites: t.List['JSONRPCSite'], *, openrpc_schema: st.OpenRPCSchema -) -> t.Callable[..., t.Dict[str, t.Any]]: +) -> t.Callable[..., st.OpenRPCSchema]: @cache @extend_schema( name=OPENRPC_DISCOVER_METHOD_NAME, @@ -57,17 +58,17 @@ def _openrpc_discover_method( schema=st.Schema(ref='https://raw.githubusercontent.com/open-rpc/meta-schema/master/schema.json'), ), ) - def openrpc_discover() -> t.Dict[str, t.Any]: + def cached_openrpc_discover_method() -> st.OpenRPCSchema: jsonrpc_site = jsonrpc_sites[0] service_describe_methods = OrderedDict( (name, (method_describe, jsonrpc_site.view_funcs[name])) - for name, method_describe in jsonrpc_site.describe()['methods'].items() + for name, method_describe in jsonrpc_site.describe().methods.items() ) for jsonrpc_site in jsonrpc_sites[1:]: service_describe_methods.update( OrderedDict( (name, (method_describe, jsonrpc_site.view_funcs[name])) - for name, method_describe in jsonrpc_site.describe()['methods'].items() + for name, method_describe in jsonrpc_site.describe().methods.items() # To ensure that has only one rpc.* method, the others will be disregarded. if not name.startswith('rpc.') ) @@ -75,46 +76,43 @@ def openrpc_discover() -> t.Dict[str, t.Any]: for name, (method_describe, view_func) in service_describe_methods.items(): fn_openrpc_method_schema = getattr(view_func, 'openrpc_method_schema', MethodExtendSchema()) # noqa: B010 - openrpc_method_schema = {k: v for k, v in fn_openrpc_method_schema.items() if v is not None} - method_schema = { - 'name': openrpc_method_schema.get('name', name), - 'description': openrpc_method_schema.get('description', method_describe['description']), + 'name': fn_openrpc_method_schema.name or name, + 'description': fn_openrpc_method_schema.description or method_describe.description, 'result': { 'name': 'default', - 'schema': {'type': st.SchemaDataType.of(method_describe['returns']['type'])}, + 'schema': {'type': st.SchemaDataType.from_rpc_describe_type(method_describe.returns.type)}, }, } method_params_schema = [] - for param in method_describe['params']: + for param in method_describe.params: method_params_schema.append( { - 'name': param['name'], - 'schema': {'type': st.SchemaDataType.of(param['type'])}, - 'required': param['required'] or None, + 'name': param.name, + 'schema': {'type': st.SchemaDataType.from_rpc_describe_type(param.type)}, + 'required': param.required or None, } ) method_schema['params'] = method_params_schema + method_schema_merged = st.Method( + **{**serializable(method_schema), **serializable(fn_openrpc_method_schema)} + ) + openrpc_schema.methods.append(method_schema_merged) + return openrpc_schema - method_schema_merged = {**method_schema, **openrpc_method_schema} - openrpc_schema.methods.append(openrpc_method_schema_from_dict(method_schema_merged)) - return openrpc_schema_to_dict(openrpc_schema) - - return openrpc_discover + return cached_openrpc_discover_method # pyright: ignore def openrpc_discover_method( jsonrpc_sites: t.List['JSONRPCSite'], *, openrpc_schema: t.Optional[st.OpenRPCSchema] = None -) -> t.Callable[..., t.Dict[str, t.Any]]: +) -> t.Callable[..., st.OpenRPCSchema]: if openrpc_schema is None: jsonrpc_site = jsonrpc_sites[0] jsonrpc_service_describe = jsonrpc_site.describe() openrpc_schema = st.OpenRPCSchema( info=st.Info( - title=jsonrpc_service_describe['name'], - version='0.0.1', - description=jsonrpc_service_describe['description'], + title=jsonrpc_service_describe.name, version='0.0.1', description=jsonrpc_service_describe.description ), - servers=st.Server(name='default', url=jsonrpc_service_describe['servers'][0]['url']), + servers=st.Server(name='default', url=jsonrpc_service_describe.servers[0].url), ) return _openrpc_discover_method(jsonrpc_sites, openrpc_schema=openrpc_schema) diff --git a/src/flask_jsonrpc/contrib/openrpc/typing.py b/src/flask_jsonrpc/contrib/openrpc/typing.py index f59c61b3..56bcdac5 100644 --- a/src/flask_jsonrpc/contrib/openrpc/typing.py +++ b/src/flask_jsonrpc/contrib/openrpc/typing.py @@ -1,12 +1,7 @@ from enum import Enum import typing as t -from dataclasses import field, dataclass -# Python 3.10+ -try: - from typing import Self -except ImportError: # pragma: no cover - from typing_extensions import Self +from pydantic import Field, BaseModel, AliasChoices OPENRPC_VERSION_DEFAULT: str = '1.3.2' @@ -17,38 +12,42 @@ class OAuth2FlowType(Enum): PASSWORD = 'password' # nosec B105 -@dataclass -class OAuth2Flow: +class OAuth2Flow(BaseModel): type: OAuth2FlowType - authorization_url: t.Optional[str] = field(default=None, metadata={'field_name': 'authorizationUrl'}) - refresh_url: t.Optional[str] = field(default=None, metadata={'field_name': 'refreshUrl'}) - token_url: t.Optional[str] = field(default=None, metadata={'field_name': 'tokenUrl'}) - scopes: t.Dict[str, str] = field(default_factory=dict) + authorization_url: t.Optional[str] = Field( + default=None, + serialization_alias='authorizationUrl', + validation_alias=AliasChoices('authorizationUrl', 'authorizationUrl'), + ) + refresh_url: t.Optional[str] = Field( + default=None, serialization_alias='refreshUrl', validation_alias=AliasChoices('refresh_url', 'refreshUrl') + ) + token_url: t.Optional[str] = Field( + default=None, serialization_alias='tokenUrl', validation_alias=AliasChoices('token_url', 'tokenUrl') + ) + scopes: t.Dict[str, str] = Field(default_factory=dict) -@dataclass -class OAuth2: +class OAuth2(BaseModel): flows: t.List[OAuth2Flow] - type: t.Literal['oauth2'] = field(default='oauth2') - description: t.Optional[str] = field(default=None) + type: t.Literal['oauth2'] = Field(default='oauth2') + description: t.Optional[str] = None -@dataclass -class BearerAuth: - in_: str = field(metadata={'field_name': 'in'}) - type: t.Literal['bearer'] = field(default='bearer') - name: str = field(default='Authorization') - description: t.Optional[str] = field(default=None) - scopes: t.Dict[str, str] = field(default_factory=dict) +class BearerAuth(BaseModel): + in_: str = Field(alias='in') + type: t.Literal['bearer'] = Field(default='bearer') + name: str = Field(default='Authorization') + description: t.Optional[str] = None + scopes: t.Dict[str, str] = Field(default_factory=dict) -@dataclass -class APIKeyAuth: - in_: str = field(metadata={'field_name': 'in'}) - type: t.Literal['apikey'] = field(default='apikey') - name: str = field(default='api_key') - description: t.Optional[str] = field(default=None) - scopes: t.Dict[str, str] = field(default_factory=dict) +class APIKeyAuth(BaseModel): + in_: str = Field(alias='in') + type: t.Literal['apikey'] = Field(default='apikey') + name: str = Field(default='api_key') + description: t.Optional[str] = None + scopes: t.Dict[str, str] = Field(default_factory=dict) class ParamStructure(Enum): @@ -66,219 +65,292 @@ class SchemaDataType(Enum): STRING = 'string' @staticmethod - def of(st: str) -> 'SchemaDataType': + def from_rpc_describe_type(st: str) -> 'SchemaDataType': type_name = st.lower() if type_name == 'number': return SchemaDataType.INTEGER return SchemaDataType(type_name) -@dataclass -class Schema: - id: t.Optional[str] = field(default=None, metadata={'field_name': '$id'}) - title: t.Optional[str] = field(default=None) - format: t.Optional[str] = field(default=None) - enum: t.Optional[t.List[t.Any]] = field(default=None) - type: t.Optional[t.Union[SchemaDataType, t.List[SchemaDataType]]] = field( +class Schema(BaseModel): + id: t.Optional[str] = Field(default=None, serialization_alias='$id', validation_alias=AliasChoices('id', '$id')) + title: t.Optional[str] = None + format: t.Optional[str] = None + enum: t.Optional[t.List[t.Any]] = None + type: t.Optional[t.Union[SchemaDataType, t.List[SchemaDataType]]] = None + all_of: t.Optional[t.List['Schema']] = Field( + default=None, serialization_alias='allOf', validation_alias=AliasChoices('all_of', 'allOf') + ) + any_of: t.Optional[t.List['Schema']] = Field( + default=None, serialization_alias='anyOf', validation_alias=AliasChoices('any_of', 'anyOf') + ) + one_of: t.Optional[t.List['Schema']] = Field( + default=None, serialization_alias='oneOf', validation_alias=AliasChoices('one_of', 'oneOf') + ) + not_: t.Optional['Schema'] = Field( + default=None, serialization_alias='not', validation_alias=AliasChoices('not_', 'not') + ) + pattern: t.Optional[str] = None + minimum: t.Optional[float] = None + maximum: t.Optional[float] = None + exclusive_minimum: t.Optional[float] = Field( + default=None, + serialization_alias='exclusiveMinimum', + validation_alias=AliasChoices('exclusive_minimum', 'exclusiveMinimum'), + ) + exclusive_maximum: t.Optional[float] = Field( + default=None, + serialization_alias='exclusiveMaximum', + validation_alias=AliasChoices('exclusive_maximum', 'exclusiveMaximum'), + ) + multiple_of: t.Optional[float] = Field( + default=None, serialization_alias='multipleOf', validation_alias=AliasChoices('multiple_of', 'multipleOf') + ) + min_length: t.Optional[int] = Field( + default=None, serialization_alias='minLength', validation_alias=AliasChoices('min_length', 'minLength') + ) + max_length: t.Optional[int] = Field( + default=None, serialization_alias='maxLength', validation_alias=AliasChoices('max_length', 'maxLength') + ) + properties: t.Optional[t.Dict[str, 'Schema']] = None + pattern_properties: t.Optional[t.Dict[str, 'Schema']] = Field( + default=None, + serialization_alias='patternProperties', + validation_alias=AliasChoices('pattern_properties', 'patternProperties'), + ) + additional_properties: t.Optional['Schema'] = Field( + default=None, + serialization_alias='additionalProperties', + validation_alias=AliasChoices('additional_properties', 'additionalProperties'), + ) + property_names: t.Optional['Schema'] = Field( + default=None, + serialization_alias='propertyNames', + validation_alias=AliasChoices('property_names', 'propertyNames'), + ) + min_properties: t.Optional[int] = Field( + default=None, + serialization_alias='minProperties', + validation_alias=AliasChoices('min_properties', 'minProperties'), + ) + max_properties: t.Optional[int] = Field( + default=None, + serialization_alias='maxProperties', + validation_alias=AliasChoices('max_properties', 'maxProperties'), + ) + required: t.Optional[t.List[str]] = None + defs: t.Optional[t.Dict[str, 'Schema']] = Field( + default=None, serialization_alias='$defs', validation_alias=AliasChoices('defs', '$defs') + ) + items: t.Optional['Schema'] = None + prefix_items: t.Optional[t.List['Schema']] = Field( + default=None, serialization_alias='prefixItems', validation_alias=AliasChoices('prefix_items', 'prefixItems') + ) + contains: t.Optional['Schema'] = None + min_contains: t.Optional[int] = Field( + default=None, serialization_alias='minContains', validation_alias=AliasChoices('min_contains', 'minContains') + ) + max_contains: t.Optional[int] = Field( + default=None, serialization_alias='maxContains', validation_alias=AliasChoices('max_contains', 'maxContains') + ) + min_items: t.Optional[int] = Field( + default=None, serialization_alias='minItems', validation_alias=AliasChoices('min_items', 'minItems') + ) + max_items: t.Optional[int] = Field( + default=None, serialization_alias='maxItems', validation_alias=AliasChoices('max_items', 'maxItems') + ) + unique_items: t.Optional[bool] = Field( + default=None, serialization_alias='uniqueItems', validation_alias=AliasChoices('unique_items', 'uniqueItems') + ) + ref: t.Optional[str] = Field(default=None, serialization_alias='$ref', validation_alias=AliasChoices('ref', '$ref')) + description: t.Optional[str] = None + deprecated: t.Optional[bool] = None + default: t.Optional[t.Any] = None + examples: t.Optional[t.List[t.Any]] = None + read_only: t.Optional[bool] = Field( + default=None, serialization_alias='readOnly', validation_alias=AliasChoices('read_only', 'readOnly') + ) + write_only: t.Optional[bool] = Field( + default=None, serialization_alias='writeOnly', validation_alias=AliasChoices('write_only', 'writeOnly') + ) + const: t.Optional[t.Any] = None + dependent_required: t.Optional[t.Dict[str, t.List[str]]] = Field( default=None, - metadata={'encode': lambda x: getattr(x, 'value', x) if x else x, 'decode': lambda x: x.value if x else x}, - ) - all_of: t.Optional[t.List['Schema']] = field(default=None, metadata={'field_name': 'allOf'}) - any_of: t.Optional[t.List['Schema']] = field(default=None, metadata={'field_name': 'anyOf'}) - one_of: t.Optional[t.List['Schema']] = field(default=None, metadata={'field_name': 'oneOf'}) - not_: t.Optional['Schema'] = field(default=None, metadata={'field_name': 'not'}) - pattern: t.Optional[str] = field(default=None) - minimum: t.Optional[float] = field(default=None) - maximum: t.Optional[float] = field(default=None) - exclusive_minimum: t.Optional[float] = field(default=None, metadata={'field_name': 'exclusiveMinimum'}) - exclusive_maximum: t.Optional[float] = field(default=None, metadata={'field_name': 'exclusiveMaximum'}) - multiple_of: t.Optional[float] = field(default=None, metadata={'field_name': 'multipleOf'}) - min_length: t.Optional[int] = field(default=None, metadata={'field_name': 'minLength'}) - max_length: t.Optional[int] = field(default=None, metadata={'field_name': 'maxLength'}) - properties: t.Optional[t.Dict[str, 'Schema']] = field(default=None) - pattern_properties: t.Optional[t.Dict[str, 'Schema']] = field( - default=None, metadata={'field_name': 'patternProperties'} - ) - additional_properties: t.Optional['Schema'] = field(default=None, metadata={'field_name': 'additionalProperties'}) - property_names: t.Optional['Schema'] = field(default=None, metadata={'field_name': 'propertyNames'}) - min_properties: t.Optional[int] = field(default=None, metadata={'field_name': 'minProperties'}) - max_properties: t.Optional[int] = field(default=None, metadata={'field_name': 'maxProperties'}) - required: t.Optional[t.List[str]] = field(default=None) - defs: t.Optional[t.Dict[str, 'Schema']] = field(default=None, metadata={'field_name': '$defs'}) - items: t.Optional['Schema'] = field(default=None) - prefix_items: t.Optional[t.List['Schema']] = field(default=None, metadata={'field_name': 'prefixItems'}) - contains: t.Optional['Schema'] = field(default=None) - min_contains: t.Optional[int] = field(default=None, metadata={'field_name': 'minContains'}) - max_contains: t.Optional[int] = field(default=None, metadata={'field_name': 'maxContains'}) - min_items: t.Optional[int] = field(default=None, metadata={'field_name': 'minItems'}) - max_items: t.Optional[int] = field(default=None, metadata={'field_name': 'maxItems'}) - unique_items: t.Optional[bool] = field(default=None, metadata={'field_name': 'uniqueItems'}) - ref: t.Optional[str] = field(default=None, metadata={'field_name': '$ref'}) - description: t.Optional[str] = field(default=None) - deprecated: t.Optional[bool] = field(default=None) - default: t.Optional[t.Any] = field(default=None) - examples: t.Optional[t.List[t.Any]] = field(default=None) - read_only: t.Optional[bool] = field(default=None, metadata={'field_name': 'readOnly'}) - write_only: t.Optional[bool] = field(default=None, metadata={'field_name': 'writeOnly'}) - const: t.Optional[t.Any] = field(default=None) - dependent_required: t.Optional[t.Dict[str, t.List[str]]] = field( - default=None, metadata={'field_name': 'dependentRequired'} - ) - dependent_schemas: t.Optional[t.Dict[str, 'Schema']] = field( - default=None, metadata={'field_name': 'dependentSchemas'} - ) - if_: t.Optional['Schema'] = field(default=None, metadata={'field_name': 'if'}) - then: t.Optional['Schema'] = field(default=None) - else_: t.Optional['Schema'] = field(default=None, metadata={'field_name': 'else'}) - schema_: t.Optional[str] = field(default=None, metadata={'field_name': '$schema'}) - - -@dataclass -class Reference: - ref: str = field(metadata={'field_name': '$ref'}) - - -@dataclass -class ContentDescriptor: + serialization_alias='dependentRequired', + validation_alias=AliasChoices('dependent_required', 'dependentRequired'), + ) + dependent_schemas: t.Optional[t.Dict[str, 'Schema']] = Field( + default=None, + serialization_alias='dependentSchemas', + validation_alias=AliasChoices('dependent_schemas', 'dependentSchemas'), + ) + if_: t.Optional['Schema'] = Field( + default=None, serialization_alias='if', validation_alias=AliasChoices('if_', 'if') + ) + then: t.Optional['Schema'] = None + else_: t.Optional['Schema'] = Field( + default=None, serialization_alias='else', validation_alias=AliasChoices('else_', 'else') + ) + schema_: t.Optional[str] = Field( + default=None, + alias='schema', + serialization_alias='$schema', + validation_alias=AliasChoices('schema_', 'schema', '$schema'), + ) + + +class Reference(BaseModel): + ref: str = Field(serialization_alias='$ref', validation_alias=AliasChoices('ref', '$ref')) + + +class ContentDescriptor(BaseModel): name: str - schema: Schema - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - required: t.Optional[bool] = field(default=None) - deprecated: t.Optional[bool] = field(default=None) + schema_: Schema = Field(alias='schema', validation_alias=AliasChoices('schema_', 'schema')) + summary: t.Optional[str] = None + description: t.Optional[str] = None + required: t.Optional[bool] = None + deprecated: t.Optional[bool] = None -@dataclass -class Contact: - name: t.Optional[str] = field(default=None) - url: t.Optional[str] = field(default=None) - email: t.Optional[str] = field(default=None) +class Contact(BaseModel): + name: t.Optional[str] = None + url: t.Optional[str] = None + email: t.Optional[str] = None -@dataclass -class License: +class License(BaseModel): name: str - url: t.Optional[str] = field(default=None) + url: t.Optional[str] = None -@dataclass -class Info: +class Info(BaseModel): title: str version: str - description: t.Optional[str] = field(default=None) - terms_of_service: t.Optional[str] = field(default=None, metadata={'field_name': 'termsOfService'}) - contact: t.Optional[Contact] = field(default=None) - license: t.Optional[License] = field(default=None) + description: t.Optional[str] = None + terms_of_service: t.Optional[str] = Field( + default=None, + serialization_alias='termsOfService', + validation_alias=AliasChoices('terms_of_service', 'termsOfService'), + ) + contact: t.Optional[Contact] = None + license_: t.Optional[License] = Field( + default=None, alias='license', validation_alias=AliasChoices('license_', 'license') + ) -@dataclass -class ServerVariable: +class ServerVariable(BaseModel): default: str - enum: t.Optional[t.List[str]] = field(default=None) - description: t.Optional[str] = field(default=None) + enum: t.Optional[t.List[str]] = None + description: t.Optional[str] = None -@dataclass -class Server: +class Server(BaseModel): url: str - name: str = field(default='default') - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - variables: t.Optional[t.Dict[str, ServerVariable]] = field(default=None) + name: str = Field(default='default') + summary: t.Optional[str] = None + description: t.Optional[str] = None + variables: t.Optional[t.Dict[str, ServerVariable]] = None -@dataclass -class Example: +class Example(BaseModel): name: str value: t.Any - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - external_value: t.Optional[str] = field(default=None, metadata={'field_name': 'externalValue'}) + summary: t.Optional[str] = None + description: t.Optional[str] = None + external_value: t.Optional[str] = Field( + default=None, + serialization_alias='externalValue', + validation_alias=AliasChoices('external_value', 'externalValue'), + ) -@dataclass -class ExamplePairing: - name: t.Optional[str] = field(default=None) - params: t.Optional[t.List[Example]] = field(default=None) - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - result: t.Optional[Example] = field(default=None) +class ExamplePairing(BaseModel): + name: t.Optional[str] = None + params: t.Optional[t.List[Example]] = None + summary: t.Optional[str] = None + description: t.Optional[str] = None + result: t.Optional[Example] = None -@dataclass -class Link: +class Link(BaseModel): name: str - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - method: t.Optional[str] = field(default=None) - params: t.Optional[t.Any] = field(default=None) - server: t.Optional[Server] = field(default=None) + summary: t.Optional[str] = None + description: t.Optional[str] = None + method: t.Optional[str] = None + params: t.Optional[t.Any] = None + server: t.Optional[Server] = None -@dataclass -class Error: +class Error(BaseModel): code: int message: str - data: t.Optional[t.Any] = field(default=None) + data: t.Optional[t.Any] = None -@dataclass -class ExternalDocumentation: +class ExternalDocumentation(BaseModel): url: str - description: t.Optional[str] = field(default=None) + description: t.Optional[str] = None -@dataclass -class Tag: +class Tag(BaseModel): name: str - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - external_docs: t.Optional[ExternalDocumentation] = field(default=None, metadata={'field_name': 'externalDocs'}) + summary: t.Optional[str] = None + description: t.Optional[str] = None + external_docs: t.Optional[ExternalDocumentation] = Field( + default=None, serialization_alias='externalDocs', validation_alias=AliasChoices('external_docs', 'externalDocs') + ) -@dataclass -class Components: - content_descriptors: t.Optional[t.Dict[str, ContentDescriptor]] = field( - default=None, metadata={'field_name': 'contentDescriptors'} +class Components(BaseModel): + content_descriptors: t.Optional[t.Dict[str, ContentDescriptor]] = Field( + default=None, + serialization_alias='contentDescriptors', + validation_alias=AliasChoices('content_descriptors', 'contentDescriptors'), ) - schemas: t.Optional[t.Dict[str, Schema]] = field(default=None) - examples: t.Optional[t.Dict[str, Example]] = field(default=None) - links: t.Optional[t.Dict[str, Link]] = field(default=None) - errors: t.Optional[t.Dict[str, Error]] = field(default=None) - example_pairing_objects: t.Optional[t.Dict[str, ExamplePairing]] = field(default=None) - tags: t.Optional[t.Dict[str, Tag]] = field(default=None) - x_security_schemes: t.Optional[t.Dict[str, t.Union[OAuth2, BearerAuth, APIKeyAuth]]] = field( - default=None, metadata={'field_name': 'x-security-schemes'} + schemas: t.Optional[t.Dict[str, Schema]] = None + examples: t.Optional[t.Dict[str, Example]] = None + links: t.Optional[t.Dict[str, Link]] = None + errors: t.Optional[t.Dict[str, Error]] = None + example_pairing_objects: t.Optional[t.Dict[str, ExamplePairing]] = None + tags: t.Optional[t.Dict[str, Tag]] = None + x_security_schemes: t.Optional[t.Dict[str, t.Union[OAuth2, BearerAuth, APIKeyAuth]]] = Field( + default=None, + serialization_alias='x-security-schemes', + validation_alias=AliasChoices('x_security_schemes', 'x-security-schemes'), ) -@dataclass -class Method: +class Method(BaseModel): name: str params: t.List[ContentDescriptor] result: ContentDescriptor - tags: t.Optional[t.List[Tag]] = field(default=None) - summary: t.Optional[str] = field(default=None) - description: t.Optional[str] = field(default=None) - external_docs: t.Optional[ExternalDocumentation] = field(default=None, metadata={'field_name': 'externalDocs'}) - deprecated: t.Optional[bool] = field(default=None) - servers: t.Optional[t.List[Server]] = field(default=None) - errors: t.Optional[t.List[Error]] = field(default=None) - links: t.Optional[t.List[Link]] = field(default=None) - param_structure: t.Optional[ParamStructure] = field(default=None, metadata={'field_name': 'paramStructure'}) - examples: t.Optional[t.List[ExamplePairing]] = field(default=None) - x_security: t.Optional[t.Dict[str, t.List[str]]] = field(default=None, metadata={'field_name': 'x-security'}) - - -@dataclass -class OpenRPCSchema: + tags: t.Optional[t.List[Tag]] = None + summary: t.Optional[str] = None + description: t.Optional[str] = None + external_docs: t.Optional[ExternalDocumentation] = Field( + default=None, serialization_alias='externalDocs', validation_alias=AliasChoices('external_docs', 'externalDocs') + ) + deprecated: t.Optional[bool] = None + servers: t.Optional[t.List[Server]] = None + errors: t.Optional[t.List[Error]] = None + links: t.Optional[t.List[Link]] = None + param_structure: t.Optional[ParamStructure] = Field( + default=None, + serialization_alias='paramStructure', + validation_alias=AliasChoices('param_structure', 'paramStructure'), + ) + examples: t.Optional[t.List[ExamplePairing]] = None + x_security: t.Optional[t.Dict[str, t.List[str]]] = Field( + default=None, serialization_alias='x-security', validation_alias=AliasChoices('x_security', 'x-security') + ) + + +class OpenRPCSchema(BaseModel): info: Info - openrpc: str = field(default=OPENRPC_VERSION_DEFAULT) - methods: t.List[Method] = field(default_factory=list) - servers: t.Union[t.List[Server], Server] = field(default_factory=list) - components: t.Optional[Components] = field(default=None) - external_docs: t.Optional[ExternalDocumentation] = field(default=None, metadata={'field_name': 'externalDocs'}) - - def __post_init__(self: Self) -> None: - if self.servers is None or (isinstance(self.servers, list) and len(self.servers) == 0): - self.servers = Server(name='default', url='localhost') + openrpc: str = Field(default=OPENRPC_VERSION_DEFAULT) + methods: t.List[Method] = [] + servers: t.Union[t.List[Server], Server] = Server(name='default', url='localhost') + components: t.Optional[Components] = None + external_docs: t.Optional[ExternalDocumentation] = Field( + default=None, serialization_alias='externalDocs', validation_alias=AliasChoices('external_docs', 'externalDocs') + ) diff --git a/src/flask_jsonrpc/contrib/openrpc/utils.py b/src/flask_jsonrpc/contrib/openrpc/utils.py index bcc0b4f7..234d866a 100644 --- a/src/flask_jsonrpc/contrib/openrpc/utils.py +++ b/src/flask_jsonrpc/contrib/openrpc/utils.py @@ -26,30 +26,26 @@ # POSSIBILITY OF SUCH DAMAGE. import typing as t -from . import typing as st +from pydantic.main import BaseModel -# Python 3.8+ -try: - from typing_extensions import TypedDict -except ImportError: # pragma: no cover - from typing import TypedDict # pylint: disable=C0412 +from . import typing as st -class MethodExtendSchema(TypedDict, total=False): - name: t.Optional[str] - params: t.Optional[t.List[st.ContentDescriptor]] - result: t.Optional[st.ContentDescriptor] - tags: t.Optional[t.List[st.Tag]] - summary: t.Optional[str] - description: t.Optional[str] - external_docs: t.Optional[st.ExternalDocumentation] - deprecated: t.Optional[bool] - servers: t.Optional[t.List[st.Server]] - errors: t.Optional[t.List[st.Error]] - links: t.Optional[t.List[st.Link]] - param_structure: t.Optional[st.ParamStructure] - examples: t.Optional[t.List[st.ExamplePairing]] - x_security: t.Optional[t.Dict[str, t.List[str]]] +class MethodExtendSchema(BaseModel): + name: t.Optional[str] = None + params: t.Optional[t.List[st.ContentDescriptor]] = None + result: t.Optional[st.ContentDescriptor] = None + tags: t.Optional[t.List[st.Tag]] = None + summary: t.Optional[str] = None + description: t.Optional[str] = None + external_docs: t.Optional[st.ExternalDocumentation] = None + deprecated: t.Optional[bool] = None + servers: t.Optional[t.List[st.Server]] = None + errors: t.Optional[t.List[st.Error]] = None + links: t.Optional[t.List[st.Link]] = None + param_structure: t.Optional[st.ParamStructure] = None + examples: t.Optional[t.List[st.ExamplePairing]] = None + x_security: t.Optional[t.Dict[str, t.List[str]]] = None def extend_schema( diff --git a/src/flask_jsonrpc/descriptor.py b/src/flask_jsonrpc/descriptor.py index d09fc532..b767ff92 100644 --- a/src/flask_jsonrpc/descriptor.py +++ b/src/flask_jsonrpc/descriptor.py @@ -71,9 +71,7 @@ def service_method_params_desc( self: Self, view_func: t.Callable[..., t.Any] ) -> t.List[fjt.ServiceMethodParamsDescribe]: return [ - fjt.ServiceMethodParamsDescribe( # pytype: disable=missing-parameter - name=name, type=self.python_type_name(tp), required=False, nullable=False - ) + fjt.ServiceMethodParamsDescribe(name=name, type=self.python_type_name(tp)) for name, tp in getattr(view_func, 'jsonrpc_method_params', {}).items() ] @@ -81,15 +79,17 @@ def service_methods_desc(self: Self) -> t.OrderedDict[str, fjt.ServiceMethodDesc methods: t.OrderedDict[str, fjt.ServiceMethodDescribe] = OrderedDict() for key, view_func in self.jsonrpc_site.view_funcs.items(): name = getattr(view_func, 'jsonrpc_method_name', key) - methods[name] = fjt.ServiceMethodDescribe( # pytype: disable=missing-parameter + method = fjt.ServiceMethodDescribe( type=JSONRPC_DESCRIBE_SERVICE_METHOD_TYPE, - description=getattr(view_func, '__doc__', None), options=getattr(view_func, 'jsonrpc_options', {}), params=self.service_method_params_desc(view_func), returns=fjt.ServiceMethodReturnsDescribe( type=self.python_type_name(getattr(view_func, 'jsonrpc_method_return', type(None))) ), ) + # mypyc: pydantic optional value + method.description = getattr(view_func, '__doc__', None) + methods[name] = method return methods def service_server_url(self: Self) -> str: @@ -101,11 +101,13 @@ def service_server_url(self: Self) -> str: ) def service_describe(self: Self) -> fjt.ServiceDescribe: - return fjt.ServiceDescribe( + serv_desc = fjt.ServiceDescribe( id=f'urn:uuid:{self.jsonrpc_site.uuid}', version=self.jsonrpc_site.version, name=self.jsonrpc_site.name, - description=self.jsonrpc_site.__doc__, servers=[fjt.ServiceServersDescribe(url=self.service_server_url())], # pytype: disable=missing-parameter methods=self.service_methods_desc(), ) + # mypyc: pydantic optional value + serv_desc.description = self.jsonrpc_site.__doc__ + return serv_desc diff --git a/src/flask_jsonrpc/encoders.py b/src/flask_jsonrpc/encoders.py new file mode 100644 index 00000000..5992630b --- /dev/null +++ b/src/flask_jsonrpc/encoders.py @@ -0,0 +1,67 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +from enum import Enum +from types import GeneratorType +import typing as t +from pathlib import PurePath +from collections import deque +import dataclasses + +from flask import typing as ft, jsonify as _jsonify + +from pydantic.main import BaseModel + + +def serializable(obj: t.Any) -> t.Any: # noqa: ANN401 + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, PurePath): + return str(obj) + if isinstance(obj, dict): + encoded_dict = {} + for key, value in obj.items(): + encoded_key = serializable(key) + encoded_value = serializable(value) + encoded_dict[encoded_key] = encoded_value + return encoded_dict + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): + encoded_list = [] + for item in obj: + encoded_list.append(serializable(item)) + return encoded_list + if dataclasses.is_dataclass(obj): + obj_dict = dataclasses.asdict(obj) # type: ignore + return serializable(obj_dict) + if isinstance(obj, BaseModel): + return serializable(obj.model_dump(exclude_none=True, by_alias=True)) + if hasattr(obj, '__dict__'): + return obj.__dict__ + return obj + + +def jsonify(obj: t.Any) -> ft.ResponseValue: # noqa: ANN401 + return _jsonify(serializable(obj)) diff --git a/src/flask_jsonrpc/funcutils.py b/src/flask_jsonrpc/funcutils.py new file mode 100644 index 00000000..b8e70d8b --- /dev/null +++ b/src/flask_jsonrpc/funcutils.py @@ -0,0 +1,105 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t +import inspect +import dataclasses + +from pydantic import ValidationError +from pydantic.main import BaseModel, create_model + +from . import types as jsonrpc_types +from .helpers import from_python_type + +# Python 3.10+ +try: + from types import NoneType, UnionType +except ImportError: # pragma: no cover + UnionType = None # type: ignore + NoneType = type(None) # type: ignore + + +def loads(param_type: t.Any, param_value: t.Any) -> t.Any: # noqa: ANN401, C901 + if param_value is None: + return param_value + + if param_type is t.Any: + return param_value + + jsonrpc_type = from_python_type(param_type, default=None) + if jsonrpc_type is None: + if inspect.isclass(param_type): + if issubclass(param_type, BaseModel): + base_model = t.cast(t.Type[BaseModel], param_type) # type: ignore + model = create_model(base_model.__name__, __base__=base_model) + try: + return model.model_validate(param_value) + except ValidationError as e: + raise TypeError(str(e)) from e + + if dataclasses.is_dataclass(param_type): + return param_type(**param_value) + + return param_type(**param_value) + + # XXX: The only type of union that is supported is: typing.Union[T, None] or typing.Optional[T] + origin_type = t.get_origin(param_type) + if origin_type is not None and (origin_type is t.Union or origin_type is UnionType): + obj_types = t.get_args(param_type) + if len(obj_types) == 2: + actual_type, check_type = obj_types + if check_type is NoneType: + return loads(actual_type, param_value) + raise TypeError( + 'the only type of union that is supported is: typing.Union[T, None] or typing.Optional[T]' + ) from None + return param_value + + if jsonrpc_types.Object.name == jsonrpc_type.name: + loaded_dict = {} + key_type, value_type = t.get_args(param_type) + for key, value in param_value.items(): + loaded_key = loads(key_type, key) + loaded_value = loads(value_type, value) + loaded_dict[loaded_key] = loaded_value + return loaded_dict + + if jsonrpc_types.Array.name == jsonrpc_type.name: + loaded_list = [] + item_type = t.get_args(param_type)[0] + for item in param_value: + loaded_list.append(loads(item_type, item)) + return loaded_list + return param_value + + +def bindfy(view_func: t.Callable[..., t.Any], params: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: # noqa: ANN401 + binded_params = {} + view_func_params = getattr(view_func, 'jsonrpc_method_params', {}) + for param_name, param_type in view_func_params.items(): + param_value = params.get(param_name) + binded_params[param_name] = loads(param_type, param_value) + return binded_params diff --git a/src/flask_jsonrpc/helpers.py b/src/flask_jsonrpc/helpers.py index 9c32b9ca..1573e621 100644 --- a/src/flask_jsonrpc/helpers.py +++ b/src/flask_jsonrpc/helpers.py @@ -53,7 +53,7 @@ def urn(name: str, *args: t.Any) -> str: # noqa: ANN401 ValueError: name is required """ if not name: - raise ValueError('name is required') + raise ValueError('name is required') from None splitted_args = [arg.split('/') for arg in args] st = ':'.join(list(itertools.chain(*splitted_args))) st = st.rstrip(':').lstrip(':') @@ -61,7 +61,7 @@ def urn(name: str, *args: t.Any) -> str: # noqa: ANN401 return f"urn:{name}{sep}{st.replace('::', ':')}".lower() -def from_python_type(tp: t.Any) -> 'JSONRPCNewType': # noqa: ANN401 +def from_python_type(tp: t.Any, default: 't.Optional[JSONRPCNewType]' = Object) -> 't.Optional[JSONRPCNewType]': # noqa: ANN401 """Convert Python type to JSONRPCNewType. >>> str(from_python_type(str)) @@ -82,10 +82,10 @@ def from_python_type(tp: t.Any) -> 'JSONRPCNewType': # noqa: ANN401 for typ in Types: if typ.check_type(tp): return typ - return Object + return default -def get(obj: t.Dict[str, t.Any], path: str, default: t.Any = None) -> t.Any: # noqa: ANN401 +def get(obj: t.Any, path: str, default: t.Any = None) -> t.Any: # noqa: ANN401 """Get the value at any depth of a nested object based on the path described by `path`. If path doesn't exist, `default` is returned. Args: @@ -104,6 +104,8 @@ def get(obj: t.Dict[str, t.Any], path: str, default: t.Any = None) -> t.Any: # >>> get(None, 'a', 'default') 'default' + >>> get('a', 'a.b.c', 'default') + 'default' >>> get({'a': 1}, 'a') 1 >>> get({'a': 1}, 'b') @@ -126,11 +128,11 @@ def get(obj: t.Dict[str, t.Any], path: str, default: t.Any = None) -> t.Any: # obj_val = obj keys = path.split('.') - for key in keys: - try: + try: + for key in keys: obj_val = getitem(obj_val, key) - except KeyError: - return default + except (TypeError, KeyError): + return default return obj_val diff --git a/src/flask_jsonrpc/settings.py b/src/flask_jsonrpc/settings.py index 230f20f0..c34eeecb 100644 --- a/src/flask_jsonrpc/settings.py +++ b/src/flask_jsonrpc/settings.py @@ -42,7 +42,7 @@ def __init__(self: Self, defaults: t.Optional[t.Dict[str, t.Any]] = None) -> Non def __getattr__(self: Self, attr: str) -> t.Any: # noqa: ANN401 if attr not in self.defaults: - raise AttributeError(f'Invalid setting: {attr!r}') + raise AttributeError(f'invalid setting: {attr!r}') from None val = self.defaults[attr] diff --git a/src/flask_jsonrpc/site.py b/src/flask_jsonrpc/site.py index 917ced4e..106e03c8 100644 --- a/src/flask_jsonrpc/site.py +++ b/src/flask_jsonrpc/site.py @@ -36,6 +36,7 @@ from .helpers import get from .settings import settings +from .funcutils import bindfy from .descriptor import JSONRPCServiceDescriptor from .exceptions import ( ParseError, @@ -97,7 +98,7 @@ def dispatch_request( 'message': f'Invalid mime type for JSON: {request.mimetype}, ' 'use header Content-Type: application/json' } - ) + ) from None json_data = self.to_json(request.data) if self.is_batch_request(json_data): @@ -117,12 +118,67 @@ def to_json(self: Self, request_data: bytes) -> t.Any: # noqa: ANN401 current_app.logger.exception('invalid json: %s', request_data) raise ParseError(data={'message': f'Invalid JSON: {request_data!r}'}) from e + def handle_view_func(self: Self, view_func: t.Callable[..., t.Any], params: t.Any) -> t.Any: # noqa: ANN401 + view_func_params = getattr(view_func, 'jsonrpc_method_params', {}) + validate = getattr(view_func, 'jsonrpc_validate', settings.DEFAULT_JSONRPC_METHOD['VALIDATE']) + try: + if isinstance(params, list): + kw_params = {} + for i, (param_name, _param_type) in enumerate(view_func_params.items()): + kw_params[param_name] = (params[i : i + 1] or [None])[0] + binded_params = bindfy(view_func, kw_params) + elif isinstance(params, dict): + binded_params = bindfy(view_func, params) + else: + raise InvalidParamsError( + data={'message': f'Parameter structures are by-position (list) or by-name (dict): {params}'} + ) from None + + sanitazed_params = {k: v for k, v in binded_params.items() if v is not None} + resp_view = current_app.ensure_sync(view_func)(**sanitazed_params) + + # TODO: Enhance the checker to return the type + view_fun_annotations = t.get_type_hints(view_func) + view_fun_return: t.Optional[t.Any] = view_fun_annotations.pop('return', None) + if validate and resp_view is not None and view_fun_return is None: + resp_view_qn = qualified_name(resp_view) + view_fun_return_qn = qualified_name(view_fun_return) + raise TypeError( + f'return type of {resp_view_qn} must be a type; got {view_fun_return_qn} instead' + ) from None + + return resp_view + except TypeError as e: + current_app.logger.exception('invalid type checked for: %s', view_func.__name__) + raise InvalidParamsError(data={'message': str(e)}) from e + + def dispatch( + self: Self, req_json: t.Dict[str, t.Any] + ) -> t.Tuple[t.Any, int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]: + method_name = req_json['method'] + params = req_json.get('params', {}) + view_func = self.view_funcs.get(method_name) + notification = getattr(view_func, 'jsonrpc_notification', settings.DEFAULT_JSONRPC_METHOD['NOTIFICATION']) + if not view_func: + raise MethodNotFoundError(data={'message': f'Method not found: {method_name}'}) from None + + if self.is_notification_request(req_json) and not notification: + raise InvalidRequestError( + data={ + 'message': f"The method {method_name!r} doesn't allow Notification " + "Request object (without an 'id' member)" + } + ) from None + + resp_view = self.handle_view_func(view_func, params) + return self.make_response(req_json, resp_view) + def handle_dispatch_except( self: Self, req_json: t.Dict[str, t.Any] ) -> t.Tuple[t.Any, int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]: try: if not self.validate(req_json): - raise InvalidRequestError(data={'message': f'Invalid JSON: {req_json!r}'}) + raise InvalidRequestError(data={'message': f'Invalid JSON: {req_json!r}'}) from None return self.dispatch(req_json) except JSONRPCError as e: current_app.logger.exception('jsonrpc error') @@ -146,7 +202,7 @@ def batch_dispatch( self: Self, reqs_json: t.List[t.Dict[str, t.Any]] ) -> t.Tuple[t.List[t.Any], int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]: if not reqs_json: - raise InvalidRequestError(data={'message': 'Empty array'}) + raise InvalidRequestError(data={'message': 'Empty array'}) from None resp_views = [] headers = Headers() @@ -160,51 +216,6 @@ def batch_dispatch( status_code = 204 return resp_views, status_code, headers - def dispatch( - self: Self, req_json: t.Dict[str, t.Any] - ) -> t.Tuple[t.Any, int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]: - method_name = req_json['method'] - params = req_json.get('params', {}) - view_func = self.view_funcs.get(method_name) - validate = getattr(view_func, 'jsonrpc_validate', settings.DEFAULT_JSONRPC_METHOD['VALIDATE']) - notification = getattr(view_func, 'jsonrpc_notification', settings.DEFAULT_JSONRPC_METHOD['NOTIFICATION']) - if not view_func: - raise MethodNotFoundError(data={'message': f'Method not found: {method_name}'}) - - if self.is_notification_request(req_json) and not notification: - raise InvalidRequestError( - data={ - 'message': f"The method {method_name!r} doesn't allow Notification " - "Request object (without an 'id' member)" - } - ) - - try: - if isinstance(params, (tuple, set, list)): - resp_view = current_app.ensure_sync(view_func)(*params) - elif isinstance(params, dict): - resp_view = current_app.ensure_sync(view_func)(**params) - else: - raise InvalidParamsError( - data={ - 'message': 'Parameter structures are by-position ' - f'(tuple, set, list) or by-name (dict): {params}' - } - ) - - # TODO: Improve the checker to return type - view_fun_annotations = t.get_type_hints(view_func) - view_fun_return: t.Optional[t.Any] = view_fun_annotations.pop('return', None) - if validate and resp_view is not None and view_fun_return is None: - resp_view_qn = qualified_name(resp_view) - view_fun_return_qn = qualified_name(view_fun_return) - raise TypeError(f'return type of {resp_view_qn} must be a type; got {view_fun_return_qn} instead') - except TypeError as e: - current_app.logger.exception('invalid type checked for: %s', view_func.__name__) - raise InvalidParamsError(data={'message': str(e)}) from e - - return self.make_response(req_json, resp_view) - def validate(self: Self, req_json: t.Dict[str, t.Any]) -> bool: return isinstance(req_json, dict) and 'method' in req_json @@ -231,7 +242,7 @@ def unpack_tuple_returns( 'the view function did not return a valid response tuple.' ' The tuple must have the form (body, status, headers),' ' (body, status), or (body, headers).' - ) + ) from None return rv, status_code, headers return resp_view, JSONRPC_DEFAULT_HTTP_STATUS_CODE, JSONRPC_DEFAULT_HTTP_HEADERS diff --git a/src/flask_jsonrpc/types.py b/src/flask_jsonrpc/types.py index 8ae929c2..1c50498a 100644 --- a/src/flask_jsonrpc/types.py +++ b/src/flask_jsonrpc/types.py @@ -24,9 +24,10 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from types import GeneratorType import typing as t from numbers import Real, Integral, Rational -from collections import OrderedDict, defaultdict +from collections import OrderedDict, deque, defaultdict from collections.abc import Mapping from typing_inspect import is_new_type # type: ignore @@ -122,7 +123,9 @@ def __str__(self: Self) -> str: String = JSONRPCNewType('String', str, bytes, bytearray) Number = JSONRPCNewType('Number', int, float, Real, Rational, Integral) Object = JSONRPCNewType('Object', dict, t.Dict, defaultdict, OrderedDict, Mapping) -Array = JSONRPCNewType('Array', list, set, t.Set, tuple, t.List, t.NamedTuple, frozenset, t.FrozenSet) +Array = JSONRPCNewType( + 'Array', list, set, t.Set, tuple, t.List, t.NamedTuple, frozenset, t.FrozenSet, GeneratorType, deque +) Boolean = JSONRPCNewType('Boolean', bool) Null = JSONRPCNewType('Null', type(None), NoneType) Types = [String, Number, Object, Array, Boolean, Null] diff --git a/src/flask_jsonrpc/typing.py b/src/flask_jsonrpc/typing.py index dd8c1a3f..d8692664 100644 --- a/src/flask_jsonrpc/typing.py +++ b/src/flask_jsonrpc/typing.py @@ -27,52 +27,48 @@ import sys import typing as t +from pydantic.main import BaseModel + # Python 3.8 if sys.version_info[:2] == (3, 8): # pragma: no cover from typing import OrderedDict else: # pragma: no cover from collections import OrderedDict -# Python 3.8+ -try: - from typing_extensions import TypedDict -except ImportError: # pragma: no cover - from typing import TypedDict # pylint: disable=C0412 - -class ServiceMethodParamsDescribe(TypedDict, total=False): +class ServiceMethodParamsDescribe(BaseModel): type: str name: str - required: bool - nullable: bool - minimum: t.Optional[int] - maximum: t.Optional[int] - pattern: t.Optional[str] - length: t.Optional[int] - description: t.Optional[str] + required: t.Optional[bool] = None + nullable: t.Optional[bool] = None + minimum: t.Optional[int] = None + maximum: t.Optional[int] = None + pattern: t.Optional[str] = None + length: t.Optional[int] = None + description: t.Optional[str] = None -class ServiceMethodReturnsDescribe(TypedDict): +class ServiceMethodReturnsDescribe(BaseModel): type: str -class ServiceMethodDescribe(TypedDict): +class ServiceMethodDescribe(BaseModel): type: str - description: t.Optional[str] - options: t.Dict[str, t.Any] - params: t.List[ServiceMethodParamsDescribe] + description: t.Optional[str] = None + options: t.Dict[str, t.Any] = {} + params: t.List[ServiceMethodParamsDescribe] = [] returns: ServiceMethodReturnsDescribe -class ServiceServersDescribe(TypedDict, total=False): +class ServiceServersDescribe(BaseModel): url: str - description: t.Optional[str] + description: t.Optional[str] = None -class ServiceDescribe(TypedDict): +class ServiceDescribe(BaseModel): id: str version: str name: str - description: t.Optional[str] + description: t.Optional[str] = None servers: t.List[ServiceServersDescribe] methods: OrderedDict[str, ServiceMethodDescribe] diff --git a/src/flask_jsonrpc/views.py b/src/flask_jsonrpc/views.py index c06efca7..01b39997 100644 --- a/src/flask_jsonrpc/views.py +++ b/src/flask_jsonrpc/views.py @@ -26,10 +26,11 @@ # POSSIBILITY OF SUCH DAMAGE. import typing as t -from flask import typing as ft, jsonify, current_app, make_response +from flask import typing as ft, current_app, make_response from flask.views import MethodView from .site import JSONRPC_VERSION_DEFAULT, JSONRPC_DEFAULT_HTTP_HEADERS +from .encoders import jsonify from .exceptions import JSONRPCError # Python 3.10+ diff --git a/src/flask_jsonrpc/wrappers.py b/src/flask_jsonrpc/wrappers.py index a3d66a94..2f859321 100644 --- a/src/flask_jsonrpc/wrappers.py +++ b/src/flask_jsonrpc/wrappers.py @@ -73,7 +73,7 @@ def _get_function(self: Self, fn: t.Callable[..., t.Any]) -> t.Callable[..., t.A return fn if ismethod(fn) and getattr(fn, '__func__', None): return fn.__func__ # pytype: disable=attribute-error,bad-return-type - raise ValueError('the view function must be either a function or a method') + raise ValueError('the view function must be either a function or a method') from None def _get_type_hints_by_signature( self: Self, fn: t.Callable[..., t.Any], fn_annotations: t.Dict[str, t.Any] @@ -98,10 +98,10 @@ def _get_annotations(self: Self, fn: t.Callable[..., t.Any], fn_options: t.Dict[ return fn_annotations def get_jsonrpc_site(self: Self) -> 'JSONRPCSite': - raise NotImplementedError('.get_jsonrpc_site must be overridden') + raise NotImplementedError('.get_jsonrpc_site must be overridden') from None def get_jsonrpc_site_api(self: Self) -> t.Type['JSONRPCView']: - raise NotImplementedError('.get_jsonrpc_site_api must be overridden') + raise NotImplementedError('.get_jsonrpc_site_api must be overridden') from None def register_view_function( self: Self, view_func: t.Callable[..., t.Any], name: t.Optional[str] = None, **options: t.Dict[str, t.Any] @@ -127,7 +127,7 @@ def method(self: Self, name: t.Optional[str] = None, **options: t.Dict[str, t.An def decorator(fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: method_name = name if name else getattr(fn, '__name__', '') if validate and not self._validate(fn): - raise ValueError(f'no type annotations present to: {method_name}') + raise ValueError(f'no type annotations present to: {method_name}') from None return self.register_view_function(fn, name, **options) return decorator diff --git a/tests/integration/test_app.py b/tests/integration/test_app.py index 93dccf45..8e94841b 100644 --- a/tests/integration/test_app.py +++ b/tests/integration/test_app.py @@ -177,9 +177,7 @@ def test_greeting_raise_invalid_params_error(self: Self) -> None: 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': { - 'message': 'Parameter structures are by-position (tuple, set, list) or by-name (dict): Wrong' - }, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -260,9 +258,7 @@ def test_echo_raise_invalid_params_error(self: Self) -> None: 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': { - 'message': 'Parameter structures are by-position (tuple, set, list) or by-name (dict): Wrong' - }, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -604,179 +600,143 @@ def test_system_describe(self: Self) -> None: 'jsonrpc.greeting': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.echo': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': '_some', 'type': 'Object', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.notify': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'jsonrpc.not_allow_notify': { 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.fails': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number', 'required': False, 'nullable': False}], + 'params': [{'name': 'n', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, }, 'jsonrpc.strangeEcho': { 'type': 'method', 'options': {'notification': True, 'validate': True}, 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'omg', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'wtf', 'type': 'Array', 'required': False, 'nullable': False}, - {'name': 'nowai', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'yeswai', 'type': 'String', 'required': False, 'nullable': False}, + {'name': 'string', 'type': 'String'}, + {'name': 'omg', 'type': 'Object'}, + {'name': 'wtf', 'type': 'Array'}, + {'name': 'nowai', 'type': 'Number'}, + {'name': 'yeswai', 'type': 'String'}, ], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.sum': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'a', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'b', 'type': 'Number', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, }, 'jsonrpc.decorators': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.returnStatusCode': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.returnHeaders': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.returnStatusCodeAndHeaders': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.not_validate': { 'type': 'method', 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'nullable': False, 'required': False, 'type': 'Object'}], + 'params': [{'name': 's', 'type': 'Object'}], 'returns': {'type': 'Object'}, - 'description': None, }, 'jsonrpc.mixin_not_validate': { 'type': 'method', 'options': {'notification': True, 'validate': False}, 'params': [ - {'name': 's', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 't', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'u', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'v', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'x', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'z', 'type': 'Object', 'required': False, 'nullable': False}, + {'name': 's', 'type': 'Object'}, + {'name': 't', 'type': 'Number'}, + {'name': 'u', 'type': 'Object'}, + {'name': 'v', 'type': 'String'}, + {'name': 'x', 'type': 'Object'}, + {'name': 'z', 'type': 'Object'}, ], 'returns': {'type': 'Object'}, - 'description': None, }, 'jsonrpc.noReturn': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'classapp.index': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'greeting': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'hello': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'echo': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': '_some', 'type': 'Object', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'returns': {'type': 'String'}, - 'description': None, }, 'notify': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'not_allow_notify': { 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'fails': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number', 'required': False, 'nullable': False}], + 'params': [{'name': 'n', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, - }, - 'rpc.describe': { - 'description': None, - 'options': {}, - 'params': [], - 'returns': {'type': 'Object'}, - 'type': 'method', }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, }, json_data['result']['methods'], ) diff --git a/tests/test_apps/app/__init__.py b/tests/test_apps/app/__init__.py index 1e65dcea..9db1745d 100644 --- a/tests/test_apps/app/__init__.py +++ b/tests/test_apps/app/__init__.py @@ -29,6 +29,7 @@ import sys import typing as t import functools +from dataclasses import dataclass from flask import Flask @@ -38,6 +39,8 @@ except ImportError: # pragma: no cover from typing_extensions import Self +from pydantic import BaseModel + try: from flask_jsonrpc import JSONRPC except ModuleNotFoundError: @@ -49,6 +52,43 @@ from flask_jsonrpc import JSONRPC +class NewColor: + name: str + tag: str + + def __init__(self: Self, name: str, tag: str) -> None: + self.name = name + self.tag = tag + + +class Color(NewColor): + id: int + + def __init__(self: Self, id: int, name: str, tag: str) -> None: + super().__init__(name, tag) + self.id = id + + +@dataclass +class NewCar: + name: str + tag: str + + +@dataclass +class Car(NewCar): + id: int + + +class NewPet(BaseModel): + name: str + tag: str + + +class Pet(NewPet): + id: int + + class App: def index(self: Self, name: str = 'Flask JSON-RPC') -> str: return f'Hello {name}' @@ -64,10 +104,10 @@ def hello(cls: t.Type[Self], name: str = 'Flask JSON-RPC') -> str: def echo(self: Self, string: str, _some: t.Any = None) -> str: # noqa: ANN401 return string - def notify(self: Self, _string: str = None) -> None: + def notify(self: Self, _string: t.Optional[str] = None) -> None: pass - def not_allow_notify(self: Self, _string: str = None) -> str: + def not_allow_notify(self: Self, _string: t.Optional[str] = None) -> str: return 'Now allow notify' def fails(self: Self, n: int) -> int: @@ -85,7 +125,7 @@ def wrapped(*args, **kwargs) -> str: # noqa: ANN002,ANN003 return wrapped -def create_app(test_config: t.Dict[str, t.Any] = None) -> Flask: # noqa: C901 pylint: disable=W0612 +def create_app(test_config: t.Optional[t.Dict[str, t.Any]] = None) -> Flask: # noqa: C901 pylint: disable=W0612 """Create and configure an instance of the Flask application.""" flask_app = Flask('apptest', instance_relative_config=True) if test_config: @@ -105,12 +145,12 @@ def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 # pylint: disable=W0612 @jsonrpc.method('jsonrpc.notify') - def notify(_string: str = None) -> None: + def notify(_string: t.Optional[str] = None) -> None: pass # pylint: disable=W0612 @jsonrpc.method('jsonrpc.not_allow_notify', notification=False) - def not_allow_notify(_string: str = None) -> str: + def not_allow_notify(_string: str = 'None') -> str: return 'Not allow notify' # pylint: disable=W0612 @@ -167,6 +207,75 @@ def mixin_not_validate(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 def no_return(_string: t.Optional[str] = None) -> t.NoReturn: raise ValueError('no return') + @jsonrpc.method('jsonrpc.createColor') + def create_color(color: NewColor) -> Color: + return Color(id=1, name=color.name, tag=color.tag) + + @jsonrpc.method('jsonrpc.createManyColor') + def create_many_colors(colors: t.List[NewColor], color: t.Optional[NewColor] = None) -> t.List[Color]: + new_color = [Color(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(colors)] + if color is not None: + return new_color + [Color(id=len(colors), name=color.name, tag=color.tag)] + return new_color + + @jsonrpc.method('jsonrpc.createManyFixColor') + def create_many_fix_colors(colors: t.Dict[str, NewPet]) -> t.List[Color]: + return [Color(id=int(color_id), name=color.name, tag=color.tag) for color_id, color in colors.items()] + + @jsonrpc.method('jsonrpc.removeColor') + def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: + return color + + @jsonrpc.method('jsonrpc.invalidUnion1') + def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: + return color + + @jsonrpc.method('jsonrpc.invalidUnion2') + def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: + return color + + @jsonrpc.method('jsonrpc.literalType') + def literal_type(x: t.Literal['X']) -> t.Literal['X']: + return x + + @jsonrpc.method('jsonrpc.createPet') + def create_pet(pet: NewPet) -> Pet: + return Pet(id=1, name=pet.name, tag=pet.tag) + + @jsonrpc.method('jsonrpc.createManyPet') + def create_many_pets(pets: t.List[NewPet], pet: t.Optional[NewPet] = None) -> t.List[Pet]: + new_pets = [Pet(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(pets)] + if pet is not None: + return new_pets + [Pet(id=len(pets), name=pet.name, tag=pet.tag)] + return new_pets + + @jsonrpc.method('jsonrpc.createManyFixPet') + def create_many_fix_pets(pets: t.Dict[str, NewPet]) -> t.List[Pet]: + return [Pet(id=int(pet_id), name=pet.name, tag=pet.tag) for pet_id, pet in pets.items()] + + @jsonrpc.method('jsonrpc.removePet') + def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: + return pet + + @jsonrpc.method('jsonrpc.createCar') + def create_car(car: NewCar) -> Car: + return Car(id=1, name=car.name, tag=car.tag) + + @jsonrpc.method('jsonrpc.createManyCar') + def create_many_cars(cars: t.List[NewCar], car: t.Optional[NewCar] = None) -> t.List[Car]: + new_cars = [Car(id=i, name=car.name, tag=car.tag) for i, car in enumerate(cars)] + if car is not None: + return new_cars + [Car(id=len(cars), name=car.name, tag=car.tag)] + return new_cars + + @jsonrpc.method('jsonrpc.createManyFixCar') + def create_many_fix_cars(cars: t.Dict[str, NewCar]) -> t.List[Car]: + return [Car(id=int(car_id), name=car.name, tag=car.tag) for car_id, car in cars.items()] + + @jsonrpc.method('jsonrpc.removeCar') + def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: + return car + class_app = App() jsonrpc.register(class_app.index, name='classapp.index') jsonrpc.register(class_app.greeting) diff --git a/tests/test_apps/async_app/__init__.py b/tests/test_apps/async_app/__init__.py index 0195235c..208605ce 100644 --- a/tests/test_apps/async_app/__init__.py +++ b/tests/test_apps/async_app/__init__.py @@ -30,6 +30,7 @@ import typing as t import asyncio import functools +from dataclasses import dataclass from flask import Flask @@ -39,6 +40,8 @@ except ImportError: # pragma: no cover from typing_extensions import Self +from pydantic import BaseModel + try: from flask_jsonrpc import JSONRPC except ModuleNotFoundError: @@ -50,6 +53,43 @@ from flask_jsonrpc import JSONRPC +class NewColor: + name: str + tag: str + + def __init__(self: Self, name: str, tag: str) -> None: + self.name = name + self.tag = tag + + +class Color(NewColor): + id: int + + def __init__(self: Self, id: int, name: str, tag: str) -> None: + super().__init__(name, tag) + self.id = id + + +@dataclass +class NewCar: + name: str + tag: str + + +@dataclass +class Car(NewCar): + id: int + + +class NewPet(BaseModel): + name: str + tag: str + + +class Pet(NewPet): + id: int + + class App: async def index(self: Self, name: str = 'Flask JSON-RPC') -> str: await asyncio.sleep(0) @@ -69,10 +109,10 @@ async def echo(self: Self, string: str, _some: t.Any = None) -> str: # noqa: AN await asyncio.sleep(0) return string - async def notify(self: Self, _string: str = None) -> None: + async def notify(self: Self, _string: t.Optional[str] = None) -> None: await asyncio.sleep(0) - async def not_allow_notify(self: Self, _string: str = None) -> str: + async def not_allow_notify(self: Self, _string: t.Optional[str] = None) -> str: await asyncio.sleep(0) return 'Now allow notify' @@ -92,7 +132,7 @@ async def wrapped(*args, **kwargs) -> str: # noqa: ANN002,ANN003 return wrapped -def create_async_app(test_config: t.Dict[str, t.Any] = None) -> Flask: # noqa: C901 pylint: disable=W0612 +def create_async_app(test_config: t.Optional[t.Dict[str, t.Any]] = None) -> Flask: # noqa: C901 pylint: disable=W0612 """Create and configure an instance of the Flask application.""" flask_app = Flask('apptest', instance_relative_config=True) if test_config: @@ -187,6 +227,90 @@ async def no_return(_string: t.Optional[str] = None) -> t.NoReturn: await asyncio.sleep(0) raise ValueError('no return') + @jsonrpc.method('jsonrpc.createColor') + async def create_color(color: NewColor) -> Color: + await asyncio.sleep(0) + return Color(id=1, name=color.name, tag=color.tag) + + @jsonrpc.method('jsonrpc.createManyColor') + async def create_many_colors(colors: t.List[NewColor], color: t.Optional[NewColor] = None) -> t.List[Color]: + new_color = [Color(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(colors)] + if color is not None: + return new_color + [Color(id=len(colors), name=color.name, tag=color.tag)] + await asyncio.sleep(0) + return new_color + + @jsonrpc.method('jsonrpc.createManyFixColor') + async def create_many_fix_colors(colors: t.Dict[str, NewPet]) -> t.List[Color]: + await asyncio.sleep(0) + return [Color(id=int(color_id), name=color.name, tag=color.tag) for color_id, color in colors.items()] + + @jsonrpc.method('jsonrpc.removeColor') + async def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: + await asyncio.sleep(0) + return color + + @jsonrpc.method('jsonrpc.invalidUnion1') + async def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: + await asyncio.sleep(0) + return color + + @jsonrpc.method('jsonrpc.invalidUnion2') + async def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: + await asyncio.sleep(0) + return color + + @jsonrpc.method('jsonrpc.literalType') + async def literal_type(x: t.Literal['X']) -> t.Literal['X']: + await asyncio.sleep(0) + return x + + @jsonrpc.method('jsonrpc.createPet') + async def create_pet(pet: NewPet) -> Pet: + await asyncio.sleep(0) + return Pet(id=1, name=pet.name, tag=pet.tag) + + @jsonrpc.method('jsonrpc.createManyPet') + async def create_many_pets(pets: t.List[NewPet], pet: t.Optional[NewPet] = None) -> t.List[Pet]: + new_pets = [Pet(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(pets)] + if pet is not None: + return new_pets + [Pet(id=len(pets), name=pet.name, tag=pet.tag)] + await asyncio.sleep(0) + return new_pets + + @jsonrpc.method('jsonrpc.createManyFixPet') + async def create_many_fix_pets(pets: t.Dict[str, NewPet]) -> t.List[Pet]: + await asyncio.sleep(0) + return [Pet(id=int(pet_id), name=pet.name, tag=pet.tag) for pet_id, pet in pets.items()] + + @jsonrpc.method('jsonrpc.removePet') + async def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: + await asyncio.sleep(0) + return pet + + @jsonrpc.method('jsonrpc.createCar') + async def create_car(car: NewCar) -> Car: + await asyncio.sleep(0) + return Car(id=1, name=car.name, tag=car.tag) + + @jsonrpc.method('jsonrpc.createManyCar') + async def create_many_cars(cars: t.List[NewCar], car: t.Optional[NewCar] = None) -> t.List[Car]: + await asyncio.sleep(0) + new_cars = [Car(id=i, name=car.name, tag=car.tag) for i, car in enumerate(cars)] + if car is not None: + return new_cars + [Car(id=len(cars), name=car.name, tag=car.tag)] + return new_cars + + @jsonrpc.method('jsonrpc.createManyFixCar') + async def create_many_fix_cars(cars: t.Dict[str, NewCar]) -> t.List[Car]: + await asyncio.sleep(0) + return [Car(id=int(car_id), name=car.name, tag=car.tag) for car_id, car in cars.items()] + + @jsonrpc.method('jsonrpc.removeCar') + async def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: + await asyncio.sleep(0) + return car + class_app = App() jsonrpc.register(class_app.index, name='classapp.index') jsonrpc.register(class_app.greeting) diff --git a/tests/unit/contrib/browse/test_app.py b/tests/unit/contrib/browse/test_app.py index 3f626bed..1a604856 100644 --- a/tests/unit/contrib/browse/test_app.py +++ b/tests/unit/contrib/browse/test_app.py @@ -118,7 +118,7 @@ def fn4(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 'name': 'app.fn1', 'type': 'method', 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'type': 'Object', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'Object'}], 'returns': {'type': 'Object'}, 'description': 'Function app.fn1', }, @@ -126,32 +126,29 @@ def fn4(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 'name': 'app.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, { 'name': 'app.fn3', 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, { 'name': 'app.fn4', 'type': 'method', 'options': {'notification': True, 'validate': False}, 'params': [ - {'name': 's', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 't', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'u', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'v', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'x', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'z', 'type': 'Object', 'required': False, 'nullable': False}, + {'name': 's', 'type': 'Object'}, + {'name': 't', 'type': 'Number'}, + {'name': 'u', 'type': 'Object'}, + {'name': 'v', 'type': 'String'}, + {'name': 'x', 'type': 'Object'}, + {'name': 'z', 'type': 'Object'}, ], 'returns': {'type': 'Object'}, - 'description': None, }, ] } @@ -162,9 +159,8 @@ def fn4(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 'name': 'app.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } assert rv.status_code == 200 @@ -224,9 +220,8 @@ def fn1(s: str) -> str: 'name': 'app.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } ] } @@ -275,17 +270,15 @@ def fn2(s: str) -> str: 'name': 'app.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, { 'name': 'app.fn3', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, ] } @@ -306,9 +299,8 @@ def fn2(s: str) -> str: 'name': 'app.fn3', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } assert rv.status_code == 200 @@ -322,17 +314,15 @@ def fn2(s: str) -> str: 'name': 'app.fn1', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, { 'name': 'app.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, ] } @@ -343,9 +333,8 @@ def fn2(s: str) -> str: 'name': 'app.fn1', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } assert rv.status_code == 200 @@ -410,9 +399,8 @@ def fn1_b3(s: str) -> str: 'name': 'blue1.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } ], 'blue2': [ @@ -420,25 +408,22 @@ def fn1_b3(s: str) -> str: 'name': 'blue2.fn1', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, { 'name': 'blue2.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, { 'name': 'blue2.not_notify', 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, ], } @@ -470,9 +455,8 @@ def fn1_b3(s: str) -> str: 'name': 'blue2.fn1', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } rv = client.get('/api/browse/blue2.fn2.json') @@ -481,9 +465,8 @@ def fn1_b3(s: str) -> str: 'name': 'blue2.fn2', 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, } rv = client.get('/api/browse/blue3.fn3.json') diff --git a/tests/unit/contrib/openrpc/test_app.py b/tests/unit/contrib/openrpc/test_app.py index aac7d8b7..76ac12f3 100644 --- a/tests/unit/contrib/openrpc/test_app.py +++ b/tests/unit/contrib/openrpc/test_app.py @@ -46,8 +46,7 @@ def test_openrpc_create() -> None: license=st.License( name='BSD License', url='https://github.com/cenobites/flask-jsonrpc?tab=BSD-3-Clause-2-ov-file' ), - ), - servers=[], + ) ), ) @@ -122,7 +121,7 @@ def fn3(s: str) -> str: return f'Foo {s}' # pylint: disable=W0612 - @openrpc.extend_schema(name='FN3') + @openrpc.extend_schema(name='FN4') @jsonrpc.method('app.fn4', notification=False) def fn4(s: str) -> str: return f'Foo {s}' @@ -246,7 +245,7 @@ def fn5(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 'summary': 'Function app.fn3', }, { - 'name': 'FN3', + 'name': 'FN4', 'params': [{'name': 's', 'schema': {'type': 'string'}}], 'result': {'name': 'default', 'schema': {'type': 'string'}}, }, diff --git a/tests/unit/contrib/openrpc/test_helpers.py b/tests/unit/contrib/openrpc/test_helpers.py deleted file mode 100644 index 303c9642..00000000 --- a/tests/unit/contrib/openrpc/test_helpers.py +++ /dev/null @@ -1,246 +0,0 @@ -# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# * Neither the name of the Cenobit Technologies nor the names of -# its contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -from flask_jsonrpc.contrib.openrpc import typing as st -from flask_jsonrpc.contrib.openrpc.helpers import openrpc_schema_to_dict - - -def test_openrpc_schema_to_dict() -> None: - openrpc_schema = st.OpenRPCSchema( - info=st.Info( - version='1.0.0', - title='Petstore Expanded', - description=( - 'A sample API that uses a petstore as an example to ' - 'demonstrate features in the OpenRPC specification' - ), - terms_of_service='https://open-rpc.org', - contact=st.Contact(name='OpenRPC Team', email='doesntexist@open-rpc.org', url='https://open-rpc.org'), - license=st.License(name='Apache 2.0', url='https://www.apache.org/licenses/LICENSE-2.0.html'), - ), - openrpc='1.0.0-rc1', - methods=[ - st.Method( - name='get_pets', - description=( - 'Returns all pets from the system that the user has access to\n' - 'Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem ' - 'sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio ' - 'lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ' - 'ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer ' - 'at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie ' - 'imperdiet. Vivamus id aliquam diam.' - ), - params=[ - st.ContentDescriptor( - name='tags', - description='tags to filter by', - schema=st.Schema(type=st.SchemaDataType.ARRAY, items=st.Schema(type=st.SchemaDataType.STRING)), - ), - st.ContentDescriptor( - name='limit', - description='maximum number of results to return', - schema=st.Schema(type=st.SchemaDataType.INTEGER), - ), - ], - result=st.ContentDescriptor( - name='pet', - description='pet response', - schema=st.Schema(type=st.SchemaDataType.ARRAY, items=st.Schema(ref='#/components/schemas/Pet')), - ), - ), - st.Method( - name='create_pet', - description='Creates a new pet in the store. Duplicates are allowed', - params=[ - st.ContentDescriptor( - name='newPet', - description='Pet to add to the store.', - schema=st.Schema(ref='#/components/schemas/NewPet'), - ) - ], - result=st.ContentDescriptor( - name='pet', description='the newly created pet', schema=st.Schema(ref='#/components/schemas/Pet') - ), - ), - st.Method( - name='get_pet_by_id', - description='Returns a user based on a single ID, if the user does not have access to the pet', - params=[ - st.ContentDescriptor( - name='id', - description='ID of pet to fetch', - required=True, - schema=st.Schema(type=st.SchemaDataType.INTEGER), - ) - ], - result=st.ContentDescriptor( - name='pet', description='pet response', schema=st.Schema(ref='#/components/schemas/Pet') - ), - ), - st.Method( - name='delete_pet_by_id', - description='deletes a single pet based on the ID supplied', - params=[ - st.ContentDescriptor( - name='id', - description='ID of pet to delete', - required=True, - schema=st.Schema(type=st.SchemaDataType.INTEGER), - ) - ], - result=st.ContentDescriptor(name='pet', description='pet deleted', schema=st.Schema()), - ), - ], - servers=[st.Server(url='http://petstore.open-rpc.org')], - components=st.Components( - schemas={ - 'Pet': st.Schema( - all_of=[ - st.Schema(ref='#/components/schemas/NewPet'), - st.Schema(required=['id'], properties={'id': st.Schema(type=st.SchemaDataType.INTEGER)}), - ] - ), - 'NewPet': st.Schema( - type=st.SchemaDataType.OBJECT, - required=['name'], - properties={ - 'name': st.Schema(type=st.SchemaDataType.STRING), - 'tag': st.Schema(type=st.SchemaDataType.STRING), - }, - ), - } - ), - external_docs=st.ExternalDocumentation( - url='https://github.com/open-rpc/examples/blob/master/service-descriptions/petstore-expanded-openrpc.json' - ), - ) - - assert openrpc_schema_to_dict(openrpc_schema) == { - 'openrpc': '1.0.0-rc1', - 'info': { - 'version': '1.0.0', - 'title': 'Petstore Expanded', - 'description': ( - 'A sample API that uses a petstore as an example to demonstrate features in ' - 'the OpenRPC specification' - ), - 'termsOfService': 'https://open-rpc.org', - 'contact': {'name': 'OpenRPC Team', 'email': 'doesntexist@open-rpc.org', 'url': 'https://open-rpc.org'}, - 'license': {'name': 'Apache 2.0', 'url': 'https://www.apache.org/licenses/LICENSE-2.0.html'}, - }, - 'servers': [{'name': 'default', 'url': 'http://petstore.open-rpc.org'}], - 'methods': [ - { - 'name': 'get_pets', - 'description': ( - 'Returns all pets from the system that the user has access to\n' - 'Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis ' - 'sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare ' - 'eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, ' - 'tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ' - 'ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus ' - 'id aliquam diam.' - ), - 'params': [ - { - 'name': 'tags', - 'description': 'tags to filter by', - 'schema': {'type': 'array', 'items': {'type': 'string'}}, - }, - { - 'name': 'limit', - 'description': 'maximum number of results to return', - 'schema': {'type': 'integer'}, - }, - ], - 'result': { - 'name': 'pet', - 'description': 'pet response', - 'schema': {'type': 'array', 'items': {'$ref': '#/components/schemas/Pet'}}, - }, - }, - { - 'name': 'create_pet', - 'description': 'Creates a new pet in the store. Duplicates are allowed', - 'params': [ - { - 'name': 'newPet', - 'description': 'Pet to add to the store.', - 'schema': {'$ref': '#/components/schemas/NewPet'}, - } - ], - 'result': { - 'name': 'pet', - 'description': 'the newly created pet', - 'schema': {'$ref': '#/components/schemas/Pet'}, - }, - }, - { - 'name': 'get_pet_by_id', - 'description': 'Returns a user based on a single ID, if the user does not have access to the pet', - 'params': [ - {'name': 'id', 'description': 'ID of pet to fetch', 'required': True, 'schema': {'type': 'integer'}} - ], - 'result': { - 'name': 'pet', - 'description': 'pet response', - 'schema': {'$ref': '#/components/schemas/Pet'}, - }, - }, - { - 'name': 'delete_pet_by_id', - 'description': 'deletes a single pet based on the ID supplied', - 'params': [ - { - 'name': 'id', - 'description': 'ID of pet to delete', - 'required': True, - 'schema': {'type': 'integer'}, - } - ], - 'result': {'name': 'pet', 'description': 'pet deleted', 'schema': {}}, - }, - ], - 'components': { - 'schemas': { - 'Pet': { - 'allOf': [ - {'$ref': '#/components/schemas/NewPet'}, - {'required': ['id'], 'properties': {'id': {'type': 'integer'}}}, - ] - }, - 'NewPet': { - 'type': 'object', - 'required': ['name'], - 'properties': {'name': {'type': 'string'}, 'tag': {'type': 'string'}}, - }, - } - }, - 'externalDocs': { - 'url': 'https://github.com/open-rpc/examples/blob/master/service-descriptions/petstore-expanded-openrpc.json' - }, - } diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 6818b244..94ed2b3e 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -174,7 +174,7 @@ def test_app_create_without_register_browse() -> None: jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) with pytest.raises( - RuntimeError, match='You need to init the Browse app before register the Site, see JSONRPC.init_browse_app(...)' + RuntimeError, match='you need to init the Browse app before register the Site, see JSONRPC.init_browse_app(...)' ): jsonrpc.register_browse(jsonrpc) diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index db41cd09..5a644c38 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -160,7 +160,7 @@ def test_app_greeting_raise_invalid_params_error(async_client: 'FlaskClient') -> 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (tuple, set, list) or by-name (dict): Wrong'}, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -230,7 +230,7 @@ def test_app_echo_raise_invalid_params_error(async_client: 'FlaskClient') -> Non 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (tuple, set, list) or by-name (dict): Wrong'}, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -491,192 +491,657 @@ def test_app_class(async_client: 'FlaskClient') -> None: assert rv.status_code == 500 +def test_app_with_pythonclass(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createColor', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} + + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}} + ) + assert rv.status_code == 400 + data = rv.get_json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] + assert data['error']['message'] == 'Invalid params' + assert data['error']['name'] == 'InvalidParamsError' + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': [ + [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}], + {'name': 'Green', 'tag': 'yay'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Blue', 'tag': 'good'}, + {'id': 1, 'name': 'Red', 'tag': 'bad'}, + {'id': 2, 'name': 'Green', 'tag': 'yay'}, + ], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixColor', + 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Blue', 'tag': 'good'}]} + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} + + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}} + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + +def test_app_with_invalid_union(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion1', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion2', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + +def test_app_with_pythontypes(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}} + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} + + +def test_app_with_dataclass(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createCar', + 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} + + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}} + ) + assert rv.status_code == 400 + data = rv.get_json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] + assert data['error']['message'] == 'Invalid params' + assert data['error']['name'] == 'InvalidParamsError' + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': [ + [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}], + {'name': 'Gol', 'tag': 'white'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Fusca', 'tag': 'blue'}, + {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}, + {'id': 2, 'name': 'Gol', 'tag': 'white'}, + ], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixCar', + 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Fusca', 'tag': 'blue'}]} + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} + + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}} + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + +def test_app_with_pydantic(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createPet', + 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} + + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}} + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': '1 validation error for NewPet\n' + 'tag\n' + " Field required [type=missing, input_value={'name': 'Eve'}, " + 'input_type=dict]\n' + ' For further information visit ' + 'https://errors.pydantic.dev/2.9/v/missing' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': [ + [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}], + {'name': 'Tequila', 'tag': 'cat'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Eve', 'tag': 'dog'}, + {'id': 1, 'name': 'Lou', 'tag': 'dog'}, + {'id': 2, 'name': 'Tequila', 'tag': 'cat'}, + ], + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixPet', + 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]} + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} + + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}} + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + def test_app_system_describe(async_client: 'FlaskClient') -> None: rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) - assert rv.json['id'] == 1 - assert rv.json['jsonrpc'] == '2.0' - assert rv.json['result']['name'] == 'Flask-JSONRPC' - assert rv.json['result']['description'] is None - assert rv.json['result']['version'] == '2.0' - assert rv.json['result']['servers'] is not None - assert 'url' in rv.json['result']['servers'][0] - assert rv.json['result']['methods'] == { + data = rv.get_json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { 'jsonrpc.greeting': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.echo': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': '_some', 'type': 'Object', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.notify': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'jsonrpc.not_allow_notify': { 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.fails': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number', 'required': False, 'nullable': False}], + 'params': [{'name': 'n', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, }, 'jsonrpc.strangeEcho': { 'type': 'method', 'options': {'notification': True, 'validate': True}, 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'omg', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'wtf', 'type': 'Array', 'required': False, 'nullable': False}, - {'name': 'nowai', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'yeswai', 'type': 'String', 'required': False, 'nullable': False}, + {'name': 'string', 'type': 'String'}, + {'name': 'omg', 'type': 'Object'}, + {'name': 'wtf', 'type': 'Array'}, + {'name': 'nowai', 'type': 'Number'}, + {'name': 'yeswai', 'type': 'String'}, ], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.sum': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'a', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'b', 'type': 'Number', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, + }, + 'jsonrpc.createCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.createColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.createManyCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'cars', 'type': 'Array'}, {'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'colors', 'type': 'Array'}, {'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'cars', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'colors', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pets', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pets', 'type': 'Array'}, {'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', }, 'jsonrpc.decorators': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.returnStatusCode': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, + }, + 'jsonrpc.removeCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.removeColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.removePet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', }, 'jsonrpc.returnHeaders': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.returnStatusCodeAndHeaders': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.not_validate': { 'type': 'method', 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'nullable': False, 'required': False, 'type': 'Object'}], + 'params': [{'name': 's', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + }, + 'jsonrpc.invalidUnion1': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.invalidUnion2': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], 'returns': {'type': 'Object'}, - 'description': None, + 'type': 'method', + }, + 'jsonrpc.literalType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'x', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', }, 'jsonrpc.mixin_not_validate': { 'type': 'method', 'options': {'notification': True, 'validate': False}, 'params': [ - {'name': 's', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 't', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'u', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'v', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'x', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'z', 'type': 'Object', 'required': False, 'nullable': False}, + {'name': 's', 'type': 'Object'}, + {'name': 't', 'type': 'Number'}, + {'name': 'u', 'type': 'Object'}, + {'name': 'v', 'type': 'String'}, + {'name': 'x', 'type': 'Object'}, + {'name': 'z', 'type': 'Object'}, ], 'returns': {'type': 'Object'}, - 'description': None, }, 'jsonrpc.noReturn': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'classapp.index': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'greeting': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'hello': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'echo': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': '_some', 'type': 'Object', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'returns': {'type': 'String'}, - 'description': None, }, 'notify': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'not_allow_notify': { 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'fails': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number', 'required': False, 'nullable': False}], + 'params': [{'name': 'n', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, - }, - 'rpc.describe': { - 'description': None, - 'options': {}, - 'params': [], - 'returns': {'type': 'Object'}, - 'type': 'method', }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, } assert rv.status_code == 200 diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 142e3218..9c97c531 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -156,7 +156,7 @@ def test_app_greeting_raise_invalid_params_error(client: 'FlaskClient') -> None: 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (tuple, set, list) or by-name (dict): Wrong'}, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -214,6 +214,19 @@ def test_app_echo(client: 'FlaskClient') -> None: assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Flask'} assert rv.status_code == 200 + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': None}}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': "missing a required argument: 'string'"}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + def test_app_echo_raise_invalid_params_error(client: 'FlaskClient') -> None: rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': 'Wrong'}) @@ -222,7 +235,7 @@ def test_app_echo_raise_invalid_params_error(client: 'FlaskClient') -> None: 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (tuple, set, list) or by-name (dict): Wrong'}, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -365,6 +378,20 @@ def test_app_sum(client: 'FlaskClient') -> None: assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 2.0} assert rv.status_code == 200 + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': {'a': None, 'b': None}} + rv = client.post('/api', json=data) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': "missing a required argument: 'a'"}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + def test_app_decorators(client: 'FlaskClient') -> None: data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': ['Python']} @@ -479,192 +506,651 @@ def test_app_class(client: 'FlaskClient') -> None: assert rv.status_code == 500 +def test_app_with_pythonclass(client: 'FlaskClient') -> None: + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createColor', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} + + rv = client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}} + ) + assert rv.status_code == 400 + data = rv.get_json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] + assert data['error']['message'] == 'Invalid params' + assert data['error']['name'] == 'InvalidParamsError' + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': [ + [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}], + {'name': 'Green', 'tag': 'yay'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Blue', 'tag': 'good'}, + {'id': 1, 'name': 'Red', 'tag': 'bad'}, + {'id': 2, 'name': 'Green', 'tag': 'yay'}, + ], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixColor', + 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Blue', 'tag': 'good'}]} + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} + + rv = client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}} + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + +def test_app_with_invalid_union(client: 'FlaskClient') -> None: + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion1', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion2', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + +def test_app_with_pythontypes(client: 'FlaskClient') -> None: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} + + +def test_app_with_dataclass(client: 'FlaskClient') -> None: + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createCar', + 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} + + rv = client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}} + ) + assert rv.status_code == 400 + data = rv.get_json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] + assert data['error']['message'] == 'Invalid params' + assert data['error']['name'] == 'InvalidParamsError' + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': [ + [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}], + {'name': 'Gol', 'tag': 'white'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Fusca', 'tag': 'blue'}, + {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}, + {'id': 2, 'name': 'Gol', 'tag': 'white'}, + ], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixCar', + 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Fusca', 'tag': 'blue'}]} + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + +def test_app_with_pydantic(client: 'FlaskClient') -> None: + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createPet', + 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} + + rv = client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}} + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': '1 validation error for NewPet\n' + 'tag\n' + " Field required [type=missing, input_value={'name': 'Eve'}, " + 'input_type=dict]\n' + ' For further information visit ' + 'https://errors.pydantic.dev/2.9/v/missing' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': [ + [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}], + {'name': 'Tequila', 'tag': 'cat'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Eve', 'tag': 'dog'}, + {'id': 1, 'name': 'Lou', 'tag': 'dog'}, + {'id': 2, 'name': 'Tequila', 'tag': 'cat'}, + ], + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixPet', + 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]} + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + def test_app_system_describe(client: 'FlaskClient') -> None: rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) - assert rv.json['id'] == 1 - assert rv.json['jsonrpc'] == '2.0' - assert rv.json['result']['name'] == 'Flask-JSONRPC' - assert rv.json['result']['description'] is None - assert rv.json['result']['version'] == '2.0' - assert rv.json['result']['servers'] is not None - assert 'url' in rv.json['result']['servers'][0] - assert rv.json['result']['methods'] == { + data = rv.get_json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { 'jsonrpc.greeting': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.echo': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': '_some', 'type': 'Object', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.notify': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'jsonrpc.not_allow_notify': { 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.fails': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number', 'required': False, 'nullable': False}], + 'params': [{'name': 'n', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, }, 'jsonrpc.strangeEcho': { 'type': 'method', 'options': {'notification': True, 'validate': True}, 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'omg', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'wtf', 'type': 'Array', 'required': False, 'nullable': False}, - {'name': 'nowai', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'yeswai', 'type': 'String', 'required': False, 'nullable': False}, + {'name': 'string', 'type': 'String'}, + {'name': 'omg', 'type': 'Object'}, + {'name': 'wtf', 'type': 'Array'}, + {'name': 'nowai', 'type': 'Number'}, + {'name': 'yeswai', 'type': 'String'}, ], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.sum': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'a', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'b', 'type': 'Number', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, + }, + 'jsonrpc.createCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.createColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.createManyCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'cars', 'type': 'Array'}, {'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'colors', 'type': 'Array'}, {'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'cars', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'colors', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pets', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pets', 'type': 'Array'}, {'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', }, 'jsonrpc.decorators': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'jsonrpc.returnStatusCode': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, + }, + 'jsonrpc.removeCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.removeColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.removePet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', }, 'jsonrpc.returnHeaders': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.returnStatusCodeAndHeaders': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 's', 'type': 'String'}], 'returns': {'type': 'Array'}, - 'description': None, }, 'jsonrpc.not_validate': { 'type': 'method', 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'nullable': False, 'required': False, 'type': 'Object'}], + 'params': [{'name': 's', 'type': 'Object'}], 'returns': {'type': 'Object'}, - 'description': None, + }, + 'jsonrpc.invalidUnion1': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.invalidUnion2': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.literalType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'x', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', }, 'jsonrpc.mixin_not_validate': { 'type': 'method', 'options': {'notification': True, 'validate': False}, 'params': [ - {'name': 's', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 't', 'type': 'Number', 'required': False, 'nullable': False}, - {'name': 'u', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'v', 'type': 'String', 'required': False, 'nullable': False}, - {'name': 'x', 'type': 'Object', 'required': False, 'nullable': False}, - {'name': 'z', 'type': 'Object', 'required': False, 'nullable': False}, + {'name': 's', 'type': 'Object'}, + {'name': 't', 'type': 'Number'}, + {'name': 'u', 'type': 'Object'}, + {'name': 'v', 'type': 'String'}, + {'name': 'x', 'type': 'Object'}, + {'name': 'z', 'type': 'Object'}, ], 'returns': {'type': 'Object'}, - 'description': None, }, 'jsonrpc.noReturn': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'classapp.index': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'greeting': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'hello': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': 'name', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'echo': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String', 'required': False, 'nullable': False}, - {'name': '_some', 'type': 'Object', 'required': False, 'nullable': False}, - ], + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], 'returns': {'type': 'String'}, - 'description': None, }, 'notify': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'Null'}, - 'description': None, }, 'not_allow_notify': { 'type': 'method', 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String', 'required': False, 'nullable': False}], + 'params': [{'name': '_string', 'type': 'String'}], 'returns': {'type': 'String'}, - 'description': None, }, 'fails': { 'type': 'method', 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number', 'required': False, 'nullable': False}], + 'params': [{'name': 'n', 'type': 'Number'}], 'returns': {'type': 'Number'}, - 'description': None, - }, - 'rpc.describe': { - 'description': None, - 'options': {}, - 'params': [], - 'returns': {'type': 'Object'}, - 'type': 'method', }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, } assert rv.status_code == 200 diff --git a/tests/unit/test_encoders.py b/tests/unit/test_encoders.py new file mode 100644 index 00000000..e8996273 --- /dev/null +++ b/tests/unit/test_encoders.py @@ -0,0 +1,116 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +from enum import Enum +import typing as t +from pathlib import Path +from collections import deque +from dataclasses import dataclass + +from pydantic.main import BaseModel + +from flask_jsonrpc.encoders import serializable + +# Python 3.10+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class EnumType(Enum): + X = 'x' + Y = 'y' + Z = 'z' + + +class GenericClass: + def __init__(self: Self) -> None: + self.attr1 = 'value1' + self.attr2 = 2 + + +@dataclass +class DataClassType: + x: str + y: int + z: t.List[str] + + +class PydanticType(BaseModel): + x: str + y: int + z: t.List[str] + + +def test_serializable() -> None: + assert serializable(None) is None + assert serializable('') == '' + assert serializable(1) == 1 + assert serializable(EnumType.X) == 'x' + assert serializable(Path('/')) == '/' + assert serializable({'key1': 'value1', 'key2': EnumType.X}) == {'key1': 'value1', 'key2': 'x'} + assert serializable([1, 2, EnumType.X, Path('/another/path')]) == [1, 2, 'x', '/another/path'] + assert serializable(deque(['a', 'b', EnumType.X])) == ['a', 'b', 'x'] + assert serializable(GenericClass()) == {'attr1': 'value1', 'attr2': 2} + assert serializable( + { + 'str': 'x', + 'int': 1, + 'none': None, + 'list': [1, '2', []], + 'dict': {'1': 2, '3': 4}, + 'dataclass': DataClassType(x='str', y=1, z=['0', '1', '2']), + 'pydantic': PydanticType(x='str', y=1, z=['0', '1', '2']), + } + ) == { + 'dataclass': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}, + 'dict': {'1': 2, '3': 4}, + 'int': 1, + 'list': [1, '2', []], + 'none': None, + 'pydantic': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}, + 'str': 'x', + } + assert serializable( + [ + 'x', + 1, + None, + [1, '2', []], + {'1': 2, '3': 4}, + DataClassType(x='str', y=1, z=['0', '1', '2']), + PydanticType(x='str', y=1, z=['0', '1', '2']), + ] + ) == [ + 'x', + 1, + None, + [1, '2', []], + {'1': 2, '3': 4}, + {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}, + {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}, + ] diff --git a/tests/unit/test_funcutils.py b/tests/unit/test_funcutils.py new file mode 100644 index 00000000..8f7c34f0 --- /dev/null +++ b/tests/unit/test_funcutils.py @@ -0,0 +1,116 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t +from dataclasses import asdict, dataclass + +import pytest +from pydantic.main import BaseModel + +from flask_jsonrpc.funcutils import loads + +# Python 3.10+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class GenericClass: + def __init__(self: Self, attr1: str, attr2: int) -> None: + self.attr1 = attr1 + self.attr2 = attr2 + + +@dataclass +class DataClassType: + x: str + y: int + z: t.List[str] + + +class PydanticType(BaseModel): + x: str + y: int + z: t.List[str] + + +def test_loads() -> None: + assert loads(str, None) is None + assert loads(t.Any, None) is None + assert loads(t.Any, 42) == 42 + assert loads(t.Any, 'test') == 'test' + assert loads(str, 'string') == 'string' + assert loads(int, 1) == 1 + assert loads(t.Optional[int], 1) == 1 + assert loads(t.Optional[int], None) is None + assert loads(t.Union[int, None], None) is None + assert loads(t.Union[int, None], 1) == 1 + assert loads(t.Union[None, int], None) is None + assert loads(t.Union[None, int], 1) == 1 + assert loads(t.Union[None, None], None) is None + with pytest.raises( + TypeError, + match='the only type of union that is supported is: typing.Union\\[T, None\\] or typing.Optional\\[T\\]', + ): + loads(t.Union[int, str, None], 1) + with pytest.raises( + TypeError, + match='the only type of union that is supported is: typing.Union\\[T, None\\] or typing.Optional\\[T\\]', + ): + loads(t.Union[int, str], 1) + assert loads(t.List[int], [1, 2, 3, 4, 5]) == [1, 2, 3, 4, 5] + assert loads(t.List[t.List[int]], [[1, 2], [3, 4]]) == [[1, 2], [3, 4]] + assert loads(t.Dict[str, int], {'a': 1, 'b': 2, 'c': 3}) == {'a': 1, 'b': 2, 'c': 3} + assert loads(t.Dict[str, t.Dict[str, int]], {'outer': {'inner': 1}}) == {'outer': {'inner': 1}} + assert loads(GenericClass, {'attr1': 'value1', 'attr2': 2}).__dict__ == GenericClass('value1', 2).__dict__ + assert ( + loads(PydanticType, {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}).model_dump() + == PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump() + ) + assert [x.model_dump() for x in loads(t.List[PydanticType], [{'x': 'str', 'y': 1, 'z': ['0', '1', '2']}])] == [ + PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump() + ] + with pytest.raises(TypeError) as excinfo: + loads(PydanticType, {'invalid_key': 'value'}) + assert "Field required [type=missing, input_value={'invalid_key': 'value'}, input_type=dict]" in str(excinfo.value) + assert { + k: v.model_dump() + for k, v in loads(t.Dict[str, PydanticType], {'obj': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}}).items() + } == {'obj': PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump()} + assert asdict(loads(DataClassType, {'x': 'str', 'y': 1, 'z': ['0', '1', '2']})) == asdict( + DataClassType(x='str', y=1, z=['0', '1', '2']) + ) + assert [asdict(x) for x in loads(t.List[DataClassType], [{'x': 'str', 'y': 1, 'z': ['0', '1', '2']}])] == [ + asdict(DataClassType(x='str', y=1, z=['0', '1', '2'])) + ] + assert { + k: asdict(v) + for k, v in loads(t.Dict[str, DataClassType], {'obj': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}}).items() + } == {'obj': asdict(DataClassType(x='str', y=1, z=['0', '1', '2']))} + with pytest.raises(TypeError) as excinfo: + loads(DataClassType, {'invalid_key': 'value'}) + assert "__init__() got an unexpected keyword argument 'invalid_key'" in str(excinfo.value) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 619dc4f1..f9a3f7c8 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -33,5 +33,5 @@ def test_settings() -> None: settings = JSONRPCSettings({'setting': True}) assert settings.setting is True - with pytest.raises(AttributeError, match="Invalid setting: 'xxx'"): + with pytest.raises(AttributeError, match="invalid setting: 'xxx'"): assert settings.xxx is None diff --git a/tox.ini b/tox.ini index 49c6ab46..730d81f4 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = py3{12,11,10,9,8} py3{12,11,10,9,8}-async style - typing-{mypy,pytype} + typing-{mypy,pytype,pyright} security-{safety,bandit} docs skip_missing_interpreters = true @@ -40,6 +40,13 @@ deps = commands = pytype +[testenv:typing-pyright] +basepython=python3.12 +deps = + -r requirements/typing.txt +commands = + pyright + [testenv:security-safety] basepython=python3.12 deps =