├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── conda.yml ├── docker-compose.yml ├── docs ├── Makefile ├── conf.py ├── index.rst ├── tcrudge.decorators.rst ├── tcrudge.exceptions.rst ├── tcrudge.handlers.rst ├── tcrudge.models.rst ├── tcrudge.response.rst └── tcrudge.utils.rst ├── pytest.ini ├── readme.md ├── readthedocs.yml ├── requirements.txt ├── setup.py ├── tcrudge ├── __init__.py ├── decorators.py ├── exceptions.py ├── handlers.py ├── models.py ├── response.py └── utils │ ├── __init__.py │ ├── json.py │ ├── schema.py │ ├── validation.py │ └── xhtml_escape.py └── tests ├── __init__.py ├── conftest.py ├── test_handlers.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Byte-compiled / optimized / DLL files 7 | *.py[cod] 8 | __pycache__ 9 | __pycache__/ 10 | *$py.class 11 | 12 | # Pycharm 13 | .idea/ 14 | 15 | # Installer logs 16 | pip-log.txt 17 | 18 | # Docs 19 | docs/build/ 20 | docs/_build/ 21 | 22 | # Virtualenv 23 | venv 24 | 25 | # Vagrant 26 | .vagrant/ 27 | 28 | # Pytest 29 | .cache/ 30 | 31 | # Unit test / coverage reports 32 | .coverage 33 | htmlcov/ 34 | 35 | # Distribution / packaging 36 | .env/ 37 | build/ 38 | dist/ 39 | tcrudge.egg-info/ 40 | 41 | # Pypi 42 | .pypirc 43 | .pkg 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | 4 | services: 5 | - postgresql 6 | 7 | addons: 8 | apt: 9 | sources: 10 | - precise-pgdg-9.5 11 | packages: 12 | - postgresql-9.5 13 | - postgresql-contrib-9.5 14 | postgresql: "9.5" 15 | 16 | env: 17 | global: 18 | - CODECLIMATE_REPO_TOKEN=none 19 | - PIP_DISABLE_PIP_VERSION_CHECK=on 20 | - DATABASE_URL=postgres://postgres@localhost:5432/travis_ci_test 21 | 22 | before_script: 23 | - sudo cp /etc/postgresql/9.4/main/pg_hba.conf /etc/postgresql/9.5/main/pg_hba.conf 24 | - sudo /etc/init.d/postgresql restart 25 | - pip install codeclimate-test-reporter 26 | 27 | after_script: 28 | - coveralls 29 | 30 | python: 31 | - "3.5" 32 | - "3.5-dev" 33 | - "3.6-dev" 34 | - "nightly" 35 | 36 | install: 37 | - "pip install -r requirements.txt" 38 | - "pip install coveralls" 39 | 40 | script: pytest 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5.2-onbuild -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Sergey Borisov, Maxim Shalamov, Aleksandr Nikolaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /conda.yml: -------------------------------------------------------------------------------- 1 | name: py35 2 | dependencies: 3 | - openssl=1.0.2g=0 4 | - pip=8.1.1=py35_0 5 | - python=3.5.1=0 6 | - readline=6.2=2 7 | - setuptools=20.3=py35_0 8 | - sqlite=3.9.2=0 9 | - tk=8.5.18=0 10 | - wheel=0.29.0=py35_0 11 | - xz=5.0.5=1 12 | - zlib=1.2.8=0 13 | - pip: 14 | - aiopg==0.10.0 15 | - peewee==2.8.3 16 | - peewee-async==0.5.5 17 | - psycopg2==2.6.2 18 | - tornado==4.4.2 19 | - jsonschema==2.5.1 20 | - msgpack-python==0.4.8 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | tcrudge: 4 | build: . 5 | links: 6 | - pg 7 | volumes: 8 | - .:/usr/src/app 9 | command: python -m http.server 8000 10 | depends_on: 11 | - pg 12 | pg: 13 | image: postgres 14 | expose: 15 | - "5432" 16 | environment: 17 | - POSTGRES_PASSWORD=dbpass 18 | - POSTGRES_USER=user 19 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = tcrudge 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tcrudge documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jan 6 22:56:23 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 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 | # 20 | import os 21 | import sys 22 | import re 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = ['sphinx.ext.autodoc', 36 | 'sphinx.ext.doctest'] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = 'tcrudge' 52 | copyright = '2017, CodeTeam' 53 | author = 'CodeTeam' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | 60 | 61 | def get_release(): 62 | regexp = re.compile(r"^__version__\W*=\W*'([\d.abrcdev]+)'") 63 | root = os.path.dirname(os.path.dirname(__file__)) 64 | init_py = os.path.join(root, 'tcrudge', '__init__.py') 65 | with open(init_py) as f: 66 | for line in f: 67 | match = regexp.match(line) 68 | if match is not None: 69 | return match.group(1) 70 | else: 71 | raise RuntimeError('Cannot find version in tcrudge/__init__.py') 72 | 73 | 74 | def get_version(_release): 75 | parts = _release.split('.') 76 | return '.'.join(parts[:2]) 77 | 78 | # The full version, including alpha/beta/rc tags. 79 | release = get_release() 80 | 81 | # The short X.Y version. 82 | version = get_version(release) 83 | 84 | # The language for content autogenerated by Sphinx. Refer to documentation 85 | # for a list of supported languages. 86 | # 87 | # This is also used if you do content translation via gettext catalogs. 88 | # Usually you set "language" from the command line for these cases. 89 | language = None 90 | 91 | # List of patterns, relative to source directory, that match files and 92 | # directories to ignore when looking for source files. 93 | # This patterns also effect to html_static_path and html_extra_path 94 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # If true, `todo` and `todoList` produce output, else they produce nothing. 100 | todo_include_todos = False 101 | 102 | 103 | # -- Options for HTML output ---------------------------------------------- 104 | 105 | # The theme to use for HTML and HTML Help pages. See the documentation for 106 | # a list of builtin themes. 107 | # 108 | html_theme = 'alabaster' 109 | 110 | # Theme options are theme-specific and customize the look and feel of a theme 111 | # further. For a list of options available for each theme, see the 112 | # documentation. 113 | # 114 | # html_theme_options = {} 115 | 116 | # Add any paths that contain custom static files (such as style sheets) here, 117 | # relative to this directory. They are copied after the builtin static files, 118 | # so a file named "default.css" will overwrite the builtin "default.css". 119 | html_static_path = [] 120 | 121 | 122 | # -- Options for HTMLHelp output ------------------------------------------ 123 | 124 | # Output file base name for HTML help builder. 125 | htmlhelp_basename = 'tcrudgedoc' 126 | 127 | 128 | # -- Options for LaTeX output --------------------------------------------- 129 | 130 | latex_elements = { 131 | # The paper size ('letterpaper' or 'a4paper'). 132 | # 133 | # 'papersize': 'letterpaper', 134 | 135 | # The font size ('10pt', '11pt' or '12pt'). 136 | # 137 | # 'pointsize': '10pt', 138 | 139 | # Additional stuff for the LaTeX preamble. 140 | # 141 | # 'preamble': '', 142 | 143 | # Latex figure (float) alignment 144 | # 145 | # 'figure_align': 'htbp', 146 | } 147 | 148 | # Grouping the document tree into LaTeX files. List of tuples 149 | # (source start file, target name, title, 150 | # author, documentclass [howto, manual, or own class]). 151 | latex_documents = [ 152 | (master_doc, 'tcrudge.tex', 'tcrudge Documentation', 153 | 'CodeTeam', 'manual'), 154 | ] 155 | 156 | 157 | # -- Options for manual page output --------------------------------------- 158 | 159 | # One entry per manual page. List of tuples 160 | # (source start file, name, description, authors, manual section). 161 | man_pages = [ 162 | (master_doc, 'tcrudge', 'tcrudge Documentation', 163 | [author], 1) 164 | ] 165 | 166 | 167 | # -- Options for Texinfo output ------------------------------------------- 168 | 169 | # Grouping the document tree into Texinfo files. List of tuples 170 | # (source start file, target name, title, author, 171 | # dir menu entry, description, category) 172 | texinfo_documents = [ 173 | (master_doc, 'tcrudge', 'tcrudge Documentation', 174 | author, 'tcrudge', 'One line description of project.', 175 | 'Miscellaneous'), 176 | ] 177 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. tcrudge documentation master file, created by 2 | sphinx-quickstart on Fri Jan 6 22:56:23 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to tcrudge's documentation! 7 | =================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | 13 | TCrudge - Simple configurable framework to create CRUDL (Create, Read, Update, Delete, List) for models based on Tornado and Peewee ORM. 14 | TCrudge is under heavy development - tons of bugs are expected. You can use it in production, but API can be broken at any moment. 15 | 16 | Installation 17 | ============ 18 | 19 | Tcrudge is distributed via pypi (https://pypi.python.org/pypi/tcrudge/) :: 20 | 21 | pip install tcrudge 22 | 23 | You can manually install latest version via GitHub:: 24 | 25 | pip install git+https://github.com/CodeTeam/tcrudge.git 26 | 27 | Example 28 | ======= 29 | One-file sample application:: 30 | 31 | import asyncio 32 | 33 | import peewee 34 | import peewee_async 35 | from playhouse.db_url import parse 36 | from tornado import web 37 | from tornado.ioloop import IOLoop 38 | 39 | from tcrudge.handlers import ApiListHandler, ApiItemHandler 40 | from tcrudge.models import BaseModel 41 | 42 | # Configure Tornado to use asyncio 43 | IOLoop.configure('tornado.platform.asyncio.AsyncIOMainLoop') 44 | 45 | # Create database 46 | DATABASE_URL = 'postgresql://user:dbpass@pg/test' 47 | 48 | db_param = parse(DATABASE_URL) 49 | 50 | db = peewee_async.PooledPostgresqlDatabase(**db_param) 51 | 52 | 53 | # CRUDL Model 54 | class Company(BaseModel): 55 | name = peewee.TextField() 56 | active = peewee.BooleanField() 57 | 58 | class Meta: 59 | database = db 60 | 61 | 62 | # CL Handler 63 | class CompanyDetailHandler(ApiItemHandler): 64 | model_cls = Company 65 | 66 | 67 | # RUD Handler 68 | class CompanyListHandler(ApiListHandler): 69 | model_cls = Company 70 | default_filter = {'active': True} 71 | 72 | 73 | app_handlers = [ 74 | ('^/api/v1/companies/', CompanyListHandler), 75 | ('^/api/v1/companies/([^/]+)/', CompanyDetailHandler) 76 | ] 77 | 78 | application = web.Application(app_handlers) 79 | 80 | # ORM 81 | application.objects = peewee_async.Manager(db) 82 | 83 | with application.objects.allow_sync(): 84 | # Creates table, if not exists 85 | Company.create_table(True) 86 | 87 | application.listen(8080, '0.0.0.0') 88 | loop = asyncio.get_event_loop() 89 | # Start application 90 | loop.run_forever() 91 | 92 | Module documentation 93 | ==================== 94 | 95 | .. toctree:: 96 | 97 | tcrudge.exceptions 98 | tcrudge.models 99 | tcrudge.response 100 | tcrudge.handlers 101 | tcrudge.decorators 102 | tcrudge.utils -------------------------------------------------------------------------------- /docs/tcrudge.decorators.rst: -------------------------------------------------------------------------------- 1 | tcrudge.decorators module 2 | ========================= 3 | 4 | .. automodule:: tcrudge.decorators 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/tcrudge.exceptions.rst: -------------------------------------------------------------------------------- 1 | tcrudge.exceptions module 2 | ========================= 3 | 4 | .. automodule:: tcrudge.exceptions 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/tcrudge.handlers.rst: -------------------------------------------------------------------------------- 1 | tcrudge.handlers module 2 | ======================= 3 | 4 | .. automodule:: tcrudge.handlers 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/tcrudge.models.rst: -------------------------------------------------------------------------------- 1 | tcrudge.models module 2 | ===================== 3 | 4 | .. automodule:: tcrudge.models 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/tcrudge.response.rst: -------------------------------------------------------------------------------- 1 | tcrudge.response module 2 | ======================= 3 | 4 | .. automodule:: tcrudge.response 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/tcrudge.utils.rst: -------------------------------------------------------------------------------- 1 | tcrudge.utils package 2 | ===================== 3 | 4 | tcrudge.utils.json module 5 | ------------------------- 6 | 7 | .. automodule:: tcrudge.utils.json 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | tcrudge.utils.validation module 13 | ------------------------------- 14 | 15 | .. automodule:: tcrudge.utils.validation 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = --cov=tcrudge --cov-report term-missing -v 4 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | TCrudge - simple async CRUDL based on Tornado and Peewee ORM (Peewee Async) 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/tcrudge/badge/?version=latest)](http://tcrudge.readthedocs.io/en/latest/?badge=latest) 4 | [![Build Status](https://travis-ci.org/CodeTeam/tcrudge.svg?branch=master)](https://travis-ci.org/CodeTeam/tcrudge) 5 | [![Code Climate](https://codeclimate.com/github/CodeTeam/tcrudge/badges/gpa.svg)](https://codeclimate.com/github/CodeTeam/tcrudge) 6 | [![Issue Count](https://codeclimate.com/github/CodeTeam/tcrudge/badges/issue_count.svg)](https://codeclimate.com/github/CodeTeam/tcrudge) 7 | [![Coverage Status](https://coveralls.io/repos/github/CodeTeam/tcrudge/badge.svg?branch=master)](https://coveralls.io/github/CodeTeam/tcrudge?branch=master) 8 | 9 | Full documentation (http://tcrudge.readthedocs.io/en/latest/) 10 | 11 | # What is it? 12 | Simple configurable framework to create CRUDL (Create, Read, Update, Delete, List) for models. 13 | TCrudge is under heavy development - tons of bugs are expected. You can use it in production, but API can be broken at any moment. 14 | 15 | # Why? 16 | Tornado is fast. Peewee is great. REST is wonderful. 17 | 18 | # Dependencies 19 | * Tornado (https://github.com/tornadoweb/tornado) 20 | * Peewee (https://github.com/coleifer/peewee) 21 | * Peewee-async (https://github.com/05bit/peewee-async) 22 | 23 | # Installation 24 | tcrudge is distributed via pypi: https://pypi.python.org/pypi/tcrudge/ 25 | ``` 26 | pip install tcrudge 27 | ``` 28 | 29 | You can manually install latest version via GitHub: 30 | ``` 31 | pip install git+https://github.com/CodeTeam/tcrudge.git 32 | ``` 33 | 34 | # How to? 35 | Describe models using Peewee ORM. Subclass ```tcrudge.ApiListHandler``` and ```tcrudge.ApiItemHandler```. Connect handlers with models using model_cls handler attribute. Add urls to tornado.Application url dispatcher. 36 | 37 | For detailed example see tests (also, tests are available in Docker container with py.test). 38 | 39 | You can run tests in docker container only. 40 | You'll need docker and docker-compose. 41 | 42 | 1. Go to project root directory 43 | 2. Run docker-compose up, it builts and runs containers. 44 | 3. Go to tcrudge container bash: docker exec -ti tcrudge_tcrudge_1 bash 45 | 4. Run: DATABASE_URL=postgresql://user:dbpass@pg/test pytest 46 | 47 | # Features? 48 | 49 | 1. DELETE request on item is disabled by default. To enable it implement _delete method in your model. 50 | 2. Models are fat. _create, _update, _delete methods are supposed to provide different logic on CRUD operations 51 | 3. Django-style filtering in list request: ```__gt```, ```__gte```, ```__lt```, ```__lte```, ```__in```, ```__isnull```, ```__like```, ```__ilike```, ```__ne``` are supported. Use ```/?model_field__=``` for complex or ```/?model_field=``` for simple filtering. 52 | 4. Django-style order by: use ```/?order_by=,``` etc 53 | 5. Serialization is provided by Peewee: ```playhouse.shortcuts.model_to_dict```. ```recurse```, ```exclude``` and ```max_depth``` params are implemented in base class for better experience. If you want to serialize recurse foreign keys, do not forget to modify ```get_queryset``` method (see Peewee docs for details, use ```.join()``` and ```.select()```) 54 | 6. Validation is provided out-of-the box via jsonschema. Just set input schemas for base methods (e.g. post_schema_input, get_schema_input etc). Request query is validated for *GET* and *HEAD*. Request body is validated for *POST*, *PUT* and *DELETE*. 55 | 7. Pagination is activated by default for lists. Use ```default_limit``` and ```max_limit``` for customization. Pagination params are set through headers (X-Limit, X-Offset) or query: ```/?limit=100&offset=5```. Total amount of items is not returned by default. HEAD request should be sent or total param set to 1: ```/?total=1``` 56 | 8. List handler supports default filtering and ordering. Use ```default_filter``` and ```default_order_by``` class properties. 57 | 58 | # Example 59 | 60 | ```python 61 | import asyncio 62 | 63 | import peewee 64 | import peewee_async 65 | from playhouse.db_url import parse 66 | from tornado import web 67 | from tornado.ioloop import IOLoop 68 | 69 | from tcrudge.handlers import ApiListHandler, ApiItemHandler 70 | from tcrudge.models import BaseModel 71 | 72 | # Configure Tornado to use asyncio 73 | IOLoop.configure('tornado.platform.asyncio.AsyncIOMainLoop') 74 | 75 | # Create database 76 | DATABASE_URL = 'postgresql://user:dbpass@pg/test' 77 | 78 | db_param = parse(DATABASE_URL) 79 | 80 | db = peewee_async.PooledPostgresqlDatabase(**db_param) 81 | 82 | 83 | # CRUDL Model 84 | class Company(BaseModel): 85 | name = peewee.TextField() 86 | active = peewee.BooleanField() 87 | 88 | class Meta: 89 | database = db 90 | 91 | 92 | # CL Handler 93 | class CompanyDetailHandler(ApiItemHandler): 94 | model_cls = Company 95 | 96 | 97 | # RUD Handler 98 | class CompanyListHandler(ApiListHandler): 99 | model_cls = Company 100 | default_filter = {'active': True} 101 | 102 | 103 | app_handlers = [ 104 | ('^/api/v1/companies/', CompanyListHandler), 105 | ('^/api/v1/companies/([^/]+)/', CompanyDetailHandler) 106 | ] 107 | 108 | application = web.Application(app_handlers) 109 | 110 | # ORM 111 | application.objects = peewee_async.Manager(db) 112 | 113 | with application.objects.allow_sync(): 114 | # Creates table, if not exists 115 | Company.create_table(True) 116 | 117 | application.listen(8080, '0.0.0.0') 118 | loop = asyncio.get_event_loop() 119 | # Start application 120 | loop.run_forever() 121 | 122 | ``` 123 | 124 | # Сontributors 125 | * [Borisov Sergey] (https://github.com/juntatalor) 126 | * [Shalamov Maxim] (https://github.com/mvshalamov) 127 | * [Nikolaev Alexander] (https://github.com/wokli) 128 | * [Krasavina Alina] (https://github.com/thaelathy) 129 | * [Ivanov Denis] (https://github.com/steinerr) 130 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | conda: 2 | file: conda.yml -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core 2 | aiopg==0.10.0 3 | peewee==2.8.3 4 | peewee-async==0.5.5 5 | psycopg2==2.6.2 6 | tornado==4.4.2 7 | jsonschema==2.5.1 8 | msgpack-python==0.4.8 9 | 10 | # Development & Testing 11 | py==1.4.31 12 | pytest==3.0.2 13 | pytest-cov==2.3.1 14 | pytest-env==0.6.0 15 | pytest-tornado==0.4.5 16 | coverage==4.2 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import sys 5 | from io import open 6 | 7 | from setuptools import setup 8 | 9 | 10 | def get_long_description(f): 11 | try: 12 | import pypandoc 13 | except ImportError: 14 | return 'No description' 15 | return pypandoc.convert(f, 'rst') 16 | 17 | 18 | install_requires = [ 19 | 'peewee>=2.8.3', 20 | 'peewee-async>=0.5.5', 21 | 'tornado>=4.4.2', 22 | 'jsonschema>=2.5.1', 23 | 'msgpack-python>=0.4.8', 24 | ] 25 | 26 | extras_require = {'tests': [ 27 | 'py>=1.4.31', 28 | 'pytest>=3.0.2', 29 | 'pytest-cov>=2.3.1', 30 | 'pytest-env>=0.6.0', 31 | 'pytest-tornado>=0.4.5', 32 | 'coverage>=4.2' 33 | ], } 34 | 35 | 36 | def get_version(package): 37 | """ 38 | Return package version as listed in `__version__` in `init.py`. 39 | """ 40 | regexp = re.compile(r"^__version__\W*=\W*'([\d.abrcdev]+)'") 41 | init_py = os.path.join(package, '__init__.py') 42 | with open(init_py) as f: 43 | for line in f: 44 | match = regexp.match(line) 45 | if match is not None: 46 | return match.group(1) 47 | else: 48 | raise RuntimeError('Cannot find version in tcrudge/__init__.py') 49 | 50 | 51 | def get_packages(package): 52 | """ 53 | Return root package and all sub-packages. 54 | """ 55 | return [dirpath 56 | for dirpath, dirnames, filenames in os.walk(package) 57 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 58 | 59 | 60 | def get_package_data(package): 61 | """ 62 | Return all files under the root package, that are not in a 63 | package themselves. 64 | """ 65 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 66 | for dirpath, dirnames, filenames in os.walk(package) 67 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 68 | 69 | filepaths = [] 70 | for base, filenames in walk: 71 | filepaths.extend([os.path.join(base, filename) 72 | for filename in filenames]) 73 | return {package: filepaths} 74 | 75 | 76 | version = get_version('tcrudge') 77 | 78 | if sys.argv[-1] == 'publish': 79 | try: 80 | import pypandoc 81 | except ImportError: 82 | print("pypandoc not installed.\nUse `pip install pypandoc`.\nExiting.") 83 | sys.exit(1) 84 | pypandoc.download_pandoc() 85 | if os.system("pip freeze | grep twine"): 86 | print("twine not installed.\nUse `pip install twine`.\nExiting.") 87 | sys.exit() 88 | os.system("python setup.py sdist bdist_wheel") 89 | os.system("twine upload dist/*") 90 | shutil.rmtree('dist') 91 | shutil.rmtree('build') 92 | shutil.rmtree('tcrudge.egg-info') 93 | sys.exit() 94 | 95 | setup( 96 | name='tcrudge', 97 | version=version, 98 | url='https://github.com/CodeTeam/tcrudge', 99 | license='MIT', 100 | description='Tornado RESTful API with Peewee', 101 | long_description=get_long_description('readme.md'), 102 | author='Code Team', 103 | author_email='saborisov@sberned.ru', 104 | packages=get_packages('tcrudge'), 105 | package_data=get_package_data('tcrudge'), 106 | install_requires=install_requires, 107 | extras_require=extras_require, 108 | zip_safe=False, 109 | classifiers=[ 110 | 'Development Status :: 3 - Alpha', 111 | 'Environment :: Web Environment', 112 | 'Intended Audience :: Developers', 113 | 'License :: OSI Approved :: MIT License', 114 | 'Operating System :: OS Independent', 115 | 'Programming Language :: Python', 116 | 'Programming Language :: Python :: 3.5', 117 | 'Topic :: Internet :: WWW/HTTP', 118 | ] 119 | ) 120 | -------------------------------------------------------------------------------- /tcrudge/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple async CRUDL framework based on Tornado and Peewee ORM. 3 | 4 | Validates input using JSON-schema. 5 | 6 | Supports JSON and MessagePack responses. 7 | """ 8 | 9 | __version__ = '0.9.11' 10 | -------------------------------------------------------------------------------- /tcrudge/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module containing decorators. 3 | """ 4 | 5 | 6 | def perm_roles(items): 7 | """ 8 | Check roles from input list. Auth logic is up to user. 9 | """ 10 | 11 | def wrap(f): 12 | async def func(self, *args, **kw): 13 | auth = await self.is_auth() 14 | if auth: 15 | roles = await self.get_roles() 16 | valid_permission = False 17 | for r in roles: 18 | if r in items: 19 | valid_permission = True 20 | break 21 | if not valid_permission: 22 | await self.bad_permissions() 23 | return await f(self, *args, **kw) 24 | else: 25 | await self.bad_permissions() 26 | 27 | return func 28 | 29 | return wrap 30 | -------------------------------------------------------------------------------- /tcrudge/exceptions.py: -------------------------------------------------------------------------------- 1 | from tornado.web import HTTPError as _HTTPError 2 | 3 | 4 | class HTTPError(_HTTPError): 5 | """ 6 | Custom HTTPError class 7 | Expands kwargs with body argument 8 | Usage: 9 | raise HTTPError(400, b'Something bad happened') 10 | """ 11 | def __init__(self, status_code=500, log_message=None, *args, **kwargs): 12 | super(HTTPError, self).__init__(status_code, log_message, *args, **kwargs) 13 | self.body = kwargs.get('body') 14 | -------------------------------------------------------------------------------- /tcrudge/handlers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module contains basic handlers: 3 | 4 | * BaseHandler - to be used for custom handlers. For instance - RPC, if you wish. 5 | * ApiHandler - Abstract for API handlers above. 6 | * ApiListHandler - Create (POST), List view (GET). 7 | * ApiItemHandler - detailed view (GET), Update (PUT), Delete (DELETE). 8 | """ 9 | 10 | import json 11 | import operator 12 | import traceback 13 | from abc import ABCMeta, abstractmethod 14 | 15 | import peewee 16 | from jsonschema.validators import validator_for 17 | from playhouse.shortcuts import model_to_dict 18 | from tornado import web 19 | from tornado.gen import multi 20 | from tornado.escape import xhtml_escape 21 | 22 | from tcrudge.exceptions import HTTPError 23 | from tcrudge.models import FILTER_MAP 24 | from tcrudge.response import response_json, response_msgpack 25 | from tcrudge.utils.validation import prepare 26 | from tcrudge.utils.xhtml_escape import xhtml_escape_complex_object 27 | 28 | 29 | class BaseHandler(web.RequestHandler): 30 | """ 31 | Base helper class. Provides basic handy responses. 32 | 33 | To be used for customized handlers that don't fit REST API recommendations. 34 | 35 | Defines response types in relation to Accept header. Response interface is 36 | described in corresponding module. 37 | 38 | By default, inherited handlers have callback functions for JSON and 39 | MessagePack responses. 40 | """ 41 | 42 | response_callbacks = { 43 | 'application/json': response_json, 44 | 'application/x-msgpack': response_msgpack, 45 | } 46 | default_callback = staticmethod(response_json) 47 | 48 | def get_query_argument(self, name, default= object(), strip=True): 49 | val = super().get_query_argument(name, default, strip) 50 | if isinstance(val, str): 51 | return xhtml_escape(val) 52 | return val 53 | 54 | def get_response(self, result=None, errors=None, **kwargs): 55 | """ 56 | Method returns conventional formatted byte answer. 57 | 58 | It gets Accept header, returns answer processed by callback. 59 | 60 | :param result: contains result if succeeded 61 | :param errors: contains errors if any 62 | :param kwargs: other answer attributes 63 | :return: byte answer of appropriate content type 64 | :rtype: bytes 65 | 66 | """ 67 | _errors = xhtml_escape_complex_object(errors) if errors else [] 68 | # Set success flag 69 | success = not _errors 70 | 71 | answer = { 72 | 'result': result, 73 | 'errors': _errors, 74 | 'success': success, 75 | } 76 | 77 | accept = self.request.headers.get('Accept', 'application/json') 78 | # Get callback 79 | callback = self.response_callbacks.get(accept, self.default_callback) 80 | return callback(self, {**answer, **kwargs}) 81 | 82 | def response(self, result=None, errors=None, **kwargs): 83 | """ 84 | Method writes the response and finishes the request. 85 | 86 | :param result: contains result if succeeded 87 | :param errors: contains errors if any 88 | :param kwargs: other answer attributes 89 | """ 90 | self.write(self.get_response(result, errors, **kwargs)) 91 | self.finish() 92 | 93 | def write_error(self, status_code, **kwargs): 94 | """ 95 | Method gets traceback, writes it into response, finishes response. 96 | 97 | :param status_code: tornado parameter to format html, we don't use it. 98 | :type status_code: int 99 | :param kwargs: in debug mode must contain exc_info. 100 | :type kwargs: dict 101 | """ 102 | exc_info = kwargs.get('exc_info') 103 | if self.settings.get( 104 | "serve_traceback") and exc_info: # pragma: no cover 105 | # in debug mode, try to send a traceback 106 | self.set_header('Content-Type', 'text/plain') 107 | for line in traceback.format_exception(*exc_info): 108 | self.write(line) 109 | # exc_info[1] - HTTPError instance 110 | # Finish request with exception body or exception reason 111 | err_text = getattr(exc_info[1], 'body', self._reason) 112 | self.write(err_text) 113 | self.finish() 114 | 115 | async def validate(self, data, schema, format_checker=None, **kwargs): 116 | """ 117 | Method to validate parameters. 118 | Raises HTTPError(400) with error info for invalid data. 119 | 120 | :param data: bytes or dict 121 | :param schema: dict, valid JSON schema 122 | (http://json-schema.org/latest/json-schema-validation.html) 123 | :return: None if data is not valid. Else dict(data) 124 | """ 125 | # Get and parse arguments 126 | if isinstance(data, dict): 127 | _data = data # pragma: no cover 128 | else: 129 | try: 130 | _data = json.loads(data.decode()) 131 | except ValueError as exc: 132 | # json.loads error 133 | raise HTTPError( 134 | 400, 135 | body=self.get_response( 136 | errors=[ 137 | { 138 | 'code': '', 139 | 'message': 'Request body is not a valid json object', 140 | 'detail': str(exc) 141 | } 142 | ] 143 | ) 144 | ) 145 | v = validator_for(schema)(schema, format_checker=format_checker) 146 | errors = [] 147 | for error in v.iter_errors(_data): 148 | # error is an instance of jsonschema.exceptions.ValidationError 149 | err_msg = xhtml_escape(error.message) 150 | errors.append({'code': '', 151 | 'message': 'Validation failed', 152 | 'detail': err_msg}) 153 | if errors: 154 | # data does not pass validation 155 | raise HTTPError(400, body=self.get_response(errors=errors)) 156 | return _data 157 | 158 | async def bad_permissions(self): 159 | """ 160 | Returns answer of access denied. 161 | 162 | :raises: HTTPError 401 163 | """ 164 | raise HTTPError( 165 | 401, 166 | body=self.get_response( 167 | errors=[ 168 | { 169 | 'code': '', 170 | 'message': 'Access denied' 171 | } 172 | ] 173 | ) 174 | ) 175 | 176 | async def is_auth(self): 177 | """ 178 | Validate user authorized. Abstract. Auth logic is up to user. 179 | """ 180 | return True 181 | 182 | async def get_roles(self): 183 | """ 184 | Gets roles. Abstract. Auth logic is up to user. 185 | """ 186 | 187 | return [] 188 | 189 | 190 | class ApiHandler(BaseHandler, metaclass=ABCMeta): 191 | """ 192 | Base helper class for API functions. 193 | model_cls MUST be defined. 194 | """ 195 | 196 | # Fields to be excluded by default from serialization 197 | exclude_fields = () 198 | 199 | # Serializer recursion 200 | recurse = False 201 | 202 | # Serializer max depth 203 | max_depth = None 204 | 205 | @property 206 | @abstractmethod 207 | def model_cls(self): # pragma: no cover 208 | """ 209 | Model class must be defined. Otherwise it'll crash a little later even 210 | if nothing seems to be accessing a model class. If you think you don't 211 | need a model class, consider the architecture. Maybe it doesn't 212 | fit REST. In that case use BaseHandler. 213 | 214 | https://github.com/CodeTeam/tcrudge/issues/6 215 | """ 216 | raise NotImplementedError('Model class must be defined.') 217 | 218 | @property 219 | def get_schema_output(self): # pragma: no cover 220 | """ 221 | Maybe you'd ask: "What's a get-schema?" 222 | 223 | The answer is that we wanted to check input of every request method 224 | in a homologous way. So we decided to describe any input and output 225 | using JSON schema. 226 | 227 | Schema must be a dict. 228 | """ 229 | return {} 230 | 231 | async def serialize(self, model): 232 | """ 233 | Method to serialize a model. 234 | 235 | By default all fields are serialized by model_to_dict. 236 | The model can be any model instance to pass through this method. It 237 | MUST be a Model instance, it won't work for basic types containing 238 | such instances. 239 | 240 | User have to handle it by their own hands. 241 | 242 | :param model: Model instance to serialize. 243 | :type model: Model instance. 244 | :return: serialized model. 245 | :rtype: dict 246 | """ 247 | return model_to_dict(model, 248 | recurse=self.recurse, 249 | exclude=self.exclude_fields, 250 | max_depth=self.max_depth) 251 | 252 | def get_base_queryset(self): 253 | return self.model_cls.select() 254 | 255 | 256 | class ApiListHandler(ApiHandler): 257 | """ 258 | Base List API Handler. Supports C, L from CRUDL. 259 | Handles pagination, 260 | 261 | * default limit is defined 262 | * maximum limit is defined 263 | 264 | One can redefine that in their code. 265 | 266 | Other pagination parameters are: 267 | 268 | * limit - a positive number of items to show on a single page, int. 269 | * offset - a positive int to define the position in result set to start with. 270 | * total - A boolean to define total amount of items to be put in result set or not. 1 or 0. 271 | 272 | Those parameters can be sent as either GET parameters or HTTP headers. 273 | HTTP headers are more significant during parameters processing, but GET 274 | parameters are preferable to use as conservative way of pagination. 275 | HTTP headers are: 276 | 277 | * X-Limit 278 | * X-Offset 279 | * X-Total 280 | 281 | "exclude" filter args are for pagination, you must not redefine them ever. 282 | Otherwise you'd have to also redefine the prepare method. 283 | 284 | Some fieldnames can be added to that list. Those are fields one wishes not 285 | to be included to filters. 286 | """ 287 | # Pagination settings 288 | # Default amount of items to be listed (if no limit passed by request 289 | # headers or querystring) 290 | default_limit = 50 291 | # Maximum amount of items to be listed (if limit passed by request is 292 | # greater than this amount - it will be truncated) 293 | max_limit = 100 294 | 295 | # Arguments that should not be passed to filter 296 | exclude_filter_args = ['limit', 'offset', 'total'] 297 | 298 | def __init__(self, *args, **kwargs): 299 | super(ApiListHandler, self).__init__(*args, **kwargs) 300 | # Pagination params 301 | # Number of items to fetch 302 | self.limit = None 303 | # Number of items to skip 304 | self.offset = None 305 | # Should total amount of items be included in result? 306 | self.total = False 307 | # Prefetch queries 308 | self.prefetch_queries = [] 309 | 310 | @property 311 | def get_schema_input(self): 312 | """ 313 | JSON Schema to validate GET Url parameters. 314 | By default it contains pagination parameters as required fields. 315 | If you wish to use query filters via GET parameters, you need to 316 | redefine get_schema_input so that request with filter parameters 317 | would be valid. 318 | 319 | In schema you must define every possible way to filter a field, 320 | you wish to be filtered, in every manner it should be filtered. 321 | For example, if you wish to filter by a field "name" so that the query 322 | returns you every object with name like given string:: 323 | 324 | { 325 | "type": "object", 326 | "additionalProperties": False, 327 | "properties": { 328 | "name__like": {"type": "string"}, 329 | "total": {"type": "string"}, 330 | "limit": {"type": "string"}, 331 | "offset": {"type": "string"}, 332 | "order_by": {"type": "string"}, 333 | }, 334 | } 335 | 336 | 337 | If you wish to filter by a field "created_dt" by given range:: 338 | 339 | { 340 | "type": "object", 341 | "additionalProperties": False, 342 | "properties": { 343 | "created_dt__gte": {"type": "string"}, 344 | "created_dt__lte": {"type": "string"}, 345 | "total": {"type": "string"}, 346 | "limit": {"type": "string"}, 347 | "offset": {"type": "string"}, 348 | "order_by": {"type": "string"}, 349 | }, 350 | } 351 | 352 | 353 | To cut it short, you need to add parameters like "field__operator" 354 | for every field you wish to be filtered and for every operator you 355 | wish to be used. 356 | 357 | Every schema must be a dict. 358 | 359 | :return: returns schema. 360 | :rtype: dict 361 | """ 362 | return { 363 | "type": "object", 364 | "additionalProperties": False, 365 | "properties": { 366 | "total": {"type": "string"}, 367 | "limit": {"type": "string"}, 368 | "offset": {"type": "string"}, 369 | "order_by": {"type": "string"}, 370 | }, 371 | } 372 | 373 | @property 374 | def post_schema_output(self): 375 | """ 376 | JSON Schema to validate POST request body. Abstract. 377 | 378 | Every schema must be a dict. 379 | 380 | :return: dict 381 | """ 382 | return {} 383 | 384 | @property 385 | def post_schema_input(self): # pragma: no cover 386 | """ 387 | JSON schema of our model is generated here. Basically it is used for 388 | Create method - list handler, method POST. 389 | 390 | Hint: Modified version of this schema can be used for Update (PUT, 391 | detail view). 392 | 393 | :return: JSON schema of given model_cls Model. 394 | :rtype: dict 395 | """ 396 | return self.model_cls.to_schema(excluded=['id']) 397 | 398 | @property 399 | def default_filter(self): 400 | """ 401 | Default queryset WHERE clause. Used for list queries first. 402 | One must redefine it to customize filters. 403 | 404 | :return: dict 405 | """ 406 | return {} 407 | 408 | @property 409 | def default_order_by(self): 410 | """ 411 | Default queryset ORDER BY clause. Used for list queries. 412 | Order by must contain a string with a model field name. 413 | """ 414 | return () 415 | 416 | def prepare(self): 417 | """ 418 | Method to get and validate offset and limit params for GET REST request. 419 | Total is boolean 1 or 0. 420 | 421 | Works for GET method only. 422 | """ 423 | if self.request.method == 'GET': 424 | prepare(self) 425 | 426 | @classmethod 427 | def qs_filter(cls, qs, flt, value, process_value=True): 428 | """ 429 | Private method to set WHERE part of query. 430 | If required, Django-style filter is available via qs.filter() 431 | and peewee.DQ - this method provides joins. 432 | 433 | Filter relational operators are: 434 | * NOT - '-', not operator, should be user as prefix 435 | * < - 'lt', less than 436 | * > - 'gt', greater than 437 | * <= - 'lte', less than or equal 438 | * >= - 'gte', greater than or equal 439 | * != - 'ne', not equal 440 | * LIKE - 'like', classic like operator 441 | * ILIKE - 'ilike', case-insensitive like operator 442 | * IN - 'in', classic in. Values should be separated by comma 443 | * ISNULL - 'isnull', operator to know if smth is equal to null. Use -__isnull for IS NOT NULL 444 | """ 445 | neg = False 446 | if flt[0] in '-': 447 | # Register NOT filter clause 448 | neg = True 449 | flt = flt[1:] 450 | fld_name, _, k = flt.rpartition('__') 451 | if not fld_name: 452 | # No underscore, simple filter 453 | fld_name, k = k, '' 454 | 455 | # Get filter 456 | op = FILTER_MAP.get(k, operator.eq) 457 | 458 | if neg: 459 | _op = op 460 | op = lambda f, x: operator.inv(_op(f, x)) 461 | 462 | # Get field from model 463 | # raised AttributeError should be handled on higher level 464 | fld = getattr(cls.model_cls, fld_name) 465 | 466 | # Additional value processing 467 | if process_value: 468 | _v = value.decode() 469 | if isinstance(fld, peewee.BooleanField) and _v in ('0', 'f'): 470 | # Assume that '0' and 'f' are FALSE for boolean field 471 | _v = False 472 | elif k == 'in': 473 | # Force set parameter to list 474 | _v = _v.split(',') 475 | elif k == 'isnull': 476 | # ISNULL. Force set parameter to None 477 | _v = None 478 | else: 479 | _v = value 480 | 481 | # Send parameter to ORM 482 | return qs.where(op(fld, _v)) 483 | 484 | @classmethod 485 | def qs_order_by(cls, qs, value, process_value=True): 486 | """ 487 | Set ORDER BY part of response. 488 | 489 | Fields are passed in a string with commas to separate values. 490 | '-' prefix means descending order, otherwise it is ascending order. 491 | 492 | :return: orderbyed queryset 493 | :rtype: queryset 494 | """ 495 | # Empty parameters are skipped 496 | if process_value: 497 | _v = (_ for _ in value.decode().split(',') if _) 498 | else: 499 | _v = (value,) 500 | for ordr in _v: 501 | if ordr[0] == '-': 502 | # DESC order 503 | fld = getattr(cls.model_cls, ordr[1:]) 504 | qs = qs.order_by(fld.desc(), extend=True) 505 | else: 506 | # ASC order 507 | fld = getattr(cls.model_cls, ordr) 508 | qs = qs.order_by(fld, extend=True) 509 | return qs 510 | 511 | def get_queryset(self, paginate=True): 512 | """ 513 | Get queryset for model. 514 | Override this method to change logic. 515 | 516 | By default it uses qs_filter and qs_order_by. 517 | All arguments for WHERE clause are passed with AND condition. 518 | """ 519 | # Set limit / offset parameters 520 | qs = self.get_base_queryset() 521 | if paginate: 522 | qs = qs.limit(self.limit).offset(self.offset) 523 | 524 | # Set default filter values 525 | for k, v in self.default_filter.items(): 526 | qs = self.qs_filter(qs, k, v, process_value=False) 527 | 528 | # Set default order_by values 529 | for v in self.default_order_by: 530 | qs = self.qs_order_by(qs, v, process_value=False) 531 | 532 | for k, v in self.request.arguments.items(): 533 | if k in self.exclude_filter_args: 534 | # Skipping special arguments (limit, offset etc) 535 | continue 536 | elif k == 'order_by': 537 | # Ordering 538 | qs = self.qs_order_by(qs, v[0]) 539 | else: 540 | # Filtration. All arguments passed with AND condition (WHERE 541 | # <...> AND <...> etc) 542 | qs = self.qs_filter(qs, k, v[0]) 543 | return qs 544 | 545 | async def _get_items(self, qs): 546 | """ 547 | Gets queryset and paginates it. 548 | It executes database query. If total amount of items should be 549 | received (self.total = True), queries are executed in parallel. 550 | 551 | :param qs: peewee queryset 552 | :return: tuple: executed query, pagination info (dict) 553 | :raises: In case of bad query parameters - HTTP 400. 554 | """ 555 | pagination = {'offset': self.offset} 556 | try: 557 | if self.total: 558 | # Execute requests to database in parallel (items + total) 559 | awaitables = [] 560 | qs_total = self.get_queryset(paginate=False) 561 | if self.prefetch_queries: 562 | # Support of prefetch queries 563 | awaitables.append(self.application.objects.prefetch(qs, 564 | *self.prefetch_queries)) 565 | else: 566 | awaitables.append(self.application.objects.execute(qs)) 567 | awaitables.append(self.application.objects.count(qs_total)) 568 | items, total = await multi(awaitables) 569 | # Set total items number 570 | pagination['total'] = total 571 | else: 572 | if self.prefetch_queries: 573 | items = await self.application.objects.prefetch(qs, 574 | *self.prefetch_queries) 575 | else: 576 | items = await self.application.objects.execute(qs) 577 | except (peewee.DataError, ValueError) as e: 578 | # Bad parameters 579 | raise HTTPError( 580 | 400, 581 | body=self.get_response( 582 | errors=[ 583 | { 584 | 'code': '', 585 | 'message': 'Bad query arguments', 586 | 'detail': str(e) 587 | } 588 | ] 589 | ) 590 | ) 591 | # Set number of fetched items 592 | pagination['limit'] = len(items) # TODO WTF? Why limit is set? 593 | 594 | return items, pagination 595 | 596 | async def get(self): 597 | """ 598 | Handles GET request. 599 | 600 | 1. Validates GET parameters using GET input schema and validator. 601 | 2. Executes query using given query parameters. 602 | 3. Paginates. 603 | 4. Serializes result. 604 | 5. Writes to response, not finishing it. 605 | 606 | :raises: In case of bad query parameters - HTTP 400. 607 | """ 608 | await self.validate({k: self.get_argument(k) for k in self.request.query_arguments.keys()}, 609 | self.get_schema_input) 610 | try: 611 | qs = self.get_queryset() 612 | except AttributeError as e: 613 | # Wrong field name in filter or order_by 614 | raise HTTPError( 615 | 400, 616 | body=self.get_response( 617 | errors=[ 618 | { 619 | 'code': '', 620 | 'message': 'Bad query arguments', 621 | 'detail': str(e) 622 | } 623 | ] 624 | ) 625 | ) 626 | items, pagination = await self._get_items(qs) 627 | result = [] 628 | for m in items: 629 | result.append(await self.serialize(m)) 630 | self.response(result={'items': result}, pagination=pagination) 631 | 632 | async def head(self): 633 | """ 634 | Handles HEAD request. 635 | 636 | 1. Validates GET parameters using GET input schema and validator. 637 | 2. Fetches total amount of items and returns it in X-Total header. 638 | 3. Finishes response. 639 | 640 | :raises: In case of bad query parameters - HTTPError 400. 641 | """ 642 | await self.validate({k: self.get_argument(k) for k in self.request.query_arguments.keys()}, 643 | self.get_schema_input) 644 | try: 645 | qs = self.get_queryset(paginate=False) 646 | except AttributeError as e: 647 | # Wrong field name in filter or order_by 648 | # Request.body is not available in HEAD request 649 | # No detail info will be provided 650 | raise HTTPError(400) 651 | try: 652 | total_num = await self.application.objects.count(qs) 653 | except (peewee.DataError, peewee.ProgrammingError, ValueError) as e: 654 | # Bad parameters 655 | # Request.body is not available in HEAD request 656 | # No detail info will be provided 657 | raise HTTPError(400) 658 | self.set_header('X-Total', total_num) 659 | self.finish() 660 | 661 | async def post(self): 662 | """ 663 | Handles POST request. 664 | Validates data and creates new item. 665 | Returns serialized object written to response. 666 | 667 | HTTPError 405 is raised in case of not creatable model (there must be 668 | _create method implemented in model class). 669 | 670 | HTTPError 400 is raised in case of violated constraints, invalid 671 | parameters and other data and integrity errors. 672 | 673 | :raises: HTTPError 405, 400 674 | """ 675 | data = await self.validate(self.request.body, self.post_schema_input) 676 | try: 677 | item = await self.model_cls._create(self.application, data) 678 | except AttributeError as e: 679 | # We can only create item if _create() model method implemented 680 | raise HTTPError( 681 | 405, 682 | body=self.get_response( 683 | errors=[ 684 | { 685 | 'code': '', 686 | 'message': 'Method not allowed', 687 | 'detail': str(e) 688 | } 689 | ] 690 | ) 691 | ) 692 | except (peewee.IntegrityError, peewee.DataError) as e: 693 | raise HTTPError( 694 | 400, 695 | body=self.get_response( 696 | errors=[ 697 | { 698 | 'code': '', 699 | 'message': 'Invalid parameters', 700 | 'detail': str(e) 701 | } 702 | ] 703 | ) 704 | ) 705 | self.response(result=await self.serialize(item)) 706 | 707 | 708 | class ApiItemHandler(ApiHandler): 709 | """ 710 | Base Item API Handler. 711 | Supports R, U, D from CRUDL. 712 | """ 713 | 714 | def __init__(self, *args, **kwargs): 715 | super(ApiItemHandler, self).__init__(*args, **kwargs) 716 | self._instance = None 717 | 718 | @property 719 | def get_schema_input(self): 720 | """ 721 | JSON Schema to validate DELETE request body. 722 | 723 | :returns: GET JSON schema 724 | :rtype: dict 725 | """ 726 | return { 727 | "type": "object", 728 | "additionalProperties": False, 729 | "properties": {} 730 | } 731 | 732 | @property 733 | def put_schema_input(self): 734 | """ 735 | JSON Schema to validate PUT request body. 736 | 737 | :return: JSON schema of PUT 738 | :rtype: dict 739 | """ 740 | return self.model_cls.to_schema(excluded=['id']) 741 | 742 | @property 743 | def delete_schema_input(self): 744 | """ 745 | JSON Schema to validate DELETE request body. 746 | 747 | :returns: JSON schema for DELETE. 748 | :rtype: dict 749 | """ 750 | return { 751 | "type": "object", 752 | "additionalProperties": False, 753 | "properties": {} 754 | } 755 | 756 | @property 757 | def put_schema_output(self): # pragma: no cover 758 | """ 759 | Returns PUT Schema, empty be default. 760 | 761 | :rtype: dict 762 | """ 763 | return {} 764 | 765 | @property 766 | def delete_schema_output(self): # pragma: no cover 767 | """ 768 | Returns DELETE Schema, empty be default. 769 | 770 | :rtype: dict 771 | """ 772 | return {} 773 | 774 | def get_queryset(self, item_id): 775 | return self.get_base_queryset().where(self.model_cls._meta.primary_key == item_id) 776 | 777 | async def get_item(self, item_id): 778 | """ 779 | Fetches item from database by PK. 780 | Result is cached in self._instance for multiple calls 781 | 782 | :raises: HTTP 404 if no item found. 783 | :returns: raw object if exists. 784 | :rtype: ORM model instance. 785 | """ 786 | if not self._instance: 787 | try: 788 | self._instance = await self.application.objects.get(self.get_queryset(item_id)) 789 | except (self.model_cls.DoesNotExist, ValueError) as e: 790 | raise HTTPError( 791 | 404, 792 | body=self.get_response( 793 | errors=[ 794 | { 795 | 'code': '', 796 | 'message': 'Item not found', 797 | 'detail': str(e) 798 | } 799 | ] 800 | ) 801 | ) 802 | return self._instance 803 | 804 | async def get(self, item_id): 805 | """ 806 | Handles GET request. 807 | 808 | 1. Validates request. 809 | 2. Writes serialized object of ORM model instance to response. 810 | """ 811 | await self.validate({k: self.get_argument(k) for k in self.request.query_arguments.keys()}, 812 | self.get_schema_input, item_id=item_id) 813 | item = await self.get_item(item_id) 814 | 815 | self.response(result=await self.serialize(item)) 816 | 817 | async def put(self, item_id): 818 | """ 819 | Handles PUT request. 820 | Validates data and updates given item. 821 | 822 | Returns serialized model. 823 | 824 | Raises 405 in case of not updatable model (there must be 825 | _update method implemented in model class). 826 | 827 | Raises 400 in case of violated constraints, invalid parameters and other 828 | data and integrity errors. 829 | 830 | :raises: HTTP 405, HTTP 400. 831 | """ 832 | item = await self.get_item(item_id) 833 | 834 | data = await self.validate(self.request.body, self.put_schema_input, item_id=item_id) 835 | try: 836 | item = await item._update(self.application, data) 837 | except AttributeError as e: 838 | # We can only update item if model method _update is implemented 839 | raise HTTPError( 840 | 405, 841 | body=self.get_response( 842 | errors=[ 843 | { 844 | 'code': '', 845 | 'message': 'Method not allowed', 846 | 'detail': str(e) 847 | } 848 | ] 849 | ) 850 | ) 851 | except (peewee.IntegrityError, peewee.DataError) as e: 852 | raise HTTPError( 853 | 400, 854 | body=self.get_response( 855 | errors=[ 856 | { 857 | 'code': '', 858 | 'message': 'Invalid parameters', 859 | 'detail': str(e) 860 | } 861 | ] 862 | ) 863 | ) 864 | 865 | self.response(result=await self.serialize(item)) 866 | 867 | async def delete(self, item_id): 868 | """ 869 | Handles DELETE request. 870 | 871 | _delete method must be defined to handle delete logic. If method 872 | is not defined, HTTP 405 is raised. 873 | 874 | If deletion is finished, writes to response HTTP code 200 and 875 | a message 'Item deleted'. 876 | 877 | :raises: HTTPError 405 if model object is not deletable. 878 | """ 879 | # DELETE usually does not have body to validate. 880 | await self.validate(self.request.body or {}, self.delete_schema_input, item_id=item_id) 881 | item = await self.get_item(item_id) 882 | try: 883 | # We can only delete item if model method _delete() is implemented 884 | await item._delete(self.application) 885 | except AttributeError as e: 886 | raise HTTPError( 887 | 405, 888 | body=self.get_response( 889 | errors=[ 890 | { 891 | 'code': '', 892 | 'message': 'Method not allowed', 893 | 'detail': str(e) 894 | } 895 | ] 896 | ) 897 | ) 898 | 899 | self.response(result='Item deleted') 900 | -------------------------------------------------------------------------------- /tcrudge/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module contains basic model class. 3 | """ 4 | 5 | import operator 6 | import peewee 7 | 8 | from tcrudge.utils.schema import Schema 9 | 10 | 11 | class BaseModel(peewee.Model): 12 | """ 13 | Basic abstract ORM model. 14 | """ 15 | 16 | async def _update(self, app, data): 17 | """ 18 | By default method sets all given attributes. 19 | 20 | :returns: updated self instance. 21 | """ 22 | for k, v in data.items(): 23 | setattr(self, k, v) 24 | await app.objects.update(self) 25 | return self 26 | 27 | @classmethod 28 | async def _create(cls, app, data): 29 | """ 30 | By default method creates instance with all given attributes. 31 | 32 | :returns: created object. 33 | """ 34 | return await app.objects.create(cls, **data) 35 | 36 | async def _delete(self, app): 37 | """ 38 | By default model deletion is not allowed. 39 | """ 40 | raise AttributeError 41 | 42 | @classmethod 43 | def to_schema(cls, excluded=None): 44 | """ 45 | Generates JSON schema from ORM model. User can exclude some fields 46 | from serialization, by default the only fields to exclude are 47 | pagination settings. 48 | 49 | :param excluded: Excluded parameters. 50 | :type excluded: list or tuple. 51 | :return: JSON schema. 52 | :rtype: dict. 53 | """ 54 | if not excluded: 55 | excluded = [] 56 | schema = Schema.create_default_schema() 57 | excluded += getattr(cls._meta, "excluded", []) 58 | for field, type_field in cls._meta.fields.items(): 59 | if field not in excluded: 60 | schema.add_object( 61 | { 62 | field: type_field.get_column_type() 63 | } 64 | ) 65 | if not type_field.null: 66 | schema.add_schema( 67 | { 68 | "required": [field] 69 | } 70 | ) 71 | else: 72 | schema.add_object( 73 | { 74 | field: None 75 | } 76 | ) 77 | return schema.to_dict() 78 | 79 | 80 | # Operator mapping 81 | FILTER_MAP = { 82 | # < 83 | 'lt': operator.lt, 84 | # > 85 | 'gt': operator.gt, 86 | # <= 87 | 'lte': operator.le, 88 | # >= 89 | 'gte': operator.ge, 90 | # != 91 | 'ne': operator.ne, 92 | # LIKE 93 | 'like': operator.mod, 94 | # ILIKE 95 | 'ilike': operator.pow, 96 | # IN 97 | 'in': operator.lshift, 98 | # ISNULL 99 | 'isnull': operator.rshift 100 | } 101 | -------------------------------------------------------------------------------- /tcrudge/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to handle different response formats must receive two arguments: 3 | * handler: subclass of tornado.web.RequestHandler; 4 | * answer: dictionary with response data. 5 | 6 | And it should return bytes. 7 | """ 8 | 9 | import json 10 | 11 | import msgpack 12 | 13 | from tcrudge.utils.json import json_serial 14 | 15 | 16 | def response_json(handler, response): 17 | """ 18 | Default JSON response. 19 | 20 | Sets JSON content type to given handler. 21 | 22 | Serializes result with JSON serializer and sends JSON as response body. 23 | 24 | :return: Bytes of JSONised response 25 | :rtype: bytes 26 | """ 27 | 28 | handler.set_header('Content-Type', 'application/json') 29 | return json.dumps(response, default=json_serial) 30 | 31 | 32 | def response_msgpack(handler, response): 33 | """ 34 | Optional MSGPACK response. 35 | 36 | Sets MSGPACK content type to given handler. 37 | 38 | Packs response with MSGPACK. 39 | 40 | :return: Bytes of MSGPACK packed response 41 | :rtype: bytes 42 | """ 43 | handler.set_header('Content-Type', 'application/x-msgpack') 44 | return msgpack.packb(response, default=json_serial) 45 | -------------------------------------------------------------------------------- /tcrudge/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeTeam/tcrudge/7e33d6f9bcdfbe316596a03c9707b91613235794/tcrudge/utils/__init__.py -------------------------------------------------------------------------------- /tcrudge/utils/json.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | 5 | def json_serial(obj): 6 | """ 7 | JSON serializer for objects not serializable by default json code. 8 | 9 | :param obj: object to serialize 10 | :type obj: date, datetime or UUID 11 | 12 | :return: formatted and serialized object 13 | :rtype: str 14 | """ 15 | if isinstance(obj, datetime.datetime) or isinstance(obj, datetime.date): 16 | # Datetime serializer 17 | return obj.isoformat() 18 | elif isinstance(obj, uuid.UUID): 19 | return str(obj) 20 | raise TypeError("Type %s not serializable" % type(obj)) 21 | -------------------------------------------------------------------------------- /tcrudge/utils/schema.py: -------------------------------------------------------------------------------- 1 | # Forked from https://github.com/wolverdude/GenSON 2 | 3 | import copy 4 | import json 5 | from collections import defaultdict 6 | from warnings import warn 7 | 8 | JS_TYPES = { 9 | dict: 'object', 10 | list: 'array', 11 | str: 'string', 12 | type(u''): 'string', 13 | int: 'integer', 14 | float: 'number', 15 | bool: 'boolean', 16 | type(None): 'null', 17 | } 18 | 19 | PEEWEE_TYPES = { 20 | 'SERIAL': [ 21 | {"type": "integer"}, 22 | {"type": "string", "pattern": "^[+-]?[0-9]+$"}, 23 | ], 24 | 'TEXT': 'string', 25 | 'TIMESTAMP': 'string', 26 | 'DATE': {"type": "string", "pattern": "^[0-3][0-9].[0-3][0-9].[0-9]{1,4}$"}, 27 | 'BOOLEAN': 'boolean', 28 | 'JSONB': 'object', 29 | 'JSON': 'object', 30 | 'INTEGER': [ 31 | {"type": "integer"}, 32 | {"type": "string", "pattern": "^[+-]?[0-9]+$"} 33 | ], 34 | 'REAL': [ 35 | {"type": "number"}, 36 | {"type": "string", "pattern": "^[+-]?([0-9]*[.])?[0-9]+$"} 37 | ], 38 | 'NUMERIC': [ 39 | {"type": "number"}, 40 | {"type": "string", "pattern": "^[+-]?([0-9]*[.])?[0-9]+$"} 41 | ], 42 | } 43 | 44 | JS_TYPES.update(PEEWEE_TYPES) 45 | 46 | 47 | class Schema(object): # pragma: no cover 48 | """ 49 | Basic schema generator class. Schema objects can be loaded up 50 | with existing schemas and objects before being serialized. 51 | """ 52 | 53 | @classmethod 54 | def create_default_schema(cls): 55 | sch = cls() 56 | sch.add_schema( 57 | { 58 | "type": "object", "properties": {}, 59 | "additionalProperties": False, 60 | } 61 | ) 62 | return sch 63 | 64 | def __init__(self, merge_arrays=True): 65 | """ 66 | Builds a schema generator object. 67 | arguments: 68 | * `merge_arrays` (default `True`): Assume all array items share 69 | the same schema (as they should). The alternate behavior is to 70 | merge schemas based on position in the array. 71 | """ 72 | 73 | self._options = { 74 | 'merge_arrays': merge_arrays 75 | } 76 | 77 | self._type = [] # set() 78 | self._required = None 79 | self._properties = defaultdict(lambda: Schema(**self._options)) 80 | self._items = None 81 | self._other = {} 82 | 83 | def add_schema(self, schema): 84 | """ 85 | Merges in an existing schema. 86 | arguments: 87 | * `schema` (required - `dict` or `Schema`): 88 | an existing JSON Schema to merge. 89 | """ 90 | 91 | # serialize instances of Schema before parsing 92 | if isinstance(schema, Schema): 93 | schema = schema.to_dict() 94 | 95 | # parse properties and add them individually 96 | for prop, val in schema.items(): 97 | if prop == 'type': 98 | self._add_type(val) 99 | elif prop == 'required': 100 | self._add_required(val) 101 | elif prop == 'properties': 102 | self._add_properties(val, 'add_schema') 103 | elif prop == 'items': 104 | self._add_items(val, 'add_schema') 105 | elif prop not in self._other: 106 | self._other[prop] = val 107 | elif self._other[prop] != val: 108 | warn(('Schema incompatible. Keyword {0!r} has ' 109 | 'conflicting values ({1!r} vs. {2!r}). Using ' 110 | '{1!r}').format(prop, self._other[prop], val)) 111 | 112 | # make sure the 'required' key gets set regardless 113 | if 'required' not in schema: 114 | self._add_required([]) 115 | 116 | # return self for easy method chaining 117 | return self 118 | 119 | def add_object(self, obj): 120 | """ 121 | Modify the schema to accomodate an object. 122 | arguments: 123 | * `obj` (required - `dict`): 124 | a JSON object to use in generate the schema. 125 | """ 126 | 127 | if isinstance(obj, dict): 128 | self._generate_object(obj) 129 | elif isinstance(obj, list): 130 | self._generate_array(obj) 131 | else: 132 | self._generate_basic(obj) 133 | 134 | # return self for easy method chaining 135 | return self 136 | 137 | def to_dict(self, recurse=True): 138 | """ 139 | Convert the current schema to a `dict`. 140 | """ 141 | # start with existing fields 142 | schema = dict(self._other) 143 | 144 | # unpack the type field 145 | if self._type: 146 | if isinstance(self._get_type(), dict): 147 | schema = self._get_type() 148 | else: 149 | schema['type'] = self._get_type() 150 | 151 | # call recursively on subschemas if object or array 152 | if 'object' in self._type: 153 | # schema['properties'] = self._get_properties(recurse) 154 | properties = copy.deepcopy(self._get_properties(recurse)) 155 | 156 | for key_prop, val_prop in self._get_properties(recurse).items(): 157 | if isinstance(val_prop.get('type'), list): 158 | res = {"anyOf": []} 159 | for it in val_prop['type']: 160 | if isinstance(it, dict): 161 | res["anyOf"].append(it) 162 | else: 163 | res["anyOf"].append({'type': it}) 164 | properties[key_prop] = res 165 | schema['properties'] = properties 166 | if self._required: 167 | schema['required'] = self._get_required() 168 | 169 | elif 'array' in self._type: 170 | items = self._get_items(recurse) 171 | if items or isinstance(items, dict): 172 | schema['items'] = items 173 | 174 | return schema 175 | 176 | def to_json(self, *args, **kwargs): 177 | """ 178 | Convert the current schema directly to serialized JSON. 179 | """ 180 | return json.dumps(self.to_dict(), *args, **kwargs) 181 | 182 | def __eq__(self, other): 183 | """required for comparing array items to ensure there aren't duplicates 184 | """ 185 | if not isinstance(other, Schema): 186 | return False 187 | 188 | # check type first, before recursing the whole of both objects 189 | if self._get_type() != other._get_type(): 190 | return False 191 | 192 | return self.to_dict() == other.to_dict() 193 | 194 | def __ne__(self, other): 195 | return not self.__eq__(other) 196 | 197 | # private methods 198 | 199 | # getters 200 | 201 | def _get_type(self): 202 | schema_type = self._type[:] # get a copy 203 | 204 | # remove any redundant integer type 205 | if 'integer' in schema_type and 'number' in schema_type: 206 | schema_type.remove('integer') 207 | 208 | # unwrap if only one item, else convert to array 209 | if len(schema_type) == 1: 210 | (schema_type,) = schema_type 211 | 212 | return schema_type 213 | 214 | def _get_required(self): 215 | return sorted(self._required) if self._required else [] 216 | 217 | def _get_properties(self, recurse=True): 218 | if not recurse: 219 | return dict(self._properties) 220 | 221 | properties = {} 222 | for prop, subschema in self._properties.items(): 223 | properties[prop] = subschema.to_dict() 224 | return properties 225 | 226 | def _get_items(self, recurse=True): 227 | if not recurse: 228 | return self._items 229 | 230 | if self._options['merge_arrays']: 231 | return self._items.to_dict() 232 | else: 233 | return [subschema.to_dict() for subschema in self._items] 234 | 235 | # setters 236 | 237 | def _add_type(self, val_type): 238 | if isinstance(val_type, (str, dict)): 239 | if val_type not in self._type: 240 | self._type.append(val_type) 241 | elif isinstance(val_type, list): 242 | for el in val_type: 243 | if el not in self._type: 244 | self._type.append(el) 245 | else: 246 | self._type = list(set(self._type) | set(val_type)) 247 | 248 | def _add_required(self, required): 249 | if self._required is None: 250 | # if not already set, set to this 251 | self._required = set(required) 252 | else: 253 | # use intersection to limit to properties present in both 254 | self._required |= set(required) 255 | 256 | def _add_properties(self, properties, func): 257 | # recursively modify subschemas 258 | for prop, val in properties.items(): 259 | getattr(self._properties[prop], func)(val) 260 | 261 | def _add_items(self, items, func): 262 | if self._options['merge_arrays']: 263 | self._add_items_merge(items, func) 264 | else: 265 | self._add_items_sep(items, func) 266 | 267 | def _add_items_merge(self, items, func): 268 | if not self._items: 269 | self._items = Schema(**self._options) 270 | 271 | method = getattr(self._items, func) 272 | for item in items: 273 | method(item) 274 | 275 | def _add_items_sep(self, items, func): 276 | if not self._items: 277 | self._items = [] 278 | 279 | while len(self._items) < len(items): 280 | self._items.append(Schema(**self._options)) 281 | 282 | for subschema, item in zip(self._items, items): 283 | getattr(subschema, func)(item) 284 | 285 | # generate from object 286 | 287 | def _generate_object(self, obj): 288 | self._add_type('object') 289 | # self._add_required(obj.keys()) 290 | self._add_properties(obj, 'add_object') 291 | 292 | def _generate_array(self, array): 293 | self._add_type('array') 294 | self._add_items(array, 'add_object') 295 | 296 | def _generate_basic(self, val): 297 | if val in JS_TYPES.keys(): 298 | val_type = JS_TYPES[val] 299 | else: 300 | val_type = JS_TYPES[type(val)] 301 | self._add_type(val_type) 302 | -------------------------------------------------------------------------------- /tcrudge/utils/validation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for common validation tools. 3 | """ 4 | 5 | 6 | def validate_integer(val, min_value=None, max_value=None, default=None): 7 | """ 8 | Validates the input val parameter. 9 | 10 | If it is can not be converted to integer, returns default_value. 11 | 12 | If it is less than min_value, returns min_value. 13 | 14 | If it is greater than max_value, returns max_value. 15 | 16 | :param val: number to validate 17 | :type val: int, float, digital string 18 | 19 | :param min_value: min value of validation range 20 | :type min_value: int 21 | 22 | :param max_value: max value of validation range 23 | :type max_value: int 24 | 25 | :param default: default value to return in case of exception 26 | :type default: int 27 | 28 | :return: None, min, max, default or result - int 29 | :rtype: NoneType, int 30 | """ 31 | try: 32 | result = int(val) # TODO - check for non int in values 33 | except (TypeError, ValueError): 34 | return default 35 | if min_value is not None and result < min_value: 36 | result = min_value 37 | if max_value is not None and result > max_value: 38 | result = max_value 39 | return result 40 | 41 | 42 | def prepare(handler): 43 | """ 44 | Works for GET requests only 45 | 46 | Validates the request's GET method to define 47 | if there are X-Limit and X-Offset headers to 48 | extract them and concat with handler directly 49 | """ 50 | # Headers are more significant when taking limit and offset 51 | if handler.request.method == 'GET': 52 | # No more than MAX_LIMIT records at once 53 | # Not less than 1 record at once 54 | limit = handler.request.headers.get('X-Limit', 55 | handler.get_query_argument('limit', 56 | handler.default_limit)) 57 | handler.limit = validate_integer(limit, 1, handler.max_limit, 58 | handler.default_limit) 59 | 60 | # Offset should be a non negative integer 61 | offset = handler.request.headers.get('X-Offset', 62 | handler.get_query_argument('offset', 63 | 0)) 64 | handler.offset = validate_integer(offset, 0, None, 0) 65 | 66 | # Force send total amount of items 67 | handler.total = 'X-Total' in handler.request.headers or \ 68 | handler.get_query_argument('total', None) == '1' 69 | -------------------------------------------------------------------------------- /tcrudge/utils/xhtml_escape.py: -------------------------------------------------------------------------------- 1 | from functools import singledispatch 2 | 3 | from tornado.escape import xhtml_escape 4 | 5 | 6 | @singledispatch 7 | def xhtml_escape_complex_object(obj): 8 | raise TypeError('Escaped object type must be a tuple, list, dict or str, not {}.'.format(type(obj))) 9 | 10 | 11 | @xhtml_escape_complex_object.register(str) 12 | def __xhtml_escape_str(obj): 13 | return xhtml_escape(obj) 14 | 15 | 16 | @xhtml_escape_complex_object.register(dict) 17 | def __xhtml_escape_object_dict(obj): 18 | escaped_dict = {} 19 | for k, v in obj.items(): 20 | escaped_dict[k] = xhtml_escape_complex_object(v) 21 | return escaped_dict 22 | 23 | 24 | @xhtml_escape_complex_object.register(list) 25 | @xhtml_escape_complex_object.register(tuple) 26 | def __xhtml_escape_list(obj): 27 | escaped_list = tuple(xhtml_escape_complex_object(i) for i in obj) 28 | return escaped_list 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeTeam/tcrudge/7e33d6f9bcdfbe316596a03c9707b91613235794/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | General fixtures for Tornado application testing. 3 | """ 4 | 5 | import os 6 | 7 | import peewee_async 8 | import psycopg2 9 | import pytest 10 | from playhouse.db_url import parse 11 | from tornado.ioloop import IOLoop 12 | from tornado.web import Application 13 | 14 | IOLoop.configure('tornado.platform.asyncio.AsyncIOMainLoop') 15 | 16 | db_param = parse(os.environ.get('DATABASE_URL')) 17 | TEST_DB = db_param.get('database', 'testdb') 18 | TEST_USER = db_param.get('user', 'postgres') 19 | TEST_PWD = db_param.get('password', '') 20 | TEST_HOST = db_param.get('host', 'localhost') 21 | TEST_PORT = db_param.get('port', 5432) 22 | 23 | db = peewee_async.PooledPostgresqlDatabase(**db_param) 24 | db.allow_sync = False 25 | 26 | 27 | @pytest.fixture(scope='session') 28 | def app(async_db): 29 | """ 30 | Application fixture. 31 | Required by pytest-tornado. 32 | """ 33 | application = Application() 34 | application.objects = peewee_async.Manager(async_db) 35 | return application 36 | 37 | 38 | @pytest.fixture(scope='session', autouse=True) 39 | def async_db(request): 40 | """ 41 | Database fixture. 42 | Creates Postgresql test database and applies yoyo migrations. 43 | Drops database on teardown. 44 | """ 45 | # Create database 46 | with psycopg2.connect( 47 | 'host={0} dbname=postgres user={1} password={2}'.format(TEST_HOST, TEST_USER, TEST_PWD)) as conn: 48 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 49 | with conn.cursor() as cur: 50 | cur.execute('CREATE DATABASE %s' % TEST_DB) 51 | 52 | def teardown(): 53 | # Opened connections should be terminated before dropping database 54 | terminate_sql = "SELECT pg_terminate_backend(pg_stat_activity.pid) " \ 55 | "FROM pg_stat_activity " \ 56 | "WHERE pg_stat_activity.datname = %s " \ 57 | "AND pid <> pg_backend_pid();" 58 | with psycopg2.connect( 59 | 'host={0} dbname=postgres user={1} password={2}'.format(TEST_HOST, TEST_USER, TEST_PWD)) as conn: 60 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 61 | with conn.cursor() as cur: 62 | cur.execute(terminate_sql, (TEST_DB,)) 63 | cur.execute('DROP DATABASE %s' % TEST_DB) 64 | 65 | request.addfinalizer(teardown) 66 | 67 | return db 68 | 69 | 70 | @pytest.fixture 71 | def clean_table(request, async_db): 72 | """ 73 | This fixture should be used only with request.param set to iterable with 74 | subclasses of peewee.Model or single peewee.Model. 75 | It clears all data in request.param table 76 | Usage: 77 | @pytest.mark.parametrize('clean_table', [(Log, Route)], indirect=True) 78 | """ 79 | 80 | def teardown(): 81 | with async_db.allow_sync(): 82 | for param in request.param: 83 | param.delete().execute() 84 | 85 | request.addfinalizer(teardown) 86 | -------------------------------------------------------------------------------- /tests/test_handlers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | import peewee 5 | import pytest 6 | from playhouse.shortcuts import model_to_dict 7 | from tornado.httpclient import HTTPError 8 | 9 | from tcrudge.decorators import perm_roles 10 | from tcrudge.handlers import ApiListHandler, ApiItemHandler 11 | from tcrudge.models import BaseModel 12 | from tcrudge.utils.json import json_serial 13 | from tests.conftest import db 14 | 15 | TEST_DATA = [ 16 | { 17 | 'tf_text': 'Test field 1', 18 | 'tf_integer': 10, 19 | 'tf_datetime': datetime.datetime(2016, 5, 5, 11), 20 | 'tf_boolean': True, 21 | }, 22 | { 23 | 'tf_text': 'Test field 2', 24 | 'tf_integer': 20, 25 | 'tf_datetime': datetime.datetime(2016, 1, 10, 12), 26 | 'tf_boolean': True, 27 | }, 28 | { 29 | 'tf_text': 'Test field 3', 30 | 'tf_integer': -10, 31 | 'tf_datetime': datetime.datetime(2016, 9, 15, 12), 32 | 'tf_boolean': False, 33 | }, 34 | ] 35 | 36 | TEST_INVALID_DATA = [ 37 | { 38 | 'tf_text': 'Test field 4', 39 | 'tf_integer': -10, 40 | 'tf_datetime': '', 41 | 'tf_boolean': False, 42 | }, 43 | ] 44 | 45 | 46 | class ApiTestModel(BaseModel): 47 | tf_text = peewee.TextField() 48 | tf_integer = peewee.IntegerField(null=True) 49 | tf_datetime = peewee.DateTimeField(default=datetime.datetime.now) 50 | tf_boolean = peewee.BooleanField() 51 | tf_decimal = peewee.FloatField(null=True) 52 | 53 | class Meta: 54 | database = db 55 | excluded = ['tf_boolean'] 56 | 57 | async def _delete(self, app): 58 | await app.objects.delete(self) 59 | 60 | 61 | class ApiTestModelFK(BaseModel): 62 | tf_foreign_key = peewee.ForeignKeyField(ApiTestModel, related_name='rel_items') 63 | 64 | class Meta: 65 | database = db 66 | 67 | 68 | class ApiListTestHandler(ApiListHandler): 69 | model_cls = ApiTestModel 70 | 71 | @property 72 | def post_schema_input(self): 73 | return { 74 | "type": "object", 75 | "additionalProperties": False, 76 | "required": ['tf_text', 'tf_datetime', 'tf_boolean'], 77 | "properties": { 78 | 'tf_text': {"type": "string"}, 79 | 'tf_integer': {"type": "integer"}, 80 | 'tf_datetime': {"type": "string", "format": "datetime"}, 81 | 'tf_boolean': {"type": "boolean"} 82 | } 83 | } 84 | 85 | 86 | class ApiListTestHandlerPrefetch(ApiListHandler): 87 | model_cls = ApiTestModel 88 | 89 | async def serialize(self, m): 90 | result = await super(ApiListTestHandlerPrefetch, self).serialize(m) 91 | result['rel_items'] = [] 92 | for prefetched_item in m.rel_items_prefetch: 93 | result['rel_items'].append(model_to_dict(prefetched_item, recurse=False)) 94 | return result 95 | 96 | def get_queryset(self, paginate=True): 97 | # Set prefetch queries 98 | self.prefetch_queries.append( 99 | ApiTestModelFK.select() 100 | ) 101 | return super(ApiListTestHandlerPrefetch, self).get_queryset(paginate) 102 | 103 | 104 | class ApiItemTestHandler(ApiItemHandler): 105 | model_cls = ApiTestModel 106 | 107 | 108 | class ApiListTestFKHandler(ApiListHandler): 109 | model_cls = ApiTestModelFK 110 | 111 | 112 | class ApiListTestFKHandlerCustomSchema(ApiListHandler): 113 | model_cls = ApiTestModelFK 114 | 115 | post_schema_input = { 116 | 'properties': { 117 | 'tf_foreign_key': {'anyOf': [{'type': 'integer'}, 118 | {'pattern': '^[+-]?[0-9]+$', 'type': 'string'}] 119 | } 120 | }, 121 | 'required': ['tf_foreign_key'], 122 | 'additionalProperties': False, 'type': 'object' 123 | } 124 | 125 | class ApiListTestHandlerOverriddenOrderby(ApiListHandler): 126 | model_cls = ApiTestModel 127 | 128 | def qs_order_by(cls, qs, value, process_value=True): # checking that we go to the overridden method 129 | qs = qs.where(1 != 1) # if no data - ok 130 | return qs 131 | 132 | 133 | class ApiItemTestFKHandler(ApiItemHandler): 134 | model_cls = ApiTestModelFK 135 | 136 | 137 | class DecTestHandler(ApiListHandler): 138 | model_cls = ApiTestModel 139 | 140 | @perm_roles(['admin']) 141 | async def get(self): 142 | await super().get() 143 | 144 | 145 | @pytest.fixture(scope='session') 146 | def app_base_handlers(request, app, async_db): 147 | """ 148 | Fixture modifies application handlers adding base API handlers and creates table for test models 149 | """ 150 | app.add_handlers(".*$", [(r'^/test/api_test_model/?$', ApiListTestHandler)]) 151 | app.add_handlers(".*$", [(r'^/test/api_test_model/([^/]+)/?$', ApiItemTestHandler)]) 152 | app.add_handlers(".*$", [(r'^/test/api_test_model_fk/?$', ApiListTestFKHandler)]) 153 | app.add_handlers(".*$", [(r'^/test/api_test_model_fk_custom_schema/?$', ApiListTestFKHandlerCustomSchema)]) 154 | app.add_handlers(".*$", [(r'^/test/api_test_model_fk/([^/]+)/?$', ApiItemTestFKHandler)]) 155 | app.add_handlers(".*$", [(r'^/test/api_test_model_prefetch/?$', ApiListTestHandlerPrefetch)]) 156 | app.add_handlers(".*$", [(r'^/test/api_test_model_dec/?$', DecTestHandler)]) 157 | app.add_handlers(".*$", [(r'^/test/api_test_model_overridden_orderby/?$', ApiListTestHandlerOverriddenOrderby)]) 158 | 159 | with async_db.allow_sync(): 160 | ApiTestModel.create_table() 161 | ApiTestModelFK.create_table() 162 | 163 | def teardown(): 164 | with async_db.allow_sync(): 165 | ApiTestModelFK.drop_table() 166 | ApiTestModel.drop_table() 167 | 168 | request.addfinalizer(teardown) 169 | 170 | return app 171 | 172 | 173 | @pytest.fixture 174 | def test_data(async_db): 175 | """ 176 | Helper fixture to create test data 177 | """ 178 | res = [] 179 | with async_db.allow_sync(): 180 | for data in TEST_DATA: 181 | res.append(ApiTestModel.create(**data)) 182 | return res 183 | 184 | 185 | def test_generate_schema(): 186 | """ 187 | Helper fixture to create test data 188 | """ 189 | schema = ApiTestModel.to_schema() 190 | assert { 191 | 'properties': { 192 | 'tf_datetime': {'type': 'string'}, 193 | 'tf_decimal': {'anyOf': [{'type': 'number'}, 194 | {'pattern': '^[+-]?([0-9]*[.])?[0-9]+$', 'type': 'string'}, 195 | {'type': 'null'}]}, 196 | 'tf_integer': {'anyOf': [{'type': 'integer'}, {'pattern': '^[+-]?[0-9]+$', 'type': 'string'}, 197 | {'type': 'null'}]}, 198 | 'id': {'anyOf': [{'type': 'integer'}, {'pattern': '^[+-]?[0-9]+$', 'type': 'string'}]}, 199 | 'tf_text': {'type': 'string'}}, 'required': ['id', 'tf_datetime', 'tf_text'], 200 | 'additionalProperties': False, 'type': 'object' 201 | } == schema 202 | 203 | schema1 = ApiTestModel.to_schema(excluded=['id']) 204 | assert { 205 | 'properties': { 206 | 'tf_datetime': {'type': 'string'}, 207 | 'tf_decimal': {'anyOf': [{'type': 'number'}, 208 | {'pattern': '^[+-]?([0-9]*[.])?[0-9]+$', 'type': 'string'}, 209 | {'type': 'null'}]}, 210 | 'tf_integer': {'anyOf': [{'type': 'integer'}, {'pattern': '^[+-]?[0-9]+$', 'type': 'string'}, 211 | {'type': 'null'}]}, 212 | 'tf_text': {'type': 'string'}}, 'required': ['tf_datetime', 'tf_text'], 213 | 'additionalProperties': False, 'type': 'object' 214 | } == schema1 215 | 216 | schema2 = ApiTestModelFK.to_schema(excluded=['id']) 217 | assert { 218 | 'properties': { 219 | 'tf_foreign_key': {'anyOf': [{'type': 'integer'}, 220 | {'pattern': '^[+-]?[0-9]+$', 'type': 'string'}] 221 | } 222 | }, 223 | 'required': ['tf_foreign_key'], 224 | 'additionalProperties': False, 'type': 'object' 225 | } == schema2 226 | 227 | 228 | @pytest.mark.gen_test 229 | async def test_perm_roles_decorator(http_client, base_url, app_base_handlers, monkeypatch): 230 | with pytest.raises(HTTPError) as e: 231 | await http_client.fetch(base_url + '/test/api_test_model_dec/', method='GET') 232 | 233 | async def success_get_roles(self): 234 | return ['admin'] 235 | 236 | monkeypatch.setattr(DecTestHandler, 'get_roles', success_get_roles) 237 | res = await http_client.fetch(base_url + '/test/api_test_model_dec/', method='GET') 238 | assert res.code == 200 239 | 240 | async def error_is_auth(self): 241 | return False 242 | 243 | monkeypatch.setattr(DecTestHandler, 'is_auth', error_is_auth) 244 | 245 | with pytest.raises(HTTPError) as e: 246 | await http_client.fetch(base_url + '/test/api_test_model_dec/', method='GET') 247 | 248 | 249 | @pytest.mark.gen_test 250 | @pytest.mark.usefixtures('app_base_handlers', 'test_data', 'clean_table') 251 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 252 | async def test_base_api_list_head(http_client, base_url): 253 | # Fetch data 254 | res = await http_client.fetch(base_url + '/test/api_test_model', method='HEAD') 255 | 256 | assert res.code == 200 257 | assert 'X-Total' in res.headers 258 | assert int(res.headers['X-Total']) == len(TEST_DATA) 259 | 260 | 261 | @pytest.mark.gen_test 262 | @pytest.mark.usefixtures('app_base_handlers', 'test_data', 'clean_table') 263 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 264 | @pytest.mark.parametrize(['url_param', 'cnt'], [('tf_integer__gt=0', 2), 265 | ('tf_datetime__gte=2016-9-15', 1), 266 | ('tf_datetime__gt=2016-9-15%2023:59:59', 0), 267 | ('tf_integer__gte=10', 2), 268 | ('tf_text__ne=Test%20field%201', 2), 269 | ('tf_integer__lt=-10', 0), 270 | ('tf_integer__lte=-10', 1), 271 | ('tf_integer__in=1,2,-10', 1), 272 | ('-tf_text__isnull=', 3), 273 | ('tf_text__isnull', 0), 274 | ('tf_text__like=test%25', 0), 275 | ('tf_text__ilike=test%25', 3), 276 | ('limit=1', 1), 277 | ('limit=2&offset=1', 2), 278 | ('order_by=tf_integer', 3), 279 | ('order_by=tf_text,-tf_integer,', 3), 280 | ('tf_boolean=0', 1), 281 | ]) 282 | async def test_base_api_list_filter(http_client, base_url, url_param, cnt, monkeypatch): 283 | monkeypatch.setattr(ApiListTestHandler, 'get_schema_input', 284 | { 285 | 286 | }) 287 | res = await http_client.fetch(base_url + '/test/api_test_model/?%s' % url_param) 288 | 289 | assert res.code == 200 290 | data = json.loads(res.body.decode()) 291 | assert data['success'] 292 | assert data['errors'] == [] 293 | assert len(data['result']['items']) == cnt 294 | 295 | 296 | @pytest.mark.gen_test 297 | @pytest.mark.usefixtures('clean_table') 298 | @pytest.mark.parametrize('clean_table', [(ApiTestModelFK, ApiTestModel)], indirect=True) 299 | @pytest.mark.parametrize('total', ['0', '1']) 300 | async def test_base_api_list_prefetch(http_client, base_url, test_data, app_base_handlers, total): 301 | # Create test FK models 302 | for i in range(5): 303 | await app_base_handlers.objects.create(ApiTestModelFK, tf_foreign_key=test_data[0]) 304 | 305 | res = await http_client.fetch(base_url + '/test/api_test_model_prefetch/?total=%s' % total) 306 | 307 | assert res.code == 200 308 | data = json.loads(res.body.decode()) 309 | assert data['success'] 310 | assert data['errors'] == [] 311 | # Check prefetch 312 | assert len(data['result']['items'][0]['rel_items']) == 5 313 | 314 | 315 | @pytest.mark.gen_test 316 | @pytest.mark.usefixtures('app_base_handlers', 'test_data', 'clean_table') 317 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 318 | async def test_base_api_list_filter_default(http_client, base_url, monkeypatch): 319 | monkeypatch.setattr(ApiListTestHandler, 'default_filter', {'tf_integer__gt': 0}) 320 | monkeypatch.setattr(ApiListTestHandler, 'default_order_by', ('tf_text',)) 321 | res = await http_client.fetch(base_url + '/test/api_test_model/') 322 | 323 | assert res.code == 200 324 | data = json.loads(res.body.decode()) 325 | assert data['success'] 326 | assert data['errors'] == [] 327 | assert len(data['result']['items']) == 2 328 | 329 | 330 | @pytest.mark.gen_test 331 | @pytest.mark.usefixtures('app_base_handlers', 'test_data', 'clean_table') 332 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 333 | async def test_base_api_list_force_total_header(http_client, base_url): 334 | res = await http_client.fetch(base_url + '/test/api_test_model/', headers={'X-Total': ''}) 335 | 336 | assert res.code == 200 337 | data = json.loads(res.body.decode()) 338 | assert data['errors'] == [] 339 | assert data['success'] 340 | assert data['pagination']['total'] == len(data['result']['items']) == len(TEST_DATA) 341 | 342 | 343 | @pytest.mark.gen_test 344 | @pytest.mark.usefixtures('app_base_handlers', 'test_data', 'clean_table') 345 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 346 | async def test_base_api_list_force_total_query(http_client, base_url): 347 | res = await http_client.fetch(base_url + '/test/api_test_model/?total=1') 348 | 349 | assert res.code == 200 350 | data = json.loads(res.body.decode()) 351 | assert data['errors'] == [] 352 | assert data['success'] 353 | assert data['pagination']['total'] == len(data['result']['items']) == len(TEST_DATA) 354 | 355 | 356 | @pytest.mark.gen_test 357 | @pytest.mark.usefixtures('app_base_handlers') 358 | @pytest.mark.parametrize('url_param', [('tf_bad_field=Some_data',), 359 | ('tf_integer=ABC',), 360 | ]) 361 | async def test_base_api_list_filter_bad_request(http_client, base_url, url_param): 362 | with pytest.raises(HTTPError) as e: 363 | await http_client.fetch(base_url + '/test/api_test_model/?%s' % url_param) 364 | assert e.value.code == 400 365 | data = json.loads(e.value.response.body.decode()) 366 | assert data['result'] is None 367 | assert not data['success'] 368 | assert len(data['errors']) == 1 369 | assert data['errors'][0]['message'] == 'Validation failed' 370 | 371 | 372 | @pytest.mark.gen_test 373 | @pytest.mark.usefixtures('app_base_handlers') 374 | @pytest.mark.parametrize('url_param', [ 375 | ('order_by=some_bad_field',), 376 | ]) 377 | async def test_base_api_list_filter_bad_request1(http_client, base_url, url_param): 378 | with pytest.raises(HTTPError) as e: 379 | await http_client.fetch(base_url + '/test/api_test_model/?%s' % url_param) 380 | assert e.value.code == 400 381 | data = json.loads(e.value.response.body.decode()) 382 | assert data['result'] is None 383 | assert not data['success'] 384 | assert len(data['errors']) == 1 385 | assert data['errors'][0]['message'] == 'Bad query arguments' 386 | 387 | 388 | @pytest.mark.gen_test 389 | @pytest.mark.usefixtures('app_base_handlers') 390 | @pytest.mark.parametrize('url_param', [ 391 | ('order_by=`1`',), 392 | ]) 393 | async def test_base_api_list_filter_bad_request1(http_client, base_url, url_param): 394 | with pytest.raises(HTTPError) as e: 395 | await http_client.fetch(base_url + '/test/api_test_model/?%s' % url_param) 396 | assert e.value.code == 400 397 | data = json.loads(e.value.response.body.decode()) 398 | assert data['result'] is None 399 | assert not data['success'] 400 | assert len(data['errors']) == 1 401 | assert '<' in data['errors'][0]['detail'] 402 | assert '>' in data['errors'][0]['detail'] 403 | 404 | 405 | @pytest.mark.gen_test 406 | @pytest.mark.usefixtures('app_base_handlers', 'clean_table') 407 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 408 | @pytest.mark.parametrize(['body', 'message'], [(b'', 'Request body is not a valid json object'), 409 | (json.dumps({}).encode(), 'Validation failed'), 410 | ]) 411 | async def test_base_api_list_bad_request(http_client, base_url, body, message): 412 | with pytest.raises(HTTPError) as e: 413 | await http_client.fetch(base_url + '/test/api_test_model/', method='POST', body=body) 414 | assert e.value.code == 400 415 | data = json.loads(e.value.response.body.decode()) 416 | assert data['result'] is None 417 | assert not data['success'] 418 | for error in data['errors']: 419 | print(error) 420 | assert error['message'] == message 421 | 422 | 423 | @pytest.mark.gen_test 424 | @pytest.mark.usefixtures('app_base_handlers', 'clean_table') 425 | @pytest.mark.parametrize('clean_table', [(ApiTestModelFK,)], indirect=True) 426 | async def test_base_api_list_bad_fk(http_client, base_url): 427 | # Create model with invalid FK 428 | data = { 429 | 'tf_foreign_key': 1 430 | } 431 | with pytest.raises(HTTPError) as e: 432 | await http_client.fetch(base_url + '/test/api_test_model_fk/', method='POST', body=json.dumps(data).encode()) 433 | assert e.value.code == 400 434 | data = json.loads(e.value.response.body.decode()) 435 | assert data['result'] is None 436 | assert not data['success'] 437 | assert len(data['errors']) == 1 438 | assert data['errors'][0]['message'] == 'Invalid parameters' 439 | 440 | 441 | @pytest.mark.gen_test 442 | @pytest.mark.usefixtures('app_base_handlers', 'clean_table') 443 | @pytest.mark.parametrize('clean_table', [(ApiTestModelFK,)], indirect=True) 444 | async def test_base_api_list_bad_fk_invalid_integer(http_client, base_url): 445 | # Create model with invalid FK 446 | data = { 447 | 'tf_foreign_key': '' 448 | } 449 | with pytest.raises(HTTPError) as e: 450 | await http_client.fetch(base_url + '/test/api_test_model_fk/', method='POST', body=json.dumps(data).encode()) 451 | assert e.value.code == 400 452 | data = json.loads(e.value.response.body.decode()) 453 | assert data['result'] is None 454 | assert not data['success'] 455 | assert len(data['errors']) == 1 456 | assert data['errors'][0]['message'] == 'Validation failed' 457 | 458 | 459 | @pytest.mark.gen_test 460 | @pytest.mark.usefixtures('app_base_handlers', 'clean_table') 461 | @pytest.mark.parametrize('clean_table', [(ApiTestModelFK,)], indirect=True) 462 | async def test_base_api_list_bad_fk_invalid_integer_custom_schema(http_client, base_url): 463 | # Create model with invalid FK 464 | data = { 465 | 'tf_foreign_key': '' 466 | } 467 | with pytest.raises(HTTPError) as e: 468 | await http_client.fetch(base_url + '/test/api_test_model_fk_custom_schema/', method='POST', body=json.dumps(data).encode()) 469 | assert e.value.code == 400 470 | data = json.loads(e.value.response.body.decode()) 471 | assert data['result'] is None 472 | assert not data['success'] 473 | assert len(data['errors']) == 1 474 | assert data['errors'][0]['message'] == 'Validation failed' 475 | 476 | 477 | @pytest.mark.gen_test 478 | @pytest.mark.usefixtures('clean_table') 479 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 480 | async def test_base_api_list_post(http_client, base_url, app_base_handlers): 481 | data = TEST_INVALID_DATA[0] 482 | resp = await http_client.fetch(base_url + '/test/api_test_model/', method='POST', 483 | body=json.dumps(data, default=json_serial).encode()) 484 | assert resp.code == 400 485 | data = json.loads(e.value.response.body.decode()) 486 | assert data['result'] is None 487 | assert not data['success'] 488 | assert len(data['errors']) == 1 489 | assert data['errors'][0]['message'] == 'Invalid parameters' 490 | 491 | @pytest.mark.gen_test 492 | @pytest.mark.usefixtures('clean_table') 493 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 494 | async def test_base_api_list_post(http_client, base_url, app_base_handlers): 495 | data = TEST_DATA[0] 496 | resp = await http_client.fetch(base_url + '/test/api_test_model/', method='POST', 497 | body=json.dumps(data, default=json_serial).encode()) 498 | assert resp.code == 200 499 | data = json.loads(resp.body.decode()) 500 | assert data['errors'] == [] 501 | assert data['success'] 502 | item_id = data['result']['id'] 503 | # Fetch item from database 504 | await app_base_handlers.objects.get(ApiTestModel, id=item_id) 505 | 506 | 507 | @pytest.mark.gen_test 508 | @pytest.mark.parametrize('item_id', [('1',), ('ABC',)]) 509 | async def test_base_api_item_not_found(http_client, base_url, item_id): 510 | with pytest.raises(HTTPError) as e: 511 | await http_client.fetch(base_url + '/test/api_test_model/%s' % item_id) 512 | assert e.value.code == 404 513 | 514 | 515 | @pytest.mark.gen_test 516 | @pytest.mark.usefixtures('app_base_handlers', 'clean_table') 517 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 518 | async def test_base_api_item_get(http_client, base_url, test_data): 519 | resp = await http_client.fetch(base_url + '/test/api_test_model/%s' % test_data[0].id) 520 | assert resp.code == 200 521 | data = json.loads(resp.body.decode()) 522 | assert data['success'] 523 | assert data['errors'] == [] 524 | for k, v in TEST_DATA[0].items(): 525 | if isinstance(v, datetime.datetime): 526 | assert data['result'][k] == v.isoformat() 527 | else: 528 | assert data['result'][k] == v 529 | 530 | 531 | @pytest.mark.gen_test 532 | @pytest.mark.usefixtures('app_base_handlers', 'clean_table') 533 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 534 | async def test_base_api_item_get_msgpack(http_client, base_url, test_data): 535 | resp = await http_client.fetch(base_url + '/test/api_test_model/%s' % test_data[0].id, 536 | headers={'Accept': 'application/x-msgpack'}) 537 | assert resp.code == 200 538 | import msgpack 539 | data = msgpack.loads(resp.body) 540 | print(data) 541 | assert data[b'success'] 542 | assert data[b'errors'] == [] 543 | for k, v in TEST_DATA[0].items(): 544 | if isinstance(v, datetime.datetime): 545 | assert data[b'result'][k.encode()] == v.isoformat().encode() 546 | elif isinstance(v, (bool, int)): 547 | assert data[b'result'][k.encode()] == v 548 | else: 549 | assert data[b'result'][k.encode()] == v.encode() 550 | 551 | 552 | @pytest.mark.gen_test 553 | @pytest.mark.usefixtures('clean_table') 554 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 555 | async def test_base_api_list_overridden_orderby(http_client, base_url): 556 | data = TEST_DATA[0] 557 | await http_client.fetch(base_url + '/test/api_test_model/', method='POST', 558 | body=json.dumps(data, default=json_serial).encode()) 559 | res = await http_client.fetch(base_url + '/test/api_test_model_overridden_orderby/?order_by=ololo') 560 | assert res.code == 200 561 | data = json.loads(res.body.decode()) 562 | assert data['result'] == {'items': []} 563 | assert data['success'] 564 | 565 | 566 | @pytest.mark.gen_test 567 | @pytest.mark.usefixtures('clean_table') 568 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 569 | async def test_base_api_item_put(http_client, base_url, app_base_handlers, test_data, monkeypatch): 570 | # Update data 571 | upd_data = { 572 | 'tf_text': 'Data changed', 573 | 'tf_integer': 110, 574 | 'tf_datetime': datetime.datetime(2015, 5, 5, 11), 575 | 'tf_boolean': False 576 | } 577 | monkeypatch.setattr(ApiItemHandler, 'put_schema_input', {}) 578 | resp = await http_client.fetch(base_url + '/test/api_test_model/%s' % test_data[0].id, method='PUT', 579 | body=json.dumps(upd_data, default=json_serial).encode()) 580 | assert resp.code == 200 581 | data = json.loads(resp.body.decode()) 582 | assert data['success'] 583 | assert data['errors'] == [] 584 | 585 | # Fetch item from database 586 | item = await app_base_handlers.objects.get(ApiTestModel, id=test_data[0].id) 587 | for k, v in upd_data.items(): 588 | assert getattr(item, k) == v 589 | 590 | 591 | @pytest.mark.gen_test 592 | @pytest.mark.usefixtures('clean_table') 593 | @pytest.mark.parametrize('clean_table', [(ApiTestModelFK, ApiTestModel)], indirect=True) 594 | async def test_base_api_item_put_bad_fk(http_client, base_url, app_base_handlers, test_data): 595 | # Create new ApiTestModelFK 596 | item = await app_base_handlers.objects.create(ApiTestModelFK, tf_foreign_key=test_data[0].id) 597 | 598 | # Try to update with invalid FK 599 | upd_data = { 600 | 'tf_foreign_key': 12345 601 | } 602 | with pytest.raises(HTTPError) as e: 603 | await http_client.fetch(base_url + '/test/api_test_model_fk/%s' % item.id, method='PUT', 604 | body=json.dumps(upd_data).encode()) 605 | assert e.value.code == 400 606 | 607 | 608 | @pytest.mark.gen_test 609 | @pytest.mark.usefixtures('clean_table') 610 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 611 | async def test_base_api_item_delete(http_client, base_url, app_base_handlers, test_data): 612 | resp = await http_client.fetch(base_url + '/test/api_test_model/%s' % test_data[0].id, method='DELETE') 613 | assert resp.code == 200 614 | data = json.loads(resp.body.decode()) 615 | assert data['success'] 616 | assert data['errors'] == [] 617 | assert data['result'] == 'Item deleted' 618 | # Check that item has been deleted 619 | with pytest.raises(ApiTestModel.DoesNotExist): 620 | await app_base_handlers.objects.get(ApiTestModel, id=test_data[0].id) 621 | 622 | 623 | @pytest.mark.gen_test 624 | @pytest.mark.usefixtures('clean_table', 'app_base_handlers') 625 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 626 | async def test_base_api_item_delete_405(http_client, base_url, test_data, monkeypatch): 627 | # Removing delete from this CRUD 628 | monkeypatch.delattr(ApiTestModel, '_delete') 629 | 630 | with pytest.raises(HTTPError) as e: 631 | await http_client.fetch(base_url + '/test/api_test_model/%s' % test_data[0].id, method='DELETE') 632 | assert e.value.code == 405 633 | 634 | 635 | @pytest.mark.gen_test 636 | @pytest.mark.usefixtures('clean_table', 'app_base_handlers') 637 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 638 | async def test_base_api_list_post_405(http_client, base_url, monkeypatch): 639 | # Removing post from this CRUD 640 | monkeypatch.delattr(BaseModel, '_create') 641 | data = TEST_DATA[0] 642 | with pytest.raises(HTTPError) as e: 643 | await http_client.fetch(base_url + '/test/api_test_model/', method='POST', 644 | body=json.dumps(data, default=json_serial).encode()) 645 | assert e.value.code == 405 646 | 647 | 648 | @pytest.mark.gen_test 649 | @pytest.mark.usefixtures('clean_table', 'app_base_handlers') 650 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 651 | async def test_base_api_item_put_405(http_client, base_url, test_data, monkeypatch): 652 | # Remove put from this CRUD 653 | monkeypatch.delattr(BaseModel, '_update') 654 | monkeypatch.setattr(ApiItemHandler, 'put_schema_input', {}) 655 | # Update data 656 | upd_data = { 657 | 'tf_text': 'Data changed', 658 | 'tf_integer': 110, 659 | 'tf_datetime': datetime.datetime(2015, 5, 5, 11), 660 | 'tf_boolean': False 661 | } 662 | with pytest.raises(HTTPError) as e: 663 | await http_client.fetch(base_url + '/test/api_test_model/%s' % test_data[0].id, method='PUT', 664 | body=json.dumps(upd_data, default=json_serial).encode()) 665 | assert e.value.code == 405 666 | 667 | 668 | @pytest.mark.gen_test 669 | @pytest.mark.usefixtures('clean_table', 'app_base_handlers', 'test_data') 670 | @pytest.mark.parametrize('clean_table', [(ApiTestModel,)], indirect=True) 671 | async def test_api_list_validate_get(http_client, base_url, monkeypatch): 672 | monkeypatch.setattr(ApiListTestHandler, 'get_schema_input', 673 | { 674 | 'type': 'object', 675 | 'additionalProperties': False, 676 | 'properties': {} 677 | }) 678 | with pytest.raises(HTTPError) as e: 679 | await http_client.fetch(base_url + '/test/api_test_model/?a=1') 680 | assert e.value.code == 400 681 | data = json.loads(e.value.response.body.decode()) 682 | assert not data['success'] 683 | assert len(data['errors']) == 1 684 | assert data['errors'][0]['message'] == 'Validation failed' 685 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import uuid 4 | 5 | import pytest 6 | 7 | from tcrudge.utils.json import json_serial 8 | from tcrudge.utils.validation import validate_integer 9 | from tcrudge.utils.xhtml_escape import xhtml_escape_complex_object 10 | 11 | 12 | @pytest.mark.parametrize(['val', 'min_value', 'max_value', 'default', 'res'], 13 | [(None, None, None, 5, 5), 14 | ('Not integer', None, None, 0, 0), 15 | ('-10', 4, None, None, 4), 16 | (5, None, 1, None, 1)]) 17 | def test_validate_integer(val, min_value, max_value, default, res): 18 | assert validate_integer(val, min_value, max_value, default) == res 19 | 20 | 21 | def test_serial(): 22 | t = {'datetime': datetime.datetime(2016, 6, 1, 10, 33, 6), 23 | 'date': datetime.date(2016, 8, 3), 24 | 'uuid': uuid.uuid4()} 25 | json.dumps(t, default=json_serial) 26 | 27 | class A: 28 | pass 29 | 30 | t = {'unknown_type': A()} 31 | 32 | with pytest.raises(TypeError): 33 | json.dumps(t, default=json_serial) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ('initial_val', 'valid_value'), 38 | ( 39 | ('&<>"\'', '&<>"''), 40 | (('&', '<', '>', '"', '\''), ('&', '<', '>', '"', ''')), 41 | (['&', '<', '>', '"', '\''], ('&', '<', '>', '"', ''')), 42 | ( 43 | {'1': '&', '2': '<', '3': '>', '4': '"', '5': '\''}, 44 | {'1': '&', '2': '<', '3': '>', '4': '"', '5': '''}, 45 | ), 46 | ( 47 | { 48 | '1': '&', 49 | '2': {'1': ('&', ), '2': ['&', '<'], '3': {'3': '\''}}, 50 | }, 51 | { 52 | '1': '&', 53 | '2': {'1': ('&', ), '2': ('&', '<'), '3': {'3': '''}}, 54 | } 55 | ) 56 | ) 57 | ) 58 | def test_xhtml_escape_complex_object(initial_val, valid_value): 59 | result = xhtml_escape_complex_object(initial_val) 60 | assert result == valid_value 61 | 62 | 63 | def test_xhtml_escape_complex_object_error(): 64 | with pytest.raises(TypeError): 65 | xhtml_escape_complex_object(None) 66 | 67 | --------------------------------------------------------------------------------