diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dc9f4c2..d58bdbd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Changelog ========= +1.11.0 (2024-12-08) +------------------ + +- added exclude_param argument to be able to exclude a json-rpc parameter from validation and schema extraction. + + 1.10.1 (2024-11-13) ------------------ diff --git a/examples/aiohttp_dependency_injection.py b/examples/aiohttp_dependency_injection.py new file mode 100644 index 0000000..35b7d50 --- /dev/null +++ b/examples/aiohttp_dependency_injection.py @@ -0,0 +1,81 @@ +import uuid +from typing import Any, Optional, Type + +import pydantic +from aiohttp import web +from dependency_injector import containers, providers +from dependency_injector.wiring import Provide, inject + +import pjrpc.server +import pjrpc.server.specs.extractors.pydantic +from pjrpc.server.integration import aiohttp +from pjrpc.server.specs import extractors +from pjrpc.server.specs import openapi as specs +from pjrpc.server.validators import pydantic as validators + + +def is_di_injected(name: str, annotation: Type[Any], default: Any) -> bool: + return type(default) is Provide + + +methods = pjrpc.server.MethodRegistry() +validator = validators.PydanticValidator(exclude_param=is_di_injected) + + +class UserService: + def __init__(self): + self._users = {} + + def add_user(self, user: dict) -> str: + user_id = uuid.uuid4().hex + self._users[user_id] = user + + return user_id + + def get_user(self, user_id: uuid.UUID) -> Optional[dict]: + return self._users.get(user_id) + + +class Container(containers.DeclarativeContainer): + wiring_config = containers.WiringConfiguration(modules=["__main__"]) + user_service = providers.Factory(UserService) + + +class User(pydantic.BaseModel): + name: str + surname: str + age: int + + +@specs.annotate(summary='Creates a user', tags=['users']) +@methods.add(context='request') +@validator.validate +@inject +async def add_user( + request: web.Request, + user: User, + user_service: UserService = Provide[Container.user_service], +) -> dict: + user_dict = user.model_dump() + user_id = user_service.add_user(user_dict) + + return {'id': user_id, **user_dict} + + +jsonrpc_app = aiohttp.Application( + '/api/v1', + specs=[ + specs.OpenAPI( + info=specs.Info(version="1.0.0", title="User storage"), + schema_extractor=extractors.pydantic.PydanticSchemaExtractor(exclude_param=is_di_injected), + ui=specs.SwaggerUI(), + ), + ], +) +jsonrpc_app.dispatcher.add_methods(methods) +jsonrpc_app.app['users'] = {} + +jsonrpc_app.app.container = Container() + +if __name__ == "__main__": + web.run_app(jsonrpc_app.app, host='localhost', port=8080) diff --git a/pjrpc/__about__.py b/pjrpc/__about__.py index eda4339..021126c 100644 --- a/pjrpc/__about__.py +++ b/pjrpc/__about__.py @@ -2,7 +2,7 @@ __description__ = 'Extensible JSON-RPC library' __url__ = 'https://github.com/dapper91/pjrpc' -__version__ = '1.10.1' +__version__ = '1.11.0' __author__ = 'Dmitry Pershin' __email__ = 'dapper1291@gmail.com' diff --git a/pjrpc/server/specs/extractors/docstring.py b/pjrpc/server/specs/extractors/docstring.py index 2439348..e5ca377 100644 --- a/pjrpc/server/specs/extractors/docstring.py +++ b/pjrpc/server/specs/extractors/docstring.py @@ -6,13 +6,20 @@ from pjrpc.common.typedefs import MethodType from pjrpc.server.specs.extractors import BaseSchemaExtractor, JsonRpcError from pjrpc.server.specs.schemas import build_request_schema, build_response_schema +from pjrpc.server.typedefs import ExcludeFunc class DocstringSchemaExtractor(BaseSchemaExtractor): """ docstring method specification generator. + + :param exclude_param: a function that decides if the parameters must be excluded + from schema (useful for dependency injection) """ + def __init__(self, exclude_param: Optional[ExcludeFunc] = None): + self._exclude_param = exclude_param or (lambda *args: False) + def extract_params_schema( self, method_name: str, @@ -26,7 +33,7 @@ def extract_params_schema( if method.__doc__: doc = docstring_parser.parse(method.__doc__) for param in doc.params: - if param.arg_name in exclude: + if param.arg_name in exclude or self._exclude_param(param.arg_name, None, None): continue parameters_schema[param.arg_name] = { diff --git a/pjrpc/server/specs/extractors/pydantic.py b/pjrpc/server/specs/extractors/pydantic.py index 9d0e289..c2454e8 100644 --- a/pjrpc/server/specs/extractors/pydantic.py +++ b/pjrpc/server/specs/extractors/pydantic.py @@ -6,6 +6,7 @@ from pjrpc.common import exceptions from pjrpc.common.typedefs import MethodType from pjrpc.server.specs.extractors import BaseSchemaExtractor +from pjrpc.server.typedefs import ExcludeFunc def to_camel(string: str) -> str: @@ -86,9 +87,12 @@ class PydanticSchemaExtractor(BaseSchemaExtractor): Pydantic method specification extractor. :param config_args: model configuration parameters + :param exclude_param: a function that decides if the parameters must be excluded + from schema (useful for dependency injection) """ - def __init__(self, **config_args: Any): + def __init__(self, exclude_param: Optional[ExcludeFunc] = None, **config_args: Any): + self._exclude_param = exclude_param or (lambda *args: False) self._config_args = config_args def extract_params_schema( @@ -223,7 +227,7 @@ def _build_params_model( field_definitions: Dict[str, Any] = {} for param in signature.parameters.values(): - if param.name in exclude: + if param.name in exclude or self._exclude_param(param.name, param.annotation, param.default): continue if param.kind in [inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY]: diff --git a/pjrpc/server/typedefs.py b/pjrpc/server/typedefs.py index 75db74c..17f358b 100644 --- a/pjrpc/server/typedefs.py +++ b/pjrpc/server/typedefs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable, Optional, Union +from typing import Any, Awaitable, Callable, Optional, Type, Union import pjrpc.common.exceptions from pjrpc.common import Request, Response, UnsetType @@ -12,6 +12,7 @@ 'MiddlewareResponse', 'MiddlewareType', 'ErrorHandlerType', + 'ExcludeFunc', 'ResponseOrUnset', 'ContextType', ] @@ -56,3 +57,7 @@ pjrpc.exceptions.JsonRpcError, ] '''Synchronous server error handler''' # for sphinx autodoc + + +ExcludeFunc = Callable[[str, Optional[Type[Any]], Optional[Any]], bool] +'''Parameter exclude function''' # for sphinx autodoc diff --git a/pjrpc/server/validators/__init__.py b/pjrpc/server/validators/__init__.py index 957a66f..3c7cf44 100644 --- a/pjrpc/server/validators/__init__.py +++ b/pjrpc/server/validators/__init__.py @@ -2,9 +2,10 @@ JSON-RPC method parameters validators. """ -from .base import BaseValidator, ValidationError +from .base import BaseValidator, ExcludeFunc, ValidationError __all__ = [ 'BaseValidator', + 'ExcludeFunc', 'ValidationError', ] diff --git a/pjrpc/server/validators/base.py b/pjrpc/server/validators/base.py index 2d5920a..9cad33a 100644 --- a/pjrpc/server/validators/base.py +++ b/pjrpc/server/validators/base.py @@ -1,9 +1,10 @@ import functools as ft import inspect -from typing import Any, Dict, Iterable, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple from pjrpc.common.typedefs import JsonRpcParams, MethodType from pjrpc.server import utils +from pjrpc.server.typedefs import ExcludeFunc class ValidationError(Exception): @@ -15,8 +16,14 @@ class ValidationError(Exception): class BaseValidator: """ Base method parameters validator. Uses :py:func:`inspect.signature` for validation. + + :param exclude_param: a function that decides if the parameters must be excluded + from validation (useful for dependency injection) """ + def __init__(self, exclude_param: Optional[ExcludeFunc] = None): + self._exclude_param = exclude_param or (lambda *args: False) + def validate(self, maybe_method: Optional[MethodType] = None, **kwargs: Any) -> MethodType: """ Decorator marks a method the parameters of which to be validated when calling it using JSON-RPC protocol. @@ -73,7 +80,7 @@ def bind(self, signature: inspect.Signature, params: Optional['JsonRpcParams']) raise ValidationError(str(e)) from e @ft.lru_cache(None) - def signature(self, method: MethodType, exclude: Tuple[str]) -> inspect.Signature: + def signature(self, method: MethodType, exclude: Tuple[str, ...]) -> inspect.Signature: """ Returns method signature. @@ -83,8 +90,10 @@ def signature(self, method: MethodType, exclude: Tuple[str]) -> inspect.Signatur """ signature = inspect.signature(method) - parameters = signature.parameters.copy() - for item in exclude: - parameters.pop(item, None) - return signature.replace(parameters=tuple(parameters.values())) + method_parameters: List[inspect.Parameter] = [] + for param in signature.parameters.values(): + if param.name not in exclude and not self._exclude_param(param.name, param.annotation, param.default): + method_parameters.append(param) + + return signature.replace(parameters=method_parameters) diff --git a/pjrpc/server/validators/jsonschema.py b/pjrpc/server/validators/jsonschema.py index 75b0f7f..68558d7 100644 --- a/pjrpc/server/validators/jsonschema.py +++ b/pjrpc/server/validators/jsonschema.py @@ -3,6 +3,7 @@ import jsonschema from pjrpc.common.typedefs import JsonRpcParams, MethodType +from pjrpc.server.typedefs import ExcludeFunc from . import base @@ -12,9 +13,12 @@ class JsonSchemaValidator(base.BaseValidator): Parameters validator based on `jsonschema `_ library. :param kwargs: default jsonschema validator arguments + :param exclude_param: a function that decides if the parameters must be excluded + from validation (useful for dependency injection) """ - def __init__(self, **kwargs: Any): + def __init__(self, exclude_param: Optional[ExcludeFunc] = None, **kwargs: Any): + super().__init__(exclude_param=exclude_param) kwargs.setdefault('types', {'array': (list, tuple)}) self.default_kwargs = kwargs diff --git a/pjrpc/server/validators/pydantic.py b/pjrpc/server/validators/pydantic.py index 8aa1a42..686f58e 100644 --- a/pjrpc/server/validators/pydantic.py +++ b/pjrpc/server/validators/pydantic.py @@ -5,6 +5,7 @@ import pydantic from pjrpc.common.typedefs import JsonRpcParams +from pjrpc.server.typedefs import ExcludeFunc from . import base @@ -16,9 +17,12 @@ class PydanticValidator(base.BaseValidator): :param coerce: if ``True`` returns converted (coerced) parameters according to parameter type annotation otherwise returns parameters as is + :param exclude_param: a function that decides if the parameters must be excluded + from validation (useful for dependency injection) """ - def __init__(self, coerce: bool = True, **config_args: Any): + def __init__(self, coerce: bool = True, exclude_param: Optional[ExcludeFunc] = None, **config_args: Any): + super().__init__(exclude_param=exclude_param) self._coerce = coerce config_args.setdefault('extra', 'forbid') diff --git a/pyproject.toml b/pyproject.toml index 8a38945..42b00b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pjrpc" -version = "1.10.1" +version = "1.11.0" description = "Extensible JSON-RPC library" authors = ["Dmitry Pershin "] license = "Unlicense"