diff --git a/fastapi_jsonrpc/__init__.py b/fastapi_jsonrpc/__init__.py index a3bff50..46f530a 100644 --- a/fastapi_jsonrpc/__init__.py +++ b/fastapi_jsonrpc/__init__.py @@ -24,13 +24,13 @@ from fastapi.dependencies.utils import solve_dependencies, get_dependant, get_flat_dependant, \ get_parameterless_sub_dependant from fastapi.exceptions import RequestValidationError, HTTPException -from fastapi.routing import APIRoute, APIRouter, serialize_response +from fastapi.routing import APIRoute, APIRouter, request_response, serialize_response from pydantic import BaseModel, ValidationError, StrictStr, Field, create_model, ConfigDict from starlette.background import BackgroundTasks from starlette.concurrency import run_in_threadpool from starlette.requests import Request from starlette.responses import Response, JSONResponse -from starlette.routing import Match, request_response, compile_path +from starlette.routing import Match, compile_path import fastapi.params import aiojobs import warnings @@ -453,29 +453,6 @@ def fix_query_dependencies(dependant: Dependant): fix_query_dependencies(sub_dependant) -def clone_dependant(dependant: Dependant) -> Dependant: - new_dependant = Dependant() - new_dependant.path_params = dependant.path_params - new_dependant.query_params = dependant.query_params - new_dependant.header_params = dependant.header_params - new_dependant.cookie_params = dependant.cookie_params - new_dependant.body_params = dependant.body_params - new_dependant.dependencies = dependant.dependencies - new_dependant.security_requirements = dependant.security_requirements - new_dependant.request_param_name = dependant.request_param_name - new_dependant.websocket_param_name = dependant.websocket_param_name - new_dependant.response_param_name = dependant.response_param_name - new_dependant.background_tasks_param_name = dependant.background_tasks_param_name - new_dependant.security_scopes = dependant.security_scopes - new_dependant.security_scopes_param_name = dependant.security_scopes_param_name - new_dependant.name = dependant.name - new_dependant.call = dependant.call - new_dependant.use_cache = dependant.use_cache - new_dependant.path = dependant.path - new_dependant.cache_key = dependant.cache_key - return new_dependant - - def insert_dependencies(target: Dependant, dependencies: Optional[Sequence[Depends]] = None): assert target.path if not dependencies: @@ -1032,7 +1009,7 @@ def endpoint(__request__: _Request): f"params={flat_dependant.query_params}" ) - self.shared_dependant = clone_dependant(self.dependant) + self.shared_dependant = copy.copy(self.dependant) # No shared 'Body' params, because each JSON-RPC request in batch has own body self.shared_dependant.body_params = [] @@ -1419,7 +1396,10 @@ def update_refs(value): fine_schema = {} for key, schema in data['components']['schemas'].items(): - fine_schema_name = key[:-len(schema['title'].replace('.', '__'))].replace('__', '.') + schema['title'] + if 'title' in schema: + fine_schema_name = key[:-len(schema['title'].replace('.', '__'))].replace('__', '.') + schema['title'] + else: + fine_schema_name = key.replace('__', '.') old2new_schema_name[key] = fine_schema_name fine_schema[fine_schema_name] = schema data['components']['schemas'] = fine_schema @@ -1600,4 +1580,4 @@ def echo( app.bind_entrypoint(api_v1) - uvicorn.run(app, port=5000, debug=True, access_log=False) # noqa \ No newline at end of file + uvicorn.run(app, port=5000, debug=True, access_log=False) # noqa diff --git a/tests/conftest.py b/tests/conftest.py index 1cc3504..b273757 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import logging import platform +import sys from json import dumps as json_dumps from unittest.mock import ANY @@ -162,3 +163,8 @@ def _openapi_compatible(obj: dict): return obj return _openapi_compatible + + +collect_ignore = [] +if sys.version_info < (3, 12): + collect_ignore.append("test_openrpc_type_keyword.py") diff --git a/tests/test_openapi.py b/tests/test_openapi.py index dd4e73e..e5aed8a 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -588,8 +588,13 @@ def probe( }) -@pytest.fixture(params=['uniq-sig', 'same-sig']) -def api_package(request, pytester): +@pytest.fixture(params=['no-collide', 'collide']) +def api_package_signature(request): + return request.param + + +@pytest.fixture() +def api_package(api_package_signature, pytester): """Create package with structure api \ mobile.py @@ -624,11 +629,11 @@ def probe( return [1, 2, 3] """ - if request.param == 'uniq-sig': + if api_package_signature == 'no-collide': mobile_param_name = 'mobile_data' web_param_name = 'web_data' else: - assert request.param == 'same-sig' + assert api_package_signature == 'collide' mobile_param_name = web_param_name = 'data' api_dir = pytester.mkpydir('api') @@ -650,7 +655,8 @@ def probe( return api_dir -def test_component_name_isolated_by_their_path(pytester, api_package): +@pytest.mark.usefixtures('api_package') +def test_component_name_isolated_by_their_path(pytester, api_package_signature): """Test we can mix methods with same names in one openapi.json schema """ @@ -685,17 +691,13 @@ def test_no_collide(app_client): assert path in paths # Response model the same and deduplicated - assert '_Response[probe]' in schemas - - if '_Params[probe]' not in schemas: - for component_name in ( - 'api.mobile._Params[probe]', - 'api.mobile._Request[probe]', - 'api.web._Params[probe]', - 'api.web._Request[probe]', - ): - assert component_name in schemas -''') + if '{package_type}' == 'collide': + assert ('api.mobile._Params[probe]' in schemas) ^ ('api.web._Params[probe]' in schemas) + assert ('api.mobile._Request[probe]' in schemas) ^ ('api.web._Request[probe]' in schemas) + else: + assert 'api.mobile._Params[probe]' in schemas and 'api.web._Params[probe]' in schemas + assert 'api.mobile._Request[probe]' in schemas and 'api.web._Request[probe]' in schemas +'''.format(package_type=api_package_signature)) # force reload module to drop component cache # it's more efficient than use pytest.runpytest_subprocess() @@ -732,4 +734,4 @@ def test_no_entrypoints__ok(fastapi_jsonrpc_components_fine_names): app_client = TestClient(app) resp = app_client.get('/openapi.json') resp.raise_for_status() - assert resp.status_code == 200 \ No newline at end of file + assert resp.status_code == 200 diff --git a/tests/test_openrpc_type_keyword.py b/tests/test_openrpc_type_keyword.py new file mode 100644 index 0000000..27b51f4 --- /dev/null +++ b/tests/test_openrpc_type_keyword.py @@ -0,0 +1,82 @@ +from typing import List + +from pydantic import BaseModel + + +def test_py312_type_keyword_field(ep, app, app_client): + class Model1(BaseModel): + x: int + + class Model2(BaseModel): + y: int + + type Model = Model1 | Model2 + + class Input(BaseModel): + data: Model + + class Output(BaseModel): + result: List[int] + + @ep.method() + def my_method_type_keyword(inp: Input) -> Output: + if isinstance(inp.data, Model1): + return Output(result=[inp.data.x]) + return Output(result=[inp.data.y]) + + app.bind_entrypoint(ep) + + resp = app_client.get('/openrpc.json') + schema = resp.json() + + assert len(schema['methods']) == 1 + assert schema['methods'][0]['params'] == [ + { + 'name': 'inp', + 'schema': {'$ref': '#/components/schemas/Input'}, + 'required': True, + } + ] + assert schema['methods'][0]['result'] == { + 'name': 'my_method_type_keyword_Result', + 'schema': {'$ref': '#/components/schemas/Output'}, + } + + assert schema['components']['schemas'] == { + 'Input': { + 'properties': {'data': {'$ref': '#/components/schemas/Model'}}, + 'required': ['data'], + 'title': 'Input', + 'type': 'object', + }, + 'Model': { + 'anyOf': [ + {'$ref': '#/components/schemas/Model1'}, + {'$ref': '#/components/schemas/Model2'}, + ] + }, + 'Model1': { + 'properties': {'x': {'title': 'X', 'type': 'integer'}}, + 'required': ['x'], + 'title': 'Model1', + 'type': 'object', + }, + 'Model2': { + 'properties': {'y': {'title': 'Y', 'type': 'integer'}}, + 'required': ['y'], + 'title': 'Model2', + 'type': 'object', + }, + 'Output': { + 'properties': { + 'result': { + 'items': {'type': 'integer'}, + 'title': 'Result', + 'type': 'array', + } + }, + 'required': ['result'], + 'title': 'Output', + 'type': 'object', + }, + }