├── demos ├── rest_api │ ├── cars │ │ ├── __init__.py │ │ └── api │ │ │ └── __init__.py │ ├── README.md │ ├── API_Documentation.md │ └── app.py └── helloworld │ ├── helloworld │ ├── __init__.py │ └── api.py │ ├── README.md │ ├── helloworld.py │ └── API_Documentation.md ├── tests ├── __init__.py ├── utils.py ├── test_api_doc_gen.py ├── helloworld_API_documentation.md ├── test_schema.py ├── test_tornado_json.py └── func_test.py ├── requirements.txt ├── MANIFEST.in ├── docs ├── installation.rst ├── docgen.rst ├── restapi.rst ├── tornado_json.rst ├── index.rst ├── requesthandler_guidelines.rst ├── using_tornado_json.rst ├── changelog.rst ├── make.bat ├── Makefile └── conf.py ├── tox.ini ├── tornado_json ├── constants.py ├── gen.py ├── __init__.py ├── exceptions.py ├── application.py ├── jsend.py ├── utils.py ├── requesthandlers.py ├── schema.py ├── api_doc_gen.py └── routes.py ├── .gitignore ├── .travis.yml ├── Changes_checklist.md ├── maintenance.md ├── LICENSE ├── setup.py └── README.md /demos/rest_api/cars/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/helloworld/helloworld/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test module for tornado_json""" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tornado>=6.0.3,<7.0 2 | jsonschema>=3.1.1,<4.0 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include README.md 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from traceback import print_exc 2 | 3 | 4 | def handle_import_error(err): 5 | print_exc() 6 | print("Please run `py.test` from the root project directory") 7 | exit(1) 8 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Simply run:: 6 | 7 | pip install Tornado-JSON 8 | 9 | Alternatively, clone the GitHub repository:: 10 | 11 | git clone https://github.com/hfaran/Tornado-JSON.git 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py35, py36, py37, py38}-tornado6 3 | 4 | [testenv] 5 | deps= 6 | pytest 7 | pytest-cov 8 | tornado6: tornado>=6.0.3,<7.0 9 | jsonschema>=3.1.1,<4.0 10 | 11 | commands= 12 | py.test -vv --cov="tornado_json" --cov-report=term 13 | -------------------------------------------------------------------------------- /tornado_json/constants.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from tornado import version_info as tornado_version_info 4 | 5 | 6 | PY3 = sys.version_info[0] == 3 7 | PY2 = sys.version_info[0] == 2 8 | 9 | (TORNADO_MAJOR, 10 | TORNADO_MINOR, 11 | TORNADO_PATCH) = tornado_version_info[:3] 12 | 13 | HTTP_METHODS = ["get", "put", "post", "patch", "delete", "head", "options"] 14 | -------------------------------------------------------------------------------- /demos/rest_api/README.md: -------------------------------------------------------------------------------- 1 | # Creating a REST API Using URL Annotations 2 | 3 | This demo serves as an introduction and explanation of URL annotations (a fancy name for what are two class variables: `__urls__` and `__url_names__` that provide some cool functionality. See the corresponding blurb in [the documentation](http://tornado-json.readthedocs.org/en/latest/restapi.html) for details. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.mo 2 | *.egg-info 3 | *.egg 4 | *.EGG 5 | *.EGG-INFO 6 | .cache 7 | bin 8 | build 9 | develop-eggs 10 | downloads 11 | eggs 12 | fake-eggs 13 | parts 14 | dist 15 | .installed.cfg 16 | .mr.developer.cfg 17 | .hg 18 | .bzr 19 | .svn 20 | *.pyc 21 | *.pyo 22 | *.tmp* 23 | 24 | .idea 25 | .vscode 26 | 27 | # Test artifacts 28 | .coverage 29 | .tox/ 30 | API_Documentation.md 31 | -------------------------------------------------------------------------------- /tornado_json/gen.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from tornado import gen 4 | 5 | 6 | def coroutine(func): 7 | """Tornado-JSON compatible wrapper for ``tornado.gen.coroutine`` 8 | 9 | Annotates original argspec.args of ``func`` as attribute ``__argspec_args`` 10 | """ 11 | wrapper = gen.coroutine(func) 12 | wrapper.__argspec_args = inspect.getfullargspec(func).args 13 | return wrapper 14 | -------------------------------------------------------------------------------- /docs/docgen.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Documentation Generation 3 | ======================== 4 | 5 | Public API Usage Documentation 6 | ------------------------------ 7 | 8 | API Usage documentation is generated by the ``tornado_json.api_doc_gen`` 9 | module. The ``api_doc_gen`` method is run on startup so to generate 10 | documentation, simply run your app and the documentation will written to 11 | ``API_Documentation.md``. in the current folder. 12 | -------------------------------------------------------------------------------- /tornado_json/__init__.py: -------------------------------------------------------------------------------- 1 | # As setup.py imports this module to get the version, try not to do anything 2 | # with dependencies for the project here. If that happens, setup.py 3 | # should not import tornado_json and instead use this find_version 4 | # thing: https://github.com/jezdez/envdir/blob/a062497e4339d5eb11e8a95dc6186dea6231aeb1/setup.py#L24 5 | # Alternatively, just put the version in a text file or something to avoid 6 | # this. 7 | 8 | __version__ = '2.0.0' 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | env: 8 | - TORNADO_VERSION=6.0.3 9 | install: 10 | - "pip install -r requirements.txt" 11 | - "pip install tornado==$TORNADO_VERSION" 12 | - "pip install 'pytest'" 13 | - "pip install 'pytest-cov'" 14 | - "pip install coverage" 15 | - "pip install coveralls" 16 | - "pip install mock" 17 | script: 18 | coverage run --source=tornado_json setup.py test 19 | after_success: 20 | coveralls --verbose 21 | -------------------------------------------------------------------------------- /tornado_json/exceptions.py: -------------------------------------------------------------------------------- 1 | from tornado.web import HTTPError 2 | 3 | 4 | class APIError(HTTPError): 5 | """Equivalent to ``RequestHandler.HTTPError`` except for in name""" 6 | 7 | 8 | def api_assert(condition, *args, **kwargs): 9 | """Assertion to fail with if not ``condition`` 10 | 11 | Asserts that ``condition`` is ``True``, else raises an ``APIError`` 12 | with the provided ``args`` and ``kwargs`` 13 | 14 | :type condition: bool 15 | """ 16 | if not condition: 17 | raise APIError(*args, **kwargs) 18 | -------------------------------------------------------------------------------- /demos/helloworld/README.md: -------------------------------------------------------------------------------- 1 | # Hello World 2 | 3 | This demo contains several handlers that aim to show you the ropes. To get started, [visit the documentation on readthedocs](http://tornado-json.readthedocs.org/en/latest/) for a walkthrough of creating the app and setting up the single `HelloWorldHandler`. After you're done that, see the rest of the handlers in `helloworld.api`; they're annotated with comments explaining. 4 | 5 | ## Diving In 6 | 7 | If you just want to run some code, and see things happen... 8 | 9 | ``` 10 | python helloworld.py 11 | ``` 12 | 13 | Take a look through `API_Documentation.md` for auto-generated API documentation, and see `helloworld.api` for more example code. 14 | -------------------------------------------------------------------------------- /Changes_checklist.md: -------------------------------------------------------------------------------- 1 | ## Before Merging into `master` 2 | * Builds should not be broken 3 | * Examples should be updated to reflect changes 4 | * Documentation should be updated to reflect changes 5 | 6 | ## Before Doing a Release 7 | * Update changelog in `docs` with changes 8 | * Bump the version in `__init__.py` 9 | * Publish a [new release on GitHub](https://github.com/hfaran/Tornado-JSON/releases) 10 | * Upload to [PyPI](https://pypi.python.org/pypi/Tornado-JSON) 11 | 12 | ## After the Release 13 | * Trigger a new documentation build on [readthedocs](https://readthedocs.org/projects/tornado-json/) 14 | * Mark active the new version on RTD (or otherwise do any version management as necessary) 15 | -------------------------------------------------------------------------------- /maintenance.md: -------------------------------------------------------------------------------- 1 | **This is just a list of commands I've found useful for project maintenance** 2 | 3 | 4 | * Install project with files.txt record 5 | 6 | ```sudo python setup.py install --record files.txt``` 7 | 8 | * "uninstall" package installed with files.txt record 9 | 10 | ```cat files.txt | sudo xargs rm -rf``` 11 | 12 | * Generate/update base docs/ folder with Sphinx 13 | 14 | ```sphinx-apidoc -F -o docs tornado_json``` 15 | 16 | * Run tests from root project directory 17 | 18 | * `py.test --cov="tornado_json" --cov-report=term --cov-report=html` 19 | * `nosetests --with-cov --cov-report term-missing --cov tornado_json tests/` 20 | * With `tox>=1.8.0` installed for both py27 and py34 21 | * `sudo tox # runs test matrix with py27,py34 and tornado322,402` 22 | -------------------------------------------------------------------------------- /demos/rest_api/API_Documentation.md: -------------------------------------------------------------------------------- 1 | **This documentation is automatically generated.** 2 | 3 | **Output schemas only represent `data` and not the full output; see output examples and the JSend specification.** 4 | 5 | # /api/cars/\(?P\\[a\-zA\-Z0\-9\_\]\+\)/\(?P\\[a\-zA\-Z0\-9\_\]\+\)/\(?P\\[a\-zA\-Z0\-9\_\]\+\)/?$ 6 | 7 | Content-Type: application/json 8 | 9 | 10 | 11 |
12 |
13 | 14 | # /api/cars/\(?P\\[a\-zA\-Z0\-9\_\]\+\)/\(?P\\[a\-zA\-Z0\-9\_\]\+\)/?$ 15 | 16 | Content-Type: application/json 17 | 18 | 19 | 20 |
21 |
22 | 23 | # /api/cars/\(?P\\[a\-zA\-Z0\-9\_\]\+\)/?$ 24 | 25 | Content-Type: application/json 26 | 27 | 28 | 29 |
30 |
31 | 32 | # /api/cars/? 33 | 34 | Content-Type: application/json 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/restapi.rst: -------------------------------------------------------------------------------- 1 | Creating a REST API Using URL Annotations 2 | ========================================= 3 | 4 | You may have noticed that the automatic URL generation 5 | is meant to be quick and easy-to-use for simple cases (creating an 6 | API in 15 minutes kind of thing). 7 | 8 | It is more powerful though, however, as you can customize it 9 | to get the URLs for RequestHandlers how you want without 10 | having to make additions to output from ``routes.get_routes`` 11 | yourself. This is done through the use of "URL annotations". 12 | ``APIHandler`` and ``ViewHandler`` have two "magic" attributes 13 | (``__urls__`` and ``__url_names__``) that allow you to define custom routes right in the handler 14 | body. See relevant documentation in the 15 | `REST API `__ 16 | example in the demos. 17 | -------------------------------------------------------------------------------- /demos/rest_api/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.7 2 | 3 | # ---- The following so demo can be run without having to install package ----# 4 | import sys 5 | sys.path.append("../../") 6 | # ---- Can be removed if Tornado-JSON is installed ----# 7 | 8 | # This module contains essentially the same boilerplate 9 | # as the corresponding one in the helloworld example; 10 | # refer to that for details. 11 | 12 | import json 13 | import tornado.ioloop 14 | from tornado_json.routes import get_routes 15 | from tornado_json.application import Application 16 | 17 | 18 | def main(): 19 | import cars 20 | routes = get_routes(cars) 21 | print("Routes\n======\n\n" + json.dumps( 22 | [(url, repr(rh)) for url, rh in routes], 23 | indent=2) 24 | ) 25 | application = Application(routes=routes, settings={}) 26 | 27 | application.listen(8888) 28 | tornado.ioloop.IOLoop.instance().start() 29 | 30 | 31 | if __name__ == '__main__': 32 | main() 33 | -------------------------------------------------------------------------------- /docs/tornado_json.rst: -------------------------------------------------------------------------------- 1 | tornado_json Package 2 | ==================== 3 | 4 | :mod:`api_doc_gen` Module 5 | ------------------------- 6 | 7 | .. automodule:: tornado_json.api_doc_gen 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | :mod:`application` Module 13 | ------------------------- 14 | 15 | .. automodule:: tornado_json.application 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | :mod:`jsend` Module 21 | ------------------- 22 | 23 | .. automodule:: tornado_json.jsend 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | :mod:`requesthandlers` Module 29 | ----------------------------- 30 | 31 | .. automodule:: tornado_json.requesthandlers 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | :mod:`routes` Module 37 | -------------------- 38 | 39 | .. automodule:: tornado_json.routes 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Hamza Faran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /demos/helloworld/helloworld.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # ---- The following so demo can be run without having to install package ----# 4 | import sys 5 | sys.path.append("../../") 6 | # ---- Can be removed if Tornado-JSON is installed ----# 7 | 8 | import json 9 | import tornado.ioloop 10 | from tornado_json.routes import get_routes 11 | from tornado_json.application import Application 12 | 13 | 14 | def main(): 15 | # Pass the web app's package the get_routes and it will generate 16 | # routes based on the submodule names and ending with lowercase 17 | # request handler name (with 'handler' removed from the end of the 18 | # name if it is the name). 19 | # [("/api/helloworld", helloworld.api.HelloWorldHandler)] 20 | import helloworld 21 | routes = get_routes(helloworld) 22 | print("Routes\n======\n\n" + json.dumps( 23 | [(url, repr(rh)) for url, rh in routes], 24 | indent=2) 25 | ) 26 | # Create the application by passing routes and any settings 27 | application = Application(routes=routes, settings={}, generate_docs=True) 28 | 29 | # Start the application on port 8888 30 | application.listen(8888) 31 | tornado.ioloop.IOLoop.instance().start() 32 | 33 | 34 | if __name__ == '__main__': 35 | main() 36 | -------------------------------------------------------------------------------- /tornado_json/application.py: -------------------------------------------------------------------------------- 1 | import tornado.web 2 | 3 | from tornado_json.api_doc_gen import api_doc_gen 4 | from tornado_json.constants import TORNADO_MAJOR 5 | 6 | 7 | class Application(tornado.web.Application): 8 | """Entry-point for the app 9 | 10 | - Generate API documentation using provided routes 11 | - Initialize the application 12 | 13 | :type routes: [(url, RequestHandler), ...] 14 | :param routes: List of routes for the app 15 | :type settings: dict 16 | :param settings: Settings for the app 17 | :param db_conn: Database connection 18 | :param bool generate_docs: If set, will generate API documentation for 19 | provided ``routes``. Documentation is written as API_Documentation.md 20 | in the cwd. 21 | """ 22 | 23 | def __init__(self, routes, settings, db_conn=None, 24 | generate_docs=False): 25 | if generate_docs: 26 | # Generate API Documentation 27 | api_doc_gen(routes) 28 | 29 | # Unless compress_response was specifically set to False in 30 | # settings, enable it 31 | compress_response = "compress_response" if TORNADO_MAJOR >= 4 else "gzip" 32 | if compress_response not in settings: 33 | settings[compress_response] = True 34 | 35 | tornado.web.Application.__init__( 36 | self, 37 | routes, 38 | **settings 39 | ) 40 | 41 | self.db_conn = db_conn 42 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. tornado_json documentation master file, created by 2 | sphinx-quickstart on Thu Dec 19 00:44:46 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Tornado-JSON 7 | ======================================== 8 | 9 | Tornado-JSON is a small extension of `Tornado `__ with the intent providing 10 | the tools necessary to get a JSON API up and running quickly. See 11 | `demos/helloworld/ `__ 12 | for a quick example and the `accompanying 13 | walkthrough `__ 14 | in the documentation. 15 | 16 | Some of the key features the included modules provide: 17 | 18 | - Input and output `JSON Schema `__ validation 19 | by decorating RequestHandlers with ``schema.validate`` 20 | - Automated *route generation* with ``routes.get_routes(package)`` 21 | - *Automated Public API documentation* using schemas and provided 22 | descriptions 23 | - Standardized output using the 24 | `JSend `__ specification 25 | 26 | **Contents**: 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | 31 | installation 32 | using_tornado_json 33 | requesthandler_guidelines 34 | docgen 35 | restapi 36 | changelog 37 | tornado_json 38 | 39 | 40 | 41 | Indices and tables 42 | ================== 43 | 44 | * :ref:`genindex` 45 | * :ref:`modindex` 46 | * :ref:`search` 47 | -------------------------------------------------------------------------------- /docs/requesthandler_guidelines.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Request Handler Guidelines 3 | ========================== 4 | 5 | Schemas and Public API Documentation 6 | ------------------------------------ 7 | 8 | Use the ``schema.validate`` decorator on methods which will automatically 9 | validate the request body and output against the schemas provided. The schemas 10 | must be valid JSON schemas; 11 | `readthedocs `__ 12 | for an example. 13 | Additionally, ``return`` the data from the 14 | request handler, rather than writing it back (the decorator will take 15 | care of that). 16 | 17 | The docstring of the method, as well as the schemas will be used to generate 18 | **public** API documentation. 19 | 20 | .. code:: python 21 | 22 | class ExampleHandler(APIHandler): 23 | @schema.validate(input_schema=..., output_schema=...) 24 | def post(self): 25 | """I am the public API documentation of this route""" 26 | ... 27 | return data 28 | 29 | 30 | Assertions 31 | ---------- 32 | 33 | 34 | Use ``exceptions.api_assert`` to fail when some the client does not meet some 35 | API pre-condition/requirement, e.g., an invalid or incomplete request is 36 | made. When using an assertion is not suitable, 37 | ``raise APIError( ... )``; don't use ``self.fail`` directly. 38 | 39 | .. code:: python 40 | 41 | class ExampleHandler(APIHandler): 42 | @schema.validate() 43 | def post(self): 44 | ... 45 | api_assert(condition, status_code, log_message=log_message) 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | __DIR__ = os.path.abspath(os.path.dirname(__file__)) 4 | import codecs 5 | from setuptools import setup 6 | from setuptools.command.test import test as TestCommand 7 | import tornado_json 8 | 9 | 10 | def read(filename): 11 | """Read and return `filename` in root dir of project and return string""" 12 | return codecs.open(os.path.join(__DIR__, filename), 'r').read() 13 | 14 | 15 | install_requires = read("requirements.txt").split() 16 | long_description = read('README.md') 17 | 18 | 19 | class Pytest(TestCommand): 20 | 21 | def finalize_options(self): 22 | TestCommand.finalize_options(self) 23 | self.test_args = ['--verbose'] 24 | self.test_suite = True 25 | 26 | def run_tests(self): 27 | # Using pytest rather than tox because Travis-CI has issues with tox 28 | # Import here, cause outside the eggs aren't loaded 29 | import pytest 30 | errcode = pytest.main(self.test_args) 31 | 32 | sys.exit(errcode) 33 | 34 | 35 | setup( 36 | name="Tornado-JSON", 37 | version=tornado_json.__version__, 38 | url='https://github.com/hfaran/Tornado-JSON', 39 | license='MIT License', 40 | author='Hamza Faran', 41 | description=('A simple JSON API framework based on Tornado'), 42 | long_description=long_description, 43 | packages=['tornado_json'], 44 | install_requires=install_requires, 45 | tests_require=['pytest'], 46 | cmdclass = {'test': Pytest}, 47 | data_files=[ 48 | # Populate this with any files config files etc. 49 | ], 50 | classifiers=[ 51 | "Development Status :: 6 - Production/Stable", 52 | "Intended Audience :: Developers", 53 | "License :: OSI Approved :: MIT License", 54 | "Natural Language :: English", 55 | "Operating System :: OS Independent", 56 | "Programming Language :: Python :: 3.6", 57 | "Topic :: Software Development :: Libraries :: Application Frameworks", 58 | ] 59 | ) 60 | -------------------------------------------------------------------------------- /tornado_json/jsend.py: -------------------------------------------------------------------------------- 1 | class JSendMixin(object): 2 | """http://labs.omniti.com/labs/jsend 3 | 4 | JSend is a specification that lays down some rules for how JSON 5 | responses from web servers should be formatted. 6 | 7 | JSend focuses on application-level (as opposed to protocol- or 8 | transport-level) messaging which makes it ideal for use in 9 | REST-style applications and APIs. 10 | """ 11 | 12 | def success(self, data): 13 | """When an API call is successful, the JSend object is used as a simple 14 | envelope for the results, using the data key. 15 | 16 | :type data: A JSON-serializable object 17 | :param data: Acts as the wrapper for any data returned by the API 18 | call. If the call returns no data, data should be set to null. 19 | """ 20 | self.write({'status': 'success', 'data': data}) 21 | self.finish() 22 | 23 | def fail(self, data): 24 | """There was a problem with the data submitted, or some pre-condition 25 | of the API call wasn't satisfied. 26 | 27 | :type data: A JSON-serializable object 28 | :param data: Provides the wrapper for the details of why the request 29 | failed. If the reasons for failure correspond to POST values, 30 | the response object's keys SHOULD correspond to those POST values. 31 | """ 32 | self.write({'status': 'fail', 'data': data}) 33 | self.finish() 34 | 35 | def error(self, message, data=None, code=None): 36 | """An error occurred in processing the request, i.e. an exception was 37 | thrown. 38 | 39 | :type data: A JSON-serializable object 40 | :param data: A generic container for any other information about the 41 | error, i.e. the conditions that caused the error, 42 | stack traces, etc. 43 | :type message: A JSON-serializable object 44 | :param message: A meaningful, end-user-readable (or at the least 45 | log-worthy) message, explaining what went wrong 46 | :type code: int 47 | :param code: A numeric code corresponding to the error, if applicable 48 | """ 49 | result = {'status': 'error', 'message': message} 50 | if data: 51 | result['data'] = data 52 | if code: 53 | result['code'] = code 54 | self.write(result) 55 | self.finish() 56 | -------------------------------------------------------------------------------- /tornado_json/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | from functools import wraps 4 | from collections.abc import Mapping 5 | 6 | 7 | def deep_update(source, overrides): 8 | """Update a nested dictionary or similar mapping. 9 | 10 | Modify ``source`` in place. 11 | 12 | :type source: collections.Mapping 13 | :type overrides: collections.Mapping 14 | :rtype: collections.Mapping 15 | """ 16 | for key, value in overrides.items(): 17 | if isinstance(value, Mapping) and value: 18 | returned = deep_update(source.get(key, {}), value) 19 | source[key] = returned 20 | else: 21 | source[key] = overrides[key] 22 | return source 23 | 24 | 25 | def container(dec): 26 | """Meta-decorator (for decorating decorators) 27 | 28 | Keeps around original decorated function as a property ``orig_func`` 29 | 30 | :param dec: Decorator to decorate 31 | :type dec: function 32 | :returns: Decorated decorator 33 | """ 34 | # Credits: http://stackoverflow.com/a/1167248/1798683 35 | @wraps(dec) 36 | def meta_decorator(f): 37 | decorator = dec(f) 38 | decorator.orig_func = f 39 | return decorator 40 | return meta_decorator 41 | 42 | 43 | def extract_method(wrapped_method): 44 | """Gets original method if wrapped_method was decorated 45 | 46 | :rtype: any([types.FunctionType, types.MethodType]) 47 | """ 48 | # If method was decorated with validate, the original method 49 | # is available as orig_func thanks to our container decorator 50 | return wrapped_method.orig_func if \ 51 | hasattr(wrapped_method, "orig_func") else wrapped_method 52 | 53 | 54 | def is_method(method): 55 | method = extract_method(method) 56 | # Can be either a method or a function 57 | return type(method) in [types.MethodType, types.FunctionType] 58 | 59 | 60 | def is_handler_subclass(cls, classnames=("ViewHandler", "APIHandler")): 61 | """Determines if ``cls`` is indeed a subclass of ``classnames``""" 62 | if isinstance(cls, list): 63 | return any(is_handler_subclass(c) for c in cls) 64 | elif isinstance(cls, type): 65 | return any(c.__name__ in classnames for c in inspect.getmro(cls)) 66 | else: 67 | raise TypeError( 68 | "Unexpected type `{}` for class `{}`".format( 69 | type(cls), 70 | cls 71 | ) 72 | ) 73 | -------------------------------------------------------------------------------- /tests/test_api_doc_gen.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | import pytest 5 | from tornado.web import URLSpec 6 | 7 | from .utils import handle_import_error 8 | 9 | try: 10 | sys.path.append('.') 11 | from tornado_json.api_doc_gen import _get_tuple_from_route 12 | from tornado_json.api_doc_gen import get_api_docs 13 | from tornado_json.api_doc_gen import _get_notes 14 | from tornado_json.routes import get_routes 15 | sys.path.append("demos/helloworld") 16 | import helloworld 17 | except ImportError as err: 18 | handle_import_error(err) 19 | 20 | 21 | def test__get_tuple_from_route(): 22 | """Test tornado_json.api_doc_gen._get_tuple_from_route""" 23 | 24 | class handler_class(object): 25 | def post(self): 26 | pass 27 | 28 | def get(self, pk): 29 | pass 30 | 31 | def delete(self, pk): 32 | pass 33 | 34 | pattern = r"/$" 35 | expected_output = (pattern, handler_class, ['post']) 36 | 37 | # Test 2-tuple 38 | assert _get_tuple_from_route((pattern, handler_class)) == expected_output 39 | # Test 3-tuple (with the extra arg as kwarg(s) 40 | assert _get_tuple_from_route((pattern, handler_class, None)) == expected_output 41 | # Test URLSpec 42 | assert _get_tuple_from_route(URLSpec(pattern, handler_class)) == expected_output 43 | 44 | pattern = r"/(?P[a-zA-Z0-9_\\-]+)/$" 45 | expected_output = (pattern, handler_class, ['get', 'delete']) 46 | # Test 2-tuple 47 | assert _get_tuple_from_route((pattern, handler_class)) == expected_output 48 | # Test 3-tuple (with the extra arg as kwarg(s) 49 | assert _get_tuple_from_route((pattern, handler_class, None)) == expected_output 50 | # Test URLSpec 51 | assert _get_tuple_from_route(URLSpec(pattern, handler_class)) == expected_output 52 | 53 | # Test invalid type 54 | with pytest.raises(TypeError): 55 | _get_tuple_from_route([]) 56 | # Test malformed tuple (i.e., smaller length than 2) 57 | with pytest.raises(AssertionError): 58 | _get_tuple_from_route(("foobar",)) 59 | 60 | 61 | 62 | def test__get_api_docs(): 63 | relative_dir = os.path.abspath(os.path.dirname(__file__)) 64 | filepath = os.path.join(relative_dir, "helloworld_API_documentation.md") 65 | HELLOWORLD_DOC = open(filepath).read() 66 | 67 | assert get_api_docs(get_routes(helloworld)) == HELLOWORLD_DOC 68 | 69 | 70 | def test___get_notes(): 71 | def test_no_doc(): 72 | pass 73 | 74 | assert _get_notes(test_no_doc) is None 75 | 76 | def test_doc(): 77 | """This is not a drill""" 78 | pass 79 | 80 | assert test_doc.__doc__ in _get_notes(test_doc) 81 | -------------------------------------------------------------------------------- /demos/helloworld/API_Documentation.md: -------------------------------------------------------------------------------- 1 | **This documentation is automatically generated.** 2 | 3 | **Output schemas only represent `data` and not the full output; see output examples and the JSend specification.** 4 | 5 | # /api/asynchelloworld/\(?P\\[a\-zA\-Z0\-9\_\\\-\]\+\)/?$ 6 | 7 | Content-Type: application/json 8 | 9 | ## GET 10 | 11 | 12 | **Input Schema** 13 | ```json 14 | null 15 | ``` 16 | 17 | 18 | 19 | **Output Schema** 20 | ```json 21 | { 22 | "type": "string" 23 | } 24 | ``` 25 | 26 | 27 | **Output Example** 28 | ```json 29 | "Hello (asynchronous) world! My name is Fred." 30 | ``` 31 | 32 | 33 | **Notes** 34 | 35 | Shouts hello to the world (asynchronously)! 36 | 37 | 38 | 39 |
40 |
41 | 42 | # /api/freewilled/? 43 | 44 | Content-Type: application/json 45 | 46 | 47 | 48 |
49 |
50 | 51 | # /api/greeting/\(?P\\[a\-zA\-Z0\-9\_\\\-\]\+\)/\(?P\\[a\-zA\-Z0\-9\_\\\-\]\+\)/?$ 52 | 53 | Content-Type: application/json 54 | 55 | ## GET 56 | 57 | 58 | **Input Schema** 59 | ```json 60 | null 61 | ``` 62 | 63 | 64 | 65 | **Output Schema** 66 | ```json 67 | { 68 | "type": "string" 69 | } 70 | ``` 71 | 72 | 73 | **Output Example** 74 | ```json 75 | "Greetings, Named Person!" 76 | ``` 77 | 78 | 79 | **Notes** 80 | 81 | Greets you. 82 | 83 | 84 | 85 |
86 |
87 | 88 | # /api/helloworld/? 89 | 90 | Content-Type: application/json 91 | 92 | ## GET 93 | 94 | 95 | **Input Schema** 96 | ```json 97 | null 98 | ``` 99 | 100 | 101 | 102 | **Output Schema** 103 | ```json 104 | { 105 | "type": "string" 106 | } 107 | ``` 108 | 109 | 110 | **Output Example** 111 | ```json 112 | "Hello world!" 113 | ``` 114 | 115 | 116 | **Notes** 117 | 118 | Shouts hello to the world! 119 | 120 | 121 | 122 |
123 |
124 | 125 | # /api/postit/? 126 | 127 | Content-Type: application/json 128 | 129 | ## POST 130 | 131 | 132 | **Input Schema** 133 | ```json 134 | { 135 | "properties": { 136 | "body": { 137 | "type": "string" 138 | }, 139 | "index": { 140 | "type": "number" 141 | }, 142 | "title": { 143 | "type": "string" 144 | } 145 | }, 146 | "type": "object" 147 | } 148 | ``` 149 | 150 | 151 | **Input Example** 152 | ```json 153 | { 154 | "body": "Equally important message", 155 | "index": 0, 156 | "title": "Very Important Post-It Note" 157 | } 158 | ``` 159 | 160 | 161 | **Output Schema** 162 | ```json 163 | { 164 | "properties": { 165 | "message": { 166 | "type": "string" 167 | } 168 | }, 169 | "type": "object" 170 | } 171 | ``` 172 | 173 | 174 | **Output Example** 175 | ```json 176 | { 177 | "message": "Very Important Post-It Note was posted." 178 | } 179 | ``` 180 | 181 | 182 | **Notes** 183 | 184 | POST the required parameters to post a Post-It note 185 | 186 | * `title`: Title of the note 187 | * `body`: Body of the note 188 | * `index`: An easy index with which to find the note 189 | 190 | 191 | -------------------------------------------------------------------------------- /tests/helloworld_API_documentation.md: -------------------------------------------------------------------------------- 1 | **This documentation is automatically generated.** 2 | 3 | **Output schemas only represent `data` and not the full output; see output examples and the JSend specification.** 4 | 5 | # /api/asynchelloworld/\(?P\\[a\-zA\-Z0\-9\_\\\-\]\+\)/?$ 6 | 7 | Content-Type: application/json 8 | 9 | ## GET 10 | 11 | 12 | **Input Schema** 13 | ```json 14 | null 15 | ``` 16 | 17 | 18 | 19 | **Output Schema** 20 | ```json 21 | { 22 | "type": "string" 23 | } 24 | ``` 25 | 26 | 27 | **Output Example** 28 | ```json 29 | "Hello (asynchronous) world! My name is Fred." 30 | ``` 31 | 32 | 33 | **Notes** 34 | 35 | Shouts hello to the world (asynchronously)! 36 | 37 | 38 | 39 |
40 |
41 | 42 | # /api/freewilled/? 43 | 44 | Content-Type: application/json 45 | 46 | 47 | 48 |
49 |
50 | 51 | # /api/greeting/\(?P\\[a\-zA\-Z0\-9\_\\\-\]\+\)/\(?P\\[a\-zA\-Z0\-9\_\\\-\]\+\)/?$ 52 | 53 | Content-Type: application/json 54 | 55 | ## GET 56 | 57 | 58 | **Input Schema** 59 | ```json 60 | null 61 | ``` 62 | 63 | 64 | 65 | **Output Schema** 66 | ```json 67 | { 68 | "type": "string" 69 | } 70 | ``` 71 | 72 | 73 | **Output Example** 74 | ```json 75 | "Greetings, Named Person!" 76 | ``` 77 | 78 | 79 | **Notes** 80 | 81 | Greets you. 82 | 83 | 84 | 85 |
86 |
87 | 88 | # /api/helloworld/? 89 | 90 | Content-Type: application/json 91 | 92 | ## GET 93 | 94 | 95 | **Input Schema** 96 | ```json 97 | null 98 | ``` 99 | 100 | 101 | 102 | **Output Schema** 103 | ```json 104 | { 105 | "type": "string" 106 | } 107 | ``` 108 | 109 | 110 | **Output Example** 111 | ```json 112 | "Hello world!" 113 | ``` 114 | 115 | 116 | **Notes** 117 | 118 | Shouts hello to the world! 119 | 120 | 121 | 122 |
123 |
124 | 125 | # /api/postit/? 126 | 127 | Content-Type: application/json 128 | 129 | ## POST 130 | 131 | 132 | **Input Schema** 133 | ```json 134 | { 135 | "properties": { 136 | "body": { 137 | "type": "string" 138 | }, 139 | "index": { 140 | "type": "number" 141 | }, 142 | "title": { 143 | "type": "string" 144 | } 145 | }, 146 | "type": "object" 147 | } 148 | ``` 149 | 150 | 151 | **Input Example** 152 | ```json 153 | { 154 | "body": "Equally important message", 155 | "index": 0, 156 | "title": "Very Important Post-It Note" 157 | } 158 | ``` 159 | 160 | 161 | **Output Schema** 162 | ```json 163 | { 164 | "properties": { 165 | "message": { 166 | "type": "string" 167 | } 168 | }, 169 | "type": "object" 170 | } 171 | ``` 172 | 173 | 174 | **Output Example** 175 | ```json 176 | { 177 | "message": "Very Important Post-It Note was posted." 178 | } 179 | ``` 180 | 181 | 182 | **Notes** 183 | 184 | POST the required parameters to post a Post-It note 185 | 186 | * `title`: Title of the note 187 | * `body`: Body of the note 188 | * `index`: An easy index with which to find the note 189 | 190 | 191 | -------------------------------------------------------------------------------- /tornado_json/requesthandlers.py: -------------------------------------------------------------------------------- 1 | from tornado.web import RequestHandler 2 | from jsonschema import ValidationError 3 | 4 | from tornado_json.jsend import JSendMixin 5 | from tornado_json.exceptions import APIError 6 | 7 | 8 | class BaseHandler(RequestHandler): 9 | """BaseHandler for all other RequestHandlers""" 10 | 11 | __url_names__ = ["__self__"] 12 | __urls__ = [] 13 | 14 | @property 15 | def db_conn(self): 16 | """Returns database connection abstraction 17 | 18 | If no database connection is available, raises an AttributeError 19 | """ 20 | db_conn = self.application.db_conn 21 | if not db_conn: 22 | raise AttributeError("No database connection was provided.") 23 | return db_conn 24 | 25 | 26 | class ViewHandler(BaseHandler): 27 | """Handler for views""" 28 | 29 | def initialize(self): 30 | """ 31 | - Set Content-type for HTML 32 | """ 33 | self.set_header("Content-Type", "text/html") 34 | 35 | 36 | class APIHandler(BaseHandler, JSendMixin): 37 | """RequestHandler for API calls 38 | 39 | - Sets header as ``application/json`` 40 | - Provides custom write_error that writes error back as JSON \ 41 | rather than as the standard HTML template 42 | """ 43 | 44 | def initialize(self): 45 | """ 46 | - Set Content-type for JSON 47 | """ 48 | self.set_header("Content-Type", "application/json") 49 | 50 | def write_error(self, status_code, **kwargs): 51 | """Override of RequestHandler.write_error 52 | 53 | Calls ``error()`` or ``fail()`` from JSendMixin depending on which 54 | exception was raised with provided reason and status code. 55 | 56 | :type status_code: int 57 | :param status_code: HTTP status code 58 | """ 59 | def get_exc_message(exception): 60 | return exception.log_message if \ 61 | hasattr(exception, "log_message") else str(exception) 62 | 63 | self.clear() 64 | self.set_status(status_code) 65 | 66 | # Any APIError exceptions raised will result in a JSend fail written 67 | # back with the log_message as data. Hence, log_message should NEVER 68 | # expose internals. Since log_message is proprietary to HTTPError 69 | # class exceptions, all exceptions without it will return their 70 | # __str__ representation. 71 | # All other exceptions result in a JSend error being written back, 72 | # with log_message only written if debug mode is enabled 73 | exception = kwargs["exc_info"][1] 74 | if any(isinstance(exception, c) for c in [APIError, ValidationError]): 75 | # ValidationError is always due to a malformed request 76 | if isinstance(exception, ValidationError): 77 | self.set_status(400) 78 | self.fail(get_exc_message(exception)) 79 | else: 80 | self.error( 81 | message=self._reason, 82 | data=get_exc_message(exception) if self.settings.get("debug") 83 | else None, 84 | code=status_code 85 | ) 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tornado-JSON 2 | 3 | [![Join the chat at https://gitter.im/hfaran/Tornado-JSON](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/hfaran/Tornado-JSON?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | [![Build Status](https://travis-ci.org/hfaran/Tornado-JSON.png?branch=master)](https://travis-ci.org/hfaran/Tornado-JSON) 6 | [![Coverage Status](https://coveralls.io/repos/hfaran/Tornado-JSON/badge.png)](https://coveralls.io/r/hfaran/Tornado-JSON?branch=master) 7 | [![Documentation Status](https://readthedocs.org/projects/tornado-json/badge/?version=latest)](https://readthedocs.org/projects/tornado-json/?badge=latest) 8 | 9 | [![Latest Version](https://img.shields.io/pypi/v/Tornado-JSON.svg)](https://pypi.python.org/pypi/Tornado-JSON/) 10 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/Tornado-JSON.svg)](https://pypi.python.org/pypi/Tornado-JSON/) 11 | [![Development Status](https://img.shields.io/pypi/status/Tornado-JSON.svg)](https://pypi.python.org/pypi/Tornado-JSON/) 12 | [![Download format](https://img.shields.io/pypi/format/Tornado-JSON.svg)](https://pypi.python.org/pypi/Tornado-JSON/) 13 | [![License](https://img.shields.io/pypi/l/Tornado-JSON.svg)](https://pypi.python.org/pypi/Tornado-JSON/) 14 | 15 | 16 | ## Overview 17 | 18 | Tornado-JSON is a small extension of [Tornado](http://www.tornadoweb.org/en/stable/) with the intent of providing the tools necessary to get a JSON API up and running quickly. 19 | 20 | Some of the key features the included modules provide: 21 | 22 | * Input and output **[JSON Schema](http://json-schema.org/) validation** by decorating RequestHandlers with `@schema.validate` 23 | * **Automated route generation** with `routes.get_routes(package)` 24 | * **Automated [GFM](https://help.github.com/articles/github-flavored-markdown)-formatted API documentation** using schemas and provided descriptions 25 | * **Standardized JSON output** using the **[JSend](http://labs.omniti.com/labs/jsend)** specification 26 | 27 | 28 | ## Usage 29 | 30 | Check out the [Hello World demo](https://github.com/hfaran/Tornado-JSON/tree/master/demos/helloworld) for a quick example and the [accompanying walkthrough](http://tornado-json.readthedocs.org/en/latest/using_tornado_json.html) in the documentation. And then [**explore Tornado-JSON on readthedocs for the rest!**](http://tornado-json.readthedocs.org/en/latest/index.html#) 31 | 32 | ```python 33 | import tornado.ioloop 34 | from tornado_json.routes import get_routes 35 | from tornado_json.application import Application 36 | 37 | import mywebapp 38 | 39 | 40 | # Automatically generate routes for your webapp 41 | routes = get_routes(mywebapp) 42 | # Create and start application 43 | application = Application(routes=routes, settings={}) 44 | application.listen(8888) 45 | tornado.ioloop.IOLoop.instance().start() 46 | ``` 47 | 48 | ### Example Projects That Use Tornado-JSON 49 | 50 | * https://github.com/hfaran/CitySportsLeague-Server 51 | * https://github.com/hfaran/LivesPool 52 | 53 | 54 | ## Installation 55 | 56 | * For the possibly stable 57 | 58 | ```bash 59 | pip install Tornado-JSON 60 | ``` 61 | 62 | * For the latest and greatest 63 | 64 | ```bash 65 | git clone https://github.com/hfaran/Tornado-JSON.git 66 | cd Tornado-JSON 67 | python setup.py develop 68 | ``` 69 | 70 | 71 | ## Contributing 72 | 73 | If there is something you would like to see improved, you would be awesome for [opening an issue about it](https://github.com/hfaran/Tornado-JSON/issues/new), and I'll promise my best to take a look. 74 | 75 | Pull requests are absolutely welcome as well! 76 | 77 | 78 | ## License 79 | 80 | This project is licensed under the MIT License. 81 | 82 | 83 | ## Running Tests 84 | 85 | ```bash 86 | sudo pip2 install tox 87 | sudo pip3 install tox 88 | tox # Will run test matrix 89 | ``` 90 | -------------------------------------------------------------------------------- /demos/helloworld/helloworld/api.py: -------------------------------------------------------------------------------- 1 | from tornado import gen 2 | from tornado.ioloop import IOLoop 3 | 4 | from tornado_json.requesthandlers import APIHandler 5 | from tornado_json import schema 6 | from tornado_json.gen import coroutine 7 | 8 | 9 | class HelloWorldHandler(APIHandler): 10 | 11 | # Decorate any HTTP methods with the `schema.validate` decorator 12 | # to validate input to it and output from it as per the 13 | # the schema ``input_schema`` and ``output_schema`` arguments passed. 14 | # Simply use `return` rather than `self.write` to write back 15 | # your output. 16 | @schema.validate( 17 | output_schema={"type": "string"}, 18 | output_example="Hello world!" 19 | ) 20 | def get(self): 21 | """Shouts hello to the world!""" 22 | return "Hello world!" 23 | 24 | 25 | class PostIt(APIHandler): 26 | 27 | @schema.validate( 28 | input_schema={ 29 | "type": "object", 30 | "properties": { 31 | "title": {"type": "string"}, 32 | "body": {"type": "string"}, 33 | "index": {"type": "number"}, 34 | } 35 | }, 36 | input_example={ 37 | "title": "Very Important Post-It Note", 38 | "body": "Equally important message", 39 | "index": 0 40 | }, 41 | output_schema={ 42 | "type": "object", 43 | "properties": { 44 | "message": {"type": "string"} 45 | } 46 | }, 47 | output_example={ 48 | "message": "Very Important Post-It Note was posted." 49 | }, 50 | ) 51 | def post(self): 52 | """ 53 | POST the required parameters to post a Post-It note 54 | 55 | * `title`: Title of the note 56 | * `body`: Body of the note 57 | * `index`: An easy index with which to find the note 58 | """ 59 | # `schema.validate` will JSON-decode `self.request.body` for us 60 | # and set self.body as the result, so we can use that here 61 | return { 62 | "message": "{} was posted.".format(self.body["title"]) 63 | } 64 | 65 | 66 | class Greeting(APIHandler): 67 | 68 | # When you include extra arguments in the signature of an HTTP 69 | # method, Tornado-JSON will generate a route that matches the extra 70 | # arguments; here, you can GET /api/greeting/John/Smith and you will 71 | # get a response back that says, "Greetings, John Smith!" 72 | # You can match the regex equivalent of `\w+`. 73 | @schema.validate( 74 | output_schema={"type": "string"}, 75 | output_example="Greetings, Named Person!" 76 | ) 77 | def get(self, fname, lname): 78 | """Greets you.""" 79 | return "Greetings, {} {}!".format(fname, lname) 80 | 81 | 82 | class AsyncHelloWorld(APIHandler): 83 | 84 | def hello(self, name, callback=None): 85 | return "Hello (asynchronous) world! My name is {}.".format(name) 86 | 87 | @schema.validate( 88 | output_schema={"type": "string"}, 89 | output_example="Hello (asynchronous) world! My name is Fred." 90 | ) 91 | # ``tornado_json.gen.coroutine`` must be used for coroutines 92 | # ``tornado.gen.coroutine`` CANNOT be used directly 93 | 94 | async def get(self, name): 95 | """Shouts hello to the world (asynchronously)!""" 96 | # Asynchronously yield a result from a method 97 | return await IOLoop.current().run_in_executor(None, self.hello, name) 98 | 99 | 100 | class FreeWilledHandler(APIHandler): 101 | 102 | # And of course, you aren't forced to use schema validation; 103 | # if you want your handlers to do something more custom, 104 | # they definitely can. 105 | def get(self): 106 | # If you don't know where `self.success` comes from, it is defined 107 | # in the `JSendMixin` mixin in tornado_json.jsend. `APIHandler` 108 | # inherits from this and thus gets the methods. 109 | self.success("I don't need no stinkin' schema validation.") 110 | # If you're feeling really bold, you could even skip JSend 111 | # altogether and do the following EVIL thing: 112 | # self.write("I'm writing back a string that isn't JSON! Take that!") 113 | -------------------------------------------------------------------------------- /demos/rest_api/cars/api/__init__.py: -------------------------------------------------------------------------------- 1 | from tornado_json.requesthandlers import APIHandler 2 | 3 | 4 | DATA = { 5 | "Ford": { 6 | "Fusion": { 7 | "2013": "http://www.ford.ca/cars/fusion/2013/", 8 | "2014": "http://www.ford.ca/cars/fusion/2014/" 9 | }, 10 | "Taurus": { 11 | "2013": "http://www.ford.ca/cars/taurus/2013/", 12 | "2014": "http://www.ford.ca/cars/taurus/2014/" 13 | } 14 | } 15 | } 16 | 17 | 18 | class CarsAPIHandler(APIHandler): 19 | 20 | # APIHandler has two "special" attributes: 21 | # * __url_names__ : 22 | # This is a list of names you'd like the 23 | # the requesthandler to be called in auto- 24 | # generated routes, i.e., since the absolute 25 | # path of this handler (in context of the 26 | # ``cars`` package) is, ``cars.api.CarsAPIHandler``, 27 | # if you've read earlier documentation, you'll 28 | # know that the associated URL to this that 29 | # will be autogenerated is "/api/carsapi". 30 | # __url_names__ can change the last ``carsapi`` 31 | # part to whatever is in the list. So in this 32 | # case, we change it to "/api/cars". If we added 33 | # additional names to the list, we would generate 34 | # more routes with the given names. 35 | # Of course, this isn't an actual handler, just 36 | # a handy superclass that we'll let all of the handlers 37 | # below inherit from so they can have a base URL of 38 | # "/api/cars" also, but extended to match additional 39 | # things based on the parameters of their ``get`` methods. 40 | # 41 | # An important note on __url_names__ is that by default, 42 | # it exists as ``__url_names__ = ["__self__"]``. The 43 | # ``__self__`` is a special value, which means that the 44 | # requesthandler should get a URL such as the ones you 45 | # assigned to the handlers in the Hello World demo. 46 | # You can either ADD to the list to keep this, or 47 | # create a new list to not. 48 | # 49 | # * __urls__ : 50 | # I'll mention __urls__ as well; this let's you just 51 | # assign a completely custom URL pattern to match to 52 | # the requesthandlers, i.e., I could add something like, 53 | # ``__urls__ = [r"/api/cars/?"]`` 54 | # and that would give me the exact same URL mapped 55 | # to this handler, but defined by me. Note that both 56 | # URLs generated from ``__url_names__`` and URLs provided 57 | # by you in ``__urls__`` will be created and assigned to 58 | # the associated requesthandler, so make sure to modify/overwrite 59 | # both attributes to get only the URLs mapped that you want. 60 | # 61 | __url_names__ = ["cars"] 62 | 63 | 64 | class MakeListHandler(CarsAPIHandler): 65 | 66 | def get(self): 67 | self.success(DATA.keys()) 68 | 69 | 70 | class MakeHandler(CarsAPIHandler): 71 | 72 | def get(self, make): 73 | try: 74 | self.success(DATA[make]) 75 | except KeyError: 76 | self.fail("No data on such make `{}`.".format(make)) 77 | 78 | 79 | class ModelHandler(CarsAPIHandler): 80 | 81 | def get(self, make, model): 82 | try: 83 | self.success(DATA[make][model]) 84 | except KeyError: 85 | self.fail("No data on `{} {}`.".format(make, model)) 86 | 87 | 88 | class YearHandler(CarsAPIHandler): 89 | 90 | def get(self, make, model, year): 91 | try: 92 | self.success(DATA[make][model][year]) 93 | except KeyError: 94 | self.fail("No data on `{} {} {}`.".format(year, make, model)) 95 | 96 | 97 | # Routes for the handlers above will look like this: 98 | # 99 | # [ 100 | # [ 101 | # "/api/cars/?", 102 | # "" 103 | # ], 104 | # [ 105 | # "/api/cars/(?P[a-zA-Z0-9_\-]+)/(?P[a-zA-Z0-9_\-]+)/?$", 106 | # "" 107 | # ], 108 | # [ 109 | # "/api/cars/(?P[a-zA-Z0-9_\-]+)/(?P[a-zA-Z0-9_\-]+)/(?P[a-zA-Z0-9_\-]+)/?$", 110 | # "" 111 | # ], 112 | # [ 113 | # "/api/cars/(?P[a-zA-Z0-9_\-]+)/?$", 114 | # "" 115 | # ] 116 | # ] 117 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tornado_json.schema import get_object_defaults 4 | from tornado_json.schema import input_schema_clean 5 | from tornado_json.schema import NoObjectDefaults 6 | 7 | 8 | class TestSchemaMethods(unittest.TestCase): 9 | 10 | def test_input_schema_clean_ignore_other_types(self): 11 | self.assertEqual(input_schema_clean('ABC-123', {'type': "string"}), 12 | "ABC-123") 13 | 14 | def test_input_schema_clean_no_defaults(self): 15 | self.assertEqual(input_schema_clean({}, {'type': "object"}), 16 | {}) 17 | 18 | def test_input_schema_clean(self): 19 | self.assertEqual( 20 | input_schema_clean( 21 | {'publishing': {'publish_date': '2012-12-12'}}, 22 | { 23 | 'type': "object", 24 | 'properties': { 25 | 'publishing': { 26 | 'type': 'object', 27 | 'properties': { 28 | 'publish_date': { 29 | 'type': 'string', 30 | }, 31 | 32 | 'published': { 33 | 'default': True, 34 | 'type': 'boolean', 35 | }, 36 | }, 37 | }, 38 | } 39 | } 40 | ), 41 | { 42 | 'publishing': { 43 | 'published': True, 44 | 'publish_date': '2012-12-12', 45 | }, 46 | } 47 | ) 48 | 49 | def test_defaults_basic(self): 50 | self.assertEqual( 51 | get_object_defaults({ 52 | 'type': 'object', 53 | 'properties': { 54 | 'title': {"type": 'string'}, 55 | 'published': {"type": 'boolean', "default": True}, 56 | } 57 | }), 58 | { 59 | 'published': True, 60 | } 61 | ) 62 | 63 | def test_defaults_no_defaults(self): 64 | with self.assertRaises(NoObjectDefaults): 65 | get_object_defaults({ 66 | 'type': 'object', 67 | 'properties': { 68 | 'address': { 69 | "type": 'object', 70 | 'properties': { 71 | 'street': { 72 | 'type': 'string', 73 | } 74 | } 75 | }, 76 | } 77 | }) 78 | 79 | def test_defaults_nested_object_default(self): 80 | self.assertEqual( 81 | get_object_defaults({ 82 | 'type': 'object', 83 | 'properties': { 84 | 'title': {"type": 'string'}, 85 | 'published': {"type": 'boolean', "default": True}, 86 | 'address': { 87 | 'type': 'object', 88 | 'properties': { 89 | 'country': { 90 | 'type': 'string', 91 | 'default': "Brazil", 92 | }, 93 | }, 94 | }, 95 | 'driver_license': { 96 | 'default': {'category': "C"}, 97 | 'type': 'object', 98 | 'properties': { 99 | 'category': { 100 | "type": "string", 101 | "maxLength": 1, 102 | "minLength": 1, 103 | }, 104 | 'shipping_city': { 105 | "type": "string", 106 | "default": "Belo Horizonte", 107 | }, 108 | } 109 | } 110 | } 111 | }), 112 | { 113 | 'published': True, 114 | 'address': { 115 | 'country': "Brazil", 116 | }, 117 | 'driver_license': { 118 | "category": "C", 119 | "shipping_city": "Belo Horizonte", 120 | } 121 | } 122 | ) 123 | 124 | 125 | if __name__ == '__main__': 126 | unittest.main() 127 | -------------------------------------------------------------------------------- /docs/using_tornado_json.rst: -------------------------------------------------------------------------------- 1 | Using Tornado-JSON 2 | ================== 3 | 4 | A Simple Hello World JSON API 5 | ----------------------------- 6 | 7 | I'll be referencing the 8 | `helloworld `__ 9 | example in the ``demos`` for this. 10 | 11 | We want to do a lot of the same things we'd usually do when creating a 12 | Tornado app with a few differences. 13 | 14 | helloworld.py 15 | ~~~~~~~~~~~~~ 16 | 17 | First, we'll import the required packages: 18 | 19 | .. code:: python 20 | 21 | import tornado.ioloop 22 | from tornado_json.routes import get_routes 23 | from tornado_json.application import Application 24 | 25 | Next we'll import the package containing our web app. This is the 26 | package where all of your RequestHandlers live. 27 | 28 | .. code:: python 29 | 30 | import helloworld 31 | 32 | Next, we write a lot of the same Tornado "boilerplate" as you'd find in 33 | the Tornado helloworld example, except, you don't have to manually 34 | specify routes because ``tornado_json`` gathers those for you and names 35 | them based on your project structure and RequestHandler names. You're 36 | free to customize ``routes`` however you want, of course, after they've 37 | been initially automatically generated. 38 | 39 | .. code:: python 40 | 41 | def main(): 42 | # Pass the web app's package the get_routes and it will generate 43 | # routes based on the submodule names and ending with lowercase 44 | # request handler name (with 'handler' removed from the end of the 45 | # name if it is the name). 46 | # [("/api/helloworld", helloworld.api.HelloWorldHandler)] 47 | routes = get_routes(helloworld) 48 | 49 | # Create the application by passing routes and any settings 50 | application = Application(routes=routes, settings={}) 51 | 52 | # Start the application on port 8888 53 | application.listen(8888) 54 | tornado.ioloop.IOLoop.instance().start() 55 | 56 | helloworld/api.py 57 | ~~~~~~~~~~~~~~~~~ 58 | 59 | Now comes the fun part where we develop the actual web app. We'll import 60 | ``APIHandler`` (this is the handler you should subclass for API routes), 61 | and the ``schema.validate`` decorator which will validate input and output 62 | schema for us. 63 | 64 | 65 | .. code:: python 66 | 67 | from tornado_json.requesthandlers import APIHandler 68 | from tornado_json import schema 69 | 70 | class HelloWorldHandler(APIHandler): 71 | """Hello!""" 72 | @schema.validate(...) 73 | def get(...): 74 | ... 75 | 76 | 77 | Next, we'll start writing our ``get`` method, but before writing the body, 78 | we'll define an output schema for it and pass it as an argument to the 79 | ``schema.validate`` decorator which will automatically validate the output 80 | against the passed schema. In addition to the schema, the docstring 81 | for each HTTP method will be used by Tornado-JSON to generate public API 82 | documentation for that route which will be automatically 83 | generated when you run the app (see the Documentation Generation section 84 | for details). Input and output schemas are as per the `JSON 85 | Schema `__ standard. 86 | 87 | 88 | .. code-block:: python 89 | 90 | @schema.validate(output_schema={"type": "string"}) 91 | def get(self): 92 | """Shouts hello to the world!""" 93 | ... 94 | 95 | 96 | Finally we'll write our ``get`` method body which will write "Hello world!" 97 | back. Notice that rather than using ``self.write`` as we usually would, 98 | we simply return the data we want to write back, which will then be 99 | validated against the output schema and be written back according to the 100 | `JSend `__ specification. The 101 | ``schema.validate`` decorator handles all of this so be sure to decorate any 102 | HTTP methods with it. 103 | 104 | 105 | .. code-block:: python 106 | 107 | @schema.validate(output_schema={"type": "string"}) 108 | def get(self): 109 | """Shouts hello to the world!""" 110 | return "Hello world!" 111 | 112 | 113 | Running our Hello World app 114 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 115 | 116 | Now, we can finally run the app ``python helloworld.py``. You should be 117 | able to send a GET request to ``localhost:8888/api/helloworld`` and get 118 | a JSONic "Hello world!" back. Additionally, you'll notice an 119 | ``API_Documentation.md`` pop up in the directory, which contains the API 120 | Documentation you can give to users about your new and fantastic API. 121 | 122 | 123 | Further Examples 124 | ---------------- 125 | 126 | See `helloworld `__ 127 | for further RequestHandler examples with features including: 128 | 129 | * Asynchronous methods in RequestHandlers (must use ``tornado_json.gen.coroutine`` rather than ``tornado.gen.coroutine``) 130 | * POSTing (or PUTing, PATCHing etc.) data; ``self.body`` 131 | * How to generate routes with URL patterns for RequestHandler methods with arguments 132 | * and possibly more! 133 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | _ 5 | --------- 6 | 7 | 8 | 1.3.4 9 | ~~~~~ 10 | 11 | * Fix regression in 1.3.3 12 | * Pin versions for supported set of dependencies 13 | 14 | 15 | 1.3.3 16 | ~~~~~ 17 | 18 | * Support Tornado >= 5.0 and Python 3.6 19 | 20 | 21 | 1.3.2 22 | ~~~~~ 23 | 24 | * Recovery release for PyPI (1.3.1 had an incomplete module included accidentally) 25 | 26 | 27 | 1.3.1 28 | ~~~~~ 29 | 30 | * Minor updates with versioning 31 | 32 | 33 | 1.3.0 34 | ~~~~~ 35 | 36 | * Added use_defaults support for schema.validate 37 | * Added support for custom validators 38 | * Bugfix: Fixed api_doc_gen duplicated entries 39 | * Bugfix: Remove pyclbr and use inspect instead for module introspection 40 | 41 | 42 | 1.2.2 43 | ~~~~~ 44 | 45 | * `generate_docs` parameter added to `Application` for optional API documentation generation 46 | 47 | 48 | 1.2.1 49 | ~~~~~ 50 | 51 | * arg_pattern now contains hyphen 52 | * Handle case where server would crash when generating docs for methods with 53 | no docstring 54 | * Add support for tornado==3.x.x gen.coroutine 55 | * Add format_checker kwarg to schema.validate 56 | 57 | 58 | 1.2.0 59 | ~~~~~ 60 | 61 | * Implement ``tornado_json.gen.coroutine`` 62 | * As a fix for `#59 `_, a custom wrapper for the ``tornado.gen.coroutine`` wrapper has been implemented. This was necessary as we lose the original argspec through it because the wrapper simply has ``(*args, **kwargs)`` as its signature. Here, we annotate the original argspec as an attribute to the wrapper so it can be referenced later by Tornado-JSON when generating routes. 63 | 64 | 65 | 1.1.0 66 | ~~~~~ 67 | 68 | * Handle routes as ``URLSpec`` and >2-tuple in ``api_doc_gen`` 69 | * Refactor ``api_doc_gen``; now has public function ``get_api_doc`` for use 70 | 71 | 72 | 1.0.0 73 | ~~~~~ 74 | 75 | * Compatibility updates for ``tornado>=4.0.0`` 76 | 77 | 78 | v0.41 79 | ~~~~~ 80 | 81 | * Fixed ``JSendMixin`` hanging if auto_finish was disabled 82 | 83 | 84 | v0.40 - Replace ``apid`` with parameterized ``schema.validate`` 85 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 86 | 87 | * The ``apid`` class-variable is no longer used 88 | * Schemas are passed as arguments to ``schema.validate`` 89 | * Method docstrings are used in public API documentation, in place of ``apid[method]["doc"]`` 90 | 91 | 92 | v0.31 - On input schema of ``None``, input is presumed to be ``None`` 93 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 94 | 95 | * Rather than forcing an input schema of ``None`` with ``GET`` and ``DELETE`` methods, whether input is JSON-decoded or not, is dependent on whether the provided input schema is ``None`` or not. This means that ``get`` and ``delete`` methods can now have request bodies if desired. 96 | 97 | 98 | v0.30 - URL Annotations 99 | ~~~~~~~~~~~~~~~~~~~~~~~ 100 | 101 | * Added ``__urls__`` and ``__url_names__`` attributes to allow flexible creation of custom URLs that make creating REST APIs etc. easy 102 | * Added a REST API demo as an example for URL annotations 103 | * Added URL annotations documentation 104 | * Refactored and improved route generation in ``routes`` 105 | 106 | 107 | v0.20 - Refactor of ``utils`` module 108 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 109 | 110 | Functions that did not belong in ``utils`` were moved to more relevant modules. This change changes the interface for Tornado-JSON in quite a big way. The following changes were made (that are not backwards compatible). 111 | 112 | * ``api_assert`` and ``APIError`` were moved to ``tornado_json.exceptions`` 113 | * ``io_schema`` was renamed ``validate`` and moved to ``tornado_json.schema`` 114 | 115 | 116 | v0.14 - Bugfixes thanks to 100% coverage 117 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 118 | 119 | * Fixes related to error-writing in ``io_schema`` and ``APIHandler.write_error`` 120 | 121 | 122 | v0.13 - Add asynchronous compatibility to io_schema 123 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 124 | 125 | * Add asynchronous functionality to io_schema 126 | 127 | 128 | v0.12 - Python3 support 129 | ~~~~~~~~~~~~~~~~~~~~~~~ 130 | 131 | * Python3.3, in addition to Python2.7, is now supported. 132 | 133 | 134 | v0.11 - Duplicate route bugfix 135 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 136 | 137 | * Fixed bug where duplicate routes would be created on existence of multiple HTTP methods. 138 | 139 | 140 | v0.10 - Route generation with URL patterns 141 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 142 | 143 | Route generation will now inspect method signatures in ``APIHandler`` and ``ViewHandler`` subclasses, and construct routes with URL patterns based on the signatures. URL patterns match ``[a-zA-Z0-9_]+``. 144 | 145 | **Backwards Compatibility**: ``body`` is no longer provided by ``io_schema`` as the sole argument to HTTP methods. Any existing code using ``body`` can now use ``self.body`` to get the same object. 146 | 147 | 148 | v0.08 - Input and output example fields 149 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 150 | 151 | * Add input_example and output_example fields 152 | * status_code 400 on ValidationError 153 | * Exclude delete from input validation 154 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\tornado_json.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\tornado_json.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/tornado_json.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/tornado_json.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/tornado_json" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/tornado_json" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /tests/test_tornado_json.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from .utils import handle_import_error 6 | 7 | try: 8 | sys.path.append('.') 9 | from tornado_json import routes 10 | from tornado_json import schema 11 | from tornado_json import exceptions 12 | from tornado_json import jsend 13 | sys.path.append('demos/helloworld') 14 | sys.path.append('demos/rest_api') 15 | import helloworld 16 | import cars 17 | except ImportError as err: 18 | handle_import_error(err) 19 | 20 | 21 | class SuccessException(Exception): 22 | """Great success!""" 23 | 24 | 25 | class MockRequestHandler(object): 26 | 27 | class Request(object): 28 | body = "{\"I am a\": \"JSON object\"}" 29 | 30 | request = Request() 31 | 32 | def fail(message): 33 | raise exceptions.APIError(message) 34 | 35 | def success(self, message): 36 | raise SuccessException 37 | 38 | 39 | class TestTornadoJSONBase(object): 40 | """Base class for all tornado_json test classes""" 41 | 42 | 43 | class TestRoutes(TestTornadoJSONBase): 44 | """Tests the routes module""" 45 | 46 | def test_get_routes(self): 47 | """Tests routes.get_routes""" 48 | assert sorted(routes.get_routes( 49 | helloworld)) == sorted([ 50 | ("/api/helloworld/?", helloworld.api.HelloWorldHandler), 51 | ("/api/asynchelloworld/(?P[a-zA-Z0-9_\\-]+)/?$", helloworld.api.AsyncHelloWorld), 52 | ("/api/postit/?", helloworld.api.PostIt), 53 | ("/api/greeting/(?P[a-zA-Z0-9_\\-]+)/" 54 | "(?P[a-zA-Z0-9_\\-]+)/?$", 55 | helloworld.api.Greeting), 56 | ("/api/freewilled/?", helloworld.api.FreeWilledHandler) 57 | ]) 58 | assert sorted(routes.get_routes( 59 | cars)) == sorted([ 60 | ("/api/cars/?", cars.api.MakeListHandler), 61 | ("/api/cars/(?P[a-zA-Z0-9_\\-]+)/(?P[a-zA-Z0-9_\\-]+)/?$", 62 | cars.api.ModelHandler), 63 | ("/api/cars/(?P[a-zA-Z0-9_\\-]+)/(?P[a-zA-Z0-9_\\-]+)/" 64 | "(?P[a-zA-Z0-9_\\-]+)/?$", cars.api.YearHandler), 65 | ("/api/cars/(?P[a-zA-Z0-9_\\-]+)/?$", cars.api.MakeHandler), 66 | ]) 67 | 68 | def test_gen_submodule_names(self): 69 | """Tests routes.gen_submodule_names""" 70 | assert list(routes.gen_submodule_names(helloworld) 71 | ) == ['helloworld.api'] 72 | 73 | def test_get_module_routes(self): 74 | """Tests routes.get_module_routes""" 75 | assert sorted(routes.get_module_routes( 76 | "cars.api")) == sorted([ 77 | ("/api/cars/?", cars.api.MakeListHandler), 78 | ("/api/cars/(?P[a-zA-Z0-9_\\-]+)/(?P[a-zA-Z0-9_\\-]+)/?$", 79 | cars.api.ModelHandler), 80 | ("/api/cars/(?P[a-zA-Z0-9_\\-]+)/(?P[a-zA-Z0-9_\\-]+)/" 81 | "(?P[a-zA-Z0-9_\\-]+)/?$", cars.api.YearHandler), 82 | ("/api/cars/(?P[a-zA-Z0-9_\\-]+)/?$", cars.api.MakeHandler), 83 | ]) 84 | 85 | 86 | class TestUtils(TestTornadoJSONBase): 87 | """Tests the utils module""" 88 | 89 | def test_api_assert(self): 90 | """Test exceptions.api_assert""" 91 | with pytest.raises(exceptions.APIError): 92 | exceptions.api_assert(False, 400) 93 | 94 | exceptions.api_assert(True, 400) 95 | 96 | class TerribleHandler(MockRequestHandler): 97 | """This 'handler' is used in test_validate""" 98 | 99 | @schema.validate(output_schema={"type": "number"}) 100 | def get(self): 101 | return "I am not the handler you are looking for." 102 | 103 | @schema.validate(output_schema={"type": "number"}, 104 | input_schema={"type": "number"}) 105 | def post(self): 106 | return "Fission mailed." 107 | 108 | class ReasonableHandler(MockRequestHandler): 109 | """This 'handler' is used in test_validate""" 110 | 111 | @schema.validate(output_schema={"type": "number"}) 112 | def get(self, fname, lname): 113 | return "I am the handler you are looking for, {} {}".format( 114 | fname, lname) 115 | 116 | @schema.validate( 117 | input_schema={ 118 | "type": "object", 119 | "properties": { 120 | "I am a": {"type": "string"}, 121 | }, 122 | "required": ["I am a"], 123 | }, 124 | output_schema={ 125 | "type": "string", 126 | } 127 | ) 128 | def post(self): 129 | # Test that self.body is available as expected 130 | assert self.body == {"I am a": "JSON object"} 131 | return "Mail received." 132 | 133 | # DONE: Test validate functionally instead; pytest.raises does 134 | # not seem to be catching errors being thrown after change 135 | # to async compatible code. 136 | # The following test left here as antiquity. 137 | # def test_validate(self): 138 | # """Tests the schema.validate decorator""" 139 | # th = self.TerribleHandler() 140 | # rh = self.ReasonableHandler() 141 | 142 | # Expect a TypeError to be raised because of invalid output 143 | # with pytest.raises(TypeError): 144 | # th.get("Duke", "Flywalker") 145 | 146 | # Expect a validation error because of invalid input 147 | # with pytest.raises(ValidationError): 148 | # th.post() 149 | 150 | # Both of these should succeed as the body matches the schema 151 | # with pytest.raises(SuccessException): 152 | # rh.get("J", "S") 153 | # with pytest.raises(SuccessException): 154 | # rh.post() 155 | 156 | 157 | class TestJSendMixin(TestTornadoJSONBase): 158 | """Tests the JSendMixin module""" 159 | 160 | class MockJSendMixinRH(jsend.JSendMixin): 161 | """Mock handler for testing JSendMixin""" 162 | _buffer = None 163 | 164 | def write(self, data): 165 | self._buffer = data 166 | 167 | def finish(self): 168 | pass 169 | 170 | @staticmethod 171 | @pytest.fixture(scope="class", autouse=True) 172 | def setup(request): 173 | """Create mock handler instance""" 174 | request.cls.jsend_rh = TestJSendMixin.MockJSendMixinRH() 175 | 176 | def test_success(self): 177 | """Tests JSendMixin.success""" 178 | data = "Huzzah!" 179 | self.jsend_rh.success(data) 180 | assert self.jsend_rh._buffer == {'status': 'success', 'data': data} 181 | 182 | def test_fail(self): 183 | """Tests JSendMixin.fail""" 184 | data = "Aww!" 185 | self.jsend_rh.fail(data) 186 | assert self.jsend_rh._buffer == {'status': 'fail', 'data': data} 187 | 188 | def test_error(self): 189 | """Tests JSendMixin.error""" 190 | message = "Drats!" 191 | data = "I am the plural form of datum." 192 | code = 9001 193 | self.jsend_rh.error(message=message, data=data, code=code) 194 | assert self.jsend_rh._buffer == { 195 | 'status': 'error', 'message': message, 'data': data, 'code': code} 196 | -------------------------------------------------------------------------------- /tornado_json/schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | from asyncio import iscoroutine 3 | from functools import wraps 4 | 5 | import jsonschema 6 | import tornado.gen 7 | from tornado.concurrent import is_future 8 | 9 | from tornado_json.exceptions import APIError 10 | from tornado_json.utils import container, deep_update 11 | 12 | 13 | class NoObjectDefaults(Exception): 14 | """ Raised when a schema type object ({"type": "object"}) has no "default" 15 | key and one of their properties also don't have a "default" key. 16 | """ 17 | 18 | 19 | def get_object_defaults(object_schema): 20 | """ 21 | Extracts default values dict (nested) from an type object schema. 22 | 23 | :param object_schema: Schema type object 24 | :type object_schema: dict 25 | :returns: Nested dict with defaults values 26 | """ 27 | default = {} 28 | for k, schema in object_schema.get('properties', {}).items(): 29 | 30 | if schema.get('type') == 'object': 31 | if 'default' in schema: 32 | default[k] = schema['default'] 33 | 34 | try: 35 | object_defaults = get_object_defaults(schema) 36 | except NoObjectDefaults: 37 | if 'default' not in schema: 38 | raise NoObjectDefaults 39 | else: 40 | if 'default' not in schema: 41 | default[k] = {} 42 | 43 | default[k].update(object_defaults) 44 | else: 45 | if 'default' in schema: 46 | default[k] = schema['default'] 47 | 48 | if default: 49 | return default 50 | 51 | raise NoObjectDefaults 52 | 53 | 54 | def input_schema_clean(input_, input_schema): 55 | """ 56 | Updates schema default values with input data. 57 | 58 | :param input_: Input data 59 | :type input_: dict 60 | :param input_schema: Input schema 61 | :type input_schema: dict 62 | :returns: Nested dict with data (defaul values updated with input data) 63 | :rtype: dict 64 | """ 65 | if input_schema.get('type') == 'object': 66 | try: 67 | defaults = get_object_defaults(input_schema) 68 | except NoObjectDefaults: 69 | pass 70 | else: 71 | return deep_update(defaults, input_) 72 | return input_ 73 | 74 | 75 | def validate(input_schema=None, output_schema=None, 76 | input_example=None, output_example=None, 77 | validator_cls=None, 78 | format_checker=None, on_empty_404=False, 79 | use_defaults=False): 80 | """Parameterized decorator for schema validation 81 | 82 | :type validator_cls: IValidator class 83 | :type format_checker: jsonschema.FormatChecker or None 84 | :type on_empty_404: bool 85 | :param on_empty_404: If this is set, and the result from the 86 | decorated method is a falsy value, a 404 will be raised. 87 | :type use_defaults: bool 88 | :param use_defaults: If this is set, will put 'default' keys 89 | from schema to self.body (If schema type is object). Example: 90 | { 91 | 'published': {'type': 'bool', 'default': False} 92 | } 93 | self.body will contains 'published' key with value False if no one 94 | comes from request, also works with nested schemas. 95 | """ 96 | @container 97 | def _validate(rh_method): 98 | """Decorator for RequestHandler schema validation 99 | 100 | This decorator: 101 | 102 | - Validates request body against input schema of the method 103 | - Calls the ``rh_method`` and gets output from it 104 | - Validates output against output schema of the method 105 | - Calls ``JSendMixin.success`` to write the validated output 106 | 107 | :type rh_method: function 108 | :param rh_method: The RequestHandler method to be decorated 109 | :returns: The decorated method 110 | :raises ValidationError: If input is invalid as per the schema 111 | or malformed 112 | :raises TypeError: If the output is invalid as per the schema 113 | or malformed 114 | :raises APIError: If the output is a falsy value and 115 | on_empty_404 is True, an HTTP 404 error is returned 116 | """ 117 | @wraps(rh_method) 118 | @tornado.gen.coroutine 119 | def _wrapper(self, *args, **kwargs): 120 | # In case the specified input_schema is ``None``, we 121 | # don't json.loads the input, but just set it to ``None`` 122 | # instead. 123 | if input_schema is not None: 124 | # Attempt to json.loads the input 125 | try: 126 | # TODO: Assuming UTF-8 encoding for all requests, 127 | # find a nice way of determining this from charset 128 | # in headers if provided 129 | encoding = "UTF-8" 130 | input_ = json.loads(self.request.body.decode(encoding)) 131 | except ValueError as e: 132 | raise jsonschema.ValidationError( 133 | "Input is malformed; could not decode JSON object." 134 | ) 135 | 136 | if use_defaults: 137 | input_ = input_schema_clean(input_, input_schema) 138 | 139 | # Validate the received input 140 | jsonschema.validate( 141 | input_, 142 | input_schema, 143 | cls=validator_cls, 144 | format_checker=format_checker 145 | ) 146 | else: 147 | input_ = None 148 | 149 | # A json.loads'd version of self.request["body"] is now available 150 | # as self.body 151 | setattr(self, "body", input_) 152 | # Call the requesthandler method 153 | output = rh_method(self, *args, **kwargs) 154 | # If the rh_method returned a Future a la `raise Return(value)` 155 | # or a python 3 coroutine we grab the output. 156 | if is_future(output) or iscoroutine(output): 157 | output = yield output 158 | 159 | # if output is empty, auto return the error 404. 160 | if not output and on_empty_404: 161 | raise APIError(404, "Resource not found.") 162 | 163 | if output_schema is not None: 164 | # We wrap output in an object before validating in case 165 | # output is a string (and ergo not a validatable JSON object) 166 | try: 167 | jsonschema.validate( 168 | {"result": output}, 169 | { 170 | "type": "object", 171 | "properties": { 172 | "result": output_schema 173 | }, 174 | "required": ["result"] 175 | } 176 | ) 177 | except jsonschema.ValidationError as e: 178 | # We essentially re-raise this as a TypeError because 179 | # we don't want this error data passed back to the client 180 | # because it's a fault on our end. The client should 181 | # only see a 500 - Internal Server Error. 182 | raise TypeError(str(e)) 183 | 184 | # If no ValidationError has been raised up until here, we write 185 | # back output 186 | self.success(output) 187 | 188 | setattr(_wrapper, "input_schema", input_schema) 189 | setattr(_wrapper, "output_schema", output_schema) 190 | setattr(_wrapper, "input_example", input_example) 191 | setattr(_wrapper, "output_example", output_example) 192 | 193 | return _wrapper 194 | return _validate 195 | -------------------------------------------------------------------------------- /tornado_json/api_doc_gen.py: -------------------------------------------------------------------------------- 1 | import json 2 | import inspect 3 | import re 4 | 5 | try: 6 | from itertools import imap as map # PY2 7 | except ImportError: 8 | pass 9 | 10 | import tornado.web 11 | from jsonschema import ValidationError, validate 12 | 13 | from tornado_json.utils import extract_method, is_method 14 | from tornado_json.constants import HTTP_METHODS 15 | from tornado_json.requesthandlers import APIHandler 16 | 17 | 18 | def _validate_example(rh, method, example_type): 19 | """Validates example against schema 20 | 21 | :returns: Formatted example if example exists and validates, otherwise None 22 | :raises ValidationError: If example does not validate against the schema 23 | """ 24 | example = getattr(method, example_type + "_example") 25 | schema = getattr(method, example_type + "_schema") 26 | 27 | if example is None: 28 | return None 29 | 30 | try: 31 | validate(example, schema) 32 | except ValidationError as e: 33 | raise ValidationError( 34 | "{}_example for {}.{} could not be validated.\n{}".format( 35 | example_type, rh.__name__, method.__name__, str(e) 36 | ) 37 | ) 38 | 39 | return json.dumps(example, indent=4, sort_keys=True) 40 | 41 | 42 | def _get_rh_methods(rh): 43 | """Yield all HTTP methods in ``rh`` that are decorated 44 | with schema.validate""" 45 | for k, v in vars(rh).items(): 46 | if all([ 47 | k in HTTP_METHODS, 48 | is_method(v), 49 | hasattr(v, "input_schema") 50 | ]): 51 | yield (k, v) 52 | 53 | 54 | def _get_tuple_from_route(route): 55 | """Return (pattern, handler_class, methods) tuple from ``route`` 56 | 57 | :type route: tuple|tornado.web.URLSpec 58 | :rtype: tuple 59 | :raises TypeError: If ``route`` is not a tuple or URLSpec 60 | """ 61 | if isinstance(route, tuple): 62 | assert len(route) >= 2 63 | pattern, handler_class = route[:2] 64 | elif isinstance(route, tornado.web.URLSpec): 65 | pattern, handler_class = route.regex.pattern, route.handler_class 66 | else: 67 | raise TypeError("Unknown route type '{}'" 68 | .format(type(route).__name__)) 69 | 70 | methods = [] 71 | route_re = re.compile(pattern) 72 | route_params = set(list(route_re.groupindex.keys()) + ['self']) 73 | for http_method in HTTP_METHODS: 74 | method = getattr(handler_class, http_method, None) 75 | if method: 76 | method = extract_method(method) 77 | method_params = set(getattr(method, "__argspec_args", 78 | inspect.getfullargspec(method).args)) 79 | if route_params.issubset(method_params) and \ 80 | method_params.issubset(route_params): 81 | methods.append(http_method) 82 | 83 | return pattern, handler_class, methods 84 | 85 | 86 | def _escape_markdown_literals(string): 87 | """Escape any markdown literals in ``string`` by prepending with \\ 88 | 89 | :type string: str 90 | :rtype: str 91 | """ 92 | literals = list("\\`*_{}[]()<>#+-.!:|") 93 | escape = lambda c: '\\' + c if c in literals else c 94 | return "".join(map(escape, string)) 95 | 96 | 97 | def _cleandoc(doc): 98 | """Remove uniform indents from ``doc`` lines that are not empty 99 | 100 | :returns: Cleaned ``doc`` 101 | """ 102 | indent_length = lambda s: len(s) - len(s.lstrip(" ")) 103 | not_empty = lambda s: s != "" 104 | 105 | lines = doc.split("\n") 106 | indent = min(map(indent_length, filter(not_empty, lines))) 107 | 108 | return "\n".join(s[indent:] for s in lines) 109 | 110 | 111 | def _add_indent(string, indent): 112 | """Add indent of ``indent`` spaces to ``string.split("\n")[1:]`` 113 | 114 | Useful for formatting in strings to already indented blocks 115 | """ 116 | lines = string.split("\n") 117 | first, lines = lines[0], lines[1:] 118 | lines = ["{indent}{s}".format(indent=" " * indent, s=s) 119 | for s in lines] 120 | lines = [first] + lines 121 | return "\n".join(lines) 122 | 123 | 124 | def _get_example_doc(rh, method, type): 125 | assert type in ("input", "output") 126 | 127 | example = _validate_example(rh, method, type) 128 | if not example: 129 | return "" 130 | res = """ 131 | **{type} Example** 132 | ```json 133 | {example} 134 | ``` 135 | """.format( 136 | type=type.capitalize(), 137 | example=_add_indent(example, 4) 138 | ) 139 | return _cleandoc(res) 140 | 141 | 142 | def _get_input_example(rh, method): 143 | return _get_example_doc(rh, method, "input") 144 | 145 | 146 | def _get_output_example(rh, method): 147 | return _get_example_doc(rh, method, "output") 148 | 149 | 150 | def _get_schema_doc(schema, type): 151 | res = """ 152 | **{type} Schema** 153 | ```json 154 | {schema} 155 | ``` 156 | """.format( 157 | schema=_add_indent(json.dumps(schema, indent=4, sort_keys=True), 4), 158 | type=type.capitalize() 159 | ) 160 | return _cleandoc(res) 161 | 162 | 163 | def _get_input_schema_doc(method): 164 | return _get_schema_doc(method.input_schema, "input") 165 | 166 | 167 | def _get_output_schema_doc(method): 168 | return _get_schema_doc(method.output_schema, "output") 169 | 170 | 171 | def _get_notes(method): 172 | doc = inspect.getdoc(method) 173 | if doc is None: 174 | return None 175 | res = """ 176 | **Notes** 177 | 178 | {} 179 | """.format(_add_indent(doc, 4)) 180 | return _cleandoc(res) 181 | 182 | 183 | def _get_method_doc(rh, method_name, method): 184 | res = """## {method_name} 185 | 186 | {input_schema} 187 | {input_example} 188 | {output_schema} 189 | {output_example} 190 | {notes} 191 | """.format( 192 | method_name=method_name.upper(), 193 | input_schema=_get_input_schema_doc(method), 194 | output_schema=_get_output_schema_doc(method), 195 | notes=_get_notes(method) or "", 196 | input_example=_get_input_example(rh, method), 197 | output_example=_get_output_example(rh, method), 198 | ) 199 | return _cleandoc("\n".join([l.rstrip() for l in res.splitlines()])) 200 | 201 | 202 | def _get_rh_doc(rh, methods): 203 | res = "\n\n".join([_get_method_doc(rh, method_name, method) 204 | for method_name, method in _get_rh_methods(rh) 205 | if method_name in methods]) 206 | return res 207 | 208 | 209 | def _get_content_type(rh): 210 | # XXX: Content-type is hard-coded but ideally should be retrieved; 211 | # the hard part is, we don't know what it is without initializing 212 | # an instance, so just leave as-is for now 213 | return "Content-Type: application/json" 214 | 215 | 216 | def _get_route_doc(url, rh, methods): 217 | route_doc = """ 218 | # {route_pattern} 219 | 220 | {content_type} 221 | 222 | {rh_doc} 223 | """.format( 224 | route_pattern=_escape_markdown_literals(url), 225 | content_type=_get_content_type(rh), 226 | rh_doc=_add_indent(_get_rh_doc(rh, methods), 4) 227 | ) 228 | return _cleandoc(route_doc) 229 | 230 | 231 | def _write_docs_to_file(documentation): 232 | # Documentation is written to the root folder 233 | with open("API_Documentation.md", "w+") as f: 234 | f.write(documentation) 235 | 236 | 237 | def get_api_docs(routes): 238 | """ 239 | Generates GitHub Markdown formatted API documentation using 240 | provided schemas in RequestHandler methods and their docstrings. 241 | 242 | :type routes: [(url, RequestHandler), ...] 243 | :param routes: List of routes (this is ideally all possible routes of the 244 | app) 245 | :rtype: str 246 | :returns: generated GFM-formatted documentation 247 | """ 248 | routes = map(_get_tuple_from_route, routes) 249 | documentation = [] 250 | for url, rh, methods in sorted(routes, key=lambda a: a[0]): 251 | if issubclass(rh, APIHandler): 252 | documentation.append(_get_route_doc(url, rh, methods)) 253 | 254 | documentation = ( 255 | "**This documentation is automatically generated.**\n\n" + 256 | "**Output schemas only represent `data` and not the full output; " + 257 | "see output examples and the JSend specification.**\n" + 258 | "\n
\n
\n".join(documentation) 259 | ) 260 | return documentation 261 | 262 | 263 | def api_doc_gen(routes): 264 | """Get and write API documentation for ``routes`` to file""" 265 | documentation = get_api_docs(routes) 266 | _write_docs_to_file(documentation) 267 | -------------------------------------------------------------------------------- /tornado_json/routes.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import pkgutil 4 | from functools import reduce 5 | from itertools import chain 6 | 7 | from tornado_json.constants import HTTP_METHODS 8 | from tornado_json.utils import extract_method, is_method, is_handler_subclass 9 | 10 | 11 | def get_routes(package): 12 | """ 13 | This will walk ``package`` and generates routes from any and all 14 | ``APIHandler`` and ``ViewHandler`` subclasses it finds. If you need to 15 | customize or remove any routes, you can do so to the list of 16 | returned routes that this generates. 17 | 18 | :type package: package 19 | :param package: The package containing RequestHandlers to generate 20 | routes from 21 | :returns: List of routes for all submodules of ``package`` 22 | :rtype: [(url, RequestHandler), ... ] 23 | """ 24 | return list(chain(*[get_module_routes(modname) for modname in 25 | gen_submodule_names(package)])) 26 | 27 | 28 | def gen_submodule_names(package): 29 | """Walk package and yield names of all submodules 30 | 31 | :type package: package 32 | :param package: The package to get submodule names of 33 | :returns: Iterator that yields names of all submodules of ``package`` 34 | :rtype: Iterator that yields ``str`` 35 | """ 36 | for importer, modname, ispkg in pkgutil.walk_packages( 37 | path=package.__path__, 38 | prefix=package.__name__ + '.', 39 | onerror=lambda x: None): 40 | yield modname 41 | 42 | 43 | def get_module_routes(module_name, custom_routes=None, exclusions=None, 44 | arg_pattern=r'(?P<{}>[a-zA-Z0-9_\-]+)'): 45 | r"""Create and return routes for module_name 46 | 47 | Routes are (url, RequestHandler) tuples 48 | 49 | :returns: list of routes for ``module_name`` with respect to ``exclusions`` 50 | and ``custom_routes``. Returned routes are with URLs formatted such 51 | that they are forward-slash-separated by module/class level 52 | and end with the lowercase name of the RequestHandler (it will also 53 | remove 'handler' from the end of the name of the handler). 54 | For example, a requesthandler with the name 55 | ``helloworld.api.HelloWorldHandler`` would be assigned the url 56 | ``/api/helloworld``. 57 | Additionally, if a method has extra arguments aside from ``self`` in 58 | its signature, routes with URL patterns will be generated to 59 | match ``r"(?P<{}>[a-zA-Z0-9_\-]+)".format(argname)`` for each 60 | argument. The aforementioned regex will match ONLY values 61 | with alphanumeric, hyphen and underscore characters. You can provide 62 | your own pattern by setting a ``arg_pattern`` param. 63 | :rtype: [(url, RequestHandler), ... ] 64 | :type module_name: str 65 | :param module_name: Name of the module to get routes for 66 | :type custom_routes: [(str, RequestHandler), ... ] 67 | :param custom_routes: List of routes that have custom URLs and therefore 68 | should be automagically generated 69 | :type exclusions: [str, str, ...] 70 | :param exclusions: List of RequestHandler names that routes should not be 71 | generated for 72 | :type arg_pattern: str 73 | :param arg_pattern: Default pattern for extra arguments of any method 74 | """ 75 | 76 | def has_method(module, cls_name, method_name): 77 | return all([ 78 | method_name in vars(getattr(module, cls_name)), 79 | is_method(reduce(getattr, [module, cls_name, method_name])) 80 | ]) 81 | 82 | def yield_args(module, cls_name, method_name): 83 | """Get signature of ``module.cls_name.method_name`` 84 | 85 | Confession: This function doesn't actually ``yield`` the arguments, 86 | just returns a list. Trust me, it's better that way. 87 | 88 | :returns: List of arg names from method_name except ``self`` 89 | :rtype: list 90 | """ 91 | wrapped_method = reduce(getattr, [module, cls_name, method_name]) 92 | method = extract_method(wrapped_method) 93 | 94 | # If using tornado_json.gen.coroutine, original args are annotated... 95 | argspec_args = getattr(method, "__argspec_args", 96 | # otherwise just grab them from the method 97 | inspect.getfullargspec(method).args) 98 | 99 | return [a for a in argspec_args if a not in ["self"]] 100 | 101 | def generate_auto_route(module, module_name, cls_name, method_name, url_name): 102 | """Generate URL for auto_route 103 | 104 | :rtype: str 105 | :returns: Constructed URL based on given arguments 106 | """ 107 | 108 | def get_handler_name(): 109 | """Get handler identifier for URL 110 | 111 | For the special case where ``url_name`` is 112 | ``__self__``, the handler is named a lowercase 113 | value of its own name with 'handler' removed 114 | from the ending if give; otherwise, we 115 | simply use the provided ``url_name`` 116 | """ 117 | if url_name == "__self__": 118 | if cls_name.lower().endswith('handler'): 119 | return cls_name.lower().replace('handler', '', 1) 120 | return cls_name.lower() 121 | else: 122 | return url_name 123 | 124 | def get_arg_route(): 125 | r"""Get remainder of URL determined by method argspec 126 | 127 | :returns: Remainder of URL which matches `\w+` regex 128 | with groups named by the method's argument spec. 129 | If there are no arguments given, returns ``""``. 130 | :rtype: str 131 | """ 132 | if yield_args(module, cls_name, method_name): 133 | return "/{}/?$".format("/".join( 134 | [arg_pattern.format(argname) for argname 135 | in yield_args(module, cls_name, method_name)] 136 | )) 137 | return r"/?" 138 | 139 | return "/{}/{}{}".format( 140 | "/".join(module_name.split(".")[1:]), 141 | get_handler_name(), 142 | get_arg_route() 143 | ) 144 | 145 | if not custom_routes: 146 | custom_routes = [] 147 | if not exclusions: 148 | exclusions = [] 149 | 150 | # Import module so we can get its request handlers 151 | module = importlib.import_module(module_name) 152 | 153 | # Generate list of RequestHandler names in custom_routes 154 | custom_routes_s = [c.__name__ for r, c in custom_routes] 155 | 156 | rhs = {cls_name: cls for (cls_name, cls) in 157 | inspect.getmembers(module, inspect.isclass)} 158 | 159 | # You better believe this is a list comprehension 160 | auto_routes = list(chain(*[ 161 | list(set(chain(*[ 162 | # Generate a route for each "name" specified in the 163 | # __url_names__ attribute of the handler 164 | [ 165 | # URL, requesthandler tuple 166 | ( 167 | generate_auto_route( 168 | module, module_name, cls_name, method_name, url_name 169 | ), 170 | getattr(module, cls_name) 171 | ) for url_name in getattr(module, cls_name).__url_names__ 172 | # Add routes for each custom URL specified in the 173 | # __urls__ attribute of the handler 174 | ] + [ 175 | ( 176 | url, 177 | getattr(module, cls_name) 178 | ) for url in getattr(module, cls_name).__urls__ 179 | ] 180 | # We create a route for each HTTP method in the handler 181 | # so that we catch all possible routes if different 182 | # HTTP methods have different argspecs and are expecting 183 | # to catch different routes. Any duplicate routes 184 | # are removed from the set() comparison. 185 | for method_name in HTTP_METHODS if has_method( 186 | module, cls_name, method_name) 187 | ]))) 188 | # foreach classname, pyclbr.Class in rhs 189 | for cls_name, cls in rhs.items() 190 | # Only add the pair to auto_routes if: 191 | # * the superclass is in the list of supers we want 192 | # * the requesthandler isn't already paired in custom_routes 193 | # * the requesthandler isn't manually excluded 194 | if is_handler_subclass(cls) 195 | and cls_name not in (custom_routes_s + exclusions) 196 | ])) 197 | 198 | routes = auto_routes + custom_routes 199 | return routes 200 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # tornado_json documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Dec 19 00:44:46 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('../')) 20 | import tornado_json 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'Tornado-JSON' 45 | copyright = u'2014, Hamza Faran' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = tornado_json.__version__ 53 | # The full version, including alpha/beta/rc tags. 54 | release = tornado_json.__version__ 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | 96 | # on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org 97 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 98 | 99 | if not on_rtd: # only import and set the theme if we're building docs locally 100 | import sphinx_rtd_theme 101 | html_theme = "sphinx_rtd_theme" 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | #html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | #html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | #html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | #html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | #html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | #html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | #html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | #html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | #html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | #html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | #html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | #html_use_opensearch = '' 169 | 170 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 171 | #html_file_suffix = None 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'tornado_jsondoc' 175 | 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | #'papersize': 'letterpaper', 182 | 183 | # The font size ('10pt', '11pt' or '12pt'). 184 | #'pointsize': '10pt', 185 | 186 | # Additional stuff for the LaTeX preamble. 187 | #'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, author, documentclass [howto/manual]). 192 | latex_documents = [ 193 | ('index', 'tornado_json.tex', u'tornado\\_json Documentation', 194 | u'Author', 'manual'), 195 | ] 196 | 197 | # The name of an image file (relative to this directory) to place at the top of 198 | # the title page. 199 | #latex_logo = None 200 | 201 | # For "manual" documents, if this is true, then toplevel headings are parts, 202 | # not chapters. 203 | #latex_use_parts = False 204 | 205 | # If true, show page references after internal links. 206 | #latex_show_pagerefs = False 207 | 208 | # If true, show URL addresses after external links. 209 | #latex_show_urls = False 210 | 211 | # Documents to append as an appendix to all manuals. 212 | #latex_appendices = [] 213 | 214 | # If false, no module index is generated. 215 | #latex_domain_indices = True 216 | 217 | 218 | # -- Options for manual page output -------------------------------------------- 219 | 220 | # One entry per manual page. List of tuples 221 | # (source start file, name, description, authors, manual section). 222 | man_pages = [ 223 | ('index', 'tornado_json', u'tornado_json Documentation', 224 | [u'Author'], 1) 225 | ] 226 | 227 | # If true, show URL addresses after external links. 228 | #man_show_urls = False 229 | 230 | 231 | # -- Options for Texinfo output ------------------------------------------------ 232 | 233 | # Grouping the document tree into Texinfo files. List of tuples 234 | # (source start file, target name, title, author, 235 | # dir menu entry, description, category) 236 | texinfo_documents = [ 237 | ('index', 'tornado_json', u'tornado_json Documentation', 238 | u'Author', 'tornado_json', 'One line description of project.', 239 | 'Miscellaneous'), 240 | ] 241 | 242 | # Documents to append as an appendix to all manuals. 243 | #texinfo_appendices = [] 244 | 245 | # If false, no module index is generated. 246 | #texinfo_domain_indices = True 247 | 248 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 249 | #texinfo_show_urls = 'footnote' 250 | 251 | 252 | # -- Options for Epub output --------------------------------------------------- 253 | 254 | # Bibliographic Dublin Core info. 255 | epub_title = u'Tornado-JSON' 256 | epub_author = u'Hamza Faran' 257 | epub_publisher = u'Hamza Faran' 258 | epub_copyright = u'2014, Hamza Faran' 259 | 260 | # The language of the text. It defaults to the language option 261 | # or en if the language is not set. 262 | #epub_language = '' 263 | 264 | # The scheme of the identifier. Typical schemes are ISBN or URL. 265 | #epub_scheme = '' 266 | 267 | # The unique identifier of the text. This can be a ISBN number 268 | # or the project homepage. 269 | #epub_identifier = '' 270 | 271 | # A unique identification for the text. 272 | #epub_uid = '' 273 | 274 | # A tuple containing the cover image and cover page html template filenames. 275 | #epub_cover = () 276 | 277 | # HTML files that should be inserted before the pages created by sphinx. 278 | # The format is a list of tuples containing the path and title. 279 | #epub_pre_files = [] 280 | 281 | # HTML files shat should be inserted after the pages created by sphinx. 282 | # The format is a list of tuples containing the path and title. 283 | #epub_post_files = [] 284 | 285 | # A list of files that should not be packed into the epub file. 286 | #epub_exclude_files = [] 287 | 288 | # The depth of the table of contents in toc.ncx. 289 | #epub_tocdepth = 3 290 | 291 | # Allow duplicate toc entries. 292 | #epub_tocdup = True 293 | -------------------------------------------------------------------------------- /tests/func_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | 4 | from jsonschema.validators import Draft4Validator, create 5 | from tornado.testing import AsyncHTTPTestCase 6 | 7 | from .utils import handle_import_error 8 | 9 | try: 10 | sys.path.append('.') 11 | from tornado_json import routes 12 | from tornado_json import schema 13 | from tornado_json import application 14 | from tornado_json import requesthandlers 15 | sys.path.append('demos/helloworld') 16 | import helloworld 17 | except ImportError as err: 18 | handle_import_error(err) 19 | 20 | 21 | def jd(obj): 22 | return json.dumps(obj) 23 | 24 | 25 | def jl(s): 26 | return json.loads(s.decode("utf-8")) 27 | 28 | 29 | class DummyView(requesthandlers.ViewHandler): 30 | """Dummy ViewHandler for coverage""" 31 | def delete(self): 32 | # Reference db_conn to test for AttributeError 33 | self.db_conn 34 | 35 | 36 | def is_int(_, instance): 37 | return ( 38 | isinstance(instance, int) 39 | ) 40 | 41 | 42 | meta_schema = Draft4Validator.META_SCHEMA.copy() 43 | meta_schema['definitions']["simpleTypes"]['enum'].append('int') 44 | 45 | type_checker = Draft4Validator.TYPE_CHECKER.redefine('int', is_int) 46 | ExtendedDraft4Validator = create(meta_schema, 47 | Draft4Validator.VALIDATORS, 48 | type_checker=type_checker) 49 | 50 | 51 | class PeopleHandler(requesthandlers.APIHandler): 52 | """Example handler with input schema validation that uses custom Validator. 53 | """ 54 | @schema.validate( 55 | input_schema={ 56 | "type": "object", 57 | "properties": { 58 | "name": {'type': "string"}, 59 | "age": {'type': "int"}, 60 | }, 61 | 'required': ['name', 'age'], 62 | }, 63 | validator_cls=ExtendedDraft4Validator 64 | ) 65 | def post(self): 66 | return self.body['name'] 67 | 68 | 69 | class FoobarHandler(requesthandlers.APIHandler): 70 | """ No use_defaults defined, so it will raise errors normally 71 | despite default key being declared in the schema. 72 | """ 73 | @schema.validate( 74 | input_schema={ 75 | "type": "object", 76 | "properties": { 77 | "times": {'type': "integer", "default": 1}, 78 | }, 79 | "required": ['times'], 80 | } 81 | ) 82 | def post(self): 83 | return self.body['times'] * "foobar" 84 | 85 | 86 | class EchoContentHandler(requesthandlers.APIHandler): 87 | 88 | @schema.validate( 89 | input_schema={ 90 | "type": "object", 91 | "properties": { 92 | "title": {'type': "string"}, 93 | "published": {'type': "boolean", "default": False}, 94 | } 95 | }, 96 | use_defaults=True 97 | ) 98 | def post(self): 99 | return self.body 100 | 101 | 102 | class DBTestHandler(requesthandlers.APIHandler): 103 | """APIHandler for testing db_conn""" 104 | def get(self): 105 | # Set application.db_conn to test if db_conn BaseHandler 106 | # property works 107 | self.application.db_conn = {"data": "Nothing to see here."} 108 | self.success(self.db_conn.get("data")) 109 | 110 | 111 | class ExplodingHandler(requesthandlers.APIHandler): 112 | 113 | @schema.validate(**{ 114 | "input_schema": None, 115 | "output_schema": { 116 | "type": "number", 117 | } 118 | }) 119 | def get(self): 120 | """This handler is used for testing purposes and is explosive.""" 121 | return "I am not the handler you are looking for." 122 | 123 | @schema.validate(**{ 124 | "input_schema": { 125 | "type": "number", 126 | }, 127 | "output_schema": { 128 | "type": "number", 129 | } 130 | }) 131 | def post(self): 132 | """This handler is used for testing purposes and is explosive.""" 133 | return "Fission mailed." 134 | 135 | 136 | class NotFoundHandler(requesthandlers.APIHandler): 137 | 138 | @schema.validate(**{ 139 | "output_schema": { 140 | "type": "number", 141 | }, 142 | "on_empty_404": True 143 | }) 144 | def get(self): 145 | """This handler is used for testing empty output.""" 146 | return 0 147 | 148 | @schema.validate(**{ 149 | "input_schema": { 150 | "type": "number", 151 | }, 152 | "output_schema": { 153 | "type": "object", 154 | "properties": { 155 | "name": {"type": "string"} 156 | }, 157 | "required": ["name", ] 158 | } 159 | }) 160 | def post(self): 161 | """This handler is used for testing empty json output.""" 162 | return {} 163 | 164 | 165 | class APIFunctionalTest(AsyncHTTPTestCase): 166 | 167 | def get_app(self): 168 | rts = routes.get_routes(helloworld) 169 | rts += [ 170 | ("/api/people", PeopleHandler), 171 | ("/api/foobar", FoobarHandler), 172 | ("/api/echocontent", EchoContentHandler), 173 | ("/api/explodinghandler", ExplodingHandler), 174 | ("/api/notfoundhandler", NotFoundHandler), 175 | ("/views/someview", DummyView), 176 | ("/api/dbtest", DBTestHandler) 177 | ] 178 | return application.Application( 179 | routes=rts, 180 | settings={"debug": True}, 181 | db_conn=None 182 | ) 183 | 184 | def test_post_custom_validator_class(self): 185 | """It should not raise errors because ExtendedDraft4Validator is used, 186 | so schema type 'int' is allowed. """ 187 | r = self.fetch( 188 | "/api/people", 189 | method="POST", 190 | body=jd({ 191 | 'name': "Paulo", 192 | 'age': 29, 193 | }) 194 | ) 195 | self.assertEqual(r.code, 200) 196 | 197 | def test_post_schema_with_default_but_use_defaults_false(self): 198 | """ Test if defaul key will be used when use_defaults its set o False. 199 | """ 200 | r = self.fetch( 201 | "/api/foobar", 202 | method="POST", 203 | body=jd({}) 204 | ) 205 | self.assertEqual(r.code, 400) 206 | 207 | def test_post_use_defaults(self): 208 | r = self.fetch( 209 | "/api/echocontent", 210 | method="POST", 211 | body=jd({ 212 | "title": "Exciting News !", 213 | }) 214 | ) 215 | self.assertEqual(r.code, 200) 216 | self.assertEqual( 217 | jl(r.body)["data"], 218 | { 219 | 'title': "Exciting News !", 220 | 'published': False, 221 | } 222 | ) 223 | 224 | def test_post_use_defaults_no_need_of_default(self): 225 | r = self.fetch( 226 | "/api/echocontent", 227 | method="POST", 228 | body=jd({ 229 | "title": "Breaking News !", 230 | "published": True, 231 | }) 232 | ) 233 | self.assertEqual(r.code, 200) 234 | self.assertEqual( 235 | jl(r.body)["data"], 236 | { 237 | 'title': "Breaking News !", 238 | 'published': True, 239 | } 240 | ) 241 | 242 | def test_synchronous_handler(self): 243 | r = self.fetch( 244 | "/api/helloworld" 245 | ) 246 | self.assertEqual(r.code, 200) 247 | self.assertEqual( 248 | jl(r.body)["data"], 249 | "Hello world!" 250 | ) 251 | 252 | def test_asynchronous_handler(self): 253 | r = self.fetch( 254 | "/api/asynchelloworld/name" 255 | ) 256 | self.assertEqual(r.code, 200) 257 | self.assertEqual( 258 | jl(r.body)["data"], 259 | "Hello (asynchronous) world! My name is name." 260 | ) 261 | 262 | def test_post_request(self): 263 | r = self.fetch( 264 | "/api/postit", 265 | method="POST", 266 | body=jd({ 267 | "title": "Very Important Post-It Note", 268 | "body": "Equally important message", 269 | "index": 0 270 | }) 271 | ) 272 | self.assertEqual(r.code, 200) 273 | self.assertEqual( 274 | jl(r.body)["data"]["message"], 275 | "Very Important Post-It Note was posted." 276 | ) 277 | 278 | def test_url_pattern_route(self): 279 | r = self.fetch( 280 | "/api/greeting/John/Smith" 281 | ) 282 | self.assertEqual(r.code, 200) 283 | self.assertEqual( 284 | jl(r.body)["data"], 285 | "Greetings, John Smith!" 286 | ) 287 | 288 | def test_write_error(self): 289 | # Test malformed output 290 | r = self.fetch( 291 | "/api/explodinghandler" 292 | ) 293 | self.assertEqual(r.code, 500) 294 | self.assertEqual( 295 | jl(r.body)["status"], 296 | "error" 297 | ) 298 | # Test malformed input 299 | r = self.fetch( 300 | "/api/explodinghandler", 301 | method="POST", 302 | body='"Yup", "this is going to end badly."]' 303 | ) 304 | self.assertEqual(r.code, 400) 305 | self.assertEqual( 306 | jl(r.body)["status"], 307 | "fail" 308 | ) 309 | 310 | def test_empty_resource(self): 311 | # Test empty output 312 | r = self.fetch( 313 | "/api/notfoundhandler" 314 | ) 315 | self.assertEqual(r.code, 404) 316 | self.assertEqual( 317 | jl(r.body)["status"], 318 | "fail" 319 | ) 320 | # Test empty output on_empty_404 is False 321 | r = self.fetch( 322 | "/api/notfoundhandler", 323 | method="POST", 324 | body="1" 325 | ) 326 | self.assertEqual(r.code, 500) 327 | self.assertEqual( 328 | jl(r.body)["status"], 329 | "error" 330 | ) 331 | 332 | def test_view_db_conn(self): 333 | r = self.fetch( 334 | "/views/someview", 335 | method="DELETE" 336 | ) 337 | self.assertEqual(r.code, 500) 338 | self.assertTrue( 339 | "No database connection was provided." in r.body.decode("UTF-8") 340 | ) 341 | 342 | def test_db_conn(self): 343 | r = self.fetch( 344 | "/api/dbtest", 345 | method="GET" 346 | ) 347 | self.assertEqual(r.code, 200) 348 | print(r.body) 349 | self.assertEqual( 350 | jl(r.body)["status"], 351 | "success" 352 | ) 353 | self.assertTrue( 354 | "Nothing to see here." in jl(r.body)["data"] 355 | ) 356 | --------------------------------------------------------------------------------