diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 99187f6..abc349c 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -10,7 +10,7 @@ jobs: with: python-version: 3.8 - run: pip install --upgrade pip - - run: pip install types-setuptools "black<23" "pylint<3" "mypy<1" "jsonschema<5" pytest "oslash<1" "aiohttp<4" "aiozmq<1" "django<5" "fastapi<1" "flask<3" "flask-socketio<5.3.1" "pyzmq" "sanic" "tornado<7" "uvicorn<1" "websockets<11" - - run: black --diff --check $(git ls-files -- '*.py' ':!:docs/*') - - run: pylint $(git ls-files -- '*.py' ':!:docs/*') + - run: pip install types-setuptools "black<23" ruff "mypy<2" "jsonschema<5" pytest "returns<1" "aiohttp<4" "aiozmq<1" "django<5" "fastapi<1" "flask<3" "flask-socketio<5.3.1" "pyzmq" "sanic" "tornado<7" "uvicorn<1" "websockets<11" + - run: ruff check --select I $(git ls-files -- '*.py' ':!:docs/*') + - run: ruff format --check $(git ls-files -- '*.py' ':!:docs/*') - run: mypy --strict $(git ls-files -- '*.py' ':!:docs/*') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e05ac27..f884896 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,38 +3,24 @@ default_language_version: exclude: (^docs) fail_fast: true repos: - - repo: https://github.com/ambv/black - rev: 22.8.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.1 hooks: - - id: black - args: [--diff, --check] - - - repo: https://github.com/PyCQA/pylint - rev: v2.15.3 - hooks: - - id: pylint - additional_dependencies: - - oslash<1 - - aiohttp<4 - - aiozmq<1 - - django<5 - - fastapi<1 - - flask<3 - - flask-socketio<5.3.1 - - jsonschema<5 - - pytest - - pyzmq - - sanic - - tornado<7 - - uvicorn<1 - - websockets<11 + - id: ruff + name: lint with ruff + - id: ruff + name: sort imports with ruff + args: [--select, I, --fix] + - id: ruff-format + name: format with ruff - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.971 + rev: v1.2.0 hooks: - id: mypy args: [--strict] additional_dependencies: + - returns<1 - aiohttp<4 - aiozmq<1 - django<5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e4296..036af4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # jsonrpcserver Change Log +## 6.0.0 (May 18, 2022) + +A small release but incrementing the major release number due to a breaking +change for async use. + +Breaking changes: + +- Async methods should be decorated with `@async_method` instead of `@method`. The + reason is due to the _type_ of the decorated function - async functions have a + different type to other functions (`Awaitable`). + +Other changes: + +- Replaced the Oslash dependency with [Returns](https://github.com/dry-python/returns). + Oslash is not meant for production use, and doesn't work in Python 3.12. +- Use `Ok` instead of `Success` when returning a response. This is to avoid confusion + with the Returns library's `Success` class. It also matches Jsonrpcclient's `Ok` type. + This is not a breaking change, `Success` will still work for now. +- Docs moved to https://github.com/explodinglabs/jsonrpcserver/wiki + ## 5.0.9 (Sep 15, 2022) - Remove unncessary `package_data` from setup.py (#243) diff --git a/README.md b/README.md index 6d050cb..a295785 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ jsonrpcserver ![PyPI](https://img.shields.io/pypi/v/jsonrpcserver.svg) ![Code Quality](https://github.com/explodinglabs/jsonrpcserver/actions/workflows/code-quality.yml/badge.svg) ![Coverage Status](https://coveralls.io/repos/github/explodinglabs/jsonrpcserver/badge.svg?branch=main) ![Downloads](https://img.shields.io/pypi/dw/jsonrpcserver) +![License](https://img.shields.io/pypi/l/jsonrpcserver.svg) Process incoming JSON-RPC requests in Python. @@ -16,24 +17,18 @@ pip install jsonrpcserver ``` ```python -from jsonrpcserver import method, serve, Success +from jsonrpcserver import method, Result, Ok @method -def ping(): - return Success("pong") +def ping() -> Result: + return Ok("pong") -if __name__ == "__main__": - serve() -``` - -Or use `dispatch` instead of `serve`: -```python response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') # => '{"jsonrpc": "2.0", "result": "pong", "id": 1}' ``` [Watch a video on how to use it.](https://www.youtube.com/watch?v=3_BMmgJaFHQ) -Full documentation is at [jsonrpcserver.com](https://www.jsonrpcserver.com/). +Full documentation is in the [wiki](https://github.com/explodinglabs/jsonrpcserver/wiki). See also: [jsonrpcclient](https://github.com/explodinglabs/jsonrpcclient) diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index fa0c384..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,177 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/jsonrpcserver.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/jsonrpcserver.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/jsonrpcserver" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/jsonrpcserver" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/async.md b/docs/async.md index 296a74f..5954d07 100644 --- a/docs/async.md +++ b/docs/async.md @@ -1,13 +1,11 @@ -# Async - Async dispatch is supported. ```python -from jsonrpcserver import method, Success, async_dispatch +from jsonrpcserver import async_dispatch, async_method, Ok, Result -@method +@async_method async def ping() -> Result: - return Success("pong") + return Ok("pong") await async_dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') ``` diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index f876374..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,61 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- Project information ----------------------------------------------------- - -project = "jsonrpcserver" -copyright = "2021, Beau Barker" -author = "Beau Barker" - -# The full version, including alpha/beta/rc tags -release = "5.0.0" - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = ["sphinx_rtd_theme", "myst_parser"] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" -html_theme_options = { - "analytics_id": "G-G05775CD6C", # UA-81795603-3 - "display_version": True, -} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -source_suffix = [".rst", ".md"] -html_show_sourcelink = False diff --git a/docs/dispatch.md b/docs/dispatch.md index c53e342..719ea9c 100644 --- a/docs/dispatch.md +++ b/docs/dispatch.md @@ -1,6 +1,6 @@ # Dispatch -The `dispatch` function takes a JSON-RPC request, calls the appropriate method +The `dispatch` function processes a JSON-RPC request, attempting to call the method(s) and gives a JSON-RPC response. ```python @@ -8,19 +8,25 @@ and gives a JSON-RPC response. '{"jsonrpc": "2.0", "result": "pong", "id": 1}' ``` +It's a pure function; it will always give you a JSON-RPC response. No exceptions will be +raised. + [See how dispatch is used in different frameworks.](examples) ## Optional parameters +The `dispatch` function takes a request as its argument, and also has some optional +parameters that allow you to customise how it works. + ### methods -This lets you specify a group of methods to dispatch to. It's an alternative to -using the `@method` decorator. The value should be a dict mapping function -names to functions. +This lets you specify the methods to dispatch to. It's an alternative to using +the `@method` decorator. The value should be a dict mapping function names to +functions. ```python def ping(): - return Success("pong") + return Ok("pong") dispatch(request, methods={"ping": ping}) ``` @@ -35,20 +41,40 @@ If specified, this will be the first argument to all methods. ```python @method def greet(context, name): - return Success(context + " " + name) + return Ok(f"Hello {context}") ->>> dispatch('{"jsonrpc": "2.0", "method": "greet", "params": ["Beau"], "id": 1}', context="Hello") +>>> dispatch('{"jsonrpc": "2.0", "method": "greet", "params": ["Beau"], "id": 1}', context="Beau") '{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' ``` ### deserializer -A function that parses the request string. Default is `json.loads`. +A function that parses the JSON request string. Default is `json.loads`. ```python dispatch(request, deserializer=ujson.loads) ``` +### jsonrpc_validator + +A function that validates the request once the JSON string has been parsed. The +function should raise an exception (any exception) if the request doesn't match +the JSON-RPC spec (https://www.jsonrpc.org/specification). Default is +`default_jsonrpc_validator` which uses Jsonschema to validate requests against +a schema. + +To disable JSON-RPC validation, pass `jsonrpc_validator=lambda _: None`, which +will improve performance because this validation takes around half the dispatch +time. + +### args_validator + +A function that validates a request's parameters against the signature of the +Python function that will be called for it. Note this should not validate the +_values_ of the parameters, it should simply ensure the parameters match the +Python function's signature. For reference, see the `validate_args` function in +`dispatcher.py`, which is the default `args_validator`. + ### serializer A function that serializes the response string. Default is `json.dumps`. @@ -56,10 +82,3 @@ A function that serializes the response string. Default is `json.dumps`. ```python dispatch(request, serializer=ujson.dumps) ``` - -### validator - -A function that validates the request once the json has been parsed. The -function should raise an exception (any exception) if the request doesn't match -the JSON-RPC spec. Default is `default_validator` which validates the request -against a schema. diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index cfa8c22..0000000 --- a/docs/examples.md +++ /dev/null @@ -1,100 +0,0 @@ -# Examples - -```{contents} -``` - -## aiohttp - -```{literalinclude} ../examples/aiohttp_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/aiohttp). - -## Django - -Create a `views.py`: - -```{literalinclude} ../examples/django_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/django). - -## FastAPI - -```{literalinclude} ../examples/fastapi_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/fastapi). - -## Flask - -```{literalinclude} ../examples/flask_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/flask). - -## http.server - -Using Python's built-in -[http.server](https://docs.python.org/3/library/http.server.html) module. - -```{literalinclude} ../examples/http_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/httpserver). - -## jsonrpcserver - -Using jsonrpcserver's built-in `serve` method. - -```{literalinclude} ../examples/jsonrpcserver_server.py -``` - -## Sanic - -```{literalinclude} ../examples/sanic_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/sanic). - -## Socket.IO - -```{literalinclude} ../examples/socketio_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/flask-socketio). - -## Tornado - -```{literalinclude} ../examples/tornado_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/tornado). - -## Websockets - -```{literalinclude} ../examples/websockets_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/websockets). - -## Werkzeug - -```{literalinclude} ../examples/werkzeug_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/werkzeug). - -## ZeroMQ - -```{literalinclude} ../examples/zeromq_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/zeromq). - -## ZeroMQ (asynchronous) - -```{literalinclude} ../examples/aiozmq_server.py -``` - -See [blog post](https://composed.blog/jsonrpc/zeromq-async). diff --git a/docs/faq.md b/docs/faq.md index f928e24..98bf4e7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,5 +1,3 @@ -# FAQ - ## How to disable schema validation? Validating requests is costly - roughly 40% of dispatching time is spent on schema validation. diff --git a/docs/index.md b/docs/index.md index 12a7251..3612046 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,23 +1,37 @@ -```{warning} -This is the documentation for version 5, released August 16, 2021. [Read about -the changes in version 5](https://composed.blog/jsonrpcserver-5-changes). +# Jsonrpcserver + +Jsonrpcserver processes JSON-RPC requests. + +## Quickstart + +Install jsonrpcserver: +```python +pip install jsonrpcserver ``` -# Process incoming JSON-RPC requests in Python | jsonrpcserver Documentation +Create a `server.py`: + +```python +from jsonrpcserver import method, serve, Ok -![jsonrpcserver](/logo.png) +@method +def ping(): + return Ok("pong") -Process incoming JSON-RPC requests in Python. +if __name__ == "__main__": + serve() +``` + +Start the server: +```sh +$ python server.py +``` -```{toctree} ---- -maxdepth: 3 -caption: Contents ---- -installation -methods -dispatch -async -faq -examples +Send a request: +```sh +$ curl -X POST http://localhost:5000 -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' +{"jsonrpc": "2.0", "result": "pong", "id": 1} ``` + +`serve` starts a basic development server. Do not use it in a production deployment. Use +a production WSGI server instead, with jsonrpcserver's [dispatch](dispatch) function. diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index 6c40ec8..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,29 +0,0 @@ -# Quickstart - -Create a `server.py`: - -```python -from jsonrpcserver import Success, method, serve - -@method -def ping(): - return Success("pong") - -if __name__ == "__main__": - serve() -``` - -Start the server: - -```sh -$ pip install jsonrpcserver -$ python server.py - * Listening on port 5000 -``` - -Test the server: - -```sh -$ curl -X POST http://localhost:5000 -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' -{"jsonrpc": "2.0", "result": "pong", "id": 1} -``` diff --git a/docs/methods.md b/docs/methods.md index dd5be3b..28457f8 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -1,23 +1,27 @@ # Methods -Methods are functions that can be called by a JSON-RPC request. To write one, -decorate a function with `@method`: +Methods are functions that can be called by a JSON-RPC request. + +## Writing methods + +To write a method, decorate a function with `@method`: ```python -from jsonrpcserver import method, Result, Success, Error +from jsonrpcserver import method, Error, Ok, Result @method def ping() -> Result: - return Success("pong") + return Ok("pong") ``` -If you don't need to respond with any value simply `return Success()`. +If you don't need to respond with any value simply `return Ok()`. ## Responses -Methods return either `Success` or `Error`. These are the [JSON-RPC response +Methods return either `Ok` or `Error`. These are the [JSON-RPC response objects](https://www.jsonrpc.org/specification#response_object) (excluding the -`jsonrpc` and `id` parts). `Error` takes a code, message, and optionally 'data'. +`jsonrpc` and `id` parts). `Error` takes a code, message, and optionally +'data'. ```python @method @@ -25,9 +29,7 @@ def test() -> Result: return Error(1, "There was a problem") ``` -```{note} Alternatively, raise a `JsonRpcError`, which takes the same arguments as `Error`. -``` ## Parameters @@ -36,7 +38,7 @@ Methods can accept arguments. ```python @method def hello(name: str) -> Result: - return Success("Hello " + name) + return Ok("Hello " + name) ``` Testing it: @@ -53,13 +55,13 @@ The JSON-RPC error code for this is **-32602**. A shortcut, *InvalidParams*, is included so you don't need to remember that. ```python -from jsonrpcserver import method, Result, InvalidParams, Success, dispatch +from jsonrpcserver import dispatch, method, InvalidParams, Ok, Result @method def within_range(num: int) -> Result: if num not in range(1, 5): return InvalidParams("Value must be 1-5") - return Success() + return Ok() ``` This is the same as saying diff --git a/examples/aiohttp_server.py b/examples/aiohttp_server.py index 41982c4..6626a32 100644 --- a/examples/aiohttp_server.py +++ b/examples/aiohttp_server.py @@ -1,12 +1,14 @@ """AioHTTP server""" + from aiohttp import web -from jsonrpcserver import method, Result, Success, async_dispatch + +from jsonrpcserver import Ok, Result, async_dispatch, async_method -@method +@async_method async def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") async def handle(request: web.Request) -> web.Response: diff --git a/examples/aiozmq_server.py b/examples/aiozmq_server.py index 60ef29d..545bceb 100644 --- a/examples/aiozmq_server.py +++ b/examples/aiozmq_server.py @@ -1,15 +1,17 @@ """AioZMQ server""" + import asyncio import aiozmq # type: ignore import zmq -from jsonrpcserver import method, Result, Success, async_dispatch + +from jsonrpcserver import Ok, Result, async_dispatch, async_method -@method +@async_method async def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") async def main() -> None: diff --git a/examples/asyncio_server.py b/examples/asyncio_server.py index d668a76..4352403 100644 --- a/examples/asyncio_server.py +++ b/examples/asyncio_server.py @@ -1,15 +1,16 @@ """Demonstrates processing a batch of 100 requests asynchronously with asyncio.""" + import asyncio import json -from jsonrpcserver import method, Result, Success, async_dispatch +from jsonrpcserver import Ok, Result, async_dispatch, async_method -@method +@async_method async def sleep_() -> Result: """JSON-RPC method""" await asyncio.sleep(1) - return Success() + return Ok() async def handle(req: str) -> None: diff --git a/examples/django_server.py b/examples/django_server.py index 52228b9..86f77fd 100644 --- a/examples/django_server.py +++ b/examples/django_server.py @@ -1,13 +1,15 @@ """Django server""" + from django.http import HttpRequest, HttpResponse # type: ignore from django.views.decorators.csrf import csrf_exempt # type: ignore -from jsonrpcserver import method, Result, Success, dispatch + +from jsonrpcserver import Ok, Result, dispatch, method @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") @csrf_exempt # type: ignore diff --git a/examples/fastapi_server.py b/examples/fastapi_server.py index 812a636..858ba07 100644 --- a/examples/fastapi_server.py +++ b/examples/fastapi_server.py @@ -1,7 +1,9 @@ """FastAPI server""" + +import uvicorn from fastapi import FastAPI, Request, Response -import uvicorn # type: ignore -from jsonrpcserver import Result, Success, dispatch, method + +from jsonrpcserver import Ok, Result, dispatch, method app = FastAPI() @@ -9,7 +11,7 @@ @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") @app.post("/") diff --git a/examples/flask_server.py b/examples/flask_server.py index 24581d9..071b110 100644 --- a/examples/flask_server.py +++ b/examples/flask_server.py @@ -1,6 +1,8 @@ """Flask server""" + from flask import Flask, Response, request -from jsonrpcserver import method, Result, Success, dispatch + +from jsonrpcserver import Ok, Result, dispatch, method app = Flask(__name__) @@ -8,7 +10,7 @@ @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") @app.route("/", methods=["POST"]) diff --git a/examples/http_server.py b/examples/http_server.py index a240ea8..59c7bd6 100644 --- a/examples/http_server.py +++ b/examples/http_server.py @@ -2,21 +2,22 @@ Demonstrates using Python's builtin http.server module to serve JSON-RPC. """ + from http.server import BaseHTTPRequestHandler, HTTPServer -from jsonrpcserver import method, Result, Success, dispatch +from jsonrpcserver import Ok, Result, dispatch, method @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") class TestHttpServer(BaseHTTPRequestHandler): """HTTPServer request handler""" - def do_POST(self) -> None: # pylint: disable=invalid-name + def do_POST(self) -> None: """POST handler""" # Process request request = self.rfile.read(int(self.headers["Content-Length"])).decode() diff --git a/examples/jsonrpcserver_server.py b/examples/jsonrpcserver_server.py index 5d87590..55f7db6 100644 --- a/examples/jsonrpcserver_server.py +++ b/examples/jsonrpcserver_server.py @@ -2,13 +2,14 @@ Uses jsonrpcserver's built-in "serve" function. """ -from jsonrpcserver import method, Result, Success, serve + +from jsonrpcserver import Ok, Result, method, serve @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") if __name__ == "__main__": diff --git a/examples/sanic_server.py b/examples/sanic_server.py index 0b4b68a..be80c26 100644 --- a/examples/sanic_server.py +++ b/examples/sanic_server.py @@ -1,8 +1,10 @@ """Sanic server""" + from sanic import Sanic from sanic.request import Request from sanic.response import HTTPResponse, json -from jsonrpcserver import Result, Success, dispatch_to_serializable, method + +from jsonrpcserver import Ok, Result, dispatch_to_serializable, method app = Sanic("JSON-RPC app") @@ -10,7 +12,7 @@ @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") @app.route("/", methods=["POST"]) diff --git a/examples/socketio_server.py b/examples/socketio_server.py index 2911f11..7dd4b94 100644 --- a/examples/socketio_server.py +++ b/examples/socketio_server.py @@ -1,7 +1,9 @@ """SocketIO server""" + from flask import Flask, Request from flask_socketio import SocketIO, send # type: ignore -from jsonrpcserver import method, Result, Success, dispatch + +from jsonrpcserver import Ok, Result, dispatch, method app = Flask(__name__) socketio = SocketIO(app) @@ -10,7 +12,7 @@ @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") @socketio.on("message") # type: ignore diff --git a/examples/tornado_server.py b/examples/tornado_server.py index 6a86b35..3a027a4 100644 --- a/examples/tornado_server.py +++ b/examples/tornado_server.py @@ -1,14 +1,16 @@ """Tornado server""" + from typing import Awaitable, Optional from tornado import ioloop, web -from jsonrpcserver import method, Result, Success, async_dispatch + +from jsonrpcserver import Ok, Result, async_dispatch, async_method -@method +@async_method async def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") class MainHandler(web.RequestHandler): diff --git a/examples/websockets_server.py b/examples/websockets_server.py index 7367a65..ed8812e 100644 --- a/examples/websockets_server.py +++ b/examples/websockets_server.py @@ -1,14 +1,16 @@ """Websockets server""" + import asyncio from websockets.server import WebSocketServerProtocol, serve -from jsonrpcserver import method, Success, Result, async_dispatch + +from jsonrpcserver import Ok, Result, async_dispatch, async_method -@method +@async_method async def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") async def main(websocket: WebSocketServerProtocol, _: str) -> None: diff --git a/examples/werkzeug_server.py b/examples/werkzeug_server.py index e6a1e8f..dcc8590 100644 --- a/examples/werkzeug_server.py +++ b/examples/werkzeug_server.py @@ -1,13 +1,15 @@ """Werkzeug server""" + from werkzeug.serving import run_simple from werkzeug.wrappers import Request, Response -from jsonrpcserver import method, Result, Success, dispatch + +from jsonrpcserver import Ok, Result, dispatch, method @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") @Request.application diff --git a/examples/zeromq_server.py b/examples/zeromq_server.py index 6d0c032..d88368c 100644 --- a/examples/zeromq_server.py +++ b/examples/zeromq_server.py @@ -1,6 +1,8 @@ """ZeroMQ server""" + import zmq -from jsonrpcserver import method, Result, Success, dispatch + +from jsonrpcserver import Ok, Result, dispatch, method socket = zmq.Context().socket(zmq.REP) @@ -8,7 +10,7 @@ @method def ping() -> Result: """JSON-RPC method""" - return Success("pong") + return Ok("pong") if __name__ == "__main__": diff --git a/jsonrpcserver/__init__.py b/jsonrpcserver/__init__.py index 9f6c5a6..8136ba0 100644 --- a/jsonrpcserver/__init__.py +++ b/jsonrpcserver/__init__.py @@ -1,28 +1,41 @@ -"""Use __all__ so mypy considers these re-exported.""" +"""Jsonrpcserver""" + +from returns.result import Result as R + +from .async_main import ( + dispatch as async_dispatch, +) +from .async_main import ( + dispatch_to_response as async_dispatch_to_response, +) +from .async_main import ( + dispatch_to_serializable as async_dispatch_to_serializable, +) +from .async_methods import method as async_method +from .exceptions import JsonRpcError +from .main import dispatch, dispatch_to_response, dispatch_to_serializable +from .methods import method +from .result import Error, ErrorResult, InvalidParams, Ok, SuccessResult +from .server import serve + +Success = Ok # For backward compatibility - version 5 used Success instead of Ok +Result = R[SuccessResult, ErrorResult] + + __all__ = [ "Error", "InvalidParams", "JsonRpcError", + "Ok", "Result", "Success", "async_dispatch", "async_dispatch_to_response", "async_dispatch_to_serializable", + "async_method", "dispatch", "dispatch_to_response", "dispatch_to_serializable", "method", "serve", ] - - -from .async_main import ( - dispatch as async_dispatch, - dispatch_to_response as async_dispatch_to_response, - dispatch_to_serializable as async_dispatch_to_serializable, -) -from .exceptions import JsonRpcError -from .main import dispatch, dispatch_to_response, dispatch_to_serializable -from .methods import method -from .result import Error, InvalidParams, Result, Success -from .server import serve diff --git a/jsonrpcserver/async_dispatcher.py b/jsonrpcserver/async_dispatcher.py index 6ce66ae..bfd47a0 100644 --- a/jsonrpcserver/async_dispatcher.py +++ b/jsonrpcserver/async_dispatcher.py @@ -1,12 +1,15 @@ """Async version of dispatcher.py""" + +import asyncio +import logging from functools import partial +from inspect import signature from itertools import starmap from typing import Any, Callable, Iterable, Tuple, Union -import asyncio -import logging -from oslash.either import Left # type: ignore +from returns.result import Failure, Result, Success +from .async_methods import Method, Methods from .dispatcher import ( Deserialized, create_request, @@ -14,60 +17,86 @@ extract_args, extract_kwargs, extract_list, - get_method, not_notification, to_response, - validate_args, validate_request, validate_result, ) from .exceptions import JsonRpcError -from .methods import Method, Methods from .request import Request -from .result import Result, InternalErrorResult, ErrorResult from .response import Response, ServerErrorResponse +from .result import ( + ErrorResult, + InternalErrorResult, + InvalidParamsResult, + MethodNotFoundResult, + SuccessResult, +) from .utils import make_list logger = logging.getLogger(__name__) -# pylint: disable=missing-function-docstring,duplicate-code - -async def call(request: Request, context: Any, method: Method) -> Result: +async def call( + request: Request, context: Any, method: Method +) -> Result[SuccessResult, ErrorResult]: try: result = await method( *extract_args(request, context), **extract_kwargs(request) ) validate_result(result) except JsonRpcError as exc: - return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) - except Exception as exc: # pylint: disable=broad-except + return Failure(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) + except Exception as exc: # Other error inside method - Internal error logger.exception(exc) - return Left(InternalErrorResult(str(exc))) + return Failure(InternalErrorResult(str(exc))) return result +def validate_args( + request: Request, context: Any, func: Method +) -> Result[Method, ErrorResult]: + """Ensure the method can be called with the arguments given. + + Returns: Either the function to be called, or an Invalid Params error result. + """ + try: + signature(func).bind(*extract_args(request, context), **extract_kwargs(request)) + except TypeError as exc: + return Failure(InvalidParamsResult(str(exc))) + return Success(func) + + +def get_method(methods: Methods, method_name: str) -> Result[Method, ErrorResult]: + """Get the requested method from the methods dict. + + Returns: Either the function to be called, or a Method Not Found result. + """ + try: + return Success(methods[method_name]) + except KeyError: + return Failure(MethodNotFoundResult(method_name)) + + async def dispatch_request( methods: Methods, context: Any, request: Request -) -> Tuple[Request, Result]: +) -> Tuple[Request, Result[SuccessResult, ErrorResult]]: method = get_method(methods, request.method).bind( partial(validate_args, request, context) ) return ( request, method - if isinstance(method, Left) - else await call( - request, context, method._value # pylint: disable=protected-access - ), + if isinstance(method, Failure) + else await call(request, context, method.unwrap()), ) async def dispatch_deserialized( methods: Methods, context: Any, - post_process: Callable[[Response], Iterable[Any]], + post_process: Callable[[Response], Response], deserialized: Deserialized, ) -> Union[Response, Iterable[Response], None]: results = await asyncio.gather( @@ -91,7 +120,7 @@ async def dispatch_to_response_pure( validator: Callable[[Deserialized], Deserialized], methods: Methods, context: Any, - post_process: Callable[[Response], Iterable[Any]], + post_process: Callable[[Response], Response], request: str, ) -> Union[Response, Iterable[Response], None]: try: @@ -100,14 +129,14 @@ async def dispatch_to_response_pure( ) return ( post_process(result) - if isinstance(result, Left) + if isinstance(result, Failure) else await dispatch_deserialized( methods, context, post_process, - result._value, # pylint: disable=protected-access + result.unwrap(), ) ) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: logger.exception(exc) - return post_process(Left(ServerErrorResponse(str(exc), None))) + return post_process(Failure(ServerErrorResponse(str(exc), None))) diff --git a/jsonrpcserver/async_main.py b/jsonrpcserver/async_main.py index 35656eb..4ff3773 100644 --- a/jsonrpcserver/async_main.py +++ b/jsonrpcserver/async_main.py @@ -1,26 +1,24 @@ """Async version of main.py. The public async functions.""" + import json from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast from .async_dispatcher import dispatch_to_response_pure +from .async_methods import Methods, global_methods from .dispatcher import Deserialized -from .main import default_validator, default_deserializer -from .methods import Methods, global_methods +from .main import default_deserializer, default_jsonrpc_validator from .response import Response, to_serializable from .sentinels import NOCONTEXT from .utils import identity -# pylint: disable=missing-function-docstring,duplicate-code - - async def dispatch_to_response( request: str, methods: Optional[Methods] = None, *, context: Any = NOCONTEXT, deserializer: Callable[[str], Deserialized] = default_deserializer, - validator: Callable[[Deserialized], Deserialized] = default_validator, + validator: Callable[[Deserialized], Deserialized] = default_jsonrpc_validator, post_process: Callable[[Response], Any] = identity, ) -> Union[Response, Iterable[Response], None]: return await dispatch_to_response_pure( diff --git a/jsonrpcserver/async_methods.py b/jsonrpcserver/async_methods.py new file mode 100644 index 0000000..63edfc1 --- /dev/null +++ b/jsonrpcserver/async_methods.py @@ -0,0 +1,33 @@ +"""Async methods""" + +from typing import Any, Awaitable, Callable, Dict, Optional, cast + +from returns.result import Result + +from .result import ErrorResult, SuccessResult + +Method = Callable[..., Awaitable[Result[SuccessResult, ErrorResult]]] +Methods = Dict[str, Method] +global_methods: Methods = {} + + +def method( + func: Optional[Method] = None, name: Optional[str] = None +) -> Callable[..., Awaitable[Any]]: + """A decorator to add a function into jsonrpcserver's internal global_methods dict. + The global_methods dict will be used by default unless a methods argument is passed + to `dispatch`. + + Functions can be renamed by passing a name argument: + + @method(name=bar) + def foo(): + ... + """ + + def decorator(func_: Method) -> Method: + nonlocal name + global_methods[name or func_.__name__] = func_ + return func_ + + return decorator(func) if callable(func) else cast(Method, decorator) diff --git a/jsonrpcserver/dispatcher.py b/jsonrpcserver/dispatcher.py index c6b6229..467869a 100644 --- a/jsonrpcserver/dispatcher.py +++ b/jsonrpcserver/dispatcher.py @@ -1,14 +1,14 @@ """Dispatcher - does the hard work of this library: parses, validates and dispatches requests, providing responses. """ -# pylint: disable=protected-access + +import logging from functools import partial from inspect import signature from itertools import starmap from typing import Any, Callable, Dict, Iterable, List, Tuple, Union -import logging -from oslash.either import Either, Left, Right # type: ignore +from returns.result import Failure, Result, Success from .exceptions import JsonRpcError from .methods import Method, Methods @@ -26,12 +26,12 @@ InternalErrorResult, InvalidParamsResult, MethodNotFoundResult, - Result, SuccessResult, ) from .sentinels import NOCONTEXT, NOID from .utils import compose, make_list +ArgsValidator = Callable[[Any, Request, Method], Result[Method, ErrorResult]] Deserialized = Union[Dict[str, Any], List[Dict[str, Any]]] logger = logging.getLogger(__name__) @@ -66,7 +66,9 @@ def extract_list( return response_list[0] -def to_response(request: Request, result: Result) -> Response: +def to_response( + request: Request, result: Result[SuccessResult, ErrorResult] +) -> Response: """Maps a Request plus a Result to a Response. A Response is just a Result plus the id from the original Request. @@ -79,9 +81,9 @@ def to_response(request: Request, result: Result) -> Response: """ assert request.id is not NOID return ( - Left(ErrorResponse(**result._error._asdict(), id=request.id)) - if isinstance(result, Left) - else Right(SuccessResponse(**result._value._asdict(), id=request.id)) + Failure(ErrorResponse(**result.failure()._asdict(), id=request.id)) + if isinstance(result, Failure) + else Success(SuccessResponse(**result.unwrap()._asdict(), id=request.id)) ) @@ -104,19 +106,25 @@ def extract_kwargs(request: Request) -> Dict[str, Any]: return request.params if isinstance(request.params, dict) else {} -def validate_result(result: Result) -> None: +def validate_result(result: Result[SuccessResult, ErrorResult]) -> None: """Validate the return value from a method. Raises an AssertionError if the result returned from a method is invalid. Returns: None """ - assert (isinstance(result, Left) and isinstance(result._error, ErrorResult)) or ( - isinstance(result, Right) and isinstance(result._value, SuccessResult) + assert ( + isinstance(result, Failure) and isinstance(result.failure(), ErrorResult) + ) or ( + isinstance(result, Success) and isinstance(result.unwrap(), SuccessResult) ), f"The method did not return a valid Result (returned {result!r})" -def call(request: Request, context: Any, method: Method) -> Result: +def call( + request: Request, + context: Any, + method: Method, +) -> Result[SuccessResult, ErrorResult]: """Call the method. Handles any exceptions raised in the method, being sure to return an Error response. @@ -132,17 +140,19 @@ def call(request: Request, context: Any, method: Method) -> Result: # Raising JsonRpcError inside the method is an alternative way of returning an error # response. except JsonRpcError as exc: - return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) + return Failure(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) # Any other uncaught exception inside method - internal error. - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: logger.exception(exc) - return Left(InternalErrorResult(str(exc))) + return Failure(InternalErrorResult(str(exc))) return result def validate_args( - request: Request, context: Any, func: Method -) -> Either[ErrorResult, Method]: + request: Request, + context: Any, + func: Method, +) -> Result[Method, ErrorResult]: """Ensure the method can be called with the arguments given. Returns: Either the function to be called, or an Invalid Params error result. @@ -150,34 +160,37 @@ def validate_args( try: signature(func).bind(*extract_args(request, context), **extract_kwargs(request)) except TypeError as exc: - return Left(InvalidParamsResult(str(exc))) - return Right(func) + return Failure(InvalidParamsResult(str(exc))) + return Success(func) -def get_method(methods: Methods, method_name: str) -> Either[ErrorResult, Method]: +def get_method(methods: Methods, method_name: str) -> Result[Method, ErrorResult]: """Get the requested method from the methods dict. Returns: Either the function to be called, or a Method Not Found result. """ try: - return Right(methods[method_name]) + return Success(methods[method_name]) except KeyError: - return Left(MethodNotFoundResult(method_name)) + return Failure(MethodNotFoundResult(method_name)) def dispatch_request( - methods: Methods, context: Any, request: Request -) -> Tuple[Request, Result]: + args_validator: ArgsValidator, + methods: Methods, + context: Any, + request: Request, +) -> Tuple[Request, Result[SuccessResult, ErrorResult]]: """Get the method, validates the arguments and calls the method. - Returns: A tuple containing the Result of the method, along with the original - Request. We need the ids from the original request to remove notifications - before responding, and create a Response. + Returns: A tuple containing the original Request, and the Result of the method call. + We need the ids from the original request to remove notifications before + responding, and create a Response. """ return ( request, get_method(methods, request.method) - .bind(partial(validate_args, request, context)) + .bind(partial(args_validator, request, context)) .bind(partial(call, request, context)), ) @@ -198,20 +211,23 @@ def not_notification(request_result: Any) -> bool: def dispatch_deserialized( + args_validator: ArgsValidator, + post_process: Callable[[Response], Response], methods: Methods, context: Any, - post_process: Callable[[Response], Iterable[Any]], deserialized: Deserialized, ) -> Union[Response, List[Response], None]: """This is simply continuing the pipeline from dispatch_to_response_pure. It exists only to be an abstraction, otherwise that function is doing too much. It continues on from the request string having been parsed and validated. - Returns: A Response, a list of Responses, or None. If post_process is passed, it's + Returns: A Result, a list of Results, or None. If post_process is passed, it's applied to the Response(s). """ results = map( - compose(partial(dispatch_request, methods, context), create_request), + compose( + partial(dispatch_request, args_validator, methods, context), create_request + ), make_list(deserialized), ) responses = starmap(to_response, filter(not_notification, results)) @@ -219,8 +235,8 @@ def dispatch_deserialized( def validate_request( - validator: Callable[[Deserialized], Deserialized], request: Deserialized -) -> Either[ErrorResponse, Deserialized]: + jsonrpc_validator: Callable[[Deserialized], Deserialized], request: Deserialized +) -> Result[Deserialized, ErrorResponse]: """Validate the request against a JSON-RPC schema. Ensures the parsed request is valid JSON-RPC. @@ -228,57 +244,59 @@ def validate_request( Returns: Either the same request passed in or an Invalid request response. """ try: - validator(request) + jsonrpc_validator(request) # Since the validator is unknown, the specific exception that will be raised is also - # unknown. Any exception raised we assume the request is invalid and return an + # unknown. Any exception raised we assume the request is invalid and return an # "invalid request" response. - except Exception: # pylint: disable=broad-except - return Left(InvalidRequestResponse("The request failed schema validation")) - return Right(request) + except Exception: + return Failure(InvalidRequestResponse("The request failed schema validation")) + return Success(request) def deserialize_request( deserializer: Callable[[str], Deserialized], request: str -) -> Either[ErrorResponse, Deserialized]: +) -> Result[Deserialized, ErrorResponse]: """Parse the JSON request string. Returns: Either the deserialized request or a "Parse Error" response. """ try: - return Right(deserializer(request)) + return Success(deserializer(request)) # Since the deserializer is unknown, the specific exception that will be raised is # also unknown. Any exception raised we assume the request is invalid, return a # parse error response. - except Exception as exc: # pylint: disable=broad-except - return Left(ParseErrorResponse(str(exc))) + except Exception as exc: + return Failure(ParseErrorResponse(str(exc))) def dispatch_to_response_pure( - *, + args_validator: ArgsValidator, deserializer: Callable[[str], Deserialized], - validator: Callable[[Deserialized], Deserialized], + jsonrpc_validator: Callable[[Deserialized], Deserialized], + post_process: Callable[[Response], Response], methods: Methods, context: Any, - post_process: Callable[[Response], Iterable[Any]], request: str, ) -> Union[Response, List[Response], None]: """A function from JSON-RPC request string to Response namedtuple(s), (yet to be serialized to json). Returns: A single Response, a list of Responses, or None. None is given for - notifications or batches of notifications, to indicate that we should not - respond. + notifications or batches of notifications, to indicate that we should + not respond. """ try: result = deserialize_request(deserializer, request).bind( - partial(validate_request, validator) + partial(validate_request, jsonrpc_validator) ) return ( post_process(result) - if isinstance(result, Left) - else dispatch_deserialized(methods, context, post_process, result._value) + if isinstance(result, Failure) + else dispatch_deserialized( + args_validator, post_process, methods, context, result.unwrap() + ) ) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # There was an error with the jsonrpcserver library. - logger.exception(exc) - return post_process(Left(ServerErrorResponse(str(exc), None))) + logging.exception(exc) + return post_process(Failure(ServerErrorResponse(str(exc), None))) diff --git a/jsonrpcserver/exceptions.py b/jsonrpcserver/exceptions.py index b2e2afb..0314e44 100644 --- a/jsonrpcserver/exceptions.py +++ b/jsonrpcserver/exceptions.py @@ -1,5 +1,7 @@ """Exceptions""" + from typing import Any + from .sentinels import NODATA diff --git a/jsonrpcserver/main.py b/jsonrpcserver/main.py index bbf824b..70fdd18 100644 --- a/jsonrpcserver/main.py +++ b/jsonrpcserver/main.py @@ -9,36 +9,43 @@ - dispatch_to_json/dispatch: Returns a JSON-RPC response string (or an empty string for notifications). """ -from importlib.resources import read_text -from typing import Any, Callable, Dict, List, Optional, Union, cast + import json +from typing import Any, Callable, Dict, List, Union, cast from jsonschema.validators import validator_for # type: ignore -from .dispatcher import dispatch_to_response_pure, Deserialized +from .dispatcher import ( + ArgsValidator, + Deserialized, + dispatch_to_response_pure, + validate_args, +) from .methods import Methods, global_methods +from .request_schema import REQUEST_SCHEMA from .response import Response, to_dict from .sentinels import NOCONTEXT from .utils import identity - +default_args_validator = validate_args default_deserializer = json.loads -# Prepare the jsonschema validator. This is global so it loads only once, not every -# time dispatch is called. -schema = json.loads(read_text(__package__, "request-schema.json")) -klass = validator_for(schema) -klass.check_schema(schema) -default_validator = klass(schema).validate +# Prepare the jsonschema validator. This is global so it loads only once, not every time +# dispatch is called. +klass = validator_for(REQUEST_SCHEMA) +klass.check_schema(REQUEST_SCHEMA) +default_jsonrpc_validator = klass(REQUEST_SCHEMA).validate def dispatch_to_response( request: str, - methods: Optional[Methods] = None, - *, + methods: Methods = global_methods, context: Any = NOCONTEXT, + args_validator: ArgsValidator = default_args_validator, deserializer: Callable[[str], Deserialized] = json.loads, - validator: Callable[[Deserialized], Deserialized] = default_validator, + jsonrpc_validator: Callable[ + [Deserialized], Deserialized + ] = default_jsonrpc_validator, post_process: Callable[[Response], Any] = identity, ) -> Union[Response, List[Response], None]: """Takes a JSON-RPC request string and dispatches it to method(s), giving Response @@ -54,9 +61,11 @@ def dispatch_to_response( populated with the @method decorator. context: If given, will be passed as the first argument to methods. deserializer: Function that deserializes the request string. - validator: Function that validates the JSON-RPC request. The function should - raise an exception if the request is invalid. To disable validation, pass - lambda _: None. + args_validator: Function that validates that the parameters in the request match + the Python function being called. + jsonrpc_validator: Function that validates the JSON-RPC request. The function + should raise an exception if the request is invalid. To disable validation, + pass lambda _: None. post_process: Function that will be applied to Responses. Returns: @@ -67,12 +76,13 @@ def dispatch_to_response( '{"jsonrpc": "2.0", "result": "pong", "id": 1}' """ return dispatch_to_response_pure( - deserializer=deserializer, - validator=validator, - post_process=post_process, - context=context, - methods=global_methods if methods is None else methods, - request=request, + args_validator, + deserializer, + jsonrpc_validator, + post_process, + methods, + context, + request, ) @@ -82,9 +92,10 @@ def dispatch_to_serializable( """Takes a JSON-RPC request string and dispatches it to method(s), giving responses as dicts (or None). """ + kwargs.setdefault("post_process", to_dict) return cast( Union[Dict[str, Any], List[Dict[str, Any]], None], - dispatch_to_response(*args, post_process=to_dict, **kwargs), + dispatch_to_response(*args, **kwargs), ) @@ -106,7 +117,7 @@ def dispatch_to_json( The rest: Passed through to dispatch_to_serializable. """ response = dispatch_to_serializable(*args, **kwargs) - # Better to respond with the empty string instead of json "null", because "null" is + # Better to respond with an empty string instead of json "null", because "null" is # an invalid JSON-RPC response. return "" if response is None else serializer(response) diff --git a/jsonrpcserver/methods.py b/jsonrpcserver/methods.py index 38d999a..6a24930 100644 --- a/jsonrpcserver/methods.py +++ b/jsonrpcserver/methods.py @@ -11,18 +11,21 @@ Methods can take either positional or named arguments, but not both. This is a limitation of JSON-RPC. """ + from typing import Any, Callable, Dict, Optional, cast -from .result import Result +from returns.result import Result + +from .result import ErrorResult, SuccessResult -Method = Callable[..., Result] +Method = Callable[..., Result[SuccessResult, ErrorResult]] Methods = Dict[str, Method] -global_methods = {} +global_methods: Methods = {} def method( - f: Optional[Method] = None, # pylint: disable=invalid-name + f: Optional[Method] = None, name: Optional[str] = None, ) -> Callable[..., Any]: """A decorator to add a function into jsonrpcserver's internal global_methods dict. diff --git a/jsonrpcserver/request.py b/jsonrpcserver/request.py index 37f88c1..d82043e 100644 --- a/jsonrpcserver/request.py +++ b/jsonrpcserver/request.py @@ -3,6 +3,7 @@ After parsing a request string, we put the (dict) requests into these Request namedtuples, simply because they're nicer to work with. """ + from typing import Any, Dict, List, NamedTuple, Union diff --git a/jsonrpcserver/request-schema.json b/jsonrpcserver/request_schema.py similarity index 51% rename from jsonrpcserver/request-schema.json rename to jsonrpcserver/request_schema.py index 52bb147..fc163e4 100644 --- a/jsonrpcserver/request-schema.json +++ b/jsonrpcserver/request_schema.py @@ -1,39 +1,32 @@ -{ +REQUEST_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", "description": "A JSON RPC 2.0 request", "oneOf": [ - { - "description": "An individual request", - "$ref": "#/definitions/request" - }, + {"description": "An individual request", "$ref": "#/definitions/request"}, { "description": "An array of requests", "type": "array", - "items": { "$ref": "#/definitions/request" }, - "minItems": 1 - } + "items": {"$ref": "#/definitions/request"}, + "minItems": 1, + }, ], "definitions": { "request": { "type": "object", - "required": [ "jsonrpc", "method" ], + "required": ["jsonrpc", "method"], "properties": { - "jsonrpc": { "enum": [ "2.0" ] }, - "method": { - "type": "string" - }, + "jsonrpc": {"enum": ["2.0"]}, + "method": {"type": "string"}, "id": { - "type": [ "string", "number", "null" ], + "type": ["string", "number", "null"], "note": [ "While allowed, null should be avoided: http://www.jsonrpc.org/specification#id1", - "While allowed, a number with a fractional part should be avoided: http://www.jsonrpc.org/specification#id2" - ] + "While allowed, a number with a fractional part should be avoided: http://www.jsonrpc.org/specification#id2", + ], }, - "params": { - "type": [ "array", "object" ] - } + "params": {"type": ["array", "object"]}, }, - "additionalProperties": false + "additionalProperties": False, } - } + }, } diff --git a/jsonrpcserver/response.py b/jsonrpcserver/response.py index 80f388e..2702b0d 100644 --- a/jsonrpcserver/response.py +++ b/jsonrpcserver/response.py @@ -2,9 +2,10 @@ https://www.jsonrpc.org/specification#response_object """ -from typing import Any, Dict, List, Type, NamedTuple, Union -from oslash.either import Either, Left # type: ignore +from typing import Any, Dict, List, NamedTuple, Union + +from returns.result import Failure, Result from .codes import ( ERROR_INVALID_REQUEST, @@ -37,11 +38,10 @@ class ErrorResponse(NamedTuple): id: Any -Response = Either[ErrorResponse, SuccessResponse] -ResponseType = Type[Either[ErrorResponse, SuccessResponse]] +Response = Result[SuccessResponse, ErrorResponse] -def ParseErrorResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-name +def ParseErrorResponse(data: Any) -> ErrorResponse: """An ErrorResponse with most attributes already populated. From the spec: "This (id) member is REQUIRED. It MUST be the same as the value of @@ -51,7 +51,7 @@ def ParseErrorResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-n return ErrorResponse(ERROR_PARSE_ERROR, "Parse error", data, None) -def InvalidRequestResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-name +def InvalidRequestResponse(data: Any) -> ErrorResponse: """An ErrorResponse with most attributes already populated. From the spec: "This (id) member is REQUIRED. It MUST be the same as the value of @@ -63,13 +63,11 @@ def InvalidRequestResponse(data: Any) -> ErrorResponse: # pylint: disable=inval def MethodNotFoundResponse(data: Any, id: Any) -> ErrorResponse: """An ErrorResponse with some attributes already populated.""" - # pylint: disable=invalid-name,redefined-builtin return ErrorResponse(ERROR_METHOD_NOT_FOUND, "Method not found", data, id) def ServerErrorResponse(data: Any, id: Any) -> ErrorResponse: """An ErrorResponse with some attributes already populated.""" - # pylint: disable=invalid-name,redefined-builtin return ErrorResponse(ERROR_SERVER_ERROR, "Server error", data, id) @@ -92,18 +90,17 @@ def to_success_dict(response: SuccessResponse) -> Dict[str, Any]: return {"jsonrpc": "2.0", "result": response.result, "id": response.id} -def to_dict(response: ResponseType) -> Dict[str, Any]: +def to_dict(response: Response) -> Dict[str, Any]: """Serialize either an error or success response object to dict""" - # pylint: disable=protected-access return ( - to_error_dict(response._error) - if isinstance(response, Left) - else to_success_dict(response._value) + to_error_dict(response.failure()) + if isinstance(response, Failure) + else to_success_dict(response.unwrap()) ) def to_serializable( - response: Union[ResponseType, List[ResponseType], None] + response: Union[Response, List[Response], None], ) -> Union[Deserialized, None]: """Serialize a response object (or list of them), to a dict, or list of them.""" if response is None: diff --git a/jsonrpcserver/result.py b/jsonrpcserver/result.py index 9dd78f9..37740c2 100644 --- a/jsonrpcserver/result.py +++ b/jsonrpcserver/result.py @@ -6,15 +6,15 @@ The public functions are Success, Error and InvalidParams. """ + from typing import Any, NamedTuple -from oslash.either import Either, Left, Right # type: ignore +from returns.result import Failure, Success +from returns.result import Result as R -from .codes import ERROR_INVALID_PARAMS, ERROR_METHOD_NOT_FOUND, ERROR_INTERNAL_ERROR +from .codes import ERROR_INTERNAL_ERROR, ERROR_INVALID_PARAMS, ERROR_METHOD_NOT_FOUND from .sentinels import NODATA -# pylint: disable=missing-class-docstring,missing-function-docstring,invalid-name - class SuccessResult(NamedTuple): result: Any = None @@ -29,11 +29,13 @@ class ErrorResult(NamedTuple): data: Any = NODATA # The spec says this value may be omitted def __repr__(self) -> str: - return f"ErrorResult(code={self.code!r}, message={self.message!r}, data={self.data!r})" + return ( + f"ErrorResult(code={self.code!r}, message={self.message!r}, " + f"data={self.data!r})" + ) -# Union of the two valid result types -Result = Either[ErrorResult, SuccessResult] +Result = R[SuccessResult, ErrorResult] # Helpers @@ -54,16 +56,16 @@ def InvalidParamsResult(data: Any = NODATA) -> ErrorResult: # Helpers (the public functions) -def Success(*args: Any, **kwargs: Any) -> Either[ErrorResult, SuccessResult]: - return Right(SuccessResult(*args, **kwargs)) +def Ok(*args: Any, **kwargs: Any) -> Success[SuccessResult]: + return Success(SuccessResult(*args, **kwargs)) -def Error(*args: Any, **kwargs: Any) -> Either[ErrorResult, SuccessResult]: - return Left(ErrorResult(*args, **kwargs)) +def Error(*args: Any, **kwargs: Any) -> Failure[ErrorResult]: + return Failure(ErrorResult(*args, **kwargs)) -def InvalidParams(*args: Any, **kwargs: Any) -> Either[ErrorResult, SuccessResult]: +def InvalidParams(*args: Any, **kwargs: Any) -> Failure[ErrorResult]: """InvalidParams is a shortcut to save you from having to pass the Invalid Params JSON-RPC code to Error. """ - return Left(InvalidParamsResult(*args, **kwargs)) + return Failure(InvalidParamsResult(*args, **kwargs)) diff --git a/jsonrpcserver/sentinels.py b/jsonrpcserver/sentinels.py index 0ed0cca..46ca287 100644 --- a/jsonrpcserver/sentinels.py +++ b/jsonrpcserver/sentinels.py @@ -12,7 +12,6 @@ class Sentinel: Has a nicer repr than `object()`. """ - # pylint: disable=too-few-public-methods def __init__(self, name: str): self.name = name diff --git a/jsonrpcserver/server.py b/jsonrpcserver/server.py index 643b6a7..c0977c2 100644 --- a/jsonrpcserver/server.py +++ b/jsonrpcserver/server.py @@ -1,7 +1,7 @@ """A simple development server for serving JSON-RPC requests using Python's builtin http.server module. """ -import logging + from http.server import BaseHTTPRequestHandler, HTTPServer from .main import dispatch @@ -10,19 +10,22 @@ class RequestHandler(BaseHTTPRequestHandler): """Handle HTTP requests""" - def do_POST(self) -> None: # pylint: disable=invalid-name + def do_POST(self) -> None: """Handle POST request""" - response = dispatch( - self.rfile.read(int(str(self.headers["Content-Length"]))).decode() - ) + request = self.rfile.read(int(str(self.headers["Content-Length"]))).decode() + response = dispatch(request) if response is not None: self.send_response(200) self.send_header("Content-type", "application/json") self.end_headers() - self.wfile.write(str(response).encode()) + self.wfile.write(response.encode()) def serve(name: str = "", port: int = 5000) -> None: - """A simple function to serve HTTP requests""" - logging.info(" * Listening on port %s", port) - HTTPServer((name, port), RequestHandler).serve_forever() + httpd = HTTPServer((name, port), RequestHandler) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.shutdown() diff --git a/jsonrpcserver/utils.py b/jsonrpcserver/utils.py index 4ee5a2c..ec47b32 100644 --- a/jsonrpcserver/utils.py +++ b/jsonrpcserver/utils.py @@ -1,9 +1,8 @@ """Utility functions""" + from functools import reduce from typing import Any, Callable, List -# pylint: disable=invalid-name - def identity(x: Any) -> Any: """Returns the argument.""" diff --git a/docs/logo.png b/logo.png similarity index 100% rename from docs/logo.png rename to logo.png diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..57eb2b9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,49 @@ +markdown_extensions: + - pymdownx.highlight: + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.details + - pymdownx.superfences + - pymdownx.mark +nav: + - Home: 'index.md' + - 'methods.md' + - 'dispatch.md' + - 'async.md' + - 'faq.md' + - 'examples.md' +repo_name: jsonrpcserver +repo_url: https://github.com/explodinglabs/jsonrpcserver +site_author: Exploding Labs +site_description: Welcome to the documentation for Jsonrcpcserver. +site_name: Jsonrpcserver +site_url: https://www.jsonrpcserver.com/ +theme: + features: + - content.code.copy + - navigation.footer + - navigation.tabs + - toc.integrate + name: material + palette: + # Palette toggle for automatic mode + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to system preference +extra: + version: + provider: mike diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..9bb71fa --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +plugins = + returns.contrib.mypy.returns_plugin diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cd8e31b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +authors = [ + {name = "Beau Barker", email = "beau@explodinglabs.com"} +] +classifiers = [ + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11" +] +description = "Process JSON-RPC requests" +dependencies = ["returns", "jsonschema"] +license = {file = "LICENSE"} +name = "jsonrpcserver" +readme = {file = "README.md", content-type = "text/markdown"} +requires-python = ">=3.8" +version = "6.0.0" + +[project.urls] +homepage = "https://www.jsonrpcserver.com" +repository = "https://github.com/explodinglabs/jsonrpcserver" + +[project.optional-dependencies] +qa = [ + "pytest", + "pytest-asyncio", + "pytest-cov", + "tox", +] +examples = [ + "aiohttp", + "aiozmq", + "flask", + "flask-socketio", + "gmqtt", + "pyzmq", + "sanic", + "tornado", + "websockets", + "werkzeug", +] + +[tool.setuptools] +include-package-data = true +packages = [ + "jsonrpcserver" +] +zip-safe = false + +[tool.pytest.ini_options] +addopts = '--doctest-glob="*.md"' +testpaths = [ + "tests", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index ebce509..0000000 --- a/setup.py +++ /dev/null @@ -1,46 +0,0 @@ -"""setup.py""" -from setuptools import setup - -with open("README.md", encoding="utf-8") as f: - README = f.read() - -setup( - author="Beau Barker", - author_email="beau@explodinglabs.com", - classifiers=[ - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], - description="Process JSON-RPC requests", - extras_require={ - "examples": [ - "aiohttp", - "aiozmq", - "flask", - "flask-socketio", - "gmqtt", - "pyzmq", - "tornado", - "websockets", - "werkzeug", - ], - "test": [ - "pytest", - "pytest-cov", - "tox", - ], - }, - include_package_data=True, - install_requires=["jsonschema<5", "oslash<1"], - license="MIT", - long_description=README, - long_description_content_type="text/markdown", - name="jsonrpcserver", - packages=["jsonrpcserver"], - url="https://github.com/explodinglabs/jsonrpcserver", - version="5.0.9", - # Be PEP 561 compliant - # https://mypy.readthedocs.io/en/stable/installed_packages.html#making-pep-561-compatible-packages - zip_safe=False, -) diff --git a/tests/test_async_dispatcher.py b/tests/test_async_dispatcher.py index 050e96d..78253c2 100644 --- a/tests/test_async_dispatcher.py +++ b/tests/test_async_dispatcher.py @@ -1,8 +1,9 @@ """Test async_dispatcher.py""" + from unittest.mock import Mock, patch -import pytest -from oslash.either import Left, Right # type: ignore +import pytest +from returns.result import Failure, Success from jsonrpcserver.async_dispatcher import ( call, @@ -10,45 +11,43 @@ dispatch_request, dispatch_to_response_pure, ) -from jsonrpcserver.main import default_deserializer, default_validator from jsonrpcserver.codes import ERROR_INTERNAL_ERROR, ERROR_SERVER_ERROR from jsonrpcserver.exceptions import JsonRpcError +from jsonrpcserver.main import default_deserializer, default_jsonrpc_validator from jsonrpcserver.request import Request from jsonrpcserver.response import ErrorResponse, SuccessResponse -from jsonrpcserver.result import ErrorResult, Result, Success, SuccessResult +from jsonrpcserver.result import ErrorResult, Ok, Result, SuccessResult from jsonrpcserver.sentinels import NOCONTEXT, NODATA from jsonrpcserver.utils import identity -# pylint: disable=missing-function-docstring,duplicate-code - async def ping() -> Result: - return Success("pong") + return Ok("pong") @pytest.mark.asyncio async def test_call() -> None: - assert await call(Request("ping", [], 1), NOCONTEXT, ping) == Right( + assert await call(Request("ping", [], 1), NOCONTEXT, ping) == Success( SuccessResult("pong") ) @pytest.mark.asyncio async def test_call_raising_jsonrpcerror() -> None: - def method() -> None: + async def method_() -> Result: raise JsonRpcError(code=1, message="foo", data=NODATA) - assert await call(Request("ping", [], 1), NOCONTEXT, method) == Left( + assert await call(Request("ping", [], 1), NOCONTEXT, method_) == Failure( ErrorResult(1, "foo") ) @pytest.mark.asyncio async def test_call_raising_exception() -> None: - def method() -> None: + async def method_() -> Result: raise ValueError("foo") - assert await call(Request("ping", [], 1), NOCONTEXT, method) == Left( + assert await call(Request("ping", [], 1), NOCONTEXT, method_) == Failure( ErrorResult(ERROR_INTERNAL_ERROR, "Internal error", "foo") ) @@ -58,7 +57,7 @@ async def test_dispatch_request() -> None: request = Request("ping", [], 1) assert await dispatch_request({"ping": ping}, NOCONTEXT, request) == ( request, - Right(SuccessResult("pong")), + Success(SuccessResult("pong")), ) @@ -69,32 +68,32 @@ async def test_dispatch_deserialized() -> None: NOCONTEXT, identity, {"jsonrpc": "2.0", "method": "ping", "id": 1}, - ) == Right(SuccessResponse("pong", 1)) + ) == Success(SuccessResponse("pong", 1)) @pytest.mark.asyncio async def test_dispatch_to_response_pure_success() -> None: assert await dispatch_to_response_pure( deserializer=default_deserializer, - validator=default_validator, + validator=default_jsonrpc_validator, post_process=identity, context=NOCONTEXT, methods={"ping": ping}, request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', - ) == Right(SuccessResponse("pong", 1)) + ) == Success(SuccessResponse("pong", 1)) @patch("jsonrpcserver.async_dispatcher.dispatch_request", side_effect=ValueError("foo")) @pytest.mark.asyncio -async def test_dispatch_to_response_pure_server_error(*_: Mock) -> None: - async def hello() -> Result: - return Success() +async def test_dispatch_to_response_pure_server_error(_: Mock) -> None: + async def ping() -> Result: + return Ok() assert await dispatch_to_response_pure( deserializer=default_deserializer, - validator=default_validator, + validator=default_jsonrpc_validator, post_process=identity, context=NOCONTEXT, - methods={"hello": hello}, - request='{"jsonrpc": "2.0", "method": "hello", "id": 1}', - ) == Left(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "hello", None)) + methods={"ping": ping}, + request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', + ) == Failure(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) diff --git a/tests/test_async_main.py b/tests/test_async_main.py index d820182..f70e83f 100644 --- a/tests/test_async_main.py +++ b/tests/test_async_main.py @@ -1,28 +1,26 @@ """Test async_main.py""" -import pytest -from oslash.either import Right # type: ignore +import pytest +from returns.result import Success from jsonrpcserver.async_main import ( + dispatch_to_json, dispatch_to_response, dispatch_to_serializable, - dispatch_to_json, ) from jsonrpcserver.response import SuccessResponse -from jsonrpcserver.result import Result, Success - -# pylint: disable=missing-function-docstring +from jsonrpcserver.result import Ok, Result async def ping() -> Result: - return Success("pong") + return Ok("pong") @pytest.mark.asyncio async def test_dispatch_to_response() -> None: assert await dispatch_to_response( '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} - ) == Right(SuccessResponse("pong", 1)) + ) == Success(SuccessResponse("pong", 1)) @pytest.mark.asyncio diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 727bf70..a58885a 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -2,12 +2,13 @@ TODO: Add tests for dispatch_requests (non-pure version) """ -from typing import Any, Callable, Dict -from unittest.mock import Mock, patch, sentinel + import json -import pytest +from typing import Any, Dict +from unittest.mock import Mock, patch, sentinel -from oslash.either import Left, Right # type: ignore +import pytest +from returns.result import Failure, Success from jsonrpcserver.codes import ( ERROR_INTERNAL_ERROR, @@ -23,9 +24,9 @@ dispatch_deserialized, dispatch_request, dispatch_to_response_pure, - extract_list, extract_args, extract_kwargs, + extract_list, get_method, not_notification, to_response, @@ -33,46 +34,41 @@ validate_request, ) from jsonrpcserver.exceptions import JsonRpcError -from jsonrpcserver.main import ( - default_deserializer, - default_validator, - dispatch_to_response, - dispatch, -) -from jsonrpcserver.methods import method +from jsonrpcserver.main import default_jsonrpc_validator +from jsonrpcserver.methods import Method from jsonrpcserver.request import Request from jsonrpcserver.response import ErrorResponse, SuccessResponse from jsonrpcserver.result import ( ErrorResult, InvalidParams, + Ok, Result, - Success, SuccessResult, ) from jsonrpcserver.sentinels import NOCONTEXT, NODATA, NOID from jsonrpcserver.utils import identity -# pylint: disable=missing-function-docstring,missing-class-docstring,too-few-public-methods,unnecessary-lambda-assignment,invalid-name,disallowed-name - def ping() -> Result: - return Success("pong") + return Ok("pong") # extract_list def test_extract_list() -> None: - assert extract_list(False, [SuccessResponse("foo", 1)]) == SuccessResponse("foo", 1) + assert extract_list(False, [Success(SuccessResponse("foo", 1))]) == Success( + SuccessResponse("foo", 1) + ) def test_extract_list_notification() -> None: - assert extract_list(False, [None]) is None + assert extract_list(False, []) is None def test_extract_list_batch() -> None: - assert extract_list(True, [SuccessResponse("foo", 1)]) == [ - SuccessResponse("foo", 1) + assert extract_list(True, [Success(SuccessResponse("foo", 1))]) == [ + Success(SuccessResponse("foo", 1)) ] @@ -85,21 +81,21 @@ def test_extract_list_batch_all_notifications() -> None: def test_to_response_SuccessResult() -> None: assert to_response( - Request("ping", [], sentinel.id), Right(SuccessResult(sentinel.result)) - ) == Right(SuccessResponse(sentinel.result, sentinel.id)) + Request("ping", [], sentinel.id), Success(SuccessResult(sentinel.result)) + ) == Success(SuccessResponse(sentinel.result, sentinel.id)) def test_to_response_ErrorResult() -> None: assert ( to_response( Request("ping", [], sentinel.id), - Left( + Failure( ErrorResult( code=sentinel.code, message=sentinel.message, data=sentinel.data ) ), ) - ) == Left( + ) == Failure( ErrorResponse(sentinel.code, sentinel.message, sentinel.data, sentinel.id) ) @@ -107,18 +103,20 @@ def test_to_response_ErrorResult() -> None: def test_to_response_InvalidParams() -> None: assert to_response( Request("ping", [], sentinel.id), InvalidParams(sentinel.data) - ) == Left(ErrorResponse(-32602, "Invalid params", sentinel.data, sentinel.id)) + ) == Failure(ErrorResponse(-32602, "Invalid params", sentinel.data, sentinel.id)) def test_to_response_InvalidParams_no_data() -> None: - assert to_response(Request("ping", [], sentinel.id), InvalidParams()) == Left( + assert to_response(Request("ping", [], sentinel.id), InvalidParams()) == Failure( ErrorResponse(-32602, "Invalid params", NODATA, sentinel.id) ) def test_to_response_notification() -> None: with pytest.raises(AssertionError): - to_response(Request("ping", [], NOID), SuccessResult(result=sentinel.result)) + to_response( + Request("ping", [], NOID), Success(SuccessResult(result=sentinel.result)) + ) # extract_args @@ -139,16 +137,21 @@ def test_extract_kwargs() -> None: assert extract_kwargs(Request("ping", {"foo": "bar"}, NOID)) == {"foo": "bar"} -# validate_result +# validate_args + + +def test_validate_args_result_no_arguments() -> None: + def f() -> Result: + return Ok() + assert validate_args(Request("f", [], NOID), NOCONTEXT, f) == Success(f) -def test_validate_result_no_arguments() -> None: - f = lambda: None - assert validate_args(Request("f", [], NOID), NOCONTEXT, f) == Right(f) +def test_validate_args_result_no_arguments_too_many_positionals() -> None: + def f() -> Result: + return Ok() -def test_validate_result_no_arguments_too_many_positionals() -> None: - assert validate_args(Request("f", ["foo"], NOID), NOCONTEXT, lambda: None) == Left( + assert validate_args(Request("f", ["foo"], NOID), NOCONTEXT, f) == Failure( ErrorResult( code=ERROR_INVALID_PARAMS, message="Invalid params", @@ -157,56 +160,69 @@ def test_validate_result_no_arguments_too_many_positionals() -> None: ) -def test_validate_result_positionals() -> None: - f = lambda x: None - assert validate_args(Request("f", [1], NOID), NOCONTEXT, f) == Right(f) +def test_validate_args_positionals() -> None: + def ping_(_: int) -> Result: + return Ok() + assert validate_args(Request("ping_", [1], NOID), NOCONTEXT, ping_) == Success( + ping_ + ) + + +def test_validate_args_positionals_not_passed() -> None: + def ping_(name: str) -> Result: + return Ok() -def test_validate_result_positionals_not_passed() -> None: assert validate_args( - Request("f", {"foo": "bar"}, NOID), NOCONTEXT, lambda x: None - ) == Left( + Request("ping_", {"foo": "bar"}, NOID), NOCONTEXT, ping_ + ) == Failure( ErrorResult( - ERROR_INVALID_PARAMS, "Invalid params", "missing a required argument: 'x'" + ERROR_INVALID_PARAMS, + "Invalid params", + "missing a required argument: 'name'", ) ) -def test_validate_result_keywords() -> None: - f = lambda **kwargs: None - assert validate_args(Request("f", {"foo": "bar"}, NOID), NOCONTEXT, f) == Right(f) +def test_validate_args_keywords() -> None: + def f(**_: str) -> Result: + return Ok() + assert validate_args(Request("f", {"foo": "bar"}, NOID), NOCONTEXT, f) == Success(f) -def test_validate_result_object_method() -> None: + +def test_validate_args_object_method() -> None: class FooClass: - def f(self, *_: str) -> str: - return "" + def f(self, *_: str) -> Result: + return Ok() g = FooClass().f - assert validate_args(Request("g", ["one", "two"], NOID), NOCONTEXT, g) == Right(g) + assert validate_args(Request("g", ["one", "two"], NOID), NOCONTEXT, g) == Success(g) # call def test_call() -> None: - assert call(Request("ping", [], 1), NOCONTEXT, ping) == Right(SuccessResult("pong")) + assert call(Request("ping", [], 1), NOCONTEXT, ping) == Success( + SuccessResult("pong") + ) def test_call_raising_jsonrpcerror() -> None: - def method_() -> None: + def method_() -> Result: raise JsonRpcError(code=1, message="foo", data=NODATA) - assert call(Request("ping", [], 1), NOCONTEXT, method_) == Left( + assert call(Request("ping", [], 1), NOCONTEXT, method_) == Failure( ErrorResult(1, "foo") ) def test_call_raising_exception() -> None: - def method_() -> None: + def method_() -> Result: raise ValueError("foo") - assert call(Request("ping", [], 1), NOCONTEXT, method_) == Left( + assert call(Request("ping", [], 1), NOCONTEXT, method_) == Failure( ErrorResult(ERROR_INTERNAL_ERROR, "Internal error", "foo") ) @@ -218,12 +234,12 @@ def method_() -> None: "argument,value", [ ( - validate_args(Request("ping", [], 1), NOCONTEXT, ping), - Right(ping), + Request("ping", [], 1), + Success(ping), ), ( - validate_args(Request("ping", ["foo"], 1), NOCONTEXT, ping), - Left( + Request("ping", ["foo"], 1), + Failure( ErrorResult( ERROR_INVALID_PARAMS, "Invalid params", @@ -233,8 +249,8 @@ def method_() -> None: ), ], ) -def test_validate_args(argument: Result, value: Result) -> None: - assert argument == value +def test_validate_args(argument: Request, value: Result) -> None: + assert validate_args(argument, NOCONTEXT, ping) == value # get_method @@ -245,11 +261,11 @@ def test_validate_args(argument: Result, value: Result) -> None: [ ( get_method({"ping": ping}, "ping"), - Right(ping), + Success(ping), ), ( get_method({"ping": ping}, "non-existant"), - Left( + Failure( ErrorResult(ERROR_METHOD_NOT_FOUND, "Method not found", "non-existant") ), ), @@ -264,18 +280,19 @@ def test_get_method(argument: Result, value: Result) -> None: def test_dispatch_request() -> None: request = Request("ping", [], 1) - assert dispatch_request({"ping": ping}, NOCONTEXT, request) == ( + assert dispatch_request(validate_args, {"ping": ping}, NOCONTEXT, request) == ( request, - Right(SuccessResult("pong")), + Success(SuccessResult("pong")), ) def test_dispatch_request_with_context() -> None: def ping_with_context(context: Any) -> Result: assert context is sentinel.context - return Success() + return Ok() dispatch_request( + validate_args, {"ping_with_context": ping_with_context}, sentinel.context, Request("ping_with_context", [], 1), @@ -307,11 +324,12 @@ def test_not_notification_false() -> None: def test_dispatch_deserialized() -> None: assert dispatch_deserialized( - methods={"ping": ping}, - context=NOCONTEXT, - post_process=identity, - deserialized={"jsonrpc": "2.0", "method": "ping", "id": 1}, - ) == Right(SuccessResponse("pong", 1)) + validate_args, + identity, + {"ping": ping}, + NOCONTEXT, + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + ) == Success(SuccessResponse("pong", 1)) # validate_request @@ -319,11 +337,11 @@ def test_dispatch_deserialized() -> None: def test_validate_request() -> None: request = {"jsonrpc": "2.0", "method": "ping"} - assert validate_request(default_validator, request) == Right(request) + assert validate_request(default_jsonrpc_validator, request) == Success(request) def test_validate_request_invalid() -> None: - assert validate_request(default_validator, {"jsonrpc": "2.0"}) == Left( + assert validate_request(default_jsonrpc_validator, {"jsonrpc": "2.0"}) == Failure( ErrorResponse( ERROR_INVALID_REQUEST, "Invalid request", @@ -338,29 +356,34 @@ def test_validate_request_invalid() -> None: def test_dispatch_to_response_pure() -> None: assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', - ) == Right(SuccessResponse("pong", 1)) + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + ) == Success(SuccessResponse("pong", 1)) def test_dispatch_to_response_pure_parse_error() -> None: """Unable to parse, must return an error""" assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request="{", - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "{", + ) == Failure( ErrorResponse( ERROR_PARSE_ERROR, "Parse error", - "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", + ( + "Expecting property name enclosed in double quotes: " + "line 1 column 2 (char 1)" + ), None, ) ) @@ -371,13 +394,14 @@ def test_dispatch_to_response_pure_invalid_request() -> None: notification). """ assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request="{}", - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "{}", + ) == Failure( ErrorResponse( ERROR_INVALID_REQUEST, "Invalid request", @@ -389,29 +413,31 @@ def test_dispatch_to_response_pure_invalid_request() -> None: def test_dispatch_to_response_pure_method_not_found() -> None: assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={}, - request='{"jsonrpc": "2.0", "method": "non_existant", "id": 1}', - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "non_existant", "id": 1}', + ) == Failure( ErrorResponse(ERROR_METHOD_NOT_FOUND, "Method not found", "non_existant", 1) ) def test_dispatch_to_response_pure_invalid_params_auto() -> None: - def f(colour: str, size: str) -> Result: # pylint: disable=unused-argument - return Success() + def f(colour: str, size: str) -> Result: + return Ok() assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"f": f}, - request='{"jsonrpc": "2.0", "method": "f", "params": {"colour":"blue"}, "id": 1}', - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"f": f}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "f", "params": {"colour":"blue"}, "id": 1}', + ) == Failure( ErrorResponse( ERROR_INVALID_PARAMS, "Invalid params", @@ -425,16 +451,17 @@ def test_dispatch_to_response_pure_invalid_params_explicitly_returned() -> None: def foo(colour: str) -> Result: if colour not in ("orange", "red", "yellow"): return InvalidParams() - return Success() + return Ok() assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo", "params": ["blue"], "id": 1}', - ) == Left(ErrorResponse(ERROR_INVALID_PARAMS, "Invalid params", NODATA, 1)) + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo", "params": ["blue"], "id": 1}', + ) == Failure(ErrorResponse(ERROR_INVALID_PARAMS, "Invalid params", NODATA, 1)) def test_dispatch_to_response_pure_internal_error() -> None: @@ -442,44 +469,47 @@ def foo() -> Result: raise ValueError("foo") assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo", "id": 1}', - ) == Left(ErrorResponse(ERROR_INTERNAL_ERROR, "Internal error", "foo", 1)) + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo", "id": 1}', + ) == Failure(ErrorResponse(ERROR_INTERNAL_ERROR, "Internal error", "foo", 1)) @patch("jsonrpcserver.dispatcher.dispatch_request", side_effect=ValueError("foo")) def test_dispatch_to_response_pure_server_error(*_: Mock) -> None: def foo() -> Result: - return Success() + return Ok() assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo", "id": 1}', - ) == Left(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo", "id": 1}', + ) == Failure(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) def test_dispatch_to_response_pure_invalid_result() -> None: """Methods should return a Result, otherwise we get an Internal Error response.""" - def not_a_result() -> None: - return None + def not_a_result() -> Result: + return None # type: ignore assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"not_a_result": not_a_result}, - request='{"jsonrpc": "2.0", "method": "not_a_result", "id": 1}', - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"not_a_result": not_a_result}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "not_a_result", "id": 1}', + ) == Failure( ErrorResponse( ERROR_INTERNAL_ERROR, "Internal error", @@ -492,17 +522,18 @@ def not_a_result() -> None: def test_dispatch_to_response_pure_raising_exception() -> None: """Allow raising an exception to return an error.""" - def raise_exception() -> None: + def raise_exception() -> Result: raise JsonRpcError(code=0, message="foo", data="bar") assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"raise_exception": raise_exception}, - request='{"jsonrpc": "2.0", "method": "raise_exception", "id": 1}', - ) == Left(ErrorResponse(0, "foo", "bar", 1)) + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"raise_exception": raise_exception}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "raise_exception", "id": 1}', + ) == Failure(ErrorResponse(0, "foo", "bar", 1)) # dispatch_to_response_pure -- Notifications @@ -511,12 +542,13 @@ def raise_exception() -> None: def test_dispatch_to_response_pure_notification() -> None: assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request='{"jsonrpc": "2.0", "method": "ping"}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "ping"}', ) is None ) @@ -525,32 +557,39 @@ def test_dispatch_to_response_pure_notification() -> None: def test_dispatch_to_response_pure_notification_parse_error() -> None: """Unable to parse, must return an error""" assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request="{", - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "{", + ) == Failure( ErrorResponse( ERROR_PARSE_ERROR, "Parse error", - "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", + ( + "Expecting property name enclosed in double quotes: " + "line 1 column 2 (char 1)" + ), None, ) ) def test_dispatch_to_response_pure_notification_invalid_request() -> None: - """Invalid JSON-RPC, must return an error. (impossible to determine if notification)""" + """Invalid JSON-RPC, must return an error. (impossible to determine if + notification) + """ assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request="{}", - ) == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "{}", + ) == Failure( ErrorResponse( ERROR_INVALID_REQUEST, "Invalid request", @@ -563,48 +602,51 @@ def test_dispatch_to_response_pure_notification_invalid_request() -> None: def test_dispatch_to_response_pure_notification_method_not_found() -> None: assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={}, - request='{"jsonrpc": "2.0", "method": "non_existant"}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "non_existant"}', ) is None ) def test_dispatch_to_response_pure_notification_invalid_params_auto() -> None: - def foo(colour: str, size: str) -> Result: # pylint: disable=unused-argument - return Success() + def foo(colour: str, size: str) -> Result: + return Ok() assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo", "params": {"colour":"blue"}}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo", "params": {"colour":"blue"}}', ) is None ) -def test_dispatch_to_response_pure_invalid_params_notification_explicitly_returned() -> None: +def test_dispatch_to_response_pure_invalid_params_notification_returned() -> None: def foo(colour: str) -> Result: if colour not in ("orange", "red", "yellow"): return InvalidParams() - return Success() + return Ok() assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo", "params": ["blue"]}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo", "params": ["blue"]}', ) is None ) @@ -616,12 +658,13 @@ def foo(bar: str) -> Result: assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo"}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo"}', ) is None ) @@ -630,32 +673,34 @@ def foo(bar: str) -> Result: @patch("jsonrpcserver.dispatcher.dispatch_request", side_effect=ValueError("foo")) def test_dispatch_to_response_pure_notification_server_error(*_: Mock) -> None: def foo() -> Result: - return Success() + return Ok() assert dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"foo": foo}, - request='{"jsonrpc": "2.0", "method": "foo"}', - ) == Left(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"foo": foo}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foo"}', + ) == Failure(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) def test_dispatch_to_response_pure_notification_invalid_result() -> None: """Methods should return a Result, otherwise we get an Internal Error response.""" - def not_a_result() -> None: - return None + def not_a_result() -> Result: + return None # type: ignore assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"not_a_result": not_a_result}, - request='{"jsonrpc": "2.0", "method": "not_a_result"}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"not_a_result": not_a_result}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "not_a_result"}', ) is None ) @@ -664,138 +709,130 @@ def not_a_result() -> None: def test_dispatch_to_response_pure_notification_raising_exception() -> None: """Allow raising an exception to return an error.""" - def raise_exception() -> None: + def raise_exception() -> Result: raise JsonRpcError(code=0, message="foo", data="bar") assert ( dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"raise_exception": raise_exception}, - request='{"jsonrpc": "2.0", "method": "raise_exception"}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"raise_exception": raise_exception}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "raise_exception"}', ) is None ) -# dispatch_to_response - - -def test_dispatch_to_response() -> None: - response = dispatch_to_response( - '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} - ) - assert response == Right(SuccessResponse("pong", 1)) - - -def test_dispatch_to_response_with_global_methods() -> None: - @method - def ping() -> Result: # pylint: disable=redefined-outer-name - return Success("ping") - - response = dispatch_to_response('{"jsonrpc": "2.0", "method": "ping", "id": 1}') - assert response == Right(SuccessResponse("pong", 1)) - - # The remaining tests are direct from the examples in the specification def test_examples_positionals() -> None: def subtract(minuend: int, subtrahend: int) -> Result: - return Success(minuend - subtrahend) + return Ok(minuend - subtrahend) response = dispatch_to_response_pure( - methods={"subtract": subtract}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request='{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"subtract": subtract}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}', ) - assert response == Right(SuccessResponse(19, 1)) + assert response == Success(SuccessResponse(19, 1)) # Second example response = dispatch_to_response_pure( - methods={"subtract": subtract}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request='{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"subtract": subtract}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}', ) - assert response == Right(SuccessResponse(-19, 2)) + assert response == Success(SuccessResponse(-19, 2)) def test_examples_nameds() -> None: def subtract(**kwargs: int) -> Result: - return Success(kwargs["minuend"] - kwargs["subtrahend"]) + return Ok(kwargs["minuend"] - kwargs["subtrahend"]) response = dispatch_to_response_pure( - methods={"subtract": subtract}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request=( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"subtract": subtract}, + NOCONTEXT, + ( '{"jsonrpc": "2.0", "method": "subtract", ' '"params": {"subtrahend": 23, "minuend": 42}, "id": 3}' ), ) - assert response == Right(SuccessResponse(19, 3)) + assert response == Success(SuccessResponse(19, 3)) # Second example response = dispatch_to_response_pure( - methods={"subtract": subtract}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request=( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"subtract": subtract}, + NOCONTEXT, + ( '{"jsonrpc": "2.0", "method": "subtract", ' '"params": {"minuend": 42, "subtrahend": 23}, "id": 4}' ), ) - assert response == Right(SuccessResponse(19, 4)) + assert response == Success(SuccessResponse(19, 4)) def test_examples_notification() -> None: + def f() -> Result: + return Ok() + response = dispatch_to_response_pure( - methods={"update": lambda: None, "foobar": lambda: None}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request='{"jsonrpc": "2.0", "method": "update", "params": [1, 2, 3, 4, 5]}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"update": f, "foobar": f}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "update", "params": [1, 2, 3, 4, 5]}', ) assert response is None # Second example response = dispatch_to_response_pure( - methods={"update": lambda: None, "foobar": lambda: None}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request='{"jsonrpc": "2.0", "method": "foobar"}', + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"update": f, "foobar": f}, + NOCONTEXT, + '{"jsonrpc": "2.0", "method": "foobar"}', ) assert response is None def test_examples_invalid_json() -> None: response = dispatch_to_response_pure( - methods={"ping": ping}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - request=( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + ( '[{"jsonrpc": "2.0", "method": "sum", ' '"params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method"]' ), ) - assert response == Left( + assert response == Failure( ErrorResponse( ERROR_PARSE_ERROR, "Parse error", @@ -808,14 +845,15 @@ def test_examples_invalid_json() -> None: def test_examples_empty_array() -> None: # This is an invalid JSON-RPC request, should return an error. response = dispatch_to_response_pure( - request="[]", - methods={"ping": ping}, - context=NOCONTEXT, - validator=default_validator, - post_process=identity, - deserializer=default_deserializer, - ) - assert response == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "[]", + ) + assert response == Failure( ErrorResponse( ERROR_INVALID_REQUEST, "Invalid request", @@ -831,14 +869,15 @@ def test_examples_invalid_jsonrpc_batch() -> None: The examples are expecting a batch response full of error responses. """ response = dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request="[1]", - ) - assert response == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "[1]", + ) + assert response == Failure( ErrorResponse( ERROR_INVALID_REQUEST, "Invalid request", @@ -854,14 +893,15 @@ def test_examples_multiple_invalid_jsonrpc() -> None: The examples are expecting a batch response full of error responses. """ response = dispatch_to_response_pure( - deserializer=default_deserializer, - validator=default_validator, - post_process=identity, - context=NOCONTEXT, - methods={"ping": ping}, - request="[1, 2, 3]", - ) - assert response == Left( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + {"ping": ping}, + NOCONTEXT, + "[1, 2, 3]", + ) + assert response == Failure( ErrorResponse( ERROR_INVALID_REQUEST, "Invalid request", @@ -872,32 +912,42 @@ def test_examples_multiple_invalid_jsonrpc() -> None: def test_examples_mixed_requests_and_notifications() -> None: - methods: Dict[str, Callable[..., Any]] = { - "sum": lambda *args: Success(sum(args)), - "notify_hello": lambda *args: Success(19), - "subtract": lambda *args: Success(args[0] - sum(args[1:])), - "get_data": lambda: Success(["hello", 5]), + """We break the spec here. The examples put an invalid jsonrpc request in the mix + here, but it's removed to test the rest, because we're not validating each request + individually. Any invalid jsonrpc will respond with a single error message. + + The spec example includes this which invalidates the entire request: + {"foo": "boo"}, + """ + methods: Dict[str, Method] = { + "sum": lambda *args: Ok(sum(args)), + "notify_hello": lambda *args: Ok(19), + "subtract": lambda *args: Ok(args[0] - sum(args[1:])), + "get_data": lambda: Ok(["hello", 5]), } - response = dispatch( - deserializer=default_deserializer, - validator=default_validator, - context=NOCONTEXT, - methods=methods, - request="""[ + response = dispatch_to_response_pure( + validate_args, + json.loads, + default_jsonrpc_validator, + identity, + methods, + NOCONTEXT, + """[ {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, - {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, + { + "jsonrpc": "2.0", + "method": "foo.get", + "params": {"name": "myself"}, + "id": "5" + }, {"jsonrpc": "2.0", "method": "get_data", "id": "9"} ]""", ) - assert json.loads(response) == [ - {"jsonrpc": "2.0", "result": 7, "id": "1"}, - {"jsonrpc": "2.0", "result": 19, "id": "2"}, - { - "jsonrpc": "2.0", - "error": {"code": -32601, "message": "Method not found", "data": "foo.get"}, - "id": "5", - }, - {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}, + assert response == [ + Success(SuccessResponse(7, id="1")), + Success(SuccessResponse(19, id="2")), + Failure(ErrorResponse(-32601, "Method not found", "foo.get", id="5")), + Success(SuccessResponse(["hello", 5], id="9")), ] diff --git a/tests/test_main.py b/tests/test_main.py index 8b83069..52c936b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,25 +1,34 @@ """Test main.py""" -from oslash.either import Right # type: ignore + +from returns.result import Success from jsonrpcserver.main import ( + dispatch_to_json, dispatch_to_response, dispatch_to_serializable, - dispatch_to_json, ) +from jsonrpcserver.methods import method from jsonrpcserver.response import SuccessResponse -from jsonrpcserver.result import Result, Success - -# pylint: disable=missing-function-docstring +from jsonrpcserver.result import Ok, Result def ping() -> Result: - return Success("pong") + return Ok("pong") def test_dispatch_to_response() -> None: assert dispatch_to_response( '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} - ) == Right(SuccessResponse("pong", 1)) + ) == Success(SuccessResponse("pong", 1)) + + +def test_dispatch_to_response_with_global_methods() -> None: + @method + def ping() -> Result: + return Ok("pong") + + response = dispatch_to_response('{"jsonrpc": "2.0", "method": "ping", "id": 1}') + assert response == Success(SuccessResponse("pong", 1)) def test_dispatch_to_serializable() -> None: diff --git a/tests/test_methods.py b/tests/test_methods.py index 5649155..901b781 100644 --- a/tests/test_methods.py +++ b/tests/test_methods.py @@ -1,13 +1,13 @@ """Test methods.py""" -from jsonrpcserver.methods import global_methods, method -# pylint: disable=missing-function-docstring +from jsonrpcserver.methods import global_methods, method +from jsonrpcserver.result import Ok, Result def test_decorator() -> None: @method - def func() -> None: - pass + def func() -> Result: + return Ok() assert callable(global_methods["func"]) diff --git a/tests/test_request.py b/tests/test_request.py index 114a34d..728cc9a 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -1,7 +1,6 @@ """Test request.py""" -from jsonrpcserver.request import Request -# pylint: disable=missing-function-docstring +from jsonrpcserver.request import Request def test_request() -> None: diff --git a/tests/test_response.py b/tests/test_response.py index 29c2385..a0da382 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,7 +1,8 @@ """Test response.py""" + from unittest.mock import sentinel -from oslash.either import Left, Right # type: ignore +from returns.result import Failure, Success from jsonrpcserver.response import ( ErrorResponse, @@ -13,8 +14,6 @@ to_serializable, ) -# pylint: disable=missing-function-docstring,invalid-name,duplicate-code - def test_SuccessResponse() -> None: response = SuccessResponse(sentinel.result, sentinel.id) @@ -65,7 +64,7 @@ def test_ServerErrorResponse() -> None: def test_to_serializable() -> None: - assert to_serializable(Right(SuccessResponse(sentinel.result, sentinel.id))) == { + assert to_serializable(Success(SuccessResponse(sentinel.result, sentinel.id))) == { "jsonrpc": "2.0", "result": sentinel.result, "id": sentinel.id, @@ -77,7 +76,7 @@ def test_to_serializable_None() -> None: def test_to_serializable_SuccessResponse() -> None: - assert to_serializable(Right(SuccessResponse(sentinel.result, sentinel.id))) == { + assert to_serializable(Success(SuccessResponse(sentinel.result, sentinel.id))) == { "jsonrpc": "2.0", "result": sentinel.result, "id": sentinel.id, @@ -86,7 +85,9 @@ def test_to_serializable_SuccessResponse() -> None: def test_to_serializable_ErrorResponse() -> None: assert to_serializable( - Left(ErrorResponse(sentinel.code, sentinel.message, sentinel.data, sentinel.id)) + Failure( + ErrorResponse(sentinel.code, sentinel.message, sentinel.data, sentinel.id) + ) ) == { "jsonrpc": "2.0", "error": { @@ -99,7 +100,9 @@ def test_to_serializable_ErrorResponse() -> None: def test_to_serializable_list() -> None: - assert to_serializable([Right(SuccessResponse(sentinel.result, sentinel.id))]) == [ + assert to_serializable( + [Success(SuccessResponse(sentinel.result, sentinel.id))] + ) == [ { "jsonrpc": "2.0", "result": sentinel.result, diff --git a/tests/test_result.py b/tests/test_result.py index 793dbed..3cb2f9b 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -1,19 +1,18 @@ """Test result.py""" + from unittest.mock import sentinel -from oslash.either import Left, Right # type: ignore +from returns.result import Failure, Success from jsonrpcserver.result import ( Error, ErrorResult, InvalidParamsResult, - Success, + Ok, SuccessResult, ) from jsonrpcserver.sentinels import NODATA -# pylint: disable=missing-function-docstring,invalid-name - def test_SuccessResult() -> None: assert SuccessResult(None).result is None @@ -58,9 +57,9 @@ def test_InvalidParamsResult_with_data() -> None: assert result.data == sentinel.data -def test_Success() -> None: - assert Success() == Right(SuccessResult(None)) +def test_Ok() -> None: + assert Ok() == Success(SuccessResult(None)) def test_Error() -> None: - assert Error(1, "foo", None) == Left(ErrorResult(1, "foo", None)) + assert Error(1, "foo", None) == Failure(ErrorResult(1, "foo", None)) diff --git a/tests/test_sentinels.py b/tests/test_sentinels.py index c5c8f02..555460a 100644 --- a/tests/test_sentinels.py +++ b/tests/test_sentinels.py @@ -1,7 +1,6 @@ """Test sentinels.py""" -from jsonrpcserver.sentinels import Sentinel -# pylint: disable=missing-function-docstring +from jsonrpcserver.sentinels import Sentinel def test_sentinel() -> None: diff --git a/tests/test_server.py b/tests/test_server.py index ad37266..94753cf 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,10 +1,9 @@ """Test server.py""" + from unittest.mock import Mock, patch from jsonrpcserver.server import serve -# pylint: disable=missing-function-docstring - @patch("jsonrpcserver.server.HTTPServer") def test_serve(*_: Mock) -> None: diff --git a/tox.ini b/tox.ini index d8673ad..bd427c4 100644 --- a/tox.ini +++ b/tox.ini @@ -11,5 +11,5 @@ setenv = PYTHONDONTWRITEBYTECODE=1 deps = pytest pytest-asyncio -commands = pytest tests +commands = pytest tests --asyncio-mode=strict install_command=pip install --trusted-host=pypi.org --trusted-host=files.pythonhosted.org {opts} {packages}