diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..69fa449 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..bb06dfb --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,55 @@ +# 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 = 'Tornado OpenAPI 3' +copyright = '2021, Correl Roush' +author = 'Correl Roush' + + +# -- 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.ext.autodoc", + "sphinx_rtd_theme", +] + +autodoc_member_order = 'groupwise' + +# 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 = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- 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' + +# 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'] diff --git a/docs/handler.rst b/docs/handler.rst new file mode 100644 index 0000000..6eb6848 --- /dev/null +++ b/docs/handler.rst @@ -0,0 +1,9 @@ +Handling Incoming Requests +========================== + +OpenAPIRequestHandler extends Tornado's RequestHandler class, providing +validation of incoming requests and translating errors into appropriate HTTP +responses. + +.. automodule:: tornado_openapi3.handler + :members: diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..5373acc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,52 @@ +.. Tornado OpenAPI 3 documentation master file, created by + sphinx-quickstart on Thu Feb 25 23:03:16 2021. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Tornado OpenAPI 3 +================= + +.. image:: https://travis-ci.com/correl/tornado-openapi3.svg?branch=master + :target: https://travis-ci.com/correl/tornado-openapi3 +.. image:: https://codecov.io/gh/correl/tornado-openapi3/branch/master/graph/badge.svg?token=CTYWWDXTL9 + :target: https://codecov.io/gh/correl/tornado-openapi3 +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + + +Tornado OpenAPI 3 request and response validation library. + +Provides integration between the `Tornado`_ web framework and `Openapi-core`_ +library for validating request and response objects against an `OpenAPI 3`_ +specification. + +.. toctree:: + :maxdepth: 2 + :caption: Getting Started + + installation + +.. toctree:: + :maxdepth: 2 + :caption: Usage + + handler + testing + +.. toctree:: + :maxdepth: 2 + :caption: Validators + + validators + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. _OpenAPI 3: https://swagger.io/specification/ +.. _Openapi-core: https://github.com/p1c2u/openapi-core +.. _Tornado: https://www.tornadoweb.org/ diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..bf1e624 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,11 @@ +Installation +============ + +Tornado OpenAPI 3 is distributed on `PyPi`_ and can be installed via ``pip`` by +running: + +.. code:: console + + $ pip install tornado-openapi3 + +.. _PyPi: https://pypi.org/project/tornado-openapi3/ diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..2119f51 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..c94dd87 --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,8 @@ +Testing API Responses +===================== + +AsyncOpenAPITestCase extends Tornado's AsyncHTTPTestCase class, providing +validation of the responses from your application and raising errors in tests. + +.. automodule:: tornado_openapi3.testing + :members: diff --git a/docs/validators.rst b/docs/validators.rst new file mode 100644 index 0000000..2b41b3a --- /dev/null +++ b/docs/validators.rst @@ -0,0 +1,11 @@ +Requests +========== + +.. automodule:: tornado_openapi3.requests + :members: + +Responses +========= + +.. automodule:: tornado_openapi3.responses + :members: diff --git a/pyproject.toml b/pyproject.toml index 1cb3034..ba4a6cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ pytest-black = "*" pytest-cov = "*" pytest-flake8 = "*" pytest-mypy = "*" +sphinx = "^3.5.1" +sphinx-rtd-theme = "^0.5.1" +pylint = "^2.7.1" [tool.coverage.report] fail_under = 100 diff --git a/tornado_openapi3/handler.py b/tornado_openapi3/handler.py index cfd1ec4..163ac33 100644 --- a/tornado_openapi3/handler.py +++ b/tornado_openapi3/handler.py @@ -21,10 +21,62 @@ logger = logging.getLogger(__name__) class OpenAPIRequestHandler(tornado.web.RequestHandler): - spec: dict = {} - custom_media_type_deserializers: dict = {} + @property + def spec(self) -> dict: + """The OpenAPI 3 specification as a Python dictionary. + + Override this in your request handlers to load or define your OpenAPI 3 + spec. + + """ + return dict() + + @property + def custom_media_type_deserializers(self) -> dict: + """A dictionary mapping media types to deserializing functions. + + If your endpoints make use of content types beyond ``application/json``, + you must add them to this dictionary with a deserializing method that + converts the raw body (as ``bytes`` or ``str``) to Python objects. + + """ + return dict() async def prepare(self) -> None: + """Called at the beginning of a request before *get/post/etc*. + + Performs OpenAPI validation of the incoming request. Problems + encountered while validating the request are translated to HTTP error + codes: + + +--------------------------+----------+----------------------------------------+ + |OpenAPI Errors |Error Code|Description | + +--------------------------+----------+----------------------------------------+ + |``PathNotFound`` |``404`` |Could not find the path for this request| + | | |in the OpenAPI specification. | + +--------------------------+----------+----------------------------------------+ + |``OperationNotFound`` |``405`` |Could not find the operation specified | + | | |for this request in the OpenAPI | + | | |specification. | + +--------------------------+----------+----------------------------------------+ + |``DeserializeError``, |``400`` |The message body could not be decoded or| + |``ValidateError`` | |did not validate against the specified | + | | |schema. | + +--------------------------+----------+----------------------------------------+ + |``InvalidSecurity`` |``401`` |Required authorization was missing from | + | | |the request. | + +--------------------------+----------+----------------------------------------+ + |``InvalidContentType`` |``415`` |The content type of the request did not | + | | |match any of the types in the OpenAPI | + | | |specification. | + +--------------------------+----------+----------------------------------------+ + |Any other ``OpenAPIError``|``500`` |An unexpected error occurred. | + +--------------------------+----------+----------------------------------------+ + + To provide content in these error requests, you may override + :meth:`on_openapi_error`. + + """ maybe_coro = super().prepare() if maybe_coro and asyncio.iscoroutine(maybe_coro): # pragma: no cover await maybe_coro @@ -52,5 +104,11 @@ class OpenAPIRequestHandler(tornado.web.RequestHandler): self.validated = result def on_openapi_error(self, status_code: int, error: OpenAPIError) -> None: + """Sets an HTTP status code and finishes the request. + + By default, no content is returned. To provide more informative + responses, you may override this method. + + """ self.set_status(status_code) self.finish() diff --git a/tornado_openapi3/requests.py b/tornado_openapi3/requests.py index 09b430c..2de2c0e 100644 --- a/tornado_openapi3/requests.py +++ b/tornado_openapi3/requests.py @@ -16,8 +16,15 @@ from .util import parse_mimetype class TornadoRequestFactory: + """Factory for converting Tornado requests to OpenAPI request objects.""" @classmethod def create(cls, request: Union[HTTPRequest, HTTPServerRequest]) -> OpenAPIRequest: + """Creates an OpenAPI request from Tornado request objects. + + Supports both :class:`tornado.httpclient.HTTPRequest` and + :class:`tornado.httputil.HTTPServerRequest` objects. + + """ if isinstance(request, HTTPRequest): if request.url: path, _, querystring = request.url.partition("?") @@ -55,9 +62,11 @@ class TornadoRequestFactory: class RequestValidator(validators.RequestValidator): + """Validator for Tornado HTTP Requests.""" def validate( self, request: Union[HTTPRequest, HTTPServerRequest] ) -> RequestValidationResult: + """Validate a Tornado HTTP request object.""" return super().validate(TornadoRequestFactory.create(request)) diff --git a/tornado_openapi3/responses.py b/tornado_openapi3/responses.py index 89365fe..d034c16 100644 --- a/tornado_openapi3/responses.py +++ b/tornado_openapi3/responses.py @@ -10,8 +10,10 @@ from .util import parse_mimetype class TornadoResponseFactory: + """Factory for converting Tornado responses to OpenAPI response objects.""" @classmethod def create(cls, response: HTTPResponse) -> OpenAPIResponse: + """Creates an OpenAPI response from Tornado response objects.""" mimetype = parse_mimetype(response.headers.get("Content-Type", "text/html")) return OpenAPIResponse( data=response.body if response.body else b"", @@ -21,7 +23,9 @@ class TornadoResponseFactory: class ResponseValidator(validators.ResponseValidator): + """Validator for Tornado HTTP Responses.""" def validate(self, response: HTTPResponse) -> ResponseValidationResult: + """Validate a Tornado HTTP response object.""" return super().validate( TornadoRequestFactory.create(response.request), TornadoResponseFactory.create(response), diff --git a/tornado_openapi3/testing.py b/tornado_openapi3/testing.py index 56e25d9..0261cef 100644 --- a/tornado_openapi3/testing.py +++ b/tornado_openapi3/testing.py @@ -8,10 +8,34 @@ from tornado_openapi3.responses import ResponseValidator class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase): - spec: dict = {} - custom_media_type_deserializers: dict = {} + @property + def spec(self) -> dict: + """The OpenAPI 3 specification as a Python dictionary. + + Override this in your request handlers to load or define your OpenAPI 3 + spec. + + """ + return dict() + + @property + def custom_media_type_deserializers(self) -> dict: + """A dictionary mapping media types to deserializing functions. + + If your endpoints make use of content types beyond ``application/json``, + you must add them to this dictionary with a deserializing method that + converts the raw body (as ``bytes`` or ``str``) to Python objects. + + """ + return dict() def setUp(self) -> None: + """Hook method for setting up the test fixture before exercising it. + + Instantiates the :class:`tornado_openapi3.responses.ResponseValidator` + for this test case. + + """ super().setUp() self.validator = ResponseValidator( create_spec(self.spec), @@ -21,6 +45,15 @@ class AsyncOpenAPITestCase(tornado.testing.AsyncHTTPTestCase): def fetch( self, path: str, raise_error: bool = False, **kwargs: Any ) -> tornado.httpclient.HTTPResponse: + """Convenience methiod to synchronously fetch a URL. + + Extends the fetch method in Tornado's + :class:``tornado.testing.AsyncHTTPTestCase`` to perform OpenAPI 3 + validation on the response received before returning it. If validation + fails, an :class:`openapi_core.exceptions.OpenAPIError` will be raised + describing the failure. + + """ response = super().fetch(path, raise_error=False, **kwargs) result = self.validator.validate(response) result.raise_for_errors()