├── .github ├── release.yml └── workflows │ ├── tests-latest-fastapi-pydantic.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.rst ├── README.src.rst ├── example1.py ├── example2.py ├── fastapi_jsonrpc ├── __init__.py └── contrib │ ├── __init__.py │ └── sentry │ ├── __init__.py │ ├── http.py │ ├── integration.py │ ├── jrpc.py │ └── test_utils.py ├── images └── fastapi-jsonrpc.png ├── pyproject.toml └── tests ├── conftest.py ├── sentry ├── __init__.py ├── conftest.py ├── test_sentry_sdk_1x.py └── test_sentry_sdk_2x.py ├── test_dependencies.py ├── test_dependencies_yield.py ├── test_handle_exception.py ├── test_http_auth.py ├── test_http_auth_shared_deps.py ├── test_http_exception.py ├── test_jsonrpc.py ├── test_jsonrpc_method.py ├── test_jsonrpc_request_id.py ├── test_middlewares.py ├── test_middlewares_exc_enter.py ├── test_middlewares_exc_exit.py ├── test_notification.py ├── test_openapi.py ├── test_openapi_dependencies.py ├── test_openrpc.py ├── test_params.py ├── test_request_class.py ├── test_shared_model.py └── test_sub_response.py /.github/release.yml: -------------------------------------------------------------------------------- 1 | # .github/release.yml 2 | 3 | changelog: 4 | exclude: 5 | labels: 6 | - ignore-for-release 7 | authors: 8 | - octocat 9 | categories: 10 | - title: Breaking Changes 🛠 11 | labels: 12 | - Semver-Major 13 | - breaking-change 14 | - title: Exciting New Features 🎉 15 | labels: 16 | - Semver-Minor 17 | - enhancement 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/tests-latest-fastapi-pydantic.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Compatibility with latest fastapi & pydantic 5 | 6 | on: 7 | schedule: 8 | - cron: '30 03 * * 1' # Test each week 9 | 10 | pull_request: 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 19 | fastapi-short: [default, latest] 20 | pydantic-short: [default, latest] 21 | 22 | name: test with Py${{ matrix.python-version }} (fastapi==${{ matrix.fastapi-short }}; pydantic==${{ matrix.pydantic-short }}) 23 | 24 | steps: 25 | - name: Checkout changes 26 | uses: actions/checkout@v2 27 | 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip poetry 36 | poetry config virtualenvs.create false 37 | poetry install --no-root 38 | 39 | - name: Install latest Pydantic 40 | if: ${{ matrix.pydantic-short == 'latest' }} 41 | run: python -m pip install --upgrade pydantic 42 | 43 | - name: Install latest FastAPI 44 | if: ${{ matrix.fastapi-short == 'latest' }} 45 | run: python -m pip install --upgrade fastapi 46 | 47 | - name: Test with pytest 48 | run: | 49 | pip show pydantic 50 | pip show fastapi 51 | python -m pytest 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | paths-ignore: 9 | - LICENSE 10 | - README.rst 11 | - README.src.rst 12 | 13 | push: 14 | branches: [master] 15 | 16 | jobs: 17 | tests: 18 | name: test with Py${{ matrix.python-version }} 19 | 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 25 | 26 | steps: 27 | - name: Checkout changes 28 | uses: actions/checkout@v2 29 | 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip poetry 38 | poetry config virtualenvs.create false 39 | poetry install --no-root 40 | 41 | - name: Test with pytest 42 | run: | 43 | python -m pytest 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | poetry.lock 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # celery beat schedule file 97 | celerybeat-schedule 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Sergey Magafurov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |tests| 2 | 3 | .. |tests| image:: https://github.com/smagafurov/fastapi-jsonrpc/actions/workflows/tests.yml/badge.svg 4 | :target: https://github.com/smagafurov/fastapi-jsonrpc/actions/workflows/tests.yml 5 | 6 | 7 | Description 8 | =========== 9 | 10 | JSON-RPC server based on fastapi: 11 | 12 | https://fastapi.tiangolo.com 13 | 14 | OpenRPC supported too. 15 | 16 | Motivation 17 | ^^^^^^^^^^ 18 | 19 | Autogenerated **OpenAPI** and **Swagger** (thanks to fastapi) for JSON-RPC!!! 20 | 21 | Installation 22 | ============ 23 | 24 | .. code-block:: bash 25 | 26 | pip install fastapi-jsonrpc 27 | 28 | Documentation 29 | ============= 30 | 31 | Read FastAPI documentation and see usage examples bellow 32 | 33 | Simple usage example 34 | ==================== 35 | 36 | .. code-block:: bash 37 | 38 | pip install uvicorn 39 | 40 | example1.py 41 | 42 | .. code-block:: python 43 | 44 | import fastapi_jsonrpc as jsonrpc 45 | from pydantic import BaseModel 46 | from fastapi import Body 47 | 48 | 49 | app = jsonrpc.API() 50 | 51 | api_v1 = jsonrpc.Entrypoint('/api/v1/jsonrpc') 52 | 53 | 54 | class MyError(jsonrpc.BaseError): 55 | CODE = 5000 56 | MESSAGE = 'My error' 57 | 58 | class DataModel(BaseModel): 59 | details: str 60 | 61 | 62 | @api_v1.method(errors=[MyError]) 63 | def echo( 64 | data: str = Body(..., examples=['123']), 65 | ) -> str: 66 | if data == 'error': 67 | raise MyError(data={'details': 'error'}) 68 | else: 69 | return data 70 | 71 | 72 | app.bind_entrypoint(api_v1) 73 | 74 | 75 | if __name__ == '__main__': 76 | import uvicorn 77 | uvicorn.run('example1:app', port=5000, debug=True, access_log=False) 78 | 79 | OpenRPC: 80 | 81 | http://127.0.0.1:5000/openrpc.json 82 | 83 | Swagger: 84 | 85 | http://127.0.0.1:5000/docs 86 | 87 | FastAPI dependencies usage example 88 | ================================== 89 | 90 | .. code-block:: bash 91 | 92 | pip install uvicorn 93 | 94 | example2.py 95 | 96 | .. code-block:: python 97 | 98 | import logging 99 | from contextlib import asynccontextmanager 100 | 101 | from pydantic import BaseModel, Field 102 | import fastapi_jsonrpc as jsonrpc 103 | from fastapi import Body, Header, Depends 104 | 105 | 106 | logger = logging.getLogger(__name__) 107 | 108 | 109 | # database models 110 | 111 | class User: 112 | def __init__(self, name): 113 | self.name = name 114 | 115 | def __eq__(self, other): 116 | if not isinstance(other, User): 117 | return False 118 | return self.name == other.name 119 | 120 | 121 | class Account: 122 | def __init__(self, account_id, owner, amount, currency): 123 | self.account_id = account_id 124 | self.owner = owner 125 | self.amount = amount 126 | self.currency = currency 127 | 128 | def owned_by(self, user: User): 129 | return self.owner == user 130 | 131 | 132 | # fake database 133 | 134 | users = { 135 | '1': User('user1'), 136 | '2': User('user2'), 137 | } 138 | 139 | accounts = { 140 | '1.1': Account('1.1', users['1'], 100, 'USD'), 141 | '1.2': Account('1.2', users['1'], 200, 'EUR'), 142 | '2.1': Account('2.1', users['2'], 300, 'USD'), 143 | } 144 | 145 | 146 | def get_user_by_token(auth_token) -> User: 147 | return users[auth_token] 148 | 149 | 150 | def get_account_by_id(account_id) -> Account: 151 | return accounts[account_id] 152 | 153 | 154 | # schemas 155 | 156 | class Balance(BaseModel): 157 | """Account balance""" 158 | amount: int = Field(..., example=100) 159 | currency: str = Field(..., example='USD') 160 | 161 | 162 | # errors 163 | 164 | class AuthError(jsonrpc.BaseError): 165 | CODE = 7000 166 | MESSAGE = 'Auth error' 167 | 168 | 169 | class AccountNotFound(jsonrpc.BaseError): 170 | CODE = 6000 171 | MESSAGE = 'Account not found' 172 | 173 | 174 | class NotEnoughMoney(jsonrpc.BaseError): 175 | CODE = 6001 176 | MESSAGE = 'Not enough money' 177 | 178 | class DataModel(BaseModel): 179 | balance: Balance 180 | 181 | 182 | # dependencies 183 | 184 | def get_auth_user( 185 | # this will become the header-parameter of json-rpc method that uses this dependency 186 | auth_token: str = Header( 187 | None, 188 | alias='user-auth-token', 189 | ), 190 | ) -> User: 191 | if not auth_token: 192 | raise AuthError 193 | 194 | try: 195 | return get_user_by_token(auth_token) 196 | except KeyError: 197 | raise AuthError 198 | 199 | 200 | def get_account( 201 | # this will become the parameter of the json-rpc method that uses this dependency 202 | account_id: str = Body(..., example='1.1'), 203 | user: User = Depends(get_auth_user), 204 | ) -> Account: 205 | try: 206 | account = get_account_by_id(account_id) 207 | except KeyError: 208 | raise AccountNotFound 209 | 210 | if not account.owned_by(user): 211 | raise AccountNotFound 212 | 213 | return account 214 | 215 | 216 | # JSON-RPC middlewares 217 | 218 | @asynccontextmanager 219 | async def logging_middleware(ctx: jsonrpc.JsonRpcContext): 220 | logger.info('Request: %r', ctx.raw_request) 221 | try: 222 | yield 223 | finally: 224 | logger.info('Response: %r', ctx.raw_response) 225 | 226 | 227 | # JSON-RPC entrypoint 228 | 229 | common_errors = [AccountNotFound, AuthError] 230 | common_errors.extend(jsonrpc.Entrypoint.default_errors) 231 | 232 | api_v1 = jsonrpc.Entrypoint( 233 | # Swagger shows for entrypoint common parameters gathered by dependencies and common_dependencies: 234 | # - json-rpc-parameter 'account_id' 235 | # - header parameter 'user-auth-token' 236 | '/api/v1/jsonrpc', 237 | errors=common_errors, 238 | middlewares=[logging_middleware], 239 | # this dependencies called once for whole json-rpc batch request 240 | dependencies=[Depends(get_auth_user)], 241 | # this dependencies called separately for every json-rpc request in batch request 242 | common_dependencies=[Depends(get_account)], 243 | ) 244 | 245 | 246 | # JSON-RPC methods of this entrypoint 247 | 248 | # this json-rpc method has one json-rpc-parameter 'account_id' and one header parameter 'user-auth-token' 249 | @api_v1.method() 250 | def get_balance( 251 | account: Account = Depends(get_account), 252 | ) -> Balance: 253 | return Balance( 254 | amount=account.amount, 255 | currency=account.currency, 256 | ) 257 | 258 | 259 | # this json-rpc method has two json-rpc-parameters 'account_id', 'amount' and one header parameter 'user-auth-token' 260 | @api_v1.method(errors=[NotEnoughMoney]) 261 | def withdraw( 262 | account: Account = Depends(get_account), 263 | amount: int = Body(..., gt=0, example=10), 264 | ) -> Balance: 265 | if account.amount - amount < 0: 266 | raise NotEnoughMoney(data={'balance': get_balance(account)}) 267 | account.amount -= amount 268 | return get_balance(account) 269 | 270 | 271 | # JSON-RPC API 272 | 273 | app = jsonrpc.API() 274 | app.bind_entrypoint(api_v1) 275 | 276 | 277 | if __name__ == '__main__': 278 | import uvicorn 279 | uvicorn.run('example2:app', port=5000, debug=True, access_log=False) 280 | 281 | OpenRPC: 282 | 283 | http://127.0.0.1:5000/openrpc.json 284 | 285 | Swagger: 286 | 287 | http://127.0.0.1:5000/docs 288 | 289 | .. image:: ./images/fastapi-jsonrpc.png 290 | 291 | Sentry 292 | ====== 293 | 294 | 295 | * (auto) JRPC method name as transaction name 296 | * (only for FastApiJsonRPCIntegration) Tracing support. Single transaction for batch requests, each method is span 297 | 298 | .. code-block:: python 299 | 300 | import sentry_sdk 301 | from fastapi_jsonrpc.contrib.sentry import FastApiJsonRPCIntegration 302 | from sentry_sdk.integrations.starlette import StarletteIntegration 303 | from sentry_sdk.integrations.fastapi import FastApiIntegration 304 | 305 | 306 | sentry_sdk.init( 307 | ..., 308 | integrations=[FastApiJsonRPCIntegration()], 309 | # if you do not use common (RESTlike) routes you can disable other integrations 310 | # for performance reasons 311 | disabled_integrations=[StarletteIntegration, FastApiIntegration], 312 | ) 313 | 314 | Development 315 | =========== 316 | 317 | * Install poetry 318 | 319 | https://github.com/sdispater/poetry#installation 320 | 321 | * Install dependencies 322 | 323 | .. code-block:: bash 324 | 325 | poetry update 326 | 327 | * Regenerate README.rst 328 | 329 | .. code-block:: bash 330 | 331 | rst_include include -q README.src.rst README.rst 332 | 333 | * Change dependencies 334 | 335 | Edit ``pyproject.toml`` 336 | 337 | .. code-block:: bash 338 | 339 | poetry update 340 | 341 | * Bump version 342 | 343 | .. code-block:: bash 344 | 345 | poetry version patch 346 | poetry version minor 347 | poetry version major 348 | 349 | * Publish to pypi 350 | 351 | .. code-block:: bash 352 | 353 | poetry publish --build 354 | 355 | -------------------------------------------------------------------------------- /README.src.rst: -------------------------------------------------------------------------------- 1 | |tests| 2 | 3 | .. |tests| image:: https://github.com/smagafurov/fastapi-jsonrpc/actions/workflows/tests.yml/badge.svg 4 | :target: https://github.com/smagafurov/fastapi-jsonrpc/actions/workflows/tests.yml 5 | 6 | 7 | Description 8 | =========== 9 | 10 | JSON-RPC server based on fastapi: 11 | 12 | https://fastapi.tiangolo.com 13 | 14 | OpenRPC supported too. 15 | 16 | Motivation 17 | ^^^^^^^^^^ 18 | 19 | Autogenerated **OpenAPI** and **Swagger** (thanks to fastapi) for JSON-RPC!!! 20 | 21 | Installation 22 | ============ 23 | 24 | .. code-block:: bash 25 | 26 | pip install fastapi-jsonrpc 27 | 28 | Documentation 29 | ============= 30 | 31 | Read FastAPI documentation and see usage examples bellow 32 | 33 | Simple usage example 34 | ==================== 35 | 36 | .. code-block:: bash 37 | 38 | pip install uvicorn 39 | 40 | example1.py 41 | 42 | .. include:: example1.py 43 | :code: python 44 | 45 | OpenRPC: 46 | 47 | http://127.0.0.1:5000/openrpc.json 48 | 49 | Swagger: 50 | 51 | http://127.0.0.1:5000/docs 52 | 53 | FastAPI dependencies usage example 54 | ================================== 55 | 56 | .. code-block:: bash 57 | 58 | pip install uvicorn 59 | 60 | example2.py 61 | 62 | .. include:: example2.py 63 | :code: python 64 | 65 | OpenRPC: 66 | 67 | http://127.0.0.1:5000/openrpc.json 68 | 69 | Swagger: 70 | 71 | http://127.0.0.1:5000/docs 72 | 73 | .. image:: ./images/fastapi-jsonrpc.png 74 | 75 | Development 76 | =========== 77 | 78 | * Install poetry 79 | 80 | https://github.com/sdispater/poetry#installation 81 | 82 | * Install dependencies 83 | 84 | .. code-block:: bash 85 | 86 | poetry update 87 | 88 | * Regenerate README.rst 89 | 90 | .. code-block:: bash 91 | 92 | rst_include include -q README.src.rst README.rst 93 | 94 | * Change dependencies 95 | 96 | Edit ``pyproject.toml`` 97 | 98 | .. code-block:: bash 99 | 100 | poetry update 101 | 102 | * Bump version 103 | 104 | .. code-block:: bash 105 | 106 | poetry version patch 107 | poetry version minor 108 | poetry version major 109 | 110 | * Publish to pypi 111 | 112 | .. code-block:: bash 113 | 114 | poetry publish --build 115 | -------------------------------------------------------------------------------- /example1.py: -------------------------------------------------------------------------------- 1 | import fastapi_jsonrpc as jsonrpc 2 | from pydantic import BaseModel 3 | from fastapi import Body 4 | 5 | 6 | app = jsonrpc.API() 7 | 8 | api_v1 = jsonrpc.Entrypoint('/api/v1/jsonrpc') 9 | 10 | 11 | class MyError(jsonrpc.BaseError): 12 | CODE = 5000 13 | MESSAGE = 'My error' 14 | 15 | class DataModel(BaseModel): 16 | details: str 17 | 18 | 19 | @api_v1.method(errors=[MyError]) 20 | def echo( 21 | data: str = Body(..., examples=['123']), 22 | ) -> str: 23 | if data == 'error': 24 | raise MyError(data={'details': 'error'}) 25 | else: 26 | return data 27 | 28 | 29 | app.bind_entrypoint(api_v1) 30 | 31 | 32 | if __name__ == '__main__': 33 | import uvicorn 34 | uvicorn.run('example1:app', port=5000, debug=True, access_log=False) 35 | -------------------------------------------------------------------------------- /example2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import asynccontextmanager 3 | 4 | from pydantic import BaseModel, Field 5 | import fastapi_jsonrpc as jsonrpc 6 | from fastapi import Body, Header, Depends 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | # database models 13 | 14 | class User: 15 | def __init__(self, name): 16 | self.name = name 17 | 18 | def __eq__(self, other): 19 | if not isinstance(other, User): 20 | return False 21 | return self.name == other.name 22 | 23 | 24 | class Account: 25 | def __init__(self, account_id, owner, amount, currency): 26 | self.account_id = account_id 27 | self.owner = owner 28 | self.amount = amount 29 | self.currency = currency 30 | 31 | def owned_by(self, user: User): 32 | return self.owner == user 33 | 34 | 35 | # fake database 36 | 37 | users = { 38 | '1': User('user1'), 39 | '2': User('user2'), 40 | } 41 | 42 | accounts = { 43 | '1.1': Account('1.1', users['1'], 100, 'USD'), 44 | '1.2': Account('1.2', users['1'], 200, 'EUR'), 45 | '2.1': Account('2.1', users['2'], 300, 'USD'), 46 | } 47 | 48 | 49 | def get_user_by_token(auth_token) -> User: 50 | return users[auth_token] 51 | 52 | 53 | def get_account_by_id(account_id) -> Account: 54 | return accounts[account_id] 55 | 56 | 57 | # schemas 58 | 59 | class Balance(BaseModel): 60 | """Account balance""" 61 | amount: int = Field(..., examples=[100]) 62 | currency: str = Field(..., examples=['USD']) 63 | 64 | 65 | # errors 66 | 67 | class AuthError(jsonrpc.BaseError): 68 | CODE = 7000 69 | MESSAGE = 'Auth error' 70 | 71 | 72 | class AccountNotFound(jsonrpc.BaseError): 73 | CODE = 6000 74 | MESSAGE = 'Account not found' 75 | 76 | 77 | class NotEnoughMoney(jsonrpc.BaseError): 78 | CODE = 6001 79 | MESSAGE = 'Not enough money' 80 | 81 | class DataModel(BaseModel): 82 | balance: Balance 83 | 84 | 85 | # dependencies 86 | 87 | def get_auth_user( 88 | # this will become the header-parameter of json-rpc method that uses this dependency 89 | auth_token: str = Header( 90 | None, 91 | alias='user-auth-token', 92 | ), 93 | ) -> User: 94 | if not auth_token: 95 | raise AuthError 96 | 97 | try: 98 | return get_user_by_token(auth_token) 99 | except KeyError: 100 | raise AuthError 101 | 102 | 103 | def get_account( 104 | # this will become the parameter of the json-rpc method that uses this dependency 105 | account_id: str = Body(..., example='1.1'), 106 | user: User = Depends(get_auth_user), 107 | ) -> Account: 108 | try: 109 | account = get_account_by_id(account_id) 110 | except KeyError: 111 | raise AccountNotFound 112 | 113 | if not account.owned_by(user): 114 | raise AccountNotFound 115 | 116 | return account 117 | 118 | 119 | # JSON-RPC middlewares 120 | 121 | @asynccontextmanager 122 | async def logging_middleware(ctx: jsonrpc.JsonRpcContext): 123 | logger.info('Request: %r', ctx.raw_request) 124 | try: 125 | yield 126 | finally: 127 | logger.info('Response: %r', ctx.raw_response) 128 | 129 | 130 | # JSON-RPC entrypoint 131 | 132 | common_errors = [AccountNotFound, AuthError] 133 | common_errors.extend(jsonrpc.Entrypoint.default_errors) 134 | 135 | api_v1 = jsonrpc.Entrypoint( 136 | # Swagger shows for entrypoint common parameters gathered by dependencies and common_dependencies: 137 | # - json-rpc-parameter 'account_id' 138 | # - header parameter 'user-auth-token' 139 | '/api/v1/jsonrpc', 140 | errors=common_errors, 141 | middlewares=[logging_middleware], 142 | # this dependencies called once for whole json-rpc batch request 143 | dependencies=[Depends(get_auth_user)], 144 | # this dependencies called separately for every json-rpc request in batch request 145 | common_dependencies=[Depends(get_account)], 146 | ) 147 | 148 | 149 | # JSON-RPC methods of this entrypoint 150 | 151 | # this json-rpc method has one json-rpc-parameter 'account_id' and one header parameter 'user-auth-token' 152 | @api_v1.method() 153 | def get_balance( 154 | account: Account = Depends(get_account), 155 | ) -> Balance: 156 | return Balance( 157 | amount=account.amount, 158 | currency=account.currency, 159 | ) 160 | 161 | 162 | # this json-rpc method has two json-rpc-parameters 'account_id', 'amount' and one header parameter 'user-auth-token' 163 | @api_v1.method(errors=[NotEnoughMoney]) 164 | def withdraw( 165 | account: Account = Depends(get_account), 166 | amount: int = Body(..., gt=0, example=10), 167 | ) -> Balance: 168 | if account.amount - amount < 0: 169 | raise NotEnoughMoney(data={'balance': get_balance(account)}) 170 | account.amount -= amount 171 | return get_balance(account) 172 | 173 | 174 | # JSON-RPC API 175 | 176 | app = jsonrpc.API() 177 | app.bind_entrypoint(api_v1) 178 | 179 | 180 | if __name__ == '__main__': 181 | import uvicorn 182 | uvicorn.run('example2:app', port=5000, debug=True, access_log=False) 183 | -------------------------------------------------------------------------------- /fastapi_jsonrpc/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smagafurov/fastapi-jsonrpc/c4bd192f2f53f8235473a974ca9e088cec223a6f/fastapi_jsonrpc/contrib/__init__.py -------------------------------------------------------------------------------- /fastapi_jsonrpc/contrib/sentry/__init__.py: -------------------------------------------------------------------------------- 1 | from .jrpc import TransactionNameGenerator, jrpc_transaction_middleware 2 | from .integration import FastApiJsonRPCIntegration 3 | 4 | __all__ = [ 5 | "FastApiJsonRPCIntegration", 6 | "TransactionNameGenerator", 7 | "jrpc_transaction_middleware", 8 | ] 9 | -------------------------------------------------------------------------------- /fastapi_jsonrpc/contrib/sentry/http.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from functools import wraps 3 | from contextvars import ContextVar 4 | 5 | from starlette.requests import Request 6 | from sentry_sdk.integrations.asgi import _get_headers 7 | 8 | sentry_asgi_context: ContextVar[dict] = ContextVar("_sentry_asgi_context") 9 | 10 | 11 | def set_shared_sentry_context(cls): 12 | original_handle_body = cls.handle_body 13 | 14 | @wraps(original_handle_body) 15 | async def _patched_handle_body(self, http_request: Request, *args, **kwargs): 16 | headers = _get_headers(http_request.scope) 17 | sentry_asgi_context.set({"sampled_sentry_trace_id": uuid.uuid4(), "asgi_headers": headers}) 18 | return await original_handle_body(self, http_request, *args, **kwargs) 19 | 20 | cls.handle_body = _patched_handle_body 21 | -------------------------------------------------------------------------------- /fastapi_jsonrpc/contrib/sentry/integration.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi_jsonrpc import MethodRoute, EntrypointRoute 3 | from sentry_sdk.integrations import Integration 4 | 5 | from .http import set_shared_sentry_context 6 | from .jrpc import TransactionNameGenerator, default_transaction_name_generator, prepend_jrpc_transaction_middleware 7 | 8 | 9 | class FastApiJsonRPCIntegration(Integration): 10 | identifier = "FastApiJsonRPCIntegration" 11 | _already_enabled: bool = False 12 | 13 | def __init__(self, transaction_name_generator: Optional[TransactionNameGenerator] = None): 14 | self.transaction_name_generator = transaction_name_generator or default_transaction_name_generator 15 | 16 | @staticmethod 17 | def setup_once(): 18 | if FastApiJsonRPCIntegration._already_enabled: 19 | return 20 | 21 | prepend_jrpc_transaction_middleware() 22 | set_shared_sentry_context(MethodRoute) 23 | set_shared_sentry_context(EntrypointRoute) 24 | 25 | FastApiJsonRPCIntegration._already_enabled = True 26 | -------------------------------------------------------------------------------- /fastapi_jsonrpc/contrib/sentry/jrpc.py: -------------------------------------------------------------------------------- 1 | from random import Random 2 | from typing import TYPE_CHECKING, Callable 3 | from contextlib import asynccontextmanager 4 | 5 | import sentry_sdk 6 | from fastapi_jsonrpc import BaseError, Entrypoint, JsonRpcContext 7 | from sentry_sdk.utils import event_from_exception, is_valid_sample_rate 8 | from sentry_sdk.consts import OP 9 | from sentry_sdk.tracing import SENTRY_TRACE_HEADER_NAME, Transaction 10 | from sentry_sdk.tracing_utils import normalize_incoming_data 11 | 12 | from .http import sentry_asgi_context 13 | 14 | if TYPE_CHECKING: 15 | from .integration import FastApiJsonRPCIntegration 16 | 17 | _DEFAULT_TRANSACTION_NAME = "generic JRPC request" 18 | TransactionNameGenerator = Callable[[JsonRpcContext], str] 19 | 20 | if hasattr(sentry_sdk.tracing, 'TransactionSource'): 21 | # sentry_sdk ^2.23 22 | TRANSACTION_SOURCE_CUSTOM = sentry_sdk.tracing.TransactionSource.CUSTOM 23 | else: 24 | # sentry_sdk ^2.0 25 | TRANSACTION_SOURCE_CUSTOM = sentry_sdk.tracing.TRANSACTION_SOURCE_CUSTOM 26 | 27 | 28 | @asynccontextmanager 29 | async def jrpc_transaction_middleware(ctx: JsonRpcContext): 30 | """ 31 | Start new transaction for each JRPC request. Applies same sampling decision for every transaction in the batch. 32 | """ 33 | 34 | current_asgi_context = sentry_asgi_context.get() 35 | headers = current_asgi_context["asgi_headers"] 36 | transaction_params = dict( 37 | # this name is replaced by event processor 38 | name=_DEFAULT_TRANSACTION_NAME, 39 | op=OP.HTTP_SERVER, 40 | source=TRANSACTION_SOURCE_CUSTOM, 41 | origin="manual", 42 | ) 43 | with sentry_sdk.isolation_scope() as jrpc_request_scope: 44 | jrpc_request_scope.clear() 45 | 46 | if SENTRY_TRACE_HEADER_NAME in headers: 47 | # continue existing trace 48 | # https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/scope.py#L471 49 | jrpc_request_scope.generate_propagation_context(headers) 50 | transaction = JrpcTransaction.continue_from_headers( 51 | normalize_incoming_data(headers), 52 | **transaction_params, 53 | ) 54 | else: 55 | # no parent transaction, start a new trace 56 | transaction = JrpcTransaction( 57 | trace_id=current_asgi_context["sampled_sentry_trace_id"].hex, 58 | **transaction_params, # type: ignore 59 | ) 60 | 61 | integration: FastApiJsonRPCIntegration | None = sentry_sdk.get_client().get_integration( # type: ignore 62 | "FastApiJsonRPCIntegration" 63 | ) 64 | name_generator = integration.transaction_name_generator if integration else default_transaction_name_generator 65 | 66 | with jrpc_request_scope.start_transaction( 67 | transaction, 68 | scope=jrpc_request_scope, 69 | ): 70 | jrpc_request_scope.add_event_processor(make_transaction_info_event_processor(ctx, name_generator)) 71 | try: 72 | yield 73 | except Exception as exc: 74 | if isinstance(exc, BaseError): 75 | raise 76 | 77 | # attaching event to current transaction 78 | event, hint = event_from_exception( 79 | exc, 80 | client_options=sentry_sdk.get_client().options, 81 | mechanism={"type": "asgi", "handled": False}, 82 | ) 83 | sentry_sdk.capture_event(event, hint=hint) 84 | # propagate error further. Possible duplicates would be suppressed by default `DedupeIntegration` 85 | raise exc from None 86 | 87 | 88 | class JrpcTransaction(Transaction): 89 | """ 90 | Overrides `_set_initial_sampling_decision` to apply same sampling decision for transactions with same `trace_id`. 91 | """ 92 | 93 | def _set_initial_sampling_decision(self, sampling_context): 94 | super()._set_initial_sampling_decision(sampling_context) 95 | # https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/tracing.py#L1125 96 | if self.sampled or not is_valid_sample_rate(self.sample_rate, source="Tracing"): 97 | return 98 | 99 | if not self.sample_rate: 100 | return 101 | 102 | # https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/tracing.py#L1158 103 | self.sampled = Random(self.trace_id).random() < self.sample_rate # noqa: S311 104 | 105 | 106 | def make_transaction_info_event_processor(ctx: JsonRpcContext, name_generator: TransactionNameGenerator) -> Callable: 107 | def _event_processor(event, _): 108 | event["transaction_info"]["source"] = TRANSACTION_SOURCE_CUSTOM 109 | if ctx.method_route is not None: 110 | event["transaction"] = name_generator(ctx) 111 | 112 | return event 113 | 114 | return _event_processor 115 | 116 | 117 | def default_transaction_name_generator(ctx: JsonRpcContext) -> str: 118 | return f"JRPC:{ctx.method_route.name}" 119 | 120 | 121 | def prepend_jrpc_transaction_middleware(): # noqa: C901 122 | # prepend the jrpc_sentry_transaction_middleware to the middlewares list. 123 | # we cannot patch Entrypoint _init_ directly, since objects can be created before invoking this integration 124 | 125 | def _prepend_transaction_middleware(self: Entrypoint): 126 | if not hasattr(self, "__patched_middlewares__"): 127 | original_middlewares = self.__dict__.get("middlewares", []) 128 | self.__patched_middlewares__ = original_middlewares 129 | 130 | # middleware was passed manually 131 | if jrpc_transaction_middleware in self.__patched_middlewares__: 132 | return self.__patched_middlewares__ 133 | 134 | self.__patched_middlewares__ = [jrpc_transaction_middleware, *self.__patched_middlewares__] 135 | return self.__patched_middlewares__ 136 | 137 | def _middleware_setter(self: Entrypoint, value): 138 | self.__patched_middlewares__ = value 139 | _prepend_transaction_middleware(self) 140 | 141 | Entrypoint.middlewares = property(_prepend_transaction_middleware, _middleware_setter) 142 | -------------------------------------------------------------------------------- /fastapi_jsonrpc/contrib/sentry/test_utils.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | 3 | 4 | def assert_jrpc_batch_sentry_items(envelops, expected_items): 5 | items = [item.type for e in envelops for item in e.items] 6 | actual_items = Counter(items) 7 | assert all(item in actual_items.items() for item in expected_items.items()), actual_items.items() 8 | transactions = get_captured_transactions(envelops) 9 | # same trace_id across jrpc batch 10 | trace_ids = set() 11 | for transaction in transactions: 12 | trace_ids.add(get_transaction_trace_id(transaction)) 13 | 14 | assert len(trace_ids) == 1, trace_ids 15 | return actual_items 16 | 17 | 18 | def get_transaction_trace_id(transaction): 19 | return transaction.payload.json["contexts"]["trace"]["trace_id"] 20 | 21 | 22 | def get_captured_transactions(envelops): 23 | return [item for e in envelops for item in e.items if item.type == "transaction"] 24 | -------------------------------------------------------------------------------- /images/fastapi-jsonrpc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smagafurov/fastapi-jsonrpc/c4bd192f2f53f8235473a974ca9e088cec223a6f/images/fastapi-jsonrpc.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-jsonrpc" 3 | version = "3.4.1" 4 | description = "JSON-RPC server based on fastapi" 5 | license = "MIT" 6 | authors = ["Sergey Magafurov "] 7 | readme = "README.rst" 8 | repository = "https://github.com/smagafurov/fastapi-jsonrpc" 9 | homepage = "https://github.com/smagafurov/fastapi-jsonrpc" 10 | keywords = ['json-rpc', 'asgi', 'swagger', 'openapi', 'fastapi', 'pydantic', 'starlette'] 11 | exclude = ["example1.py", "example2.py"] 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.9" 15 | aiojobs = ">=1.1.0" 16 | fastapi = [ 17 | {version = ">=0.112.4"}, 18 | ] 19 | pydantic = [ 20 | {version = ">=2.7.0"}, 21 | {version = "<3.0.0"}, 22 | ] 23 | starlette = ">0.0.0" 24 | 25 | [tool.poetry.dev-dependencies] 26 | uvicorn = "^0.17.0" 27 | rst_include = "^2.1.0" 28 | pytest = "^6.2" 29 | sentry-sdk = "^2.0" 30 | requests = ">0.0.0" 31 | httpx = ">=0.27.0,<0.29.0" # FastAPI/Starlette extra test deps 32 | 33 | [build-system] 34 | requires = ["poetry>=0.12"] 35 | build-backend = "poetry.masonry.api" 36 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import platform 3 | from json import dumps as json_dumps 4 | from unittest.mock import ANY 5 | 6 | import packaging.version 7 | import pydantic 8 | import pytest 9 | from _pytest.python_api import RaisesContext 10 | from starlette.testclient import TestClient 11 | import fastapi_jsonrpc as jsonrpc 12 | # Workaround for osx systems 13 | # https://stackoverflow.com/questions/58597334/unittest-performance-issue-when-using-requests-mock-on-osx 14 | if platform.system() == 'Darwin': 15 | import socket 16 | socket.gethostbyname = lambda x: '127.0.0.1' 17 | pytest_plugins = 'pytester' 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def check_no_errors(caplog): 22 | yield 23 | for when in ('setup', 'call'): 24 | messages = [ 25 | x.message for x in caplog.get_records(when) if x.levelno >= logging.ERROR 26 | ] 27 | if messages: 28 | pytest.fail( 29 | f"error messages encountered during testing: {messages!r}" 30 | ) 31 | 32 | 33 | @pytest.fixture 34 | def assert_log_errors(caplog): 35 | def _assert_log_errors(*errors): 36 | error_messages = [] 37 | error_raises = [] 38 | for error in errors: 39 | if isinstance(error, str): 40 | error_messages.append(error) 41 | error_raises.append(None) 42 | else: 43 | assert isinstance(error, RaisesContext), "errors-element must be string or pytest.raises(...)" 44 | assert error_raises[-1] is None 45 | error_raises[-1] = error 46 | 47 | error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] 48 | 49 | assert [r.message for r in error_records] == error_messages 50 | 51 | for record, error_raises_ctx in zip(error_records, error_raises): 52 | if error_raises_ctx is not None: 53 | with error_raises_ctx: 54 | raise record.exc_info[1] 55 | 56 | # clear caplog records 57 | for when in ('setup', 'call'): 58 | del caplog.get_records(when)[:] 59 | caplog.clear() 60 | 61 | return _assert_log_errors 62 | 63 | 64 | @pytest.fixture 65 | def ep_path(): 66 | return '/api/v1/jsonrpc' 67 | 68 | 69 | @pytest.fixture 70 | def ep(ep_path): 71 | return jsonrpc.Entrypoint(ep_path) 72 | 73 | 74 | @pytest.fixture 75 | def app(ep): 76 | app = jsonrpc.API() 77 | app.bind_entrypoint(ep) 78 | return app 79 | 80 | 81 | @pytest.fixture 82 | def app_client(app): 83 | with TestClient(app) as client: 84 | yield client 85 | 86 | 87 | @pytest.fixture 88 | def raw_request(app_client, ep_path): 89 | def requester(body, path_postfix='', auth=None, headers=None): 90 | resp = app_client.post( 91 | url=ep_path + path_postfix, 92 | content=body, 93 | headers=headers, 94 | auth=auth, 95 | ) 96 | return resp 97 | 98 | return requester 99 | 100 | 101 | @pytest.fixture 102 | def json_request(raw_request): 103 | def requester(data, path_postfix='', headers=None): 104 | resp = raw_request(json_dumps(data), path_postfix=path_postfix, headers=headers) 105 | return resp.json() 106 | 107 | return requester 108 | 109 | 110 | @pytest.fixture(params=[False, True]) 111 | def add_path_postfix(request): 112 | return request.param 113 | 114 | 115 | @pytest.fixture 116 | def method_request(json_request, add_path_postfix): 117 | def requester(method, params, request_id=0): 118 | if add_path_postfix: 119 | path_postfix = '/' + method 120 | else: 121 | path_postfix = '' 122 | return json_request({ 123 | 'id': request_id, 124 | 'jsonrpc': '2.0', 125 | 'method': method, 126 | 'params': params, 127 | }, path_postfix=path_postfix) 128 | 129 | return requester 130 | 131 | 132 | @pytest.fixture 133 | def ep_wait_all_requests_done(app_client, ep): 134 | """Returns function which waits until inner scheduler was empty 135 | That's means all requests are done 136 | """ 137 | def wait_empty(ep=ep): 138 | app_client.portal.call(ep.scheduler.wait_and_close) 139 | 140 | return wait_empty 141 | 142 | 143 | @pytest.fixture 144 | def openapi_compatible(): 145 | supported_openapi_versions = [packaging.version.parse("3.0.2"), packaging.version.parse("3.1.0")] 146 | 147 | if packaging.version.parse(pydantic.VERSION) >= packaging.version.parse("1.10.0"): 148 | def _openapi_compatible(value: dict): 149 | assert packaging.version.parse(value['openapi']) in supported_openapi_versions 150 | value['openapi'] = ANY 151 | return value 152 | else: 153 | def _openapi_compatible(obj: dict): 154 | for k, v in obj.items(): 155 | if isinstance(v, dict): 156 | obj[k] = _openapi_compatible(obj[k]) 157 | if 'const' in obj and 'default' in obj: 158 | del obj['default'] 159 | 160 | assert packaging.version.parse(obj['openapi']) in supported_openapi_versions 161 | obj['openapi'] = ANY 162 | 163 | return obj 164 | return _openapi_compatible 165 | -------------------------------------------------------------------------------- /tests/sentry/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smagafurov/fastapi-jsonrpc/c4bd192f2f53f8235473a974ca9e088cec223a6f/tests/sentry/__init__.py -------------------------------------------------------------------------------- /tests/sentry/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sentry_sdk 3 | from sentry_sdk import Transport 4 | from sentry_sdk.envelope import Envelope 5 | from sentry_sdk.integrations.starlette import StarletteIntegration 6 | from sentry_sdk.integrations.fastapi import FastApiIntegration 7 | from fastapi_jsonrpc.contrib.sentry import FastApiJsonRPCIntegration 8 | 9 | 10 | @pytest.fixture 11 | def capture_events(monkeypatch): 12 | def inner(): 13 | events = [] 14 | test_client = sentry_sdk.get_client() 15 | old_capture_envelope = test_client.transport.capture_envelope 16 | 17 | def append_event(envelope): 18 | for item in envelope: 19 | if item.headers.get("type") in ("event", "transaction"): 20 | events.append(item.payload.json) 21 | return old_capture_envelope(envelope) 22 | 23 | monkeypatch.setattr(test_client.transport, "capture_envelope", append_event) 24 | 25 | return events 26 | 27 | return inner 28 | 29 | 30 | @pytest.fixture 31 | def capture_envelopes(monkeypatch): 32 | def inner(): 33 | envelopes = [] 34 | test_client = sentry_sdk.get_client() 35 | old_capture_envelope = test_client.transport.capture_envelope 36 | 37 | def append_envelope(envelope): 38 | envelopes.append(envelope) 39 | return old_capture_envelope(envelope) 40 | 41 | monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope) 42 | 43 | return envelopes 44 | 45 | return inner 46 | 47 | 48 | @pytest.fixture 49 | def capture_exceptions(monkeypatch): 50 | def inner(): 51 | errors = set() 52 | old_capture_event_hub = sentry_sdk.Hub.capture_event 53 | old_capture_event_scope = sentry_sdk.Scope.capture_event 54 | 55 | def capture_event_hub(self, event, hint=None, scope=None): 56 | """ 57 | Can be removed when we remove push_scope and the Hub from the SDK. 58 | """ 59 | if hint: 60 | if "exc_info" in hint: 61 | error = hint["exc_info"][1] 62 | errors.add(error) 63 | return old_capture_event_hub(self, event, hint=hint, scope=scope) 64 | 65 | def capture_event_scope(self, event, hint=None, scope=None): 66 | if hint: 67 | if "exc_info" in hint: 68 | error = hint["exc_info"][1] 69 | errors.add(error) 70 | return old_capture_event_scope(self, event, hint=hint, scope=scope) 71 | 72 | monkeypatch.setattr(sentry_sdk.Hub, "capture_event", capture_event_hub) 73 | monkeypatch.setattr(sentry_sdk.Scope, "capture_event", capture_event_scope) 74 | 75 | return errors 76 | 77 | return inner 78 | 79 | 80 | @pytest.fixture 81 | def sentry_init(request): 82 | def inner(*a, **kw): 83 | client = sentry_sdk.Client(*a, **kw) 84 | sentry_sdk.get_global_scope().set_client(client) 85 | 86 | if request.node.get_closest_marker("forked"): 87 | # Do not run isolation if the test is already running in 88 | # ultimate isolation (seems to be required for celery tests that 89 | # fork) 90 | yield inner 91 | else: 92 | old_client = sentry_sdk.get_global_scope().client 93 | try: 94 | sentry_sdk.get_current_scope().set_client(None) 95 | yield inner 96 | finally: 97 | sentry_sdk.get_global_scope().set_client(old_client) 98 | 99 | 100 | @pytest.fixture 101 | def sentry_with_integration(sentry_init): 102 | kw = { 103 | "transport": TestTransport(), 104 | "integrations": [FastApiJsonRPCIntegration()], 105 | "traces_sample_rate": 1.0, 106 | "disabled_integrations": [StarletteIntegration, FastApiIntegration], 107 | } 108 | sentry_init(**kw) 109 | 110 | 111 | @pytest.fixture 112 | def sentry_no_integration(sentry_init): 113 | sentry_init() 114 | 115 | 116 | class TestTransport(Transport): 117 | def capture_envelope(self, _: Envelope) -> None: 118 | """ 119 | No-op capture_envelope for tests 120 | """ 121 | -------------------------------------------------------------------------------- /tests/sentry/test_sentry_sdk_1x.py: -------------------------------------------------------------------------------- 1 | """Test fixtures copied from https://github.com/getsentry/sentry-python/ 2 | TODO: move integration to sentry_sdk 3 | """ 4 | 5 | import importlib.metadata 6 | 7 | import pytest 8 | import sentry_sdk 9 | from sentry_sdk import Transport 10 | 11 | from sentry_sdk.utils import capture_internal_exceptions 12 | 13 | 14 | sentry_sdk_version = importlib.metadata.version("sentry_sdk") 15 | if not sentry_sdk_version.startswith("1."): 16 | pytest.skip(f"Testset is only for sentry_sdk 1.x, given {sentry_sdk_version=}", allow_module_level=True) 17 | 18 | 19 | @pytest.fixture 20 | def probe(ep): 21 | @ep.method() 22 | def probe() -> str: 23 | raise ZeroDivisionError 24 | 25 | @ep.method() 26 | def probe2() -> str: 27 | raise RuntimeError 28 | 29 | return ep 30 | 31 | 32 | def test_transaction_is_jsonrpc_method( 33 | probe, 34 | json_request, 35 | sentry_init, 36 | capture_exceptions, 37 | capture_events, 38 | assert_log_errors, 39 | ): 40 | sentry_init(send_default_pii=True) 41 | exceptions = capture_exceptions() 42 | events = capture_events() 43 | 44 | with pytest.warns(UserWarning, match="Implicit Sentry integration is deprecated"): 45 | # Test in batch to ensure we correctly handle multiple requests 46 | json_request( 47 | [ 48 | { 49 | "id": 1, 50 | "jsonrpc": "2.0", 51 | "method": "probe", 52 | "params": {}, 53 | }, 54 | { 55 | "id": 2, 56 | "jsonrpc": "2.0", 57 | "method": "probe2", 58 | "params": {}, 59 | }, 60 | ] 61 | ) 62 | 63 | assert {type(e) for e in exceptions} == {RuntimeError, ZeroDivisionError} 64 | 65 | assert_log_errors( 66 | "", 67 | pytest.raises(ZeroDivisionError), 68 | "", 69 | pytest.raises(RuntimeError), 70 | ) 71 | 72 | assert set([e.get("transaction") for e in events]) == { 73 | "sentry.test_sentry_sdk_1x.probe..probe", 74 | "sentry.test_sentry_sdk_1x.probe..probe2", 75 | } 76 | 77 | 78 | class _TestTransport(Transport): 79 | def __init__(self, capture_event_callback, capture_envelope_callback): 80 | Transport.__init__(self) 81 | self.capture_event = capture_event_callback 82 | self.capture_envelope = capture_envelope_callback 83 | self._queue = None 84 | 85 | 86 | @pytest.fixture 87 | def monkeypatch_test_transport(monkeypatch): 88 | def check_event(event): 89 | def check_string_keys(map): 90 | for key, value in map.items: 91 | assert isinstance(key, str) 92 | if isinstance(value, dict): 93 | check_string_keys(value) 94 | 95 | with capture_internal_exceptions(): 96 | check_string_keys(event) 97 | 98 | def check_envelope(envelope): 99 | with capture_internal_exceptions(): 100 | # Assert error events are sent without envelope to server, for compat. 101 | # This does not apply if any item in the envelope is an attachment. 102 | if not any(x.type == "attachment" for x in envelope.items): 103 | assert not any(item.data_category == "error" for item in envelope.items) 104 | assert not any(item.get_event() is not None for item in envelope.items) 105 | 106 | def inner(client): 107 | monkeypatch.setattr(client, "transport", _TestTransport(check_event, check_envelope)) 108 | 109 | return inner 110 | 111 | 112 | @pytest.fixture 113 | def sentry_init(monkeypatch_test_transport, request): 114 | def inner(*a, **kw): 115 | hub = sentry_sdk.Hub.current 116 | client = sentry_sdk.Client(*a, **kw) 117 | hub.bind_client(client) 118 | if "transport" not in kw: 119 | monkeypatch_test_transport(sentry_sdk.Hub.current.client) 120 | 121 | if request.node.get_closest_marker("forked"): 122 | # Do not run isolation if the test is already running in 123 | # ultimate isolation (seems to be required for celery tests that 124 | # fork) 125 | yield inner 126 | else: 127 | with sentry_sdk.Hub(None): 128 | yield inner 129 | 130 | 131 | @pytest.fixture 132 | def capture_events(monkeypatch): 133 | def inner(): 134 | events = [] 135 | test_client = sentry_sdk.Hub.current.client 136 | old_capture_event = test_client.transport.capture_event 137 | old_capture_envelope = test_client.transport.capture_envelope 138 | 139 | def append_event(event): 140 | events.append(event) 141 | return old_capture_event(event) 142 | 143 | def append_envelope(envelope): 144 | for item in envelope: 145 | if item.headers.get("type") in ("event", "transaction"): 146 | test_client.transport.capture_event(item.payload.json) 147 | return old_capture_envelope(envelope) 148 | 149 | monkeypatch.setattr(test_client.transport, "capture_event", append_event) 150 | monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope) 151 | return events 152 | 153 | return inner 154 | 155 | 156 | @pytest.fixture 157 | def capture_exceptions(monkeypatch): 158 | def inner(): 159 | errors = set() 160 | old_capture_event = sentry_sdk.Hub.capture_event 161 | 162 | def capture_event(self, event, hint=None): 163 | if hint: 164 | if "exc_info" in hint: 165 | error = hint["exc_info"][1] 166 | errors.add(error) 167 | return old_capture_event(self, event, hint=hint) 168 | 169 | monkeypatch.setattr(sentry_sdk.Hub, "capture_event", capture_event) 170 | return errors 171 | 172 | return inner 173 | -------------------------------------------------------------------------------- /tests/sentry/test_sentry_sdk_2x.py: -------------------------------------------------------------------------------- 1 | """Test fixtures copied from https://github.com/getsentry/sentry-python/""" 2 | 3 | import uuid 4 | import importlib.metadata 5 | import pytest 6 | from logging import getLogger 7 | from sentry_sdk.tracing import Transaction 8 | from fastapi_jsonrpc import BaseError 9 | 10 | from fastapi_jsonrpc.contrib.sentry.test_utils import ( 11 | get_transaction_trace_id, 12 | get_captured_transactions, 13 | assert_jrpc_batch_sentry_items, 14 | ) 15 | 16 | sentry_sdk_version = importlib.metadata.version("sentry_sdk") 17 | if not sentry_sdk_version.startswith("2."): 18 | pytest.skip(f"Testset is only for sentry_sdk 2.x, given {sentry_sdk_version=}", allow_module_level=True) 19 | 20 | 21 | class JrpcSampleError(BaseError): 22 | CODE = 5001 23 | MESSAGE = "Sample JRPC error" 24 | 25 | 26 | @pytest.fixture 27 | def failing_router(ep): 28 | sub_app = ep 29 | logger = getLogger("test-sentry") 30 | 31 | @sub_app.method(name="first_logged_error_method") 32 | async def first_logged_error_method() -> dict: 33 | try: 34 | raise ValueError() 35 | except Exception: 36 | logger.exception("First logged error method exc") 37 | 38 | return {"handled": True} 39 | 40 | @sub_app.method(name="second_logged_error_method") 41 | async def second_logged_error_method() -> dict: 42 | try: 43 | raise TypeError() 44 | except Exception: 45 | logger.exception("Second logged error method exc") 46 | 47 | return {"handled": True} 48 | 49 | @sub_app.method(name="unhandled_error_method") 50 | async def unhandled_error_method() -> dict: 51 | raise RuntimeError("Unhandled method exc") 52 | 53 | @sub_app.method(name="jrpc_error_method") 54 | async def jrpc_error_method() -> dict: 55 | raise JrpcSampleError() 56 | 57 | @sub_app.method(name="successful_method") 58 | async def successful_method() -> dict: 59 | return {"success": True} 60 | 61 | 62 | def test_logged_exceptions_event_creation( 63 | json_request, 64 | capture_exceptions, 65 | capture_events, 66 | capture_envelopes, 67 | failing_router, 68 | sentry_with_integration, 69 | assert_log_errors, 70 | ): 71 | exceptions = capture_exceptions() 72 | envelops = capture_envelopes() 73 | response = json_request( 74 | [ 75 | { 76 | "method": "first_logged_error_method", 77 | "params": {}, 78 | "jsonrpc": "2.0", 79 | "id": 1, 80 | }, 81 | { 82 | "method": "second_logged_error_method", 83 | "params": {}, 84 | "jsonrpc": "2.0", 85 | "id": 1, 86 | }, 87 | ], 88 | ) 89 | assert response == [ 90 | { 91 | "result": {"handled": True}, 92 | "jsonrpc": "2.0", 93 | "id": 1, 94 | }, 95 | { 96 | "result": {"handled": True}, 97 | "jsonrpc": "2.0", 98 | "id": 1, 99 | }, 100 | ] 101 | assert {type(e) for e in exceptions} == {ValueError, TypeError} 102 | assert_log_errors( 103 | "First logged error method exc", 104 | pytest.raises(ValueError), 105 | "Second logged error method exc", 106 | pytest.raises(TypeError), 107 | ) 108 | # 2 errors and 2 transactions 109 | assert_jrpc_batch_sentry_items(envelops, expected_items={"event": 2, "transaction": 2}) 110 | 111 | 112 | def test_unhandled_exception_event_creation( 113 | json_request, 114 | capture_exceptions, 115 | capture_events, 116 | capture_envelopes, 117 | failing_router, 118 | assert_log_errors, 119 | sentry_with_integration, 120 | ): 121 | exceptions = capture_exceptions() 122 | envelops = capture_envelopes() 123 | response = json_request( 124 | { 125 | "method": "unhandled_error_method", 126 | "params": {}, 127 | "jsonrpc": "2.0", 128 | "id": 1, 129 | } 130 | ) 131 | assert response == { 132 | "error": {"code": -32603, "message": "Internal error"}, 133 | "jsonrpc": "2.0", 134 | "id": 1, 135 | } 136 | assert_log_errors( 137 | "Unhandled method exc", 138 | pytest.raises(RuntimeError), 139 | ) 140 | assert {type(e) for e in exceptions} == {RuntimeError} 141 | # 1 error and 1 transaction 142 | assert_jrpc_batch_sentry_items(envelops, expected_items={"event": 1, "transaction": 1}) 143 | 144 | 145 | @pytest.mark.parametrize( 146 | "request_payload", 147 | [ 148 | [ 149 | { 150 | "method": "jrpc_error_method", 151 | "params": {}, 152 | "jsonrpc": "2.0", 153 | "id": 1, 154 | }, 155 | { 156 | "method": "unhandled_error_method", 157 | "params": {}, 158 | "jsonrpc": "2.0", 159 | "id": 1, 160 | }, 161 | { 162 | "method": "successful_method", 163 | "params": {}, 164 | "jsonrpc": "2.0", 165 | "id": 1, 166 | }, 167 | ] 168 | ], 169 | ) 170 | def test_trace_id_propagation( 171 | request_payload, 172 | json_request, 173 | capture_exceptions, 174 | capture_events, 175 | capture_envelopes, 176 | failing_router, 177 | assert_log_errors, 178 | sentry_with_integration, 179 | ): 180 | envelops = capture_envelopes() 181 | expected_trace_id = uuid.uuid4().hex 182 | incoming_transaction = Transaction(trace_id=expected_trace_id) 183 | tracing_headers = list(incoming_transaction.iter_headers()) 184 | 185 | response = json_request(request_payload, headers=tracing_headers) 186 | 187 | assert response == [ 188 | { 189 | "error": {"code": 5001, "message": "Sample JRPC error"}, 190 | "jsonrpc": "2.0", 191 | "id": 1, 192 | }, 193 | { 194 | "error": {"code": -32603, "message": "Internal error"}, 195 | "jsonrpc": "2.0", 196 | "id": 1, 197 | }, 198 | { 199 | "result": {"success": True}, 200 | "jsonrpc": "2.0", 201 | "id": 1, 202 | }, 203 | ] 204 | assert_jrpc_batch_sentry_items(envelops, expected_items={"transaction": 3, "event": 1}) 205 | for transaction in get_captured_transactions(envelops): 206 | assert get_transaction_trace_id(transaction) == expected_trace_id 207 | 208 | assert_log_errors( 209 | "Unhandled method exc", 210 | pytest.raises(RuntimeError), 211 | ) 212 | -------------------------------------------------------------------------------- /tests/test_dependencies.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import ANY 2 | 3 | import pytest 4 | from fastapi import Depends, Body, Header 5 | from typing import Tuple 6 | 7 | import fastapi_jsonrpc as jsonrpc 8 | 9 | 10 | @pytest.fixture 11 | def ep(ep_path): 12 | _shared_counter = 0 13 | _common_counter = 0 14 | 15 | def get_shared_counter( 16 | shared: str = Header('shared'), 17 | ) -> str: 18 | nonlocal _shared_counter 19 | _shared_counter += 1 20 | return f'{shared}-{_shared_counter}' 21 | 22 | def get_common_counter( 23 | common: str = Body(...), 24 | ) -> Tuple[str, int]: 25 | nonlocal _common_counter 26 | _common_counter += 1 27 | return common, _common_counter 28 | 29 | ep = jsonrpc.Entrypoint( 30 | ep_path, 31 | dependencies=[Depends(get_shared_counter)], 32 | common_dependencies=[Depends(get_common_counter)], 33 | ) 34 | 35 | @ep.method() 36 | def probe( 37 | shared_counter: str = Depends(get_shared_counter), 38 | common_counter: Tuple[str, int] = Depends(get_common_counter), 39 | ) -> Tuple[str, str, int]: 40 | return shared_counter, common_counter[0], common_counter[1] 41 | 42 | return ep 43 | 44 | 45 | def test_single(method_request): 46 | resp1 = method_request('probe', {'common': 'one'}, request_id=111) 47 | resp2 = method_request('probe', {'common': 'two'}, request_id=222) 48 | resp3 = method_request('probe', {'common': 'three'}, request_id=333) 49 | assert [resp1, resp2, resp3] == [ 50 | {'id': 111, 'jsonrpc': '2.0', 'result': ['shared-1', 'one', 1]}, 51 | {'id': 222, 'jsonrpc': '2.0', 'result': ['shared-2', 'two', 2]}, 52 | {'id': 333, 'jsonrpc': '2.0', 'result': ['shared-3', 'three', 3]}, 53 | ] 54 | 55 | 56 | def test_batch(json_request): 57 | resp = json_request([ 58 | { 59 | 'id': 111, 60 | 'jsonrpc': '2.0', 61 | 'method': 'probe', 62 | 'params': {'common': 'one'}, 63 | }, 64 | { 65 | 'id': 222, 66 | 'jsonrpc': '2.0', 67 | 'method': 'probe', 68 | 'params': {'common': 'two'}, 69 | }, 70 | { 71 | 'id': 333, 72 | 'jsonrpc': '2.0', 73 | 'method': 'probe', 74 | 'params': {'common': 'three'}, 75 | }, 76 | ]) 77 | assert resp == [ 78 | {'id': 111, 'jsonrpc': '2.0', 'result': ['shared-1', 'one', ANY]}, 79 | {'id': 222, 'jsonrpc': '2.0', 'result': ['shared-1', 'two', ANY]}, 80 | {'id': 333, 'jsonrpc': '2.0', 'result': ['shared-1', 'three', ANY]}, 81 | ] 82 | assert set(r['result'][2] for r in resp) == {1, 2, 3} 83 | -------------------------------------------------------------------------------- /tests/test_dependencies_yield.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends, Body, Header 3 | from typing import Tuple 4 | 5 | import fastapi_jsonrpc as jsonrpc 6 | 7 | 8 | @pytest.fixture 9 | def ep(ep_path): 10 | _shared_counter = 0 11 | _common_counter = 0 12 | 13 | async def get_shared_counter( 14 | shared: str = Header('shared'), 15 | ) -> str: 16 | nonlocal _shared_counter 17 | _shared_counter += 1 18 | yield f'{shared}-{_shared_counter}' 19 | 20 | async def get_common_counter( 21 | common: str = Body(...), 22 | ) -> str: 23 | nonlocal _common_counter 24 | _common_counter += 1 25 | yield f'{common}-{_common_counter}' 26 | 27 | ep = jsonrpc.Entrypoint( 28 | ep_path, 29 | dependencies=[Depends(get_shared_counter)], 30 | common_dependencies=[Depends(get_common_counter)], 31 | ) 32 | 33 | @ep.method() 34 | def probe( 35 | shared_counter: str = Depends(get_shared_counter), 36 | common_counter: str = Depends(get_common_counter), 37 | ) -> Tuple[str, str]: 38 | return shared_counter, common_counter 39 | 40 | return ep 41 | 42 | 43 | def test_single(method_request): 44 | resp1 = method_request('probe', {'common': 'one'}, request_id=111) 45 | resp2 = method_request('probe', {'common': 'two'}, request_id=222) 46 | resp3 = method_request('probe', {'common': 'three'}, request_id=333) 47 | assert [resp1, resp2, resp3] == [ 48 | {'id': 111, 'jsonrpc': '2.0', 'result': ['shared-1', 'one-1']}, 49 | {'id': 222, 'jsonrpc': '2.0', 'result': ['shared-2', 'two-2']}, 50 | {'id': 333, 'jsonrpc': '2.0', 'result': ['shared-3', 'three-3']}, 51 | ] 52 | 53 | 54 | def test_batch(json_request): 55 | resp = json_request([ 56 | { 57 | 'id': 111, 58 | 'jsonrpc': '2.0', 59 | 'method': 'probe', 60 | 'params': {'common': 'one'}, 61 | }, 62 | { 63 | 'id': 222, 64 | 'jsonrpc': '2.0', 65 | 'method': 'probe', 66 | 'params': {'common': 'two'}, 67 | }, 68 | { 69 | 'id': 333, 70 | 'jsonrpc': '2.0', 71 | 'method': 'probe', 72 | 'params': {'common': 'three'}, 73 | }, 74 | ]) 75 | assert resp == [ 76 | {'id': 111, 'jsonrpc': '2.0', 'result': ['shared-1', 'one-1']}, 77 | {'id': 222, 'jsonrpc': '2.0', 'result': ['shared-1', 'two-2']}, 78 | {'id': 333, 'jsonrpc': '2.0', 'result': ['shared-1', 'three-3']}, 79 | ] 80 | -------------------------------------------------------------------------------- /tests/test_handle_exception.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import fastapi_jsonrpc as jsonrpc 4 | 5 | 6 | class MyUnhandledException(Exception): 7 | pass 8 | 9 | 10 | class MyErrorToUnhandledException(jsonrpc.BaseError): 11 | CODE = 5000 12 | MESSAGE = 'My error' 13 | 14 | 15 | class MyErrorToConvert(jsonrpc.BaseError): 16 | CODE = 5001 17 | MESSAGE = 'My error to convert' 18 | 19 | 20 | class MyConvertedError(jsonrpc.BaseError): 21 | CODE = 5002 22 | MESSAGE = 'My converted error' 23 | 24 | 25 | @pytest.fixture 26 | def ep(ep_path): 27 | class Entrypoint(jsonrpc.Entrypoint): 28 | async def handle_exception(self, exc): 29 | if isinstance(exc, MyErrorToUnhandledException): 30 | raise MyUnhandledException('My unhandled exception') 31 | elif isinstance(exc, MyErrorToConvert): 32 | raise MyConvertedError 33 | else: 34 | raise NotImplementedError 35 | 36 | ep = Entrypoint(ep_path) 37 | 38 | @ep.method() 39 | def unhandled_exception() -> int: 40 | raise MyErrorToUnhandledException() 41 | 42 | @ep.method() 43 | def convert_error() -> int: 44 | raise MyErrorToConvert() 45 | 46 | return ep 47 | 48 | 49 | def test_unhandled_exception(ep, json_request, assert_log_errors): 50 | resp = json_request({ 51 | 'id': 111, 52 | 'jsonrpc': '2.0', 53 | 'method': 'unhandled_exception', 54 | 'params': {}, 55 | }) 56 | 57 | assert resp == { 58 | 'jsonrpc': '2.0', 59 | 'id': 111, 60 | 'error': { 61 | 'code': -32603, 62 | 'message': 'Internal error', 63 | }, 64 | } 65 | 66 | assert_log_errors('My unhandled exception', pytest.raises(MyUnhandledException)) 67 | 68 | 69 | def test_convert_error(ep, json_request, assert_log_errors): 70 | resp = json_request({ 71 | 'id': 111, 72 | 'jsonrpc': '2.0', 73 | 'method': 'convert_error', 74 | 'params': {}, 75 | }) 76 | 77 | assert resp == { 78 | 'jsonrpc': '2.0', 79 | 'id': 111, 80 | 'error': { 81 | 'code': 5002, 82 | 'message': 'My converted error', 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /tests/test_http_auth.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import contextvars 3 | from json import dumps as json_dumps 4 | 5 | import pytest 6 | from fastapi import HTTPException, Depends 7 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 8 | from starlette.status import HTTP_401_UNAUTHORIZED 9 | 10 | from fastapi_jsonrpc import JsonRpcContext 11 | 12 | 13 | security = HTTPBasic() 14 | 15 | 16 | def auth_user( 17 | credentials: HTTPBasicCredentials = Depends(security) 18 | ) -> HTTPBasicCredentials: 19 | if (credentials.username, credentials.password) != ('user', 'password'): 20 | raise HTTPException( 21 | status_code=HTTP_401_UNAUTHORIZED, 22 | detail="Incorrect username or password", 23 | headers={'WWW-Authenticate': 'Basic'}, 24 | ) 25 | return credentials 26 | 27 | 28 | @pytest.fixture 29 | def body(): 30 | return json_dumps({ 31 | 'id': 1, 32 | 'jsonrpc': '2.0', 33 | 'method': 'probe', 34 | 'params': {}, 35 | }) 36 | 37 | 38 | @pytest.fixture 39 | def ep_method_auth(ep): 40 | @ep.method() 41 | def probe( 42 | user: HTTPBasicCredentials = Depends(auth_user) 43 | ) -> str: 44 | return user.username 45 | 46 | return ep 47 | 48 | 49 | def test_method_auth(ep_method_auth, raw_request, body): 50 | resp = raw_request(body, auth=('user', 'password')) 51 | assert resp.status_code == 200 52 | assert resp.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'user'} 53 | 54 | 55 | def test_method_wrong_auth(ep_method_auth, raw_request, body): 56 | resp = raw_request(body, auth=('user', 'wrong-password')) 57 | assert resp.status_code == 401 58 | assert resp.json() == {'detail': 'Incorrect username or password'} 59 | 60 | 61 | def test_method_no_auth(ep_method_auth, raw_request, body): 62 | resp = raw_request(body) 63 | assert resp.status_code == 401 64 | assert resp.json() == {'detail': 'Not authenticated'} 65 | 66 | 67 | @pytest.fixture 68 | def ep_middleware_auth(ep): 69 | credentials_var = contextvars.ContextVar('credentials') 70 | 71 | @contextlib.asynccontextmanager 72 | async def ep_middleware(ctx: JsonRpcContext): 73 | credentials = await security(ctx.http_request) 74 | credentials = auth_user(credentials) 75 | credentials_var_token = credentials_var.set(credentials) 76 | 77 | try: 78 | yield 79 | finally: 80 | credentials_var.reset(credentials_var_token) 81 | 82 | ep.middlewares.append(ep_middleware) 83 | 84 | @ep.method() 85 | def probe() -> str: 86 | return credentials_var.get().username 87 | 88 | return ep 89 | 90 | 91 | def test_middleware_auth(ep_middleware_auth, raw_request, body): 92 | resp = raw_request(body, auth=('user', 'password')) 93 | assert resp.status_code == 200 94 | assert resp.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'user'} 95 | 96 | 97 | def test_middleware_wrong_auth(ep_middleware_auth, raw_request, body): 98 | resp = raw_request(body, auth=('user', 'wrong-password')) 99 | assert resp.status_code == 401 100 | assert resp.json() == {'detail': 'Incorrect username or password'} 101 | 102 | 103 | def test_middleware_no_auth(ep_middleware_auth, raw_request, body): 104 | resp = raw_request(body) 105 | assert resp.status_code == 401 106 | assert resp.json() == {'detail': 'Not authenticated'} 107 | -------------------------------------------------------------------------------- /tests/test_http_auth_shared_deps.py: -------------------------------------------------------------------------------- 1 | from json import dumps as json_dumps 2 | 3 | import pytest 4 | from fastapi import HTTPException, Depends 5 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 6 | from starlette.status import HTTP_401_UNAUTHORIZED 7 | 8 | from fastapi_jsonrpc import Entrypoint 9 | 10 | 11 | security = HTTPBasic() 12 | 13 | 14 | def auth_user( 15 | credentials: HTTPBasicCredentials = Depends(security) 16 | ) -> HTTPBasicCredentials: 17 | if (credentials.username, credentials.password) != ('user', 'password'): 18 | raise HTTPException( 19 | status_code=HTTP_401_UNAUTHORIZED, 20 | detail="Incorrect username or password", 21 | headers={'WWW-Authenticate': 'Basic'}, 22 | ) 23 | return credentials 24 | 25 | 26 | @pytest.fixture 27 | def body(): 28 | return json_dumps({ 29 | 'id': 1, 30 | 'jsonrpc': '2.0', 31 | 'method': 'probe', 32 | 'params': {}, 33 | }) 34 | 35 | 36 | @pytest.fixture 37 | def ep(ep_path): 38 | ep = Entrypoint( 39 | ep_path, 40 | dependencies=[Depends(auth_user)], 41 | ) 42 | 43 | @ep.method() 44 | def probe() -> str: 45 | return 'ok' 46 | 47 | return ep 48 | 49 | 50 | def test_method_auth(ep, raw_request, body): 51 | resp = raw_request(body, auth=('user', 'password')) 52 | assert resp.status_code == 200 53 | assert resp.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'ok'} 54 | 55 | 56 | def test_method_wrong_auth(ep, raw_request, body): 57 | resp = raw_request(body, auth=('user', 'wrong-password')) 58 | assert resp.status_code == 401 59 | assert resp.json() == {'detail': 'Incorrect username or password'} 60 | 61 | 62 | def test_method_no_auth(ep, raw_request, body): 63 | resp = raw_request(body) 64 | assert resp.status_code == 401 65 | assert resp.json() == {'detail': 'Not authenticated'} 66 | -------------------------------------------------------------------------------- /tests/test_http_exception.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from json import dumps as json_dumps 3 | 4 | from fastapi import HTTPException 5 | 6 | from fastapi_jsonrpc import JsonRpcContext 7 | 8 | 9 | def test_method(ep, raw_request): 10 | @ep.method() 11 | def probe() -> str: 12 | raise HTTPException(401) 13 | 14 | resp = raw_request(json_dumps({ 15 | 'id': 1, 16 | 'jsonrpc': '2.0', 17 | 'method': 'probe', 18 | 'params': {}, 19 | })) 20 | 21 | assert resp.status_code == 401 22 | assert resp.json() == {'detail': 'Unauthorized'} 23 | 24 | 25 | def test_ep_middleware_enter(ep, raw_request): 26 | @contextlib.asynccontextmanager 27 | async def middleware(_ctx: JsonRpcContext): 28 | raise HTTPException(401) 29 | # noinspection PyUnreachableCode 30 | yield 31 | 32 | ep.middlewares.append(middleware) 33 | 34 | @ep.method() 35 | def probe() -> str: 36 | return 'qwe' 37 | 38 | resp = raw_request(json_dumps({ 39 | 'id': 1, 40 | 'jsonrpc': '2.0', 41 | 'method': 'probe', 42 | 'params': {}, 43 | })) 44 | 45 | assert resp.status_code == 401 46 | assert resp.json() == {'detail': 'Unauthorized'} 47 | 48 | 49 | def test_ep_middleware_exit(ep, raw_request): 50 | @contextlib.asynccontextmanager 51 | async def middleware(_ctx: JsonRpcContext): 52 | yield 53 | raise HTTPException(401) 54 | 55 | ep.middlewares.append(middleware) 56 | 57 | @ep.method() 58 | def probe() -> str: 59 | return 'qwe' 60 | 61 | resp = raw_request(json_dumps({ 62 | 'id': 1, 63 | 'jsonrpc': '2.0', 64 | 'method': 'probe', 65 | 'params': {}, 66 | })) 67 | 68 | assert resp.status_code == 401 69 | assert resp.json() == {'detail': 'Unauthorized'} 70 | 71 | 72 | def test_method_middleware_enter(ep, raw_request): 73 | @contextlib.asynccontextmanager 74 | async def middleware(_ctx: JsonRpcContext): 75 | raise HTTPException(401) 76 | # noinspection PyUnreachableCode 77 | yield 78 | 79 | @ep.method(middlewares=[middleware]) 80 | def probe() -> str: 81 | return 'qwe' 82 | 83 | resp = raw_request(json_dumps({ 84 | 'id': 1, 85 | 'jsonrpc': '2.0', 86 | 'method': 'probe', 87 | 'params': {}, 88 | })) 89 | 90 | assert resp.status_code == 401 91 | assert resp.json() == {'detail': 'Unauthorized'} 92 | 93 | 94 | def test_method_middleware_exit(ep, raw_request): 95 | @contextlib.asynccontextmanager 96 | async def middleware(_ctx: JsonRpcContext): 97 | yield 98 | raise HTTPException(401) 99 | 100 | @ep.method(middlewares=[middleware]) 101 | def probe() -> str: 102 | return 'qwe' 103 | 104 | resp = raw_request(json_dumps({ 105 | 'id': 1, 106 | 'jsonrpc': '2.0', 107 | 'method': 'probe', 108 | 'params': {}, 109 | })) 110 | 111 | assert resp.status_code == 401 112 | assert resp.json() == {'detail': 'Unauthorized'} 113 | -------------------------------------------------------------------------------- /tests/test_jsonrpc.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from json import dumps as json_dumps 4 | from typing import List 5 | 6 | import pytest 7 | from fastapi import Body 8 | from pydantic import BaseModel 9 | 10 | 11 | @pytest.fixture 12 | def echo(ep, method_request): 13 | class EchoInfo: 14 | def __init__(self): 15 | self.history = [] 16 | 17 | echo_info = EchoInfo() 18 | 19 | @ep.method() 20 | def echo( 21 | data: str = Body(..., examples=['123']), 22 | ) -> str: 23 | echo_info.history.append(data) 24 | return data 25 | 26 | @ep.method() 27 | def no_params( 28 | ) -> str: 29 | return '123' 30 | 31 | class DataItem(BaseModel): 32 | inner_data: int 33 | 34 | @ep.method() 35 | def deep_data( 36 | data: List[DataItem] = Body(...), 37 | ) -> List[DataItem]: 38 | return data 39 | 40 | return echo_info 41 | 42 | 43 | def test_no_params(echo, json_request): 44 | resp = json_request({ 45 | 'id': 111, 46 | 'jsonrpc': '2.0', 47 | 'method': 'no_params', 48 | }) 49 | assert resp == {'id': 111, 'jsonrpc': '2.0', 'result': '123'} 50 | 51 | 52 | @pytest.mark.parametrize('request_id', [111, 'qwe']) 53 | def test_basic(echo, json_request, request_id, ep_wait_all_requests_done): 54 | resp = json_request({ 55 | 'id': request_id, 56 | 'jsonrpc': '2.0', 57 | 'method': 'echo', 58 | 'params': {'data': 'data-123'}, 59 | }) 60 | assert resp == {'id': request_id, 'jsonrpc': '2.0', 'result': 'data-123'} 61 | ep_wait_all_requests_done() 62 | assert echo.history == ['data-123'] 63 | 64 | 65 | def test_notify(echo, raw_request, ep_wait_all_requests_done): 66 | resp = raw_request(json_dumps({ 67 | 'jsonrpc': '2.0', 68 | 'method': 'echo', 69 | 'params': {'data': 'data-123'}, 70 | })) 71 | assert not resp.content 72 | ep_wait_all_requests_done() 73 | assert echo.history == ['data-123'] 74 | 75 | 76 | def test_batch_notify(echo, raw_request, ep_wait_all_requests_done): 77 | resp = raw_request(json_dumps([ 78 | { 79 | 'jsonrpc': '2.0', 80 | 'method': 'echo', 81 | 'params': {'data': 'data-111'}, 82 | }, 83 | { 84 | 'jsonrpc': '2.0', 85 | 'method': 'echo', 86 | 'params': {'data': 'data-222'}, 87 | }, 88 | ])) 89 | assert not resp.content 90 | ep_wait_all_requests_done() 91 | assert set(echo.history) == {'data-111', 'data-222'} 92 | 93 | 94 | def test_dict_error(echo, json_request): 95 | resp = json_request('qwe') 96 | assert resp == { 97 | 'error': { 98 | 'code': -32600, 99 | 'message': 'Invalid Request', 100 | 'data': {'errors': [{ 101 | 'ctx': {'class_name': '_Request'}, 102 | 'input': 'qwe', 103 | 'loc': [], 104 | 'msg': 'Input should be a valid dictionary or instance of _Request', 105 | 'type': 'model_type', 106 | }]}, 107 | }, 108 | 'id': None, 109 | 'jsonrpc': '2.0', 110 | } 111 | assert echo.history == [] 112 | 113 | 114 | def test_request_jsonrpc_validation_error(echo, json_request): 115 | resp = json_request({ 116 | 'id': 0, 117 | 'jsonrpc': '3.0', 118 | 'method': 'echo', 119 | 'params': {'data': 'data-123'}, 120 | }) 121 | assert resp == { 122 | 'error': { 123 | 'code': -32600, 124 | 'message': 'Invalid Request', 125 | 'data': {'errors': [{ 126 | 'ctx': {'expected': "'2.0'"}, 127 | 'input': '3.0', 128 | 'loc': ['jsonrpc'], 129 | 'msg': "Input should be '2.0'", 130 | 'type': 'literal_error', 131 | }]}, 132 | }, 133 | 'id': 0, 134 | 'jsonrpc': '2.0', 135 | } 136 | assert echo.history == [] 137 | 138 | 139 | def test_request_id_validation_error(echo, json_request): 140 | resp = json_request({ 141 | 'id': [123], 142 | 'jsonrpc': '2.0', 143 | 'method': 'echo', 144 | 'params': {'data': 'data-123'}, 145 | }) 146 | assert resp == { 147 | 'error': { 148 | 'code': -32600, 149 | 'message': 'Invalid Request', 150 | 'data': {'errors': [ 151 | {'input': [123], 'loc': ['id', 'str'], 'msg': 'Input should be a valid string', 'type': 'string_type'}, 152 | {'input': [123], 'loc': ['id', 'int'], 'msg': 'Input should be a valid integer', 'type': 'int_type'} 153 | ]}, 154 | }, 155 | 'id': [123], 156 | 'jsonrpc': '2.0', 157 | } 158 | assert echo.history == [] 159 | 160 | 161 | def test_request_method_validation_error(echo, json_request): 162 | resp = json_request({ 163 | 'id': 0, 164 | 'jsonrpc': '2.0', 165 | 'method': 123, 166 | 'params': {'data': 'data-123'}, 167 | }) 168 | assert resp == { 169 | 'error': { 170 | 'code': -32600, 171 | 'message': 'Invalid Request', 172 | 'data': {'errors': [ 173 | {'input': 123, 'loc': ['method'], 'msg': 'Input should be a valid string', 'type': 'string_type'}, 174 | ]}, 175 | }, 176 | 'id': 0, 177 | 'jsonrpc': '2.0', 178 | } 179 | assert echo.history == [] 180 | 181 | 182 | def test_request_params_validation_error(echo, json_request): 183 | resp = json_request({ 184 | 'id': 0, 185 | 'jsonrpc': '2.0', 186 | 'method': 'echo', 187 | 'params': 123, 188 | }) 189 | assert resp == { 190 | 'error': { 191 | 'code': -32600, 192 | 'message': 'Invalid Request', 193 | 'data': {'errors': [ 194 | {'input': 123, 'loc': ['params'], 'msg': 'Input should be a valid dictionary', 'type': 'dict_type'}]}, 195 | }, 196 | 'id': 0, 197 | 'jsonrpc': '2.0', 198 | } 199 | assert echo.history == [] 200 | 201 | 202 | def test_request_method_missing(echo, json_request): 203 | resp = json_request({ 204 | 'id': 0, 205 | 'jsonrpc': '2.0', 206 | 'params': {'data': 'data-123'}, 207 | }) 208 | assert resp == { 209 | 'error': { 210 | 'code': -32600, 211 | 'message': 'Invalid Request', 212 | 'data': {'errors': [{ 213 | 'input': {'id': 0, 'jsonrpc': '2.0', 'params': {'data': 'data-123'}}, 214 | 'loc': ['method'], 215 | 'msg': 'Field required', 216 | 'type': 'missing' 217 | }]}, 218 | }, 219 | 'id': 0, 220 | 'jsonrpc': '2.0', 221 | } 222 | assert echo.history == [] 223 | 224 | 225 | def test_request_params_missing(echo, json_request): 226 | resp = json_request({ 227 | 'id': 0, 228 | 'jsonrpc': '2.0', 229 | 'method': 'echo', 230 | }) 231 | assert resp == { 232 | 'error': { 233 | 'code': -32602, 234 | 'message': 'Invalid params', 235 | 'data': {'errors': [ 236 | {'input': None, 'loc': ['data'], 'msg': 'Field required', 'type': 'missing'}, 237 | ]}, 238 | }, 239 | 'id': 0, 240 | 'jsonrpc': '2.0', 241 | } 242 | assert echo.history == [] 243 | 244 | 245 | def test_request_extra(echo, json_request): 246 | resp = json_request({ 247 | 'id': 0, 248 | 'jsonrpc': '2.0', 249 | 'method': 'echo', 250 | 'params': {'data': 'data-123'}, 251 | 'some_extra': 123, 252 | }) 253 | assert resp == { 254 | 'error': { 255 | 'code': -32600, 256 | 'message': 'Invalid Request', 257 | 'data': {'errors': [ 258 | { 259 | 'input': 123, 260 | 'loc': ['some_extra'], 261 | 'msg': 'Extra inputs are not permitted', 262 | 'type': 'extra_forbidden', 263 | }, 264 | ]}, 265 | }, 266 | 'id': 0, 267 | 'jsonrpc': '2.0', 268 | } 269 | assert echo.history == [] 270 | 271 | 272 | def test_method_not_found(echo, json_request): 273 | resp = json_request({ 274 | 'id': 0, 275 | 'jsonrpc': '2.0', 276 | 'method': 'echo-bla-bla', 277 | 'params': {'data': 'data-123'}, 278 | }) 279 | assert resp == { 280 | 'error': {'code': -32601, 'message': 'Method not found'}, 281 | 'id': 0, 282 | 'jsonrpc': '2.0', 283 | } 284 | assert echo.history == [] 285 | 286 | 287 | def test_batch(echo, json_request, ep_wait_all_requests_done): 288 | resp = json_request([ 289 | { 290 | 'id': 111, 291 | 'jsonrpc': '2.0', 292 | 'method': 'echo', 293 | 'params': {'data': 'data-111'}, 294 | }, 295 | { 296 | 'jsonrpc': '2.0', 297 | 'method': 'echo', 298 | 'params': {'data': 'data-notify'}, 299 | }, 300 | { 301 | 'id': 'qwe', 302 | 'jsonrpc': '2.0', 303 | 'method': 'echo', 304 | 'params': {'data': 'data-qwe'}, 305 | }, 306 | { 307 | 'id': 'method-not-found', 308 | 'jsonrpc': '2.0', 309 | 'method': 'echo-bla-bla', 310 | 'params': {'data': 'data-123'}, 311 | }, 312 | ]) 313 | assert resp == [ 314 | {'id': 111, 'jsonrpc': '2.0', 'result': 'data-111'}, 315 | {'id': 'qwe', 'jsonrpc': '2.0', 'result': 'data-qwe'}, 316 | {'id': 'method-not-found', 'jsonrpc': '2.0', 'error': {'code': -32601, 'message': 'Method not found'}}, 317 | ] 318 | ep_wait_all_requests_done() 319 | assert set(echo.history) == {'data-111', 'data-notify', 'data-qwe'} 320 | 321 | 322 | def test_empty_batch(echo, json_request): 323 | resp = json_request([]) 324 | assert resp == { 325 | 'error': { 326 | 'code': -32600, 327 | 'message': 'Invalid Request', 328 | 'data': {'errors': [{'loc': [], 'msg': 'rpc call with an empty array', 'type': 'value_error.empty'}]}, 329 | }, 330 | 'id': None, 331 | 'jsonrpc': '2.0', 332 | } 333 | assert echo.history == [] 334 | 335 | 336 | @pytest.mark.parametrize('content', ['qwe', b'\xf1'], ids=['str', 'bytes']) 337 | def test_non_json__parse_error(echo, raw_request, content): 338 | resp = raw_request(content).json() 339 | assert resp == { 340 | 'error': { 341 | 'code': -32700, 342 | 'message': 'Parse error', 343 | }, 344 | 'id': None, 345 | 'jsonrpc': '2.0', 346 | } 347 | assert echo.history == [] 348 | 349 | 350 | def test_deep_data_validation(echo, json_request): 351 | resp = json_request({ 352 | 'id': 0, 353 | 'jsonrpc': '2.0', 354 | 'method': 'deep_data', 355 | 'params': {'data': [{}]}, 356 | }) 357 | assert resp == { 358 | 'error': { 359 | 'code': -32602, 360 | 'message': 'Invalid params', 361 | 'data': {'errors': [ 362 | { 363 | 'input': {}, 364 | 'loc': ['data', 0, 'inner_data'], 365 | 'msg': 'Field required', 366 | 'type': 'missing', 367 | }, 368 | ]}, 369 | }, 370 | 'id': 0, 371 | 'jsonrpc': '2.0', 372 | } 373 | -------------------------------------------------------------------------------- /tests/test_jsonrpc_method.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends 3 | 4 | from fastapi_jsonrpc import get_jsonrpc_method 5 | 6 | 7 | @pytest.fixture 8 | def probe(ep): 9 | @ep.method() 10 | def probe( 11 | jsonrpc_method: str = Depends(get_jsonrpc_method), 12 | ) -> str: 13 | return jsonrpc_method 14 | 15 | @ep.method() 16 | def probe2( 17 | jsonrpc_method: str = Depends(get_jsonrpc_method), 18 | ) -> str: 19 | return jsonrpc_method 20 | 21 | return ep 22 | 23 | 24 | def test_basic(probe, json_request): 25 | resp = json_request({ 26 | 'id': 123, 27 | 'jsonrpc': '2.0', 28 | 'method': 'probe', 29 | 'params': {}, 30 | }) 31 | assert resp == {'id': 123, 'jsonrpc': '2.0', 'result': 'probe'} 32 | 33 | 34 | def test_batch(probe, json_request): 35 | resp = json_request([ 36 | { 37 | 'id': 1, 38 | 'jsonrpc': '2.0', 39 | 'method': 'probe', 40 | 'params': {}, 41 | }, 42 | { 43 | 'id': 2, 44 | 'jsonrpc': '2.0', 45 | 'method': 'probe2', 46 | 'params': {}, 47 | }, 48 | ]) 49 | assert resp == [ 50 | {'id': 1, 'jsonrpc': '2.0', 'result': 'probe'}, 51 | {'id': 2, 'jsonrpc': '2.0', 'result': 'probe2'}, 52 | ] 53 | -------------------------------------------------------------------------------- /tests/test_jsonrpc_request_id.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends 3 | 4 | from fastapi_jsonrpc import get_jsonrpc_request_id 5 | 6 | 7 | @pytest.fixture 8 | def probe(ep): 9 | @ep.method() 10 | def probe( 11 | jsonrpc_request_id: int = Depends(get_jsonrpc_request_id), 12 | ) -> int: 13 | return jsonrpc_request_id 14 | return ep 15 | 16 | 17 | def test_basic(probe, json_request): 18 | resp = json_request({ 19 | 'id': 123, 20 | 'jsonrpc': '2.0', 21 | 'method': 'probe', 22 | 'params': {}, 23 | }) 24 | assert resp == {'id': 123, 'jsonrpc': '2.0', 'result': 123} 25 | 26 | 27 | def test_batch(probe, json_request): 28 | resp = json_request([ 29 | { 30 | 'id': 1, 31 | 'jsonrpc': '2.0', 32 | 'method': 'probe', 33 | 'params': {}, 34 | }, 35 | { 36 | 'id': 2, 37 | 'jsonrpc': '2.0', 38 | 'method': 'probe', 39 | 'params': {}, 40 | }, 41 | ]) 42 | assert resp == [ 43 | {'id': 1, 'jsonrpc': '2.0', 'result': 1}, 44 | {'id': 2, 'jsonrpc': '2.0', 'result': 2}, 45 | ] 46 | -------------------------------------------------------------------------------- /tests/test_middlewares.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import contextvars 3 | import logging 4 | import sys 5 | import uuid 6 | from collections import defaultdict 7 | from typing import Tuple 8 | 9 | import pytest 10 | from fastapi import Body 11 | 12 | import fastapi_jsonrpc as jsonrpc 13 | 14 | 15 | unique_marker = str(uuid.uuid4()) 16 | unique_marker2 = str(uuid.uuid4()) 17 | 18 | 19 | class _TestError(jsonrpc.BaseError): 20 | CODE = 33333 21 | MESSAGE = "Test error" 22 | 23 | 24 | @pytest.fixture 25 | def ep(ep_path): 26 | _calls = defaultdict(list) 27 | 28 | ep_middleware_var = contextvars.ContextVar('ep_middleware') 29 | method_middleware_var = contextvars.ContextVar('method_middleware') 30 | 31 | @contextlib.asynccontextmanager 32 | async def ep_handle_exception(_ctx: jsonrpc.JsonRpcContext): 33 | try: 34 | yield 35 | except RuntimeError as exc: 36 | logging.exception(str(exc), exc_info=exc) 37 | raise _TestError(unique_marker2) 38 | 39 | @contextlib.asynccontextmanager 40 | async def ep_middleware(ctx: jsonrpc.JsonRpcContext): 41 | nonlocal _calls 42 | ep_middleware_var.set('ep_middleware-value') 43 | _calls[ctx.raw_request.get('id')].append(( 44 | 'ep_middleware', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 45 | )) 46 | try: 47 | yield 48 | finally: 49 | _calls[ctx.raw_response.get('id')].append(( 50 | 'ep_middleware', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 51 | )) 52 | 53 | @contextlib.asynccontextmanager 54 | async def method_middleware(ctx): 55 | nonlocal _calls 56 | method_middleware_var.set('method_middleware-value') 57 | _calls[ctx.raw_request.get('id')].append(( 58 | 'method_middleware', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 59 | )) 60 | try: 61 | yield 62 | finally: 63 | _calls[ctx.raw_response.get('id')].append(( 64 | 'method_middleware', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 65 | )) 66 | 67 | ep = jsonrpc.Entrypoint( 68 | ep_path, 69 | middlewares=[ep_handle_exception, ep_middleware], 70 | ) 71 | 72 | @ep.method(middlewares=[method_middleware]) 73 | def probe( 74 | data: str = Body(..., examples=['123']), 75 | ) -> str: 76 | return data 77 | 78 | @ep.method(middlewares=[method_middleware]) 79 | def probe_error( 80 | ) -> str: 81 | raise RuntimeError(unique_marker) 82 | 83 | @ep.method(middlewares=[method_middleware]) 84 | def probe_context_vars( 85 | ) -> Tuple[str, str]: 86 | return ep_middleware_var.get(), method_middleware_var.get() 87 | 88 | ep.calls = _calls 89 | 90 | return ep 91 | 92 | 93 | def test_single(ep, method_request): 94 | resp = method_request('probe', {'data': 'one'}, request_id=111) 95 | assert resp == {'id': 111, 'jsonrpc': '2.0', 'result': 'one'} 96 | assert ep.calls == { 97 | 111: [ 98 | ( 99 | 'ep_middleware', 100 | 'enter', 101 | { 102 | 'id': 111, 103 | 'jsonrpc': '2.0', 104 | 'method': 'probe', 105 | 'params': {'data': 'one'}, 106 | }, 107 | None, 108 | None, 109 | ), 110 | ( 111 | 'method_middleware', 112 | 'enter', 113 | { 114 | 'id': 111, 115 | 'jsonrpc': '2.0', 116 | 'method': 'probe', 117 | 'params': {'data': 'one'} 118 | }, 119 | None, 120 | None, 121 | ), 122 | ( 123 | 'method_middleware', 124 | 'exit', 125 | { 126 | 'id': 111, 127 | 'jsonrpc': '2.0', 128 | 'method': 'probe', 129 | 'params': {'data': 'one'} 130 | }, 131 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 132 | None, 133 | ), 134 | ( 135 | 'ep_middleware', 136 | 'exit', 137 | { 138 | 'id': 111, 139 | 'jsonrpc': '2.0', 140 | 'method': 'probe', 141 | 'params': {'data': 'one'} 142 | }, 143 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 144 | None, 145 | ) 146 | ] 147 | } 148 | 149 | 150 | def test_single_error(ep, method_request, assert_log_errors): 151 | resp = method_request('probe_error', {'data': 'one'}, request_id=111) 152 | assert resp == { 153 | 'id': 111, 'jsonrpc': '2.0', 'error': { 154 | 'code': 33333, 'data': unique_marker2, 'message': 'Test error', 155 | } 156 | } 157 | assert ep.calls == { 158 | 111: [ 159 | ( 160 | 'ep_middleware', 161 | 'enter', 162 | { 163 | 'id': 111, 164 | 'jsonrpc': '2.0', 165 | 'method': 'probe_error', 166 | 'params': {'data': 'one'}, 167 | }, 168 | None, 169 | None, 170 | ), 171 | ( 172 | 'method_middleware', 173 | 'enter', 174 | { 175 | 'id': 111, 176 | 'jsonrpc': '2.0', 177 | 'method': 'probe_error', 178 | 'params': {'data': 'one'} 179 | }, 180 | None, 181 | None, 182 | ), 183 | ( 184 | 'method_middleware', 185 | 'exit', 186 | { 187 | 'id': 111, 188 | 'jsonrpc': '2.0', 189 | 'method': 'probe_error', 190 | 'params': {'data': 'one'} 191 | }, 192 | {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 193 | RuntimeError, 194 | ), 195 | ( 196 | 'ep_middleware', 197 | 'exit', 198 | { 199 | 'id': 111, 200 | 'jsonrpc': '2.0', 201 | 'method': 'probe_error', 202 | 'params': {'data': 'one'} 203 | }, 204 | {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 205 | RuntimeError, 206 | ) 207 | ] 208 | } 209 | 210 | assert_log_errors(unique_marker, pytest.raises(RuntimeError)) 211 | 212 | 213 | def test_batch(ep, json_request): 214 | resp = json_request([ 215 | { 216 | 'id': 111, 217 | 'jsonrpc': '2.0', 218 | 'method': 'probe', 219 | 'params': {'data': 'one'}, 220 | }, 221 | { 222 | 'id': 222, 223 | 'jsonrpc': '2.0', 224 | 'method': 'probe', 225 | 'params': {'data': 'two'}, 226 | }, 227 | ]) 228 | assert resp == [ 229 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 230 | {'id': 222, 'jsonrpc': '2.0', 'result': 'two'}, 231 | ] 232 | assert ep.calls == { 233 | 111: [ 234 | ( 235 | 'ep_middleware', 236 | 'enter', 237 | { 238 | 'id': 111, 239 | 'jsonrpc': '2.0', 240 | 'method': 'probe', 241 | 'params': {'data': 'one'}, 242 | }, 243 | None, 244 | None, 245 | ), 246 | ( 247 | 'method_middleware', 248 | 'enter', 249 | { 250 | 'id': 111, 251 | 'jsonrpc': '2.0', 252 | 'method': 'probe', 253 | 'params': {'data': 'one'} 254 | }, 255 | None, 256 | None, 257 | ), 258 | ( 259 | 'method_middleware', 260 | 'exit', 261 | { 262 | 'id': 111, 263 | 'jsonrpc': '2.0', 264 | 'method': 'probe', 265 | 'params': {'data': 'one'} 266 | }, 267 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 268 | None, 269 | ), 270 | ( 271 | 'ep_middleware', 272 | 'exit', 273 | { 274 | 'id': 111, 275 | 'jsonrpc': '2.0', 276 | 'method': 'probe', 277 | 'params': {'data': 'one'} 278 | }, 279 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 280 | None, 281 | ) 282 | ], 283 | 222: [ 284 | ( 285 | 'ep_middleware', 286 | 'enter', 287 | { 288 | 'id': 222, 289 | 'jsonrpc': '2.0', 290 | 'method': 'probe', 291 | 'params': {'data': 'two'}, 292 | }, 293 | None, 294 | None, 295 | ), 296 | ( 297 | 'method_middleware', 298 | 'enter', 299 | { 300 | 'id': 222, 301 | 'jsonrpc': '2.0', 302 | 'method': 'probe', 303 | 'params': {'data': 'two'} 304 | }, 305 | None, 306 | None, 307 | ), 308 | ( 309 | 'method_middleware', 310 | 'exit', 311 | { 312 | 'id': 222, 313 | 'jsonrpc': '2.0', 314 | 'method': 'probe', 315 | 'params': {'data': 'two'} 316 | }, 317 | {'id': 222, 'jsonrpc': '2.0', 'result': 'two'}, 318 | None, 319 | ), 320 | ( 321 | 'ep_middleware', 322 | 'exit', 323 | { 324 | 'id': 222, 325 | 'jsonrpc': '2.0', 326 | 'method': 'probe', 327 | 'params': {'data': 'two'} 328 | }, 329 | {'id': 222, 'jsonrpc': '2.0', 'result': 'two'}, 330 | None, 331 | ) 332 | ] 333 | } 334 | 335 | 336 | def test_batch_error(ep, json_request, assert_log_errors): 337 | resp = json_request([ 338 | { 339 | 'id': 111, 340 | 'jsonrpc': '2.0', 341 | 'method': 'probe_error', 342 | 'params': {'data': 'one'}, 343 | }, 344 | { 345 | 'id': 222, 346 | 'jsonrpc': '2.0', 347 | 'method': 'probe_error', 348 | 'params': {'data': 'two'}, 349 | }, 350 | ]) 351 | assert resp == [ 352 | { 353 | 'id': 111, 'jsonrpc': '2.0', 'error': { 354 | 'code': 33333, 'data': unique_marker2, 'message': 'Test error', 355 | } 356 | }, 357 | { 358 | 'id': 222, 'jsonrpc': '2.0', 'error': { 359 | 'code': 33333, 'data': unique_marker2, 'message': 'Test error', 360 | } 361 | }, 362 | ] 363 | assert ep.calls == { 364 | 111: [ 365 | ( 366 | 'ep_middleware', 367 | 'enter', 368 | { 369 | 'id': 111, 370 | 'jsonrpc': '2.0', 371 | 'method': 'probe_error', 372 | 'params': {'data': 'one'}, 373 | }, 374 | None, 375 | None, 376 | ), 377 | ( 378 | 'method_middleware', 379 | 'enter', 380 | { 381 | 'id': 111, 382 | 'jsonrpc': '2.0', 383 | 'method': 'probe_error', 384 | 'params': {'data': 'one'} 385 | }, 386 | None, 387 | None, 388 | ), 389 | ( 390 | 'method_middleware', 391 | 'exit', 392 | { 393 | 'id': 111, 394 | 'jsonrpc': '2.0', 395 | 'method': 'probe_error', 396 | 'params': {'data': 'one'} 397 | }, 398 | {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 399 | RuntimeError, 400 | ), 401 | ( 402 | 'ep_middleware', 403 | 'exit', 404 | { 405 | 'id': 111, 406 | 'jsonrpc': '2.0', 407 | 'method': 'probe_error', 408 | 'params': {'data': 'one'} 409 | }, 410 | {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 411 | RuntimeError, 412 | ) 413 | ], 414 | 222: [ 415 | ( 416 | 'ep_middleware', 417 | 'enter', 418 | { 419 | 'id': 222, 420 | 'jsonrpc': '2.0', 421 | 'method': 'probe_error', 422 | 'params': {'data': 'two'}, 423 | }, 424 | None, 425 | None, 426 | ), 427 | ( 428 | 'method_middleware', 429 | 'enter', 430 | { 431 | 'id': 222, 432 | 'jsonrpc': '2.0', 433 | 'method': 'probe_error', 434 | 'params': {'data': 'two'} 435 | }, 436 | None, 437 | None, 438 | ), 439 | ( 440 | 'method_middleware', 441 | 'exit', 442 | { 443 | 'id': 222, 444 | 'jsonrpc': '2.0', 445 | 'method': 'probe_error', 446 | 'params': {'data': 'two'} 447 | }, 448 | {'id': 222, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 449 | RuntimeError, 450 | ), 451 | ( 452 | 'ep_middleware', 453 | 'exit', 454 | { 455 | 'id': 222, 456 | 'jsonrpc': '2.0', 457 | 'method': 'probe_error', 458 | 'params': {'data': 'two'} 459 | }, 460 | {'id': 222, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 461 | RuntimeError, 462 | ) 463 | ] 464 | } 465 | 466 | assert_log_errors( 467 | unique_marker, pytest.raises(RuntimeError), 468 | unique_marker, pytest.raises(RuntimeError), 469 | ) 470 | 471 | 472 | def test_context_vars(ep, method_request): 473 | resp = method_request('probe_context_vars', {}, request_id=111) 474 | assert resp == {'id': 111, 'jsonrpc': '2.0', 'result': ['ep_middleware-value', 'method_middleware-value']} 475 | -------------------------------------------------------------------------------- /tests/test_middlewares_exc_enter.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import sys 3 | import uuid 4 | from collections import defaultdict 5 | 6 | import pytest 7 | from fastapi import Body 8 | 9 | import fastapi_jsonrpc as jsonrpc 10 | 11 | 12 | unique_marker = str(uuid.uuid4()) 13 | 14 | 15 | @pytest.fixture 16 | def ep(ep_path): 17 | _calls = defaultdict(list) 18 | 19 | @contextlib.asynccontextmanager 20 | async def mw_first(ctx: jsonrpc.JsonRpcContext): 21 | nonlocal _calls 22 | _calls[ctx.raw_request.get('id')].append(( 23 | 'mw_first', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 24 | )) 25 | try: 26 | yield 27 | finally: 28 | _calls[ctx.raw_response.get('id')].append(( 29 | 'mw_first', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 30 | )) 31 | 32 | @contextlib.asynccontextmanager 33 | async def mw_exception_enter(ctx: jsonrpc.JsonRpcContext): 34 | nonlocal _calls 35 | _calls[ctx.raw_request.get('id')].append(( 36 | 'mw_exception_enter', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 37 | )) 38 | raise RuntimeError(unique_marker) 39 | # noinspection PyUnreachableCode 40 | try: 41 | yield 42 | finally: 43 | _calls[ctx.raw_response.get('id')].append(( 44 | 'mw_exception_enter', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 45 | )) 46 | 47 | @contextlib.asynccontextmanager 48 | async def mw_last(ctx: jsonrpc.JsonRpcContext): 49 | nonlocal _calls 50 | _calls[ctx.raw_request.get('id')].append(( 51 | 'mw_last', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 52 | )) 53 | try: 54 | yield 55 | finally: 56 | _calls[ctx.raw_response.get('id')].append(( 57 | 'mw_last', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 58 | )) 59 | 60 | ep = jsonrpc.Entrypoint( 61 | ep_path, 62 | middlewares=[mw_first, mw_exception_enter, mw_last], 63 | ) 64 | 65 | @ep.method() 66 | def probe( 67 | data: str = Body(..., examples=['123']), 68 | ) -> str: 69 | return data 70 | 71 | ep.calls = _calls 72 | 73 | return ep 74 | 75 | 76 | def test_ep_exception(ep, method_request, assert_log_errors): 77 | resp = method_request('probe', {'data': 'one'}, request_id=111) 78 | assert resp == {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}} 79 | assert ep.calls == { 80 | 111: [ 81 | ( 82 | 'mw_first', 83 | 'enter', 84 | { 85 | 'id': 111, 86 | 'jsonrpc': '2.0', 87 | 'method': 'probe', 88 | 'params': {'data': 'one'}, 89 | }, 90 | None, 91 | None, 92 | ), 93 | ( 94 | 'mw_exception_enter', 95 | 'enter', 96 | { 97 | 'id': 111, 98 | 'jsonrpc': '2.0', 99 | 'method': 'probe', 100 | 'params': {'data': 'one'} 101 | }, 102 | None, 103 | None, 104 | ), 105 | ( 106 | 'mw_first', 107 | 'exit', 108 | { 109 | 'id': 111, 110 | 'jsonrpc': '2.0', 111 | 'method': 'probe', 112 | 'params': {'data': 'one'} 113 | }, 114 | {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 115 | RuntimeError, 116 | ), 117 | ] 118 | } 119 | 120 | assert_log_errors(unique_marker, pytest.raises(RuntimeError)) 121 | -------------------------------------------------------------------------------- /tests/test_middlewares_exc_exit.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import sys 3 | import uuid 4 | from collections import defaultdict 5 | 6 | import pytest 7 | from fastapi import Body 8 | 9 | import fastapi_jsonrpc as jsonrpc 10 | 11 | 12 | unique_marker = str(uuid.uuid4()) 13 | 14 | 15 | @pytest.fixture 16 | def ep(ep_path): 17 | _calls = defaultdict(list) 18 | 19 | @contextlib.asynccontextmanager 20 | async def mw_first(ctx: jsonrpc.JsonRpcContext): 21 | nonlocal _calls 22 | _calls[ctx.raw_request.get('id')].append(( 23 | 'mw_first', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 24 | )) 25 | try: 26 | yield 27 | finally: 28 | _calls[ctx.raw_response.get('id')].append(( 29 | 'mw_first', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 30 | )) 31 | 32 | @contextlib.asynccontextmanager 33 | async def mw_exception_exit(ctx: jsonrpc.JsonRpcContext): 34 | nonlocal _calls 35 | _calls[ctx.raw_request.get('id')].append(( 36 | 'mw_exception_exit', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 37 | )) 38 | # noinspection PyUnreachableCode 39 | try: 40 | yield 41 | finally: 42 | _calls[ctx.raw_response.get('id')].append(( 43 | 'mw_exception_exit', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 44 | )) 45 | raise RuntimeError(unique_marker) 46 | 47 | @contextlib.asynccontextmanager 48 | async def mw_last(ctx: jsonrpc.JsonRpcContext): 49 | nonlocal _calls 50 | _calls[ctx.raw_request.get('id')].append(( 51 | 'mw_last', 'enter', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 52 | )) 53 | try: 54 | yield 55 | finally: 56 | _calls[ctx.raw_response.get('id')].append(( 57 | 'mw_last', 'exit', ctx.raw_request, ctx.raw_response, sys.exc_info()[0] 58 | )) 59 | 60 | ep = jsonrpc.Entrypoint( 61 | ep_path, 62 | middlewares=[mw_first, mw_exception_exit, mw_last], 63 | ) 64 | 65 | @ep.method() 66 | def probe( 67 | data: str = Body(..., examples=['123']), 68 | ) -> str: 69 | return data 70 | 71 | ep.calls = _calls 72 | 73 | return ep 74 | 75 | 76 | def test_ep_exception(ep, method_request, assert_log_errors): 77 | resp = method_request('probe', {'data': 'one'}, request_id=111) 78 | assert resp == {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}} 79 | assert ep.calls == { 80 | 111: [ 81 | ( 82 | 'mw_first', 83 | 'enter', 84 | { 85 | 'id': 111, 86 | 'jsonrpc': '2.0', 87 | 'method': 'probe', 88 | 'params': {'data': 'one'}, 89 | }, 90 | None, 91 | None, 92 | ), 93 | ( 94 | 'mw_exception_exit', 95 | 'enter', 96 | { 97 | 'id': 111, 98 | 'jsonrpc': '2.0', 99 | 'method': 'probe', 100 | 'params': {'data': 'one'} 101 | }, 102 | None, 103 | None, 104 | ), 105 | ( 106 | 'mw_last', 107 | 'enter', 108 | { 109 | 'id': 111, 110 | 'jsonrpc': '2.0', 111 | 'method': 'probe', 112 | 'params': {'data': 'one'}, 113 | }, 114 | None, 115 | None, 116 | ), 117 | ( 118 | 'mw_last', 119 | 'exit', 120 | { 121 | 'id': 111, 122 | 'jsonrpc': '2.0', 123 | 'method': 'probe', 124 | 'params': {'data': 'one'}, 125 | }, 126 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 127 | None, 128 | ), 129 | ( 130 | 'mw_exception_exit', 131 | 'exit', 132 | { 133 | 'id': 111, 134 | 'jsonrpc': '2.0', 135 | 'method': 'probe', 136 | 'params': {'data': 'one'} 137 | }, 138 | {'id': 111, 'jsonrpc': '2.0', 'result': 'one'}, 139 | None, 140 | ), 141 | ( 142 | 'mw_first', 143 | 'exit', 144 | { 145 | 'id': 111, 146 | 'jsonrpc': '2.0', 147 | 'method': 'probe', 148 | 'params': {'data': 'one'} 149 | }, 150 | {'id': 111, 'jsonrpc': '2.0', 'error': {'code': -32603, 'message': 'Internal error'}}, 151 | RuntimeError, 152 | ), 153 | ] 154 | } 155 | 156 | assert_log_errors(unique_marker, pytest.raises(RuntimeError)) 157 | -------------------------------------------------------------------------------- /tests/test_notification.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import time 4 | from typing import Dict 5 | 6 | import pytest 7 | from fastapi import Body 8 | 9 | 10 | class ExecutionTracker: 11 | def __init__(self): 12 | self.executions = [] 13 | self.last_execution_time = 0 14 | 15 | def record(self, method_name, delay): 16 | self.executions.append((method_name, delay)) 17 | self.last_execution_time = time.monotonic() 18 | 19 | 20 | @pytest.fixture 21 | def tracker(): 22 | return ExecutionTracker() 23 | 24 | 25 | @pytest.fixture 26 | def ep(ep, tracker): 27 | @ep.method() 28 | async def delayed_method( 29 | delay: float = Body(..., ge=0), 30 | message: str = Body(...), 31 | ) -> dict: 32 | start_time = time.monotonic() 33 | await asyncio.sleep(delay) 34 | tracker.record("delayed_method", delay) 35 | return {"message": message, "execution_time": time.monotonic() - start_time} 36 | 37 | @ep.method() 38 | async def instant_method( 39 | message: str = Body(...), 40 | ) -> Dict[str, str]: 41 | tracker.record("instant_method", 0) 42 | return {"message": message} 43 | 44 | return ep 45 | 46 | 47 | def test_regular_request__no_background(app, json_request, tracker): 48 | start_time = time.monotonic() 49 | delay = 0.5 50 | 51 | # Запрос с ID (синхронный) 52 | response = json_request( 53 | { 54 | "jsonrpc": "2.0", 55 | "method": "delayed_method", 56 | "params": {"delay": delay, "message": "sync request"}, 57 | "id": 1 58 | } 59 | ) 60 | 61 | execution_time = time.monotonic() - start_time 62 | 63 | # Проверяем, что время выполнения больше чем задержка (т.е. запрос ждал завершения) 64 | assert execution_time >= delay 65 | assert response == { 66 | "jsonrpc": "2.0", 67 | "result": { 68 | "message": "sync request", 69 | "execution_time": pytest.approx(delay, abs=0.1) 70 | }, 71 | "id": 1 72 | } 73 | assert len(tracker.executions) == 1 74 | assert tracker.executions[0][0] == "delayed_method" 75 | 76 | 77 | def test_single_request__notification_in_background(app, app_client, tracker, ep_wait_all_requests_done): 78 | start_time = time.monotonic() 79 | delay = 0.5 80 | 81 | # Запрос без ID (уведомление, должен выполниться асинхронно) 82 | response = app_client.post( 83 | "/api/v1/jsonrpc", 84 | json={ 85 | "jsonrpc": "2.0", 86 | "method": "delayed_method", 87 | "params": {"delay": delay, "message": "async notification"} 88 | } 89 | ) 90 | 91 | execution_time = time.monotonic() - start_time 92 | 93 | # Проверяем, что время выполнения меньше чем задержка (т.е. запрос не ждал завершения) 94 | assert execution_time < delay 95 | assert response.status_code == 200 96 | assert response.content == b'' # Пустой ответ для уведомления 97 | 98 | # Ждем, чтобы убедиться что задача завершилась 99 | ep_wait_all_requests_done() 100 | 101 | # Проверяем, что функция действительно была выполнена 102 | assert len(tracker.executions) == 1 103 | assert tracker.executions[0][0] == "delayed_method" 104 | 105 | 106 | def test_batch_request__notification_in_background(app, app_client, tracker, ep_wait_all_requests_done): 107 | start_time = time.monotonic() 108 | delay1 = 0.5 109 | delay2 = 0.3 110 | 111 | # Batch-запрос с обычными запросами и уведомлениями 112 | response = app_client.post( 113 | "/api/v1/jsonrpc", 114 | json=[ 115 | # Обычный запрос 116 | { 117 | "jsonrpc": "2.0", 118 | "method": "delayed_method", 119 | "params": {"delay": delay1, "message": "sync request 1"}, 120 | "id": 1 121 | }, 122 | # Уведомление 123 | { 124 | "jsonrpc": "2.0", 125 | "method": "delayed_method", 126 | "params": {"delay": delay2, "message": "notification 1"} 127 | }, 128 | # Еще один обычный запрос 129 | { 130 | "jsonrpc": "2.0", 131 | "method": "instant_method", 132 | "params": {"message": "sync request 2"}, 133 | "id": 2 134 | }, 135 | # Еще одно уведомление 136 | { 137 | "jsonrpc": "2.0", 138 | "method": "instant_method", 139 | "params": {"message": "notification 2"} 140 | } 141 | ] 142 | ) 143 | 144 | execution_time = time.monotonic() - start_time 145 | 146 | # Проверяем, что время выполнения больше чем максимальная задержка среди обычных запросов 147 | assert execution_time >= delay1 148 | assert response.status_code == 200 149 | 150 | result = response.json() 151 | # В ответе должны быть только запросы с ID 152 | assert len(result) == 2 153 | 154 | # Проверяем содержимое ответов (порядок может быть любым) 155 | result_dict = {item["id"]: item for item in result} 156 | 157 | assert result_dict[1]["jsonrpc"] == "2.0" 158 | assert result_dict[1]["result"]["message"] == "sync request 1" 159 | assert float(result_dict[1]["result"]["execution_time"]) >= delay1 160 | 161 | assert result_dict[2]["jsonrpc"] == "2.0" 162 | assert result_dict[2]["result"]["message"] == "sync request 2" 163 | 164 | # Ждем, чтобы убедиться что все задачи завершились 165 | ep_wait_all_requests_done() 166 | 167 | # Проверяем что все функции действительно были выполнены (всего 4) 168 | assert len(tracker.executions) == 4 169 | 170 | # Проверяем типы выполненных функций (должны быть 2 delayed_method и 2 instant_method) 171 | method_counts = collections.Counter((x[0] for x in tracker.executions)) 172 | 173 | assert method_counts["delayed_method"] == 2 174 | assert method_counts["instant_method"] == 2 -------------------------------------------------------------------------------- /tests/test_openapi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from starlette.testclient import TestClient 5 | 6 | import fastapi_jsonrpc as jsonrpc 7 | from fastapi import Body 8 | from typing import List 9 | 10 | 11 | def test_basic(ep, app, app_client, openapi_compatible): 12 | # noinspection PyUnusedLocal 13 | @ep.method() 14 | def probe( 15 | data: List[str] = Body(..., examples=['111', '222']), 16 | amount: int = Body(..., gt=5, examples=[10]), 17 | ) -> List[int]: 18 | del data, amount 19 | return [1, 2, 3] 20 | 21 | app.bind_entrypoint(ep) 22 | 23 | resp = app_client.get('/openapi.json') 24 | assert resp.json() == openapi_compatible({ 25 | 'components': { 26 | 'schemas': { 27 | 'InternalError': { 28 | 'properties': { 29 | 'code': { 30 | 'default': -32603, 31 | 'example': -32603, 32 | 'title': 'Code', 33 | 'type': 'integer', 34 | }, 35 | 'message': { 36 | 'default': 'Internal error', 37 | 'example': 'Internal error', 38 | 'title': 'Message', 39 | 'type': 'string', 40 | }, 41 | }, 42 | 'title': 'InternalError', 43 | 'type': 'object', 44 | }, 45 | 'InvalidParams': { 46 | 'properties': { 47 | 'code': { 48 | 'default': -32602, 49 | 'example': -32602, 50 | 'title': 'Code', 51 | 'type': 'integer', 52 | }, 53 | 'data': {'anyOf': [ 54 | {'$ref': '#/components/schemas/_ErrorData[_Error]', }, 55 | {'type': 'null'} 56 | ]}, 57 | 'message': { 58 | 'default': 'Invalid params', 59 | 'example': 'Invalid params', 60 | 'title': 'Message', 61 | 'type': 'string', 62 | }, 63 | }, 64 | 'title': 'InvalidParams', 65 | 'type': 'object', 66 | }, 67 | 'InvalidRequest': { 68 | 'properties': { 69 | 'code': { 70 | 'default': -32600, 71 | 'example': -32600, 72 | 'title': 'Code', 73 | 'type': 'integer', 74 | }, 75 | 'data': {'anyOf': [ 76 | {'$ref': '#/components/schemas/_ErrorData[_Error]', }, 77 | {'type': 'null'} 78 | ]}, 79 | 'message': { 80 | 'default': 'Invalid Request', 81 | 'example': 'Invalid Request', 82 | 'title': 'Message', 83 | 'type': 'string', 84 | }, 85 | }, 86 | 'title': 'InvalidRequest', 87 | 'type': 'object', 88 | }, 89 | 'MethodNotFound': { 90 | 'properties': { 91 | 'code': { 92 | 'default': -32601, 93 | 'example': -32601, 94 | 'title': 'Code', 95 | 'type': 'integer', 96 | }, 97 | 'message': { 98 | 'default': 'Method not found', 99 | 'example': 'Method not found', 100 | 'title': 'Message', 101 | 'type': 'string', 102 | }, 103 | }, 104 | 'title': 'MethodNotFound', 105 | 'type': 'object', 106 | }, 107 | 'ParseError': { 108 | 'properties': { 109 | 'code': { 110 | 'default': -32700, 111 | 'example': -32700, 112 | 'title': 'Code', 113 | 'type': 'integer', 114 | }, 115 | 'message': { 116 | 'default': 'Parse error', 117 | 'example': 'Parse error', 118 | 'title': 'Message', 119 | 'type': 'string', 120 | }, 121 | }, 122 | 'title': 'ParseError', 123 | 'type': 'object', 124 | }, 125 | '_Error': { 126 | 'properties': { 127 | 'ctx': { 128 | 'title': 'Ctx', 129 | 'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 130 | }, 131 | 'loc': { 132 | 'items': {'anyOf': [ 133 | {'type': 'string'}, 134 | {'type': 'integer'}, 135 | ]}, 136 | 'title': 'Loc', 137 | 'type': 'array', 138 | }, 139 | 'msg': { 140 | 'title': 'Msg', 141 | 'type': 'string', 142 | }, 143 | 'type': { 144 | 'title': 'Type', 145 | 'type': 'string', 146 | }, 147 | }, 148 | 'required': ['loc', 'msg', 'type'], 149 | 'title': '_Error', 150 | 'type': 'object', 151 | }, 152 | '_ErrorData[_Error]': { 153 | 'properties': { 154 | 'errors': { 155 | 'anyOf': [ 156 | {'items': {'$ref': '#/components/schemas/_Error'}, 'type': 'array'}, 157 | {'type': 'null'} 158 | ], 159 | 'title': 'Errors', 160 | }, 161 | }, 162 | 'title': '_ErrorData[_Error]', 163 | 'type': 'object', 164 | }, 165 | '_ErrorResponse[InternalError]': { 166 | 'additionalProperties': False, 167 | 'properties': { 168 | 'error': { 169 | '$ref': '#/components/schemas/InternalError', 170 | }, 171 | 'id': { 172 | 'anyOf': [ 173 | { 174 | 'type': 'string', 175 | }, 176 | { 177 | 'type': 'integer', 178 | }, 179 | ], 180 | 'example': 0, 181 | 'title': 'Id', 182 | }, 183 | 'jsonrpc': { 184 | 'const': '2.0', 185 | 'default': '2.0', 186 | 'example': '2.0', 187 | 'title': 'Jsonrpc', 188 | 'type': 'string', 189 | }, 190 | }, 191 | 'required': ['error'], 192 | 'title': '_ErrorResponse[InternalError]', 193 | 'type': 'object', 194 | }, 195 | '_ErrorResponse[InvalidParams]': { 196 | 'additionalProperties': False, 197 | 'properties': { 198 | 'error': { 199 | '$ref': '#/components/schemas/InvalidParams', 200 | }, 201 | 'id': { 202 | 'anyOf': [ 203 | { 204 | 'type': 'string', 205 | }, 206 | { 207 | 'type': 'integer', 208 | }, 209 | ], 210 | 'example': 0, 211 | 'title': 'Id', 212 | }, 213 | 'jsonrpc': { 214 | 'const': '2.0', 215 | 'default': '2.0', 216 | 'example': '2.0', 217 | 'title': 'Jsonrpc', 218 | 'type': 'string', 219 | }, 220 | }, 221 | 'required': ['error'], 222 | 'title': '_ErrorResponse[InvalidParams]', 223 | 'type': 'object', 224 | }, 225 | '_ErrorResponse[InvalidRequest]': { 226 | 'additionalProperties': False, 227 | 'properties': { 228 | 'error': { 229 | '$ref': '#/components/schemas/InvalidRequest', 230 | }, 231 | 'id': { 232 | 'anyOf': [ 233 | { 234 | 'type': 'string', 235 | }, 236 | { 237 | 'type': 'integer', 238 | }, 239 | ], 240 | 'example': 0, 241 | 'title': 'Id', 242 | }, 243 | 'jsonrpc': { 244 | 'const': '2.0', 245 | 'default': '2.0', 246 | 'example': '2.0', 247 | 'title': 'Jsonrpc', 248 | 'type': 'string', 249 | }, 250 | }, 251 | 'required': ['error'], 252 | 'title': '_ErrorResponse[InvalidRequest]', 253 | 'type': 'object', 254 | }, 255 | '_ErrorResponse[MethodNotFound]': { 256 | 'additionalProperties': False, 257 | 'properties': { 258 | 'error': { 259 | '$ref': '#/components/schemas/MethodNotFound', 260 | }, 261 | 'id': { 262 | 'anyOf': [ 263 | { 264 | 'type': 'string', 265 | }, 266 | { 267 | 'type': 'integer', 268 | }, 269 | ], 270 | 'example': 0, 271 | 'title': 'Id', 272 | }, 273 | 'jsonrpc': { 274 | 'const': '2.0', 275 | 'default': '2.0', 276 | 'example': '2.0', 277 | 'title': 'Jsonrpc', 278 | 'type': 'string', 279 | }, 280 | }, 281 | 'required': ['error'], 282 | 'title': '_ErrorResponse[MethodNotFound]', 283 | 'type': 'object', 284 | }, 285 | '_ErrorResponse[ParseError]': { 286 | 'additionalProperties': False, 287 | 'properties': { 288 | 'error': { 289 | '$ref': '#/components/schemas/ParseError', 290 | }, 291 | 'id': { 292 | 'anyOf': [ 293 | { 294 | 'type': 'string', 295 | }, 296 | { 297 | 'type': 'integer', 298 | }, 299 | ], 300 | 'example': 0, 301 | 'title': 'Id', 302 | }, 303 | 'jsonrpc': { 304 | 'const': '2.0', 305 | 'default': '2.0', 306 | 'example': '2.0', 307 | 'title': 'Jsonrpc', 308 | 'type': 'string', 309 | }, 310 | }, 311 | 'required': ['error'], 312 | 'title': '_ErrorResponse[ParseError]', 313 | 'type': 'object', 314 | }, 315 | '_Params[probe]': { 316 | 'properties': { 317 | 'amount': { 318 | 'examples': [10], 319 | 'exclusiveMinimum': 5.0, 320 | 'title': 'Amount', 321 | 'type': 'integer', 322 | }, 323 | 'data': { 324 | 'examples': ['111', '222'], 325 | 'items': { 326 | 'type': 'string', 327 | }, 328 | 'title': 'Data', 329 | 'type': 'array', 330 | }, 331 | }, 332 | 'required': ['data', 'amount'], 333 | 'title': '_Params[probe]', 334 | 'type': 'object', 335 | }, 336 | '_Request': { 337 | 'additionalProperties': False, 338 | 'properties': { 339 | 'id': { 340 | 'anyOf': [ 341 | { 342 | 'type': 'string', 343 | }, 344 | { 345 | 'type': 'integer', 346 | }, 347 | ], 348 | 'example': 0, 349 | 'title': 'Id', 350 | }, 351 | 'jsonrpc': { 352 | 'const': '2.0', 353 | 'default': '2.0', 354 | 'example': '2.0', 355 | 'title': 'Jsonrpc', 356 | 'type': 'string', 357 | }, 358 | 'method': { 359 | 'title': 'Method', 360 | 'type': 'string', 361 | }, 362 | 'params': { 363 | 'additionalProperties': True, 364 | 'title': 'Params', 365 | 'type': 'object', 366 | }, 367 | }, 368 | 'required': ['method'], 369 | 'title': '_Request', 370 | 'type': 'object', 371 | }, 372 | '_Request[probe]': { 373 | 'additionalProperties': False, 374 | 'properties': { 375 | 'id': { 376 | 'anyOf': [ 377 | { 378 | 'type': 'string', 379 | }, 380 | { 381 | 'type': 'integer', 382 | }, 383 | ], 384 | 'example': 0, 385 | 'title': 'Id', 386 | }, 387 | 'jsonrpc': { 388 | 'const': '2.0', 389 | 'default': '2.0', 390 | 'example': '2.0', 391 | 'title': 'Jsonrpc', 392 | 'type': 'string', 393 | }, 394 | 'method': { 395 | 'default': 'probe', 396 | 'example': 'probe', 397 | 'title': 'Method', 398 | 'type': 'string', 399 | }, 400 | 'params': { 401 | '$ref': '#/components/schemas/_Params[probe]', 402 | }, 403 | }, 404 | 'required': ['params'], 405 | 'title': '_Request[probe]', 406 | 'type': 'object', 407 | }, 408 | '_Response': { 409 | 'additionalProperties': False, 410 | 'properties': { 411 | 'id': { 412 | 'anyOf': [ 413 | { 414 | 'type': 'string', 415 | }, 416 | { 417 | 'type': 'integer', 418 | }, 419 | ], 420 | 'example': 0, 421 | 'title': 'Id', 422 | }, 423 | 'jsonrpc': { 424 | 'const': '2.0', 425 | 'default': '2.0', 426 | 'example': '2.0', 427 | 'title': 'Jsonrpc', 428 | 'type': 'string', 429 | }, 430 | 'result': { 431 | 'additionalProperties': True, 432 | 'title': 'Result', 433 | 'type': 'object', 434 | }, 435 | }, 436 | 'required': ['jsonrpc', 'id', 'result'], 437 | 'title': '_Response', 438 | 'type': 'object', 439 | }, 440 | '_Response[probe]': { 441 | 'additionalProperties': False, 442 | 'properties': { 443 | 'id': { 444 | 'anyOf': [ 445 | { 446 | 'type': 'string', 447 | }, 448 | { 449 | 'type': 'integer', 450 | }, 451 | ], 452 | 'example': 0, 453 | 'title': 'Id', 454 | }, 455 | 'jsonrpc': { 456 | 'const': '2.0', 457 | 'default': '2.0', 458 | 'example': '2.0', 459 | 'title': 'Jsonrpc', 460 | 'type': 'string', 461 | }, 462 | 'result': { 463 | 'items': { 464 | 'type': 'integer', 465 | }, 466 | 'title': 'Result', 467 | 'type': 'array', 468 | }, 469 | }, 470 | 'required': ['jsonrpc', 'id', 'result'], 471 | 'title': '_Response[probe]', 472 | 'type': 'object', 473 | }, 474 | }, 475 | }, 476 | 'info': { 477 | 'title': 'FastAPI', 'version': '0.1.0', 478 | }, 479 | 'openapi': '3.0.2', 480 | 'paths': { 481 | '/api/v1/jsonrpc': { 482 | 'post': { 483 | 'operationId': 'entrypoint_api_v1_jsonrpc_post', 484 | 'requestBody': { 485 | 'content': { 486 | 'application/json': { 487 | 'schema': { 488 | '$ref': '#/components/schemas/_Request', 489 | }, 490 | }, 491 | }, 492 | 'required': True, 493 | }, 494 | 'responses': { 495 | '200': { 496 | 'content': { 497 | 'application/json': { 498 | 'schema': { 499 | '$ref': '#/components/schemas/_Response', 500 | }, 501 | }, 502 | }, 503 | 'description': 'Successful Response', 504 | }, 505 | '210': { 506 | 'content': { 507 | 'application/json': { 508 | 'schema': { 509 | '$ref': '#/components/schemas/_ErrorResponse[InvalidParams]', 510 | }, 511 | }, 512 | }, 513 | 'description': '[-32602] Invalid params\n\nInvalid method parameter(s)', 514 | }, 515 | '211': { 516 | 'content': { 517 | 'application/json': { 518 | 'schema': { 519 | '$ref': '#/components/schemas/_ErrorResponse[MethodNotFound]', 520 | }, 521 | }, 522 | }, 523 | 'description': '[-32601] Method not found\n\nThe method does not exist / is not available', 524 | }, 525 | '212': { 526 | 'content': { 527 | 'application/json': { 528 | 'schema': { 529 | '$ref': '#/components/schemas/_ErrorResponse[ParseError]', 530 | }, 531 | }, 532 | }, 533 | 'description': '[-32700] Parse error\n\nInvalid JSON was received by the server', 534 | }, 535 | '213': { 536 | 'content': { 537 | 'application/json': { 538 | 'schema': { 539 | '$ref': '#/components/schemas/_ErrorResponse[InvalidRequest]', 540 | }, 541 | }, 542 | }, 543 | 'description': '[-32600] Invalid Request\n\nThe JSON sent is not a valid Request object', 544 | }, 545 | '214': { 546 | 'content': { 547 | 'application/json': { 548 | 'schema': { 549 | '$ref': '#/components/schemas/_ErrorResponse[InternalError]', 550 | }, 551 | }, 552 | }, 553 | 'description': '[-32603] Internal error\n\nInternal JSON-RPC error', 554 | }, 555 | }, 556 | 'summary': 'Entrypoint', 557 | }, 558 | }, 559 | '/api/v1/jsonrpc/probe': { 560 | 'post': { 561 | 'operationId': 'probe_api_v1_jsonrpc_probe_post', 562 | 'requestBody': { 563 | 'content': { 564 | 'application/json': { 565 | 'schema': { 566 | '$ref': '#/components/schemas/_Request[probe]', 567 | }, 568 | }, 569 | }, 570 | 'required': True, 571 | }, 572 | 'responses': { 573 | '200': { 574 | 'content': { 575 | 'application/json': { 576 | 'schema': { 577 | '$ref': '#/components/schemas/_Response[probe]', 578 | }, 579 | }, 580 | }, 581 | 'description': 'Successful Response', 582 | }, 583 | }, 584 | 'summary': 'Probe', 585 | }, 586 | }, 587 | }, 588 | }) 589 | 590 | 591 | @pytest.fixture(params=['uniq-sig', 'same-sig']) 592 | def api_package(request, pytester): 593 | """Create package with structure 594 | api \ 595 | mobile.py 596 | web.py 597 | 598 | mobile.py and web.py has similar content except entrypoint path 599 | """ 600 | 601 | # Re-use our infrastructure layer 602 | try: 603 | pytester.copy_example('tests/conftest.py') 604 | except LookupError: 605 | pytester.copy_example('conftest.py') 606 | 607 | # Create api/web.py and api/mobile.py files with same methods 608 | entrypoint_tpl = """ 609 | from fastapi import Body 610 | from typing import List 611 | 612 | 613 | import fastapi_jsonrpc as jsonrpc 614 | 615 | api_v1 = jsonrpc.Entrypoint( 616 | '{ep_path}', 617 | ) 618 | 619 | @api_v1.method() 620 | def probe( 621 | {unique_param_name}: List[str] = Body(..., examples=['111', '222']), 622 | amount: int = Body(..., gt=5, examples=[10]), 623 | ) -> List[int]: 624 | return [1, 2, 3] 625 | """ 626 | 627 | if request.param == 'uniq-sig': 628 | mobile_param_name = 'mobile_data' 629 | web_param_name = 'web_data' 630 | else: 631 | assert request.param == 'same-sig' 632 | mobile_param_name = web_param_name = 'data' 633 | 634 | api_dir = pytester.mkpydir('api') 635 | mobile_py = api_dir.joinpath('mobile.py') 636 | mobile_py.write_text( 637 | entrypoint_tpl.format( 638 | ep_path='/api/v1/mobile/jsonrpc', 639 | unique_param_name=mobile_param_name, 640 | ), 641 | ) 642 | 643 | web_py = api_dir.joinpath('web.py') 644 | web_py.write_text( 645 | entrypoint_tpl.format( 646 | ep_path='/api/v1/web/jsonrpc', 647 | unique_param_name=web_param_name, 648 | ), 649 | ) 650 | return api_dir 651 | 652 | 653 | def test_component_name_isolated_by_their_path(pytester, api_package): 654 | """Test we can mix methods with same names in one openapi.json schema 655 | """ 656 | 657 | pytester.makepyfile(''' 658 | import pytest 659 | import fastapi_jsonrpc as jsonrpc 660 | 661 | 662 | # override conftest.py `app` fixture 663 | @pytest.fixture 664 | def app(): 665 | from api.web import api_v1 as api_v1_web 666 | from api.mobile import api_v1 as api_v1_mobile 667 | 668 | app = jsonrpc.API() 669 | app.bind_entrypoint(api_v1_web) 670 | app.bind_entrypoint(api_v1_mobile) 671 | return app 672 | 673 | 674 | def test_no_collide(app_client): 675 | resp = app_client.get('/openapi.json') 676 | resp_json = resp.json() 677 | 678 | paths = resp_json['paths'] 679 | schemas = resp_json['components']['schemas'] 680 | 681 | for path in ( 682 | '/api/v1/mobile/jsonrpc/probe', 683 | '/api/v1/web/jsonrpc/probe', 684 | ): 685 | assert path in paths 686 | 687 | # Response model the same and deduplicated 688 | assert '_Response[probe]' in schemas 689 | 690 | if '_Params[probe]' not in schemas: 691 | for component_name in ( 692 | 'api.mobile._Params[probe]', 693 | 'api.mobile._Request[probe]', 694 | 'api.web._Params[probe]', 695 | 'api.web._Request[probe]', 696 | ): 697 | assert component_name in schemas 698 | ''') 699 | 700 | # force reload module to drop component cache 701 | # it's more efficient than use pytest.runpytest_subprocess() 702 | sys.modules.pop('fastapi_jsonrpc') 703 | 704 | result = pytester.runpytest_inprocess() 705 | result.assert_outcomes(passed=1) 706 | 707 | 708 | def test_entrypoint_tags__append_to_method_tags(app, app_client): 709 | tagged_api = jsonrpc.Entrypoint('/tagged-entrypoint', tags=['jsonrpc']) 710 | 711 | @tagged_api.method() 712 | async def not_tagged_method(data: dict) -> dict: 713 | pass 714 | 715 | @tagged_api.method(tags=['method-tag']) 716 | async def tagged_method(data: dict) -> dict: 717 | pass 718 | 719 | app.bind_entrypoint(tagged_api) 720 | 721 | resp = app_client.get('/openapi.json') 722 | resp_json = resp.json() 723 | 724 | assert resp_json['paths']['/tagged-entrypoint']['post']['tags'] == ['jsonrpc'] 725 | assert resp_json['paths']['/tagged-entrypoint/not_tagged_method']['post']['tags'] == ['jsonrpc'] 726 | assert resp_json['paths']['/tagged-entrypoint/tagged_method']['post']['tags'] == ['jsonrpc', 'method-tag'] 727 | 728 | 729 | @pytest.mark.parametrize('fastapi_jsonrpc_components_fine_names', [True, False]) 730 | def test_no_entrypoints__ok(fastapi_jsonrpc_components_fine_names): 731 | app = jsonrpc.API(fastapi_jsonrpc_components_fine_names=fastapi_jsonrpc_components_fine_names) 732 | app_client = TestClient(app) 733 | resp = app_client.get('/openapi.json') 734 | resp.raise_for_status() 735 | assert resp.status_code == 200 -------------------------------------------------------------------------------- /tests/test_openapi_dependencies.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import ANY 2 | 3 | import pytest 4 | from fastapi import Body, Depends, Header 5 | from typing import List 6 | 7 | import fastapi_jsonrpc as jsonrpc 8 | 9 | 10 | def get_auth_token( 11 | auth_token: str = Header(..., alias='auth-token'), 12 | ) -> str: 13 | return auth_token 14 | 15 | 16 | def get_common_dep( 17 | common_dep: float = Body(...), 18 | ) -> float: 19 | return common_dep 20 | 21 | 22 | @pytest.fixture 23 | def ep(ep_path): 24 | ep = jsonrpc.Entrypoint( 25 | ep_path, 26 | dependencies=[Depends(get_auth_token)], 27 | common_dependencies=[Depends(get_common_dep)], 28 | ) 29 | 30 | @ep.method() 31 | def probe( 32 | data: List[str] = Body(..., examples=['111', '222']), 33 | ) -> List[int]: 34 | del data 35 | return [1, 2, 3] 36 | 37 | def get_probe2_dep( 38 | probe2_dep: int = Body(...), 39 | ) -> int: 40 | return probe2_dep 41 | 42 | @ep.method() 43 | def probe2( 44 | auth_token: str = Depends(get_auth_token), 45 | probe2_dep: int = Depends(get_probe2_dep), 46 | ) -> int: 47 | del auth_token 48 | del probe2_dep 49 | return 1 50 | 51 | return ep 52 | 53 | 54 | def test_basic(app_client, openapi_compatible): 55 | resp = app_client.get('/openapi.json') 56 | assert resp.json() == openapi_compatible({ 57 | 'components': { 58 | 'schemas': { 59 | 'InternalError': { 60 | 'properties': { 61 | 'code': { 62 | 'default': -32603, 63 | 'example': -32603, 64 | 'title': 'Code', 65 | 'type': 'integer', 66 | }, 67 | 'message': { 68 | 'default': 'Internal error', 69 | 'example': 'Internal error', 70 | 'title': 'Message', 71 | 'type': 'string', 72 | }, 73 | }, 74 | 'title': 'InternalError', 75 | 'type': 'object', 76 | }, 77 | 'InvalidParams': { 78 | 'properties': { 79 | 'code': { 80 | 'default': -32602, 81 | 'example': -32602, 82 | 'title': 'Code', 83 | 'type': 'integer', 84 | }, 85 | 'data': {'anyOf': [ 86 | {'$ref': '#/components/schemas/_ErrorData[_Error]', }, 87 | {'type': 'null'} 88 | ]}, 89 | 'message': { 90 | 'default': 'Invalid params', 91 | 'example': 'Invalid params', 92 | 'title': 'Message', 93 | 'type': 'string', 94 | }, 95 | }, 96 | 'title': 'InvalidParams', 97 | 'type': 'object', 98 | }, 99 | 'InvalidRequest': { 100 | 'properties': { 101 | 'code': { 102 | 'default': -32600, 103 | 'example': -32600, 104 | 'title': 'Code', 105 | 'type': 'integer', 106 | }, 107 | 'data': {'anyOf': [ 108 | {'$ref': '#/components/schemas/_ErrorData[_Error]', }, 109 | {'type': 'null'} 110 | ]}, 111 | 'message': { 112 | 'default': 'Invalid Request', 113 | 'example': 'Invalid Request', 114 | 'title': 'Message', 115 | 'type': 'string', 116 | }, 117 | }, 118 | 'title': 'InvalidRequest', 119 | 'type': 'object', 120 | }, 121 | 'MethodNotFound': { 122 | 'properties': { 123 | 'code': { 124 | 'default': -32601, 125 | 'example': -32601, 126 | 'title': 'Code', 127 | 'type': 'integer', 128 | }, 129 | 'message': { 130 | 'default': 'Method not found', 131 | 'example': 'Method not found', 132 | 'title': 'Message', 133 | 'type': 'string', 134 | }, 135 | }, 136 | 'title': 'MethodNotFound', 137 | 'type': 'object', 138 | }, 139 | 'ParseError': { 140 | 'properties': { 141 | 'code': { 142 | 'default': -32700, 143 | 'example': -32700, 144 | 'title': 'Code', 145 | 'type': 'integer', 146 | }, 147 | 'message': { 148 | 'default': 'Parse error', 149 | 'example': 'Parse error', 150 | 'title': 'Message', 151 | 'type': 'string', 152 | }, 153 | }, 154 | 'title': 'ParseError', 155 | 'type': 'object', 156 | }, 157 | '_Error': { 158 | 'properties': { 159 | 'ctx': { 160 | 'title': 'Ctx', 161 | 'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 162 | }, 163 | 'loc': { 164 | 'items': {'anyOf': [ 165 | {'type': 'string'}, 166 | {'type': 'integer'}, 167 | ]}, 168 | 'title': 'Loc', 169 | 'type': 'array', 170 | }, 171 | 'msg': { 172 | 'title': 'Msg', 173 | 'type': 'string', 174 | }, 175 | 'type': { 176 | 'title': 'Type', 177 | 'type': 'string', 178 | }, 179 | }, 180 | 'required': ['loc', 'msg', 'type'], 181 | 'title': '_Error', 182 | 'type': 'object', 183 | }, 184 | '_ErrorData[_Error]': { 185 | 'properties': { 186 | 'errors': { 187 | 'anyOf': [ 188 | {'items': {'$ref': '#/components/schemas/_Error'}, 'type': 'array'}, 189 | {'type': 'null'} 190 | ], 191 | 'title': 'Errors', 192 | }, 193 | }, 194 | 'title': '_ErrorData[_Error]', 195 | 'type': 'object', 196 | }, 197 | '_ErrorResponse[InternalError]': { 198 | 'additionalProperties': False, 199 | 'properties': { 200 | 'error': { 201 | '$ref': '#/components/schemas/InternalError', 202 | }, 203 | 'id': { 204 | 'anyOf': [ 205 | { 206 | 'type': 'string', 207 | }, 208 | { 209 | 'type': 'integer', 210 | }, 211 | ], 212 | 'example': 0, 213 | 'title': 'Id', 214 | }, 215 | 'jsonrpc': { 216 | 'const': '2.0', 217 | 'default': '2.0', 218 | 'example': '2.0', 219 | 'title': 'Jsonrpc', 220 | 'type': 'string', 221 | }, 222 | }, 223 | 'required': ['error'], 224 | 'title': '_ErrorResponse[InternalError]', 225 | 'type': 'object', 226 | }, 227 | '_ErrorResponse[InvalidParams]': { 228 | 'additionalProperties': False, 229 | 'properties': { 230 | 'error': { 231 | '$ref': '#/components/schemas/InvalidParams', 232 | }, 233 | 'id': { 234 | 'anyOf': [ 235 | { 236 | 'type': 'string', 237 | }, 238 | { 239 | 'type': 'integer', 240 | }, 241 | ], 242 | 'example': 0, 243 | 'title': 'Id', 244 | }, 245 | 'jsonrpc': { 246 | 'const': '2.0', 247 | 'default': '2.0', 248 | 'example': '2.0', 249 | 'title': 'Jsonrpc', 250 | 'type': 'string', 251 | }, 252 | }, 253 | 'required': ['error'], 254 | 'title': '_ErrorResponse[InvalidParams]', 255 | 'type': 'object', 256 | }, 257 | '_ErrorResponse[InvalidRequest]': { 258 | 'additionalProperties': False, 259 | 'properties': { 260 | 'error': { 261 | '$ref': '#/components/schemas/InvalidRequest', 262 | }, 263 | 'id': { 264 | 'anyOf': [ 265 | { 266 | 'type': 'string', 267 | }, 268 | { 269 | 'type': 'integer', 270 | }, 271 | ], 272 | 'example': 0, 273 | 'title': 'Id', 274 | }, 275 | 'jsonrpc': { 276 | 'const': '2.0', 277 | 'default': '2.0', 278 | 'example': '2.0', 279 | 'title': 'Jsonrpc', 280 | 'type': 'string', 281 | }, 282 | }, 283 | 'required': ['error'], 284 | 'title': '_ErrorResponse[InvalidRequest]', 285 | 'type': 'object', 286 | }, 287 | '_ErrorResponse[MethodNotFound]': { 288 | 'additionalProperties': False, 289 | 'properties': { 290 | 'error': { 291 | '$ref': '#/components/schemas/MethodNotFound', 292 | }, 293 | 'id': { 294 | 'anyOf': [ 295 | { 296 | 'type': 'string', 297 | }, 298 | { 299 | 'type': 'integer', 300 | }, 301 | ], 302 | 'example': 0, 303 | 'title': 'Id', 304 | }, 305 | 'jsonrpc': { 306 | 'const': '2.0', 307 | 'default': '2.0', 308 | 'example': '2.0', 309 | 'title': 'Jsonrpc', 310 | 'type': 'string', 311 | }, 312 | }, 313 | 'required': ['error'], 314 | 'title': '_ErrorResponse[MethodNotFound]', 315 | 'type': 'object', 316 | }, 317 | '_ErrorResponse[ParseError]': { 318 | 'additionalProperties': False, 319 | 'properties': { 320 | 'error': { 321 | '$ref': '#/components/schemas/ParseError', 322 | }, 323 | 'id': { 324 | 'anyOf': [ 325 | { 326 | 'type': 'string', 327 | }, 328 | { 329 | 'type': 'integer', 330 | }, 331 | ], 332 | 'example': 0, 333 | 'title': 'Id', 334 | }, 335 | 'jsonrpc': { 336 | 'const': '2.0', 337 | 'default': '2.0', 338 | 'example': '2.0', 339 | 'title': 'Jsonrpc', 340 | 'type': 'string', 341 | }, 342 | }, 343 | 'required': ['error'], 344 | 'title': '_ErrorResponse[ParseError]', 345 | 'type': 'object', 346 | }, 347 | '_Params[entrypoint]': { 348 | 'properties': { 349 | 'common_dep': { 350 | 'title': 'Common Dep', 351 | 'type': 'number', 352 | } 353 | }, 354 | 'required': ['common_dep'], 355 | 'title': '_Params[entrypoint]', 356 | 'type': 'object', 357 | }, 358 | '_Params[probe2]': { 359 | 'properties': { 360 | 'common_dep': { 361 | 'title': 'Common Dep', 362 | 'type': 'number', 363 | }, 364 | 'probe2_dep': { 365 | 'title': 'Probe2 Dep', 366 | 'type': 'integer', 367 | }, 368 | }, 369 | 'required': ['common_dep', 'probe2_dep'], 370 | 'title': '_Params[probe2]', 371 | 'type': 'object', 372 | }, 373 | '_Params[probe]': { 374 | 'properties': { 375 | 'common_dep': { 376 | 'title': 'Common Dep', 377 | 'type': 'number', 378 | }, 379 | 'data': { 380 | 'examples': ['111', '222'], 381 | 'items': { 382 | 'type': 'string', 383 | }, 384 | 'title': 'Data', 385 | 'type': 'array', 386 | }, 387 | }, 388 | 'required': ['data', 'common_dep'], 389 | 'title': '_Params[probe]', 390 | 'type': 'object', 391 | }, 392 | '_Request[entrypoint]': { 393 | 'additionalProperties': False, 394 | 'properties': { 395 | 'id': { 396 | 'anyOf': [{'type': 'string'}, {'type': 'integer'}], 397 | 'example': 0, 398 | 'title': 'Id', 399 | }, 400 | 'jsonrpc': { 401 | 'const': '2.0', 402 | 'default': '2.0', 403 | 'example': '2.0', 404 | 'title': 'Jsonrpc', 405 | 'type': 'string', 406 | }, 407 | 'method': { 408 | 'default': 'entrypoint', 409 | 'example': 'entrypoint', 410 | 'title': 'Method', 411 | 'type': 'string', 412 | }, 413 | 'params': {'$ref': '#/components/schemas/_Params[entrypoint]'} 414 | }, 415 | 'required': ['params'], 416 | 'title': '_Request[entrypoint]', 417 | 'type': 'object', 418 | }, 419 | '_Request[probe2]': { 420 | 'additionalProperties': False, 421 | 'properties': { 422 | 'id': { 423 | 'anyOf': [{'type': 'string'}, {'type': 'integer'}], 424 | 'example': 0, 425 | 'title': 'Id', 426 | }, 427 | 'jsonrpc': { 428 | 'const': '2.0', 429 | 'default': '2.0', 430 | 'example': '2.0', 431 | 'title': 'Jsonrpc', 432 | 'type': 'string', 433 | }, 434 | 'method': { 435 | 'default': 'probe2', 436 | 'example': 'probe2', 437 | 'title': 'Method', 438 | 'type': 'string', 439 | }, 440 | 'params': {'$ref': '#/components/schemas/_Params[probe2]'}, 441 | }, 442 | 'required': ['params'], 443 | 'title': '_Request[probe2]', 444 | 'type': 'object', 445 | }, 446 | '_Request[probe]': { 447 | 'additionalProperties': False, 448 | 'properties': { 449 | 'id': { 450 | 'anyOf': [ 451 | { 452 | 'type': 'string', 453 | }, 454 | { 455 | 'type': 'integer', 456 | }, 457 | ], 458 | 'example': 0, 459 | 'title': 'Id', 460 | }, 461 | 'jsonrpc': { 462 | 'const': '2.0', 463 | 'default': '2.0', 464 | 'example': '2.0', 465 | 'title': 'Jsonrpc', 466 | 'type': 'string', 467 | }, 468 | 'method': { 469 | 'default': 'probe', 470 | 'example': 'probe', 471 | 'title': 'Method', 472 | 'type': 'string', 473 | }, 474 | 'params': { 475 | '$ref': '#/components/schemas/_Params[probe]', 476 | }, 477 | }, 478 | 'required': ['params'], 479 | 'title': '_Request[probe]', 480 | 'type': 'object', 481 | }, 482 | '_Response': { 483 | 'additionalProperties': False, 484 | 'properties': { 485 | 'id': { 486 | 'anyOf': [ 487 | { 488 | 'type': 'string', 489 | }, 490 | { 491 | 'type': 'integer', 492 | }, 493 | ], 494 | 'example': 0, 495 | 'title': 'Id', 496 | }, 497 | 'jsonrpc': { 498 | 'const': '2.0', 499 | 'default': '2.0', 500 | 'example': '2.0', 501 | 'title': 'Jsonrpc', 502 | 'type': 'string', 503 | }, 504 | 'result': { 505 | 'additionalProperties': True, 506 | 'title': 'Result', 507 | 'type': 'object', 508 | }, 509 | }, 510 | 'required': ['jsonrpc', 'id', 'result'], 511 | 'title': '_Response', 512 | 'type': 'object', 513 | }, 514 | '_Response[probe2]': { 515 | 'additionalProperties': False, 516 | 'properties': { 517 | 'id': { 518 | 'anyOf': [{'type': 'string'}, {'type': 'integer'}], 519 | 'example': 0, 520 | 'title': 'Id', 521 | }, 522 | 'jsonrpc': { 523 | 'const': '2.0', 524 | 'default': '2.0', 525 | 'example': '2.0', 526 | 'title': 'Jsonrpc', 527 | 'type': 'string', 528 | }, 529 | 'result': { 530 | 'title': 'Result', 531 | 'type': 'integer', 532 | } 533 | }, 534 | 'required': ['jsonrpc', 'id', 'result'], 535 | 'title': '_Response[probe2]', 536 | 'type': 'object', 537 | }, 538 | '_Response[probe]': { 539 | 'additionalProperties': False, 540 | 'properties': { 541 | 'id': { 542 | 'anyOf': [ 543 | { 544 | 'type': 'string', 545 | }, 546 | { 547 | 'type': 'integer', 548 | }, 549 | ], 550 | 'example': 0, 551 | 'title': 'Id', 552 | }, 553 | 'jsonrpc': { 554 | 'const': '2.0', 555 | 'default': '2.0', 556 | 'example': '2.0', 557 | 'title': 'Jsonrpc', 558 | 'type': 'string', 559 | }, 560 | 'result': { 561 | 'items': { 562 | 'type': 'integer', 563 | }, 564 | 'title': 'Result', 565 | 'type': 'array', 566 | }, 567 | }, 568 | 'required': ['jsonrpc', 'id', 'result'], 569 | 'title': '_Response[probe]', 570 | 'type': 'object', 571 | }, 572 | }, 573 | }, 574 | 'info': { 575 | 'title': 'FastAPI', 'version': '0.1.0', 576 | }, 577 | 'openapi': '3.0.2', 578 | 'paths': { 579 | '/api/v1/jsonrpc': { 580 | 'post': { 581 | 'operationId': 'entrypoint_api_v1_jsonrpc_post', 582 | 'parameters': [{ 583 | 'in': 'header', 584 | 'name': 'auth-token', 585 | 'required': True, 586 | 'schema': { 587 | 'title': 'Auth-Token', 588 | 'type': 'string', 589 | }, 590 | }], 591 | 'requestBody': { 592 | 'content': { 593 | 'application/json': { 594 | 'schema': { 595 | '$ref': '#/components/schemas/_Request[entrypoint]', 596 | }, 597 | }, 598 | }, 599 | 'required': True, 600 | }, 601 | 'responses': ANY, 602 | 'summary': 'Entrypoint', 603 | }, 604 | }, 605 | '/api/v1/jsonrpc/probe': { 606 | 'post': { 607 | 'operationId': 'probe_api_v1_jsonrpc_probe_post', 608 | 'requestBody': { 609 | 'content': { 610 | 'application/json': { 611 | 'schema': { 612 | '$ref': '#/components/schemas/_Request[probe]', 613 | }, 614 | }, 615 | }, 616 | 'required': True, 617 | }, 618 | 'responses': { 619 | '200': { 620 | 'content': { 621 | 'application/json': { 622 | 'schema': { 623 | '$ref': '#/components/schemas/_Response[probe]', 624 | }, 625 | }, 626 | }, 627 | 'description': 'Successful Response', 628 | }, 629 | }, 630 | 'summary': 'Probe', 631 | }, 632 | }, 633 | '/api/v1/jsonrpc/probe2': { 634 | 'post': { 635 | 'operationId': 'probe2_api_v1_jsonrpc_probe2_post', 636 | 'parameters': [{ 637 | 'in': 'header', 638 | 'name': 'auth-token', 639 | 'required': True, 640 | 'schema': { 641 | 'title': 'Auth-Token', 642 | 'type': 'string', 643 | }, 644 | }], 645 | 'requestBody': { 646 | 'content': {'application/json': {'schema': {'$ref': '#/components/schemas/_Request[probe2]'}}}, 647 | 'required': True, 648 | }, 649 | 'responses': { 650 | '200': { 651 | 'content': { 652 | 'application/json': {'schema': {'$ref': '#/components/schemas/_Response[probe2]'}}, 653 | }, 654 | 'description': 'Successful Response', 655 | } 656 | }, 657 | 'summary': 'Probe2', 658 | }, 659 | }, 660 | }, 661 | }) 662 | -------------------------------------------------------------------------------- /tests/test_openrpc.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | import pytest 4 | from starlette.testclient import TestClient 5 | 6 | import fastapi_jsonrpc as jsonrpc 7 | from fastapi import Body 8 | from pydantic import BaseModel, Field, ConfigDict 9 | 10 | 11 | def test_basic(ep, app, app_client): 12 | @ep.method() 13 | def probe( 14 | data: List[str] = Body(..., examples=['111', '222']), 15 | amount: int = Body(..., gt=5, examples=[10]), 16 | ) -> List[int]: 17 | del data, amount 18 | return [1, 2, 3] 19 | 20 | app.bind_entrypoint(ep) 21 | 22 | resp = app_client.get('/openrpc.json') 23 | 24 | assert resp.json()['methods'] == [ 25 | { 26 | 'name': 'probe', 27 | 'params': [ 28 | { 29 | 'name': 'data', 30 | 'schema': { 31 | 'title': 'Data', 32 | 'examples': [ 33 | '111', 34 | '222' 35 | ], 36 | 'type': 'array', 37 | 'items': { 38 | 'type': 'string' 39 | } 40 | }, 41 | 'required': True 42 | }, 43 | { 44 | 'name': 'amount', 45 | 'schema': { 46 | 'title': 'Amount', 47 | 'exclusiveMinimum': 5, 48 | 'examples': [10], 49 | 'type': 'integer' 50 | }, 51 | 'required': True 52 | } 53 | ], 54 | 'result': { 55 | 'name': 'probe_Result', 56 | 'schema': { 57 | 'title': 'Result', 58 | 'type': 'array', 59 | 'items': { 60 | 'type': 'integer' 61 | } 62 | } 63 | }, 64 | 'tags': [], 65 | 'errors': [] 66 | } 67 | ] 68 | 69 | 70 | def test_info_block(app, app_client): 71 | app.title = 'Test App' 72 | app.version = '1.2.3' 73 | app.servers = [{'test': 'https://test.dev'}] 74 | 75 | resp = app_client.get('/openrpc.json') 76 | 77 | assert resp.json() == { 78 | 'openrpc': '1.2.6', 79 | 'info': { 80 | 'version': app.version, 81 | 'title': app.title, 82 | }, 83 | 'servers': app.servers, 84 | 'methods': [], 85 | 'components': { 86 | 'schemas': {}, 87 | 'errors': {}, 88 | } 89 | } 90 | 91 | 92 | def test_component_schemas(ep, app, app_client): 93 | class Input(BaseModel): 94 | x: int = Field( 95 | ..., 96 | title='x', 97 | description='X field', 98 | gt=1, 99 | lt=10, 100 | multiple_of=3, 101 | ) 102 | y: Optional[str] = Field( 103 | None, 104 | alias='Y', 105 | min_length=1, 106 | max_length=5, 107 | pattern=r'^[a-z]{4}$', 108 | ) 109 | model_config = ConfigDict(extra='forbid') 110 | 111 | class Output(BaseModel): 112 | result: List[int] = Field( 113 | ..., 114 | min_length=1, 115 | max_length=10, 116 | ) 117 | 118 | @ep.method() 119 | def my_method(inp: Input) -> Output: 120 | return Output(result=[inp.x]) 121 | 122 | app.bind_entrypoint(ep) 123 | 124 | resp = app_client.get('/openrpc.json') 125 | schema = resp.json() 126 | 127 | assert len(schema['methods']) == 1 128 | assert schema['methods'][0]['params'] == [ 129 | { 130 | 'name': 'inp', 131 | 'schema': { 132 | '$ref': '#/components/schemas/Input' 133 | }, 134 | 'required': True 135 | } 136 | ] 137 | assert schema['methods'][0]['result'] == { 138 | 'name': 'my_method_Result', 139 | 'schema': { 140 | '$ref': '#/components/schemas/Output' 141 | } 142 | } 143 | 144 | assert schema['components']['schemas'] == { 145 | 'Input': { 146 | 'title': 'Input', 147 | 'type': 'object', 148 | 'properties': { 149 | 'x': { 150 | 'title': 'x', 151 | 'description': 'X field', 152 | 'exclusiveMinimum': 1, 153 | 'exclusiveMaximum': 10, 154 | 'multipleOf': 3, 155 | 'type': 'integer' 156 | }, 157 | 'Y': { 158 | 'anyOf': [ 159 | { 160 | 'maxLength': 5, 161 | 'minLength': 1, 162 | 'pattern': '^[a-z]{4}$', 163 | 'type': 'string' 164 | }, 165 | {'type': 'null'} 166 | ], 167 | 'default': None, 168 | 'title': 'Y' 169 | } 170 | }, 171 | 'required': ['x'], 172 | 'additionalProperties': False 173 | }, 174 | 'Output': { 175 | 'title': 'Output', 176 | 'type': 'object', 177 | 'properties': { 178 | 'result': { 179 | 'title': 'Result', 180 | 'minItems': 1, 181 | 'maxItems': 10, 182 | 'type': 'array', 183 | 'items': { 184 | 'type': 'integer' 185 | } 186 | } 187 | }, 188 | 'required': ['result'] 189 | } 190 | } 191 | 192 | 193 | def test_tags(ep, app, app_client): 194 | @ep.method(tags=['tag1', 'tag2']) 195 | def my_method__with_tags() -> None: 196 | return None 197 | 198 | app.bind_entrypoint(ep) 199 | 200 | resp = app_client.get('/openrpc.json') 201 | schema = resp.json() 202 | 203 | assert len(schema['methods']) == 1 204 | assert schema['methods'][0]['tags'] == [ 205 | {'name': 'tag1'}, 206 | {'name': 'tag2'}, 207 | ] 208 | 209 | 210 | def test_errors(ep, app, app_client): 211 | class MyError(jsonrpc.BaseError): 212 | CODE = 5000 213 | MESSAGE = 'My error' 214 | 215 | class DataModel(BaseModel): 216 | details: str 217 | 218 | @ep.method(errors=[MyError]) 219 | def my_method__with_errors() -> None: 220 | return None 221 | 222 | app.bind_entrypoint(ep) 223 | 224 | resp = app_client.get('/openrpc.json') 225 | schema = resp.json() 226 | 227 | assert len(schema['methods']) == 1 228 | assert schema['methods'][0]['errors'] == [ 229 | {'$ref': '#/components/errors/5000'}, 230 | ] 231 | assert schema['components']['errors']['5000'] == { 232 | 'code': 5000, 233 | 'message': 'My error', 234 | 'data': { 235 | 'title': 'MyError.Data', 236 | 'type': 'object', 237 | 'properties': { 238 | 'details': { 239 | 'title': 'Details', 240 | 'type': 'string' 241 | } 242 | }, 243 | 'required': ['details'] 244 | } 245 | } 246 | 247 | 248 | def test_errors_merging(ep, app, app_client): 249 | class FirstError(jsonrpc.BaseError): 250 | CODE = 5000 251 | MESSAGE = 'My error' 252 | 253 | class DataModel(BaseModel): 254 | x: str 255 | 256 | class SecondError(jsonrpc.BaseError): 257 | CODE = 5000 258 | MESSAGE = 'My error' 259 | 260 | class DataModel(BaseModel): 261 | y: int 262 | 263 | @ep.method(errors=[FirstError, SecondError]) 264 | def my_method__with_mergeable_errors() -> None: 265 | return None 266 | 267 | app.bind_entrypoint(ep) 268 | 269 | resp = app_client.get('/openrpc.json') 270 | schema = resp.json() 271 | 272 | assert len(schema['methods']) == 1 273 | assert schema['methods'][0]['errors'] == [{'$ref': '#/components/errors/5000'}] 274 | assert schema['components']['errors']['5000'] == { 275 | 'code': 5000, 276 | 'message': 'My error', 277 | 'data': { 278 | 'title': 'ERROR_5000', 279 | 'anyOf': [ 280 | {'$ref': '#/components/schemas/test_openrpc.FirstError.Data'}, 281 | {'$ref': '#/components/schemas/test_openrpc.SecondError.Data'}, 282 | ], 283 | } 284 | } 285 | assert schema['components']['schemas']['test_openrpc.FirstError.Data'] == { 286 | 'title': 'FirstError.Data', 287 | 'type': 'object', 288 | 'properties': { 289 | 'x': {'type': 'string', 'title': 'X'}, 290 | }, 291 | 'required': ['x'] 292 | } 293 | assert schema['components']['schemas']['test_openrpc.SecondError.Data'] == { 294 | 'title': 'SecondError.Data', 295 | 'type': 'object', 296 | 'properties': { 297 | 'y': {'type': 'integer', 'title': 'Y'}, 298 | }, 299 | 'required': ['y'] 300 | } 301 | 302 | 303 | def test_type_hints(ep, app, app_client): 304 | Input = List[str] 305 | Output = Dict[str, List[List[float]]] 306 | 307 | @ep.method() 308 | def my_method__with_typehints(arg: Input) -> Output: 309 | return {} 310 | 311 | app.bind_entrypoint(ep) 312 | 313 | resp = app_client.get('/openrpc.json') 314 | schema = resp.json() 315 | 316 | assert len(schema['methods']) == 1 317 | assert schema['methods'][0]['params' ] == [ 318 | { 319 | 'name': 'arg', 320 | 'schema': { 321 | 'title': 'Arg', 322 | 'type': 'array', 323 | 'items': { 324 | 'type': 'string' 325 | } 326 | }, 327 | 'required': True 328 | } 329 | ] 330 | assert schema['methods'][0]['result'] == { 331 | 'name': 'my_method__with_typehints_Result', 332 | 'schema': { 333 | 'title': 'Result', 334 | 'type': 'object', 335 | 'additionalProperties': { 336 | 'type': 'array', 337 | 'items': { 338 | 'type': 'array', 339 | 'items': { 340 | 'type': 'number' 341 | } 342 | } 343 | } 344 | } 345 | } 346 | 347 | 348 | @pytest.mark.parametrize('fastapi_jsonrpc_components_fine_names', [True, False]) 349 | def test_no_entrypoints__ok(fastapi_jsonrpc_components_fine_names): 350 | app = jsonrpc.API(fastapi_jsonrpc_components_fine_names=fastapi_jsonrpc_components_fine_names) 351 | app_client = TestClient(app) 352 | resp = app_client.get('/openrpc.json') 353 | resp.raise_for_status() 354 | assert resp.status_code == 200 355 | -------------------------------------------------------------------------------- /tests/test_params.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import BaseModel, Field 3 | from typing import List 4 | 5 | from fastapi_jsonrpc import Params 6 | 7 | 8 | class WholeParams(BaseModel): 9 | data: List[str] = Field(..., examples=['111', '222']) 10 | amount: int = Field(..., gt=5, examples=[10]) 11 | 12 | 13 | @pytest.fixture 14 | def ep(ep): 15 | @ep.method() 16 | def probe( 17 | whole_params: WholeParams = Params(...) 18 | ) -> List[int]: 19 | return [int(item) + whole_params.amount for item in whole_params.data] 20 | 21 | return ep 22 | 23 | 24 | def test_basic(json_request): 25 | resp = json_request({ 26 | 'id': 1, 27 | 'jsonrpc': '2.0', 28 | 'method': 'probe', 29 | 'params': {'data': ['11', '22', '33'], 'amount': 1000}, 30 | }) 31 | assert resp == {'id': 1, 'jsonrpc': '2.0', 'result': [1011, 1022, 1033]} 32 | 33 | 34 | def test_openapi(app_client, openapi_compatible): 35 | resp = app_client.get('/openapi.json') 36 | assert resp.json() == openapi_compatible({ 37 | 'components': { 38 | 'schemas': { 39 | 'InternalError': { 40 | 'properties': { 41 | 'code': { 42 | 'default': -32603, 43 | 'example': -32603, 44 | 'title': 'Code', 45 | 'type': 'integer', 46 | }, 47 | 'message': { 48 | 'default': 'Internal error', 49 | 'example': 'Internal error', 50 | 'title': 'Message', 51 | 'type': 'string', 52 | }, 53 | }, 54 | 'title': 'InternalError', 55 | 'type': 'object', 56 | }, 57 | 'InvalidParams': { 58 | 'properties': { 59 | 'code': { 60 | 'default': -32602, 61 | 'example': -32602, 62 | 'title': 'Code', 63 | 'type': 'integer', 64 | }, 65 | 'data': {'anyOf': [ 66 | {'$ref': '#/components/schemas/_ErrorData[_Error]', }, 67 | {'type': 'null'} 68 | ]}, 69 | 'message': { 70 | 'default': 'Invalid params', 71 | 'example': 'Invalid params', 72 | 'title': 'Message', 73 | 'type': 'string', 74 | }, 75 | }, 76 | 'title': 'InvalidParams', 77 | 'type': 'object', 78 | }, 79 | 'InvalidRequest': { 80 | 'properties': { 81 | 'code': { 82 | 'default': -32600, 83 | 'example': -32600, 84 | 'title': 'Code', 85 | 'type': 'integer', 86 | }, 87 | 'data': {'anyOf': [ 88 | {'$ref': '#/components/schemas/_ErrorData[_Error]', }, 89 | {'type': 'null'} 90 | ]}, 91 | 'message': { 92 | 'default': 'Invalid Request', 93 | 'example': 'Invalid Request', 94 | 'title': 'Message', 95 | 'type': 'string', 96 | }, 97 | }, 98 | 'title': 'InvalidRequest', 99 | 'type': 'object', 100 | }, 101 | 'MethodNotFound': { 102 | 'properties': { 103 | 'code': { 104 | 'default': -32601, 105 | 'example': -32601, 106 | 'title': 'Code', 107 | 'type': 'integer', 108 | }, 109 | 'message': { 110 | 'default': 'Method not found', 111 | 'example': 'Method not found', 112 | 'title': 'Message', 113 | 'type': 'string', 114 | }, 115 | }, 116 | 'title': 'MethodNotFound', 117 | 'type': 'object', 118 | }, 119 | 'ParseError': { 120 | 'properties': { 121 | 'code': { 122 | 'default': -32700, 123 | 'example': -32700, 124 | 'title': 'Code', 125 | 'type': 'integer', 126 | }, 127 | 'message': { 128 | 'default': 'Parse error', 129 | 'example': 'Parse error', 130 | 'title': 'Message', 131 | 'type': 'string', 132 | }, 133 | }, 134 | 'title': 'ParseError', 135 | 'type': 'object', 136 | }, 137 | 'WholeParams': { 138 | 'properties': { 139 | 'amount': { 140 | 'examples': [10], 141 | 'exclusiveMinimum': 5.0, 142 | 'title': 'Amount', 143 | 'type': 'integer', 144 | }, 145 | 'data': { 146 | 'examples': ['111', '222'], 147 | 'items': {'type': 'string'}, 148 | 'title': 'Data', 149 | 'type': 'array', 150 | }, 151 | }, 152 | 'required': ['data', 'amount'], 153 | 'title': 'WholeParams', 154 | 'type': 'object', 155 | }, 156 | '_Error': { 157 | 'properties': { 158 | 'ctx': { 159 | 'title': 'Ctx', 160 | 'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 161 | }, 162 | 'loc': { 163 | 'items': {'anyOf': [ 164 | {'type': 'string'}, 165 | {'type': 'integer'}, 166 | ]}, 167 | 'title': 'Loc', 168 | 'type': 'array', 169 | }, 170 | 'msg': { 171 | 'title': 'Msg', 172 | 'type': 'string', 173 | }, 174 | 'type': { 175 | 'title': 'Type', 176 | 'type': 'string', 177 | }, 178 | }, 179 | 'required': ['loc', 'msg', 'type'], 180 | 'title': '_Error', 181 | 'type': 'object', 182 | }, 183 | '_ErrorData[_Error]': { 184 | 'properties': { 185 | 'errors': { 186 | 'anyOf': [ 187 | {'items': {'$ref': '#/components/schemas/_Error'}, 'type': 'array'}, 188 | {'type': 'null'} 189 | ], 190 | 'title': 'Errors', 191 | }, 192 | }, 193 | 'title': '_ErrorData[_Error]', 194 | 'type': 'object', 195 | }, 196 | '_ErrorResponse[InternalError]': { 197 | 'additionalProperties': False, 198 | 'properties': { 199 | 'error': { 200 | '$ref': '#/components/schemas/InternalError', 201 | }, 202 | 'id': { 203 | 'anyOf': [ 204 | { 205 | 'type': 'string', 206 | }, 207 | { 208 | 'type': 'integer', 209 | }, 210 | ], 211 | 'example': 0, 212 | 'title': 'Id', 213 | }, 214 | 'jsonrpc': { 215 | 'const': '2.0', 216 | 'default': '2.0', 217 | 218 | 'example': '2.0', 219 | 'title': 'Jsonrpc', 220 | 'type': 'string', 221 | }, 222 | }, 223 | 'required': ['error'], 224 | 'title': '_ErrorResponse[InternalError]', 225 | 'type': 'object', 226 | }, 227 | '_ErrorResponse[InvalidParams]': { 228 | 'additionalProperties': False, 229 | 'properties': { 230 | 'error': { 231 | '$ref': '#/components/schemas/InvalidParams', 232 | }, 233 | 'id': { 234 | 'anyOf': [ 235 | { 236 | 'type': 'string', 237 | }, 238 | { 239 | 'type': 'integer', 240 | }, 241 | ], 242 | 'example': 0, 243 | 'title': 'Id', 244 | }, 245 | 'jsonrpc': { 246 | 'const': '2.0', 247 | 'default': '2.0', 248 | 'example': '2.0', 249 | 'title': 'Jsonrpc', 250 | 'type': 'string', 251 | }, 252 | }, 253 | 'required': ['error'], 254 | 'title': '_ErrorResponse[InvalidParams]', 255 | 'type': 'object', 256 | }, 257 | '_ErrorResponse[InvalidRequest]': { 258 | 'additionalProperties': False, 259 | 'properties': { 260 | 'error': { 261 | '$ref': '#/components/schemas/InvalidRequest', 262 | }, 263 | 'id': { 264 | 'anyOf': [ 265 | { 266 | 'type': 'string', 267 | }, 268 | { 269 | 'type': 'integer', 270 | }, 271 | ], 272 | 'example': 0, 273 | 'title': 'Id', 274 | }, 275 | 'jsonrpc': { 276 | 'const': '2.0', 277 | 'default': '2.0', 278 | 'example': '2.0', 279 | 'title': 'Jsonrpc', 280 | 'type': 'string', 281 | }, 282 | }, 283 | 'required': ['error'], 284 | 'title': '_ErrorResponse[InvalidRequest]', 285 | 'type': 'object', 286 | }, 287 | '_ErrorResponse[MethodNotFound]': { 288 | 'additionalProperties': False, 289 | 'properties': { 290 | 'error': { 291 | '$ref': '#/components/schemas/MethodNotFound', 292 | }, 293 | 'id': { 294 | 'anyOf': [ 295 | { 296 | 'type': 'string', 297 | }, 298 | { 299 | 'type': 'integer', 300 | }, 301 | ], 302 | 'example': 0, 303 | 'title': 'Id', 304 | }, 305 | 'jsonrpc': { 306 | 'const': '2.0', 307 | 'default': '2.0', 308 | 'example': '2.0', 309 | 'title': 'Jsonrpc', 310 | 'type': 'string', 311 | }, 312 | }, 313 | 'required': ['error'], 314 | 'title': '_ErrorResponse[MethodNotFound]', 315 | 'type': 'object', 316 | }, 317 | '_ErrorResponse[ParseError]': { 318 | 'additionalProperties': False, 319 | 'properties': { 320 | 'error': { 321 | '$ref': '#/components/schemas/ParseError', 322 | }, 323 | 'id': { 324 | 'anyOf': [ 325 | { 326 | 'type': 'string', 327 | }, 328 | { 329 | 'type': 'integer', 330 | }, 331 | ], 332 | 'example': 0, 333 | 'title': 'Id', 334 | }, 335 | 'jsonrpc': { 336 | 'const': '2.0', 337 | 'default': '2.0', 338 | 'example': '2.0', 339 | 'title': 'Jsonrpc', 340 | 'type': 'string', 341 | }, 342 | }, 343 | 'required': ['error'], 344 | 'title': '_ErrorResponse[ParseError]', 345 | 'type': 'object', 346 | }, 347 | '_Request': { 348 | 'additionalProperties': False, 349 | 'properties': { 350 | 'id': { 351 | 'anyOf': [ 352 | { 353 | 'type': 'string', 354 | }, 355 | { 356 | 'type': 'integer', 357 | }, 358 | ], 359 | 'example': 0, 360 | 'title': 'Id', 361 | }, 362 | 'jsonrpc': { 363 | 'const': '2.0', 364 | 'default': '2.0', 365 | 'example': '2.0', 366 | 'title': 'Jsonrpc', 367 | 'type': 'string', 368 | }, 369 | 'method': { 370 | 'title': 'Method', 371 | 'type': 'string', 372 | }, 373 | 'params': { 374 | 'additionalProperties': True, 375 | 'title': 'Params', 376 | 'type': 'object', 377 | }, 378 | }, 379 | 'required': ['method'], 380 | 'title': '_Request', 381 | 'type': 'object', 382 | }, 383 | '_Request[probe]': { 384 | 'additionalProperties': False, 385 | 'properties': { 386 | 'id': { 387 | 'anyOf': [ 388 | { 389 | 'type': 'string', 390 | }, 391 | { 392 | 'type': 'integer', 393 | }, 394 | ], 395 | 'example': 0, 396 | 'title': 'Id', 397 | }, 398 | 'jsonrpc': { 399 | 'const': '2.0', 400 | 'default': '2.0', 401 | 'example': '2.0', 402 | 'title': 'Jsonrpc', 403 | 'type': 'string', 404 | }, 405 | 'method': { 406 | 'default': 'probe', 407 | 'example': 'probe', 408 | 'title': 'Method', 409 | 'type': 'string', 410 | }, 411 | 'params': { 412 | '$ref': '#/components/schemas/WholeParams', 413 | }, 414 | }, 415 | 'required': ['params'], 416 | 'title': '_Request[probe]', 417 | 'type': 'object', 418 | }, 419 | '_Response': { 420 | 'additionalProperties': False, 421 | 'properties': { 422 | 'id': { 423 | 'anyOf': [ 424 | { 425 | 'type': 'string', 426 | }, 427 | { 428 | 'type': 'integer', 429 | }, 430 | ], 431 | 'example': 0, 432 | 'title': 'Id', 433 | }, 434 | 'jsonrpc': { 435 | 'const': '2.0', 436 | 'default': '2.0', 437 | 'example': '2.0', 438 | 'title': 'Jsonrpc', 439 | 'type': 'string', 440 | }, 441 | 'result': { 442 | 'additionalProperties': True, 443 | 'title': 'Result', 444 | 'type': 'object', 445 | }, 446 | }, 447 | 'required': ['jsonrpc', 'id', 'result'], 448 | 'title': '_Response', 449 | 'type': 'object', 450 | }, 451 | '_Response[probe]': { 452 | 'additionalProperties': False, 453 | 'properties': { 454 | 'id': { 455 | 'anyOf': [ 456 | { 457 | 'type': 'string', 458 | }, 459 | { 460 | 'type': 'integer', 461 | }, 462 | ], 463 | 'example': 0, 464 | 'title': 'Id', 465 | }, 466 | 'jsonrpc': { 467 | 'const': '2.0', 468 | 'default': '2.0', 469 | 'example': '2.0', 470 | 'title': 'Jsonrpc', 471 | 'type': 'string', 472 | }, 473 | 'result': { 474 | 'items': { 475 | 'type': 'integer', 476 | }, 477 | 'title': 'Result', 478 | 'type': 'array', 479 | }, 480 | }, 481 | 'required': ['jsonrpc', 'id', 'result'], 482 | 'title': '_Response[probe]', 483 | 'type': 'object', 484 | }, 485 | }, 486 | }, 487 | 'info': { 488 | 'title': 'FastAPI', 'version': '0.1.0', 489 | }, 490 | 'openapi': '3.0.2', 491 | 'paths': { 492 | '/api/v1/jsonrpc': { 493 | 'post': { 494 | 'operationId': 'entrypoint_api_v1_jsonrpc_post', 495 | 'requestBody': { 496 | 'content': { 497 | 'application/json': { 498 | 'schema': { 499 | '$ref': '#/components/schemas/_Request', 500 | }, 501 | }, 502 | }, 503 | 'required': True, 504 | }, 505 | 'responses': { 506 | '200': { 507 | 'content': { 508 | 'application/json': { 509 | 'schema': { 510 | '$ref': '#/components/schemas/_Response', 511 | }, 512 | }, 513 | }, 514 | 'description': 'Successful Response', 515 | }, 516 | '210': { 517 | 'content': { 518 | 'application/json': { 519 | 'schema': { 520 | '$ref': '#/components/schemas/_ErrorResponse[InvalidParams]', 521 | }, 522 | }, 523 | }, 524 | 'description': '[-32602] Invalid params\n\nInvalid method parameter(s)', 525 | }, 526 | '211': { 527 | 'content': { 528 | 'application/json': { 529 | 'schema': { 530 | '$ref': '#/components/schemas/_ErrorResponse[MethodNotFound]', 531 | }, 532 | }, 533 | }, 534 | 'description': '[-32601] Method not found\n\nThe method does not exist / is not available', 535 | }, 536 | '212': { 537 | 'content': { 538 | 'application/json': { 539 | 'schema': { 540 | '$ref': '#/components/schemas/_ErrorResponse[ParseError]', 541 | }, 542 | }, 543 | }, 544 | 'description': '[-32700] Parse error\n\nInvalid JSON was received by the server', 545 | }, 546 | '213': { 547 | 'content': { 548 | 'application/json': { 549 | 'schema': { 550 | '$ref': '#/components/schemas/_ErrorResponse[InvalidRequest]', 551 | }, 552 | }, 553 | }, 554 | 'description': '[-32600] Invalid Request\n\nThe JSON sent is not a valid Request object', 555 | }, 556 | '214': { 557 | 'content': { 558 | 'application/json': { 559 | 'schema': { 560 | '$ref': '#/components/schemas/_ErrorResponse[InternalError]', 561 | }, 562 | }, 563 | }, 564 | 'description': '[-32603] Internal error\n\nInternal JSON-RPC error', 565 | }, 566 | }, 567 | 'summary': 'Entrypoint', 568 | }, 569 | }, 570 | '/api/v1/jsonrpc/probe': { 571 | 'post': { 572 | 'operationId': 'probe_api_v1_jsonrpc_probe_post', 573 | 'requestBody': { 574 | 'content': { 575 | 'application/json': { 576 | 'schema': { 577 | '$ref': '#/components/schemas/_Request[probe]', 578 | }, 579 | }, 580 | }, 581 | 'required': True, 582 | }, 583 | 'responses': { 584 | '200': { 585 | 'content': { 586 | 'application/json': { 587 | 'schema': { 588 | '$ref': '#/components/schemas/_Response[probe]', 589 | }, 590 | }, 591 | }, 592 | 'description': 'Successful Response', 593 | }, 594 | }, 595 | 'summary': 'Probe', 596 | }, 597 | }, 598 | }, 599 | }) 600 | -------------------------------------------------------------------------------- /tests/test_request_class.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends 3 | 4 | from fastapi_jsonrpc import Entrypoint, get_jsonrpc_method, JsonRpcRequest 5 | 6 | 7 | @pytest.fixture 8 | def ep(ep_path): 9 | class CustomJsonRpcRequest(JsonRpcRequest): 10 | extra_value: str 11 | 12 | ep = Entrypoint( 13 | ep_path, 14 | request_class=CustomJsonRpcRequest 15 | ) 16 | 17 | @ep.method() 18 | def probe( 19 | jsonrpc_method: str = Depends(get_jsonrpc_method), 20 | ) -> str: 21 | return jsonrpc_method 22 | 23 | return ep 24 | 25 | 26 | def test_custom_request_class(ep, json_request): 27 | resp = json_request({ 28 | 'id': 0, 29 | 'jsonrpc': '2.0', 30 | 'method': 'probe', 31 | 'params': {}, 32 | 'extra_value': 'test', 33 | }) 34 | assert resp == {'id': 0, 'jsonrpc': '2.0', 'result': 'probe'} 35 | 36 | 37 | def test_custom_request_class_unexpected_type(ep, json_request): 38 | resp = json_request({ 39 | 'id': 0, 40 | 'jsonrpc': '2.0', 41 | 'method': 'probe', 42 | 'params': {}, 43 | 'extra_value': {}, 44 | }) 45 | assert resp == { 46 | 'error': { 47 | 'code': -32600, 48 | 'message': 'Invalid Request', 49 | 'data': {'errors': [ 50 | {'input': {}, 'loc': ['extra_value'], 'msg': 'Input should be a valid string', 'type': 'string_type'}]}, 51 | }, 52 | 'id': 0, 53 | 'jsonrpc': '2.0', 54 | } 55 | 56 | 57 | def test_unexpected_extra(ep, json_request): 58 | resp = json_request({ 59 | 'id': 0, 60 | 'jsonrpc': '2.0', 61 | 'method': 'echo', 62 | 'params': {'data': 'data-123'}, 63 | 'extra_value': 'test', 64 | 'unexpected_extra': 123, 65 | }) 66 | assert resp == { 67 | 'error': { 68 | 'code': -32600, 69 | 'message': 'Invalid Request', 70 | 'data': {'errors': [ 71 | { 72 | 'input': 123, 73 | 'loc': ['unexpected_extra'], 74 | 'msg': 'Extra inputs are not permitted', 75 | 'type': 'extra_forbidden', 76 | }, 77 | ]}, 78 | }, 79 | 'id': 0, 80 | 'jsonrpc': '2.0' 81 | } 82 | -------------------------------------------------------------------------------- /tests/test_sub_response.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from json import dumps as json_dumps 3 | 4 | import pytest 5 | from fastapi import Body, Response 6 | 7 | from fastapi_jsonrpc import JsonRpcContext 8 | 9 | 10 | @pytest.fixture 11 | def probe_ep(ep): 12 | @contextlib.asynccontextmanager 13 | async def ep_middleware(ctx: JsonRpcContext): 14 | ctx.http_response.set_cookie(key='ep_middleware_enter', value='1') 15 | yield 16 | ctx.http_response.set_cookie(key='ep_middleware_exit', value='2') 17 | 18 | ep.middlewares.append(ep_middleware) 19 | 20 | @contextlib.asynccontextmanager 21 | async def method_middleware(ctx: JsonRpcContext): 22 | ctx.http_response.set_cookie(key='method_middleware_enter', value='3') 23 | yield 24 | ctx.http_response.set_cookie(key='method_middleware_exit', value='4') 25 | 26 | @ep.method(middlewares=[method_middleware]) 27 | def probe( 28 | http_response: Response, 29 | data: str = Body(..., examples=['123']), 30 | ) -> str: 31 | http_response.set_cookie(key='probe-cookie', value=data) 32 | http_response.status_code = 404 33 | return data 34 | 35 | return ep 36 | 37 | 38 | def test_basic(probe_ep, raw_request): 39 | body = json_dumps({ 40 | 'id': 1, 41 | 'jsonrpc': '2.0', 42 | 'method': 'probe', 43 | 'params': {'data': 'data-123'}, 44 | }) 45 | response = raw_request(body) 46 | assert response.cookies['probe-cookie'] == 'data-123' 47 | assert response.cookies['ep_middleware_enter'] == '1' 48 | assert response.cookies['ep_middleware_exit'] == '2' 49 | assert response.cookies['method_middleware_enter'] == '3' 50 | assert response.cookies['method_middleware_exit'] == '4' 51 | assert response.status_code == 404 52 | --------------------------------------------------------------------------------