├── .coveragerc ├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── TODO.md ├── requirements-ci.txt ├── requirements-dev.txt ├── restfulpy ├── __init__.py ├── application │ ├── __init__.py │ ├── application.py │ └── cli │ │ ├── __init__.py │ │ ├── configuration.py │ │ ├── database.py │ │ ├── jwttoken.py │ │ ├── main.py │ │ ├── migrate.py │ │ └── worker.py ├── authentication.py ├── authorization.py ├── cli.py ├── configuration.py ├── controllers.py ├── datetimehelpers.py ├── db.py ├── exceptions.py ├── helpers.py ├── logger.py ├── messaging │ ├── __init__.py │ ├── models.py │ └── providers.py ├── mockup.py ├── orm │ ├── __init__.py │ ├── field.py │ ├── fulltext_search.py │ ├── metadata.py │ ├── mixins.py │ ├── models.py │ └── types.py ├── principal.py ├── taskqueue.py └── testing.py ├── setup.py ├── sphinx ├── Makefile ├── _static │ └── .keep ├── _templates │ └── .keep ├── conf.py └── index.rst └── tests ├── __init__.py ├── conftest.py ├── templates └── test-email-template.mako ├── test_appcli_configuration.py ├── test_appcli_db.py ├── test_appcli_jwt.py ├── test_appcli_migrate.py ├── test_appcli_root.py ├── test_appcli_worker.py ├── test_application.py ├── test_authenticator.py ├── test_base_model.py ├── test_cli.py ├── test_commit_decorator.py ├── test_console_messenger.py ├── test_date.py ├── test_datetime.py ├── test_documentaion.py ├── test_fulltext_search.py ├── test_helpers.py ├── test_impersonation.py ├── test_json_payload.py ├── test_jsonpatch.py ├── test_messaging_models.py ├── test_messenger_factory.py ├── test_mixin_activation.py ├── test_mixin_approverequired.py ├── test_mixin_deactivation.py ├── test_mixin_filtering.py ├── test_mixin_modified.py ├── test_mixin_ordering.py ├── test_mixin_pagination.py ├── test_mixin_softdelete.py ├── test_mixins.py ├── test_orm.py ├── test_principal.py ├── test_pytest.py ├── test_refreshtoken_without_ssl.py ├── test_server_timestamp.py ├── test_smtp_provider.py ├── test_sql_exceptions.py ├── test_stateful_authenticator.py ├── test_string_encoding.py ├── test_taskqueue.py ├── test_time.py ├── test_to_json_field_info.py ├── test_uuidfield.py ├── test_validation.py └── test_validation_decorator.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | examples 4 | 5 | [report] 6 | exclude_lines = 7 | # Have to re-enable the standard pragma 8 | pragma: no cover 9 | 10 | # Don't complain if tests don't hit defensive assertion code: 11 | raise NotImplementedError 12 | pass 13 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: MfBBjD1Rkubo5ShVyNOuRIx2esQ8cM1z1 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | sphinx/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | .idea 91 | restfulpy/tests/data 92 | data 93 | .*.sw[op] 94 | 95 | # pytest 96 | .pytest_cache 97 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - COMMIT_AUTHOR_EMAIL: vahid.mardani@gmail.com 4 | - COVERALLS_REPO_TOKEN: MfBBjD1Rkubo5ShVyNOuRIx2esQ8cM1z1 5 | branches: 6 | only: 7 | - master 8 | - develop 9 | - /^migrate\/.*/ 10 | - /^(release\/)?v([1-9]!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*))?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*))?$/ 11 | addons: 12 | postgresql: 9.5 13 | services: 14 | - redis-server 15 | - postgresql 16 | language: python 17 | python: 18 | - 3.6 19 | before_install: 20 | - pip install -U pip setuptools wheel 21 | - pip install -r requirements-ci.txt 22 | 23 | install: pip install -e . 24 | script: py.test --cov=restfulpy tests 25 | after_success: travis_retry coveralls 26 | deploy: 27 | provider: pypi 28 | skip_cleanup: true 29 | user: Vahid.Mardani 30 | password: 31 | secure: AEffGTknm1+cRnrbfKEnfVRGgcS+zB/pYsZTV4P8Cxt+WzP2VQLxx1i9zaX7ZDuMzSxJJNb/OMZq4aJILdN+bRGtmFHFulbN5vtiH6XjVMUCYNradgGw4pCivPKrWDp6nyGIiG+ei5z+nLFjtfR4kYFaRI4C7KxSipaNbUwhmXsR3iv/li4uL/BV55exUpP+mn76P86KKGhvAnZzEBGwY/nBl/C4fCRd4NTehEG+KeCK9r+/sG/ssk1CFbBUEl562CuP2nUZCTFHtNpTATPv6w0bg0VRN6h4ADrWM8ysF5MXa7vjHzdGP2JFf0Gt64vTzjuI4V+J1zz1qplEyKWeRv8TIVI3puFQ3HezCbuTlFDmTpXWyD86muwK++fSKP+gYUVi4wMSsm6oARy2lynE12LXZRaw1ag6pfhOjBBC3lGRdO78xj174U3SHBoStWL5w1uO9/U4m7PVugJT8LIrHqU4+Ehva8mOHb30xdZ3w4jenl4QUeqvpJbdeVOAvd0p8Uey40JCqfXdn3qLE7NYSVsH08Bvq6bOELZ6hmp233CFcyP2yU08Dk2raXl4GLDxJx1NEj1MzKgy/8F9AvRfP8Uh/DywRPgs9qRDy0Qfw/r62LAlNn+fV45Y+wmVggAoy0luZHBjISgcpWdda9NUJg6Gn8TbwhoRh2qYTqTCbeo= 32 | on: 33 | tags: true 34 | distributions: sdist bdist_wheel 35 | repo: pylover/restfulpy 36 | all_branches: true 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is the MIT license: http://www.opensource.org/licenses/mit-license.php 2 | 3 | Copyright (c) 2005-2017 the restfulpy authors and contributors . 4 | restfulpy is a trademark of Vahid Mardani. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | software and associated documentation files (the "Software"), to deal in the Software 8 | without restriction, including without limitation the rights to use, copy, modify, merge, 9 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 10 | to whom the Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or 13 | substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 17 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 18 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # restfulpy 2 | 3 | A tool-chain for creating restful web applications. 4 | 5 | [![PyPI](http://img.shields.io/pypi/v/restfulpy.svg)](https://pypi.python.org/pypi/restfulpy) 6 | [![Build Status](https://travis-ci.org/pylover/restfulpy.svg?branch=master)](https://travis-ci.org/pylover/restfulpy) 7 | [![Coverage Status](https://coveralls.io/repos/github/pylover/restfulpy/badge.svg?branch=master)](https://coveralls.io/github/pylover/restfulpy?branch=master) 8 | 9 | ### Goals: 10 | 11 | - Automatically transform the SqlAlchemy models and queries into JSON with 12 | standard naming(camelCase). 13 | - Http form validation based on SqlAlchemy models. 14 | - Task Queue system 15 | 16 | 17 | ### Install 18 | 19 | #### PyPI 20 | 21 | ```bash 22 | pip install restfulpy 23 | ``` 24 | 25 | #### Development 26 | 27 | ```bash 28 | pip install -e . 29 | pip install -r requirements-dev.txt 30 | ``` 31 | 32 | Run tests to ensure everything is ok: 33 | 34 | ```bash 35 | pytest 36 | ``` 37 | 38 | ### Command line interface 39 | 40 | ```bash 41 | restfulpy -h 42 | ``` 43 | 44 | #### Autocompletion 45 | 46 | ```bash 47 | restfulpy completion install 48 | ``` 49 | 50 | 51 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## TODO 2 | 3 | -------------------------------------------------------------------------------- /requirements-ci.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | coveralls 3 | freezegun 4 | pytest-cov 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements-ci.txt 2 | pytest-pudb 3 | ipython 4 | 5 | -------------------------------------------------------------------------------- /restfulpy/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from .application import Application 4 | 5 | 6 | warnings.filterwarnings('ignore', message='Unknown REQUEST_METHOD') 7 | 8 | __version__ = '4.0.0' 9 | 10 | -------------------------------------------------------------------------------- /restfulpy/application/__init__.py: -------------------------------------------------------------------------------- 1 | from .application import Application 2 | 3 | 4 | -------------------------------------------------------------------------------- /restfulpy/application/application.py: -------------------------------------------------------------------------------- 1 | import time 2 | from os.path import abspath, join, dirname 3 | 4 | from nanohttp import Application as NanohttpApplication, Controller, \ 5 | HTTPStatus, HTTPInternalServerError, settings, context as nanohttp_context 6 | from sqlalchemy.exc import SQLAlchemyError 7 | 8 | from .. import logger 9 | from ..configuration import configure 10 | from ..exceptions import SQLError 11 | from ..orm import init_model, create_engine, DBSession 12 | from .cli.main import EntryPoint 13 | 14 | 15 | class Application(NanohttpApplication): 16 | """The main entry point 17 | 18 | A web application project should be started by inheritting this class 19 | and overriding some methods if desirable 20 | 21 | """ 22 | 23 | __configuration__ = None 24 | __authenticator__ = None 25 | __cli_arguments__ = [] 26 | engine = None 27 | 28 | def __init__(self, name, root= None, path_='.', authenticator=None): 29 | super(Application, self).__init__(root=root) 30 | self.name = name 31 | self.path = abspath(path_) 32 | # TODO: rename to climain 33 | self.cli_main = EntryPoint(self).main 34 | 35 | if authenticator: 36 | self.__authenticator__ = authenticator 37 | 38 | def _handle_exception(self, ex, start_response): 39 | if isinstance(ex, SQLAlchemyError): 40 | ex = SQLError(ex) 41 | logger.error(ex) 42 | if not isinstance(ex, HTTPStatus): 43 | ex = HTTPInternalServerError('Internal server error') 44 | logger.error(ex) 45 | return super()._handle_exception(ex, start_response) 46 | 47 | def configure(self, filename=None, context=None, force=False): 48 | _context = { 49 | 'appname': self.name, 50 | 'dbname': self.name.lower(), 51 | 'approot': self.path, 52 | } 53 | if context: 54 | _context.update(context) 55 | 56 | configure(context=_context, force=force) 57 | 58 | if self.__configuration__: 59 | settings.merge(self.__configuration__) 60 | 61 | if filename is not None: 62 | settings.loadfile(filename) 63 | 64 | def get_cli_arguments(self): 65 | """ 66 | This is a template method 67 | """ 68 | pass 69 | 70 | def initialize_orm(self, engine=None): 71 | if engine is None: 72 | engine = create_engine() 73 | 74 | self.engine = engine 75 | init_model(engine) 76 | 77 | # Hooks 78 | def begin_request(self): 79 | if self.__authenticator__: 80 | self.__authenticator__.authenticate_request() 81 | 82 | def begin_response(self): 83 | if settings.timestamp: 84 | nanohttp_context.response_headers.add_header( 85 | 'X-Server-Timestamp', 86 | str(time.time()) 87 | ) 88 | 89 | def end_response(self): 90 | DBSession.remove() 91 | 92 | def insert_basedata(self, args=None): 93 | raise NotImplementedError() 94 | 95 | def insert_mockup(self, args=None): 96 | raise NotImplementedError() 97 | 98 | -------------------------------------------------------------------------------- /restfulpy/application/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import EntryPoint 2 | 3 | -------------------------------------------------------------------------------- /restfulpy/application/cli/configuration.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from easycli import SubCommand, Argument 4 | from nanohttp import settings 5 | 6 | 7 | class ConfigurationDumperSubSubCommand(SubCommand): 8 | __command__ = 'dump' 9 | __help__ = 'Dump the configuration' 10 | __arguments__ = [ 11 | Argument( 12 | 'path', 13 | nargs='?', 14 | help='The config path, for example: db, stdout if omitted', 15 | ), 16 | ] 17 | 18 | def __call__(self, args): 19 | dump = settings.dumps() 20 | if args.path: 21 | with open(args.path, 'w') as f: 22 | f.write(dump) 23 | else: 24 | print(dump) 25 | 26 | 27 | class ConfigurationSubCommand(SubCommand): 28 | __command__ = 'configuration' 29 | __help__ = 'Configuration tools' 30 | __arguments__ = [ 31 | ConfigurationDumperSubSubCommand, 32 | ] 33 | 34 | -------------------------------------------------------------------------------- /restfulpy/application/cli/database.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from easycli import SubCommand, Argument 4 | 5 | from restfulpy.db import PostgreSQLManager as DBManager 6 | from restfulpy.orm import setup_schema 7 | 8 | 9 | class BasedataSubSubCommand(SubCommand): 10 | __command__ = 'basedata' 11 | __help__ = 'Setup the server\'s database.' 12 | 13 | def __call__(self, args): 14 | args.application.insert_basedata() 15 | 16 | 17 | class MockupDataSubSubCommand(SubCommand): 18 | __command__ = 'mockup' 19 | __help__ = 'Insert mockup data.' 20 | __arguments__ = [ 21 | Argument( 22 | 'mockup_args', 23 | nargs=argparse.REMAINDER, 24 | ), 25 | ] 26 | 27 | def __call__(self, args): 28 | args.application.insert_mockup(args.mockup_args) 29 | 30 | 31 | class CreateDatabaseSubSubCommand(SubCommand): 32 | __command__ = 'create' 33 | __help__ = 'Create the server\'s database.' 34 | __arguments__ = [ 35 | Argument( 36 | '-d', 37 | '--drop', 38 | dest='drop_db', 39 | action='store_true', 40 | default=False, 41 | help='Drop existing database before create another one.', 42 | ), 43 | Argument( 44 | '-s', 45 | '--schema', 46 | dest='schema', 47 | action='store_true', 48 | default=False, 49 | help='Creates database schema after creating the database.', 50 | ), 51 | Argument( 52 | '-b', 53 | '--basedata', 54 | action='store_true', 55 | default=False, 56 | help='Implies `(-s|--schema)`, Inserts basedata after schema' \ 57 | 'generation.', 58 | ), 59 | Argument( 60 | '-m', 61 | '--mockup', 62 | action='store_true', 63 | default=False, 64 | help='Implies `(-s|--schema)`, Inserts mockup data.', 65 | ), 66 | ] 67 | 68 | def __call__(self, args): 69 | with DBManager() as db_admin: 70 | if args.drop_db: 71 | db_admin.drop_database() 72 | 73 | db_admin.create_database() 74 | if args.schema or args.basedata or args.mockup: 75 | setup_schema() 76 | if args.basedata: 77 | args.application.insert_basedata() 78 | 79 | if args.mockup: 80 | args.application.insert_mockup() 81 | 82 | 83 | class DropDatabaseSubSubCommand(SubCommand): 84 | __command__ = 'drop' 85 | __help__ = 'Drop the server\'s database.' 86 | 87 | def __call__(self, args): 88 | with DBManager() as db_admin: 89 | db_admin.drop_database() 90 | 91 | 92 | class CreateDatabaseSchemaSubSubCommand(SubCommand): 93 | __command__ = 'schema' 94 | __help__ = 'Creates the database objects.' 95 | 96 | def __call__(self, args): 97 | setup_schema() 98 | 99 | 100 | class DatabaseSubCommand(SubCommand): 101 | __command__ = 'db' 102 | __help__ = 'Database administrationn' 103 | __arguments__ = [ 104 | CreateDatabaseSchemaSubSubCommand, 105 | BasedataSubSubCommand, 106 | MockupDataSubSubCommand, 107 | DropDatabaseSubSubCommand, 108 | CreateDatabaseSubSubCommand, 109 | ] 110 | 111 | -------------------------------------------------------------------------------- /restfulpy/application/cli/jwttoken.py: -------------------------------------------------------------------------------- 1 | import ujson 2 | from easycli import SubCommand, Argument 3 | 4 | from restfulpy.principal import JWTPrincipal 5 | 6 | 7 | class CreateJWTTokenSubSubCommand(SubCommand): 8 | __command__ = 'create' 9 | __help__ = 'Create a new initial jwt' 10 | __arguments__ = [ 11 | Argument( 12 | '-e', 13 | '--expire-in', 14 | default=3600, 15 | type=int, 16 | help='the max age, default: 3600 (one hour).', 17 | ), 18 | Argument( 19 | 'payload', 20 | default='{}', 21 | nargs='?', 22 | help='A JSON parsable string to treat as payload. for example: ' \ 23 | '{"a": "b"}', 24 | ), 25 | ] 26 | 27 | def __call__(self, args): 28 | payload = ujson.loads(args.payload) 29 | print(JWTPrincipal(payload).dump(args.expire_in).decode()) 30 | 31 | 32 | class JWTSubCommand(SubCommand): 33 | __command__ = 'jwt' 34 | __help__ = 'JWT management' 35 | __arguments__ = [ 36 | CreateJWTTokenSubSubCommand, 37 | ] 38 | 39 | -------------------------------------------------------------------------------- /restfulpy/application/cli/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os.path import basename 3 | 4 | from easycli import Root, Argument 5 | 6 | from .configuration import ConfigurationSubCommand 7 | from .database import DatabaseSubCommand 8 | from .jwttoken import JWTSubCommand 9 | from .migrate import MigrateSubCommand 10 | from .worker import WorkerSubCommand 11 | 12 | 13 | class EntryPoint(Root): 14 | __completion__ = True 15 | __arguments__ = [ 16 | Argument( 17 | '-p', '--process-name', 18 | metavar="PREFIX", 19 | default=basename(sys.argv[0]), 20 | help='A string indicates the name for this process.', 21 | ), 22 | Argument( 23 | '-c', '--config-file', 24 | metavar="FILE", 25 | help='Configuration file, Default: none', 26 | ), 27 | ConfigurationSubCommand, 28 | DatabaseSubCommand, 29 | JWTSubCommand, 30 | MigrateSubCommand, 31 | WorkerSubCommand, 32 | ] 33 | 34 | def __init__(self, application): 35 | self.application = application 36 | self.__command__ = application.name 37 | self.__help__ = '%s command line interface.' % application.name 38 | self.__arguments__.extend(self.application.__cli_arguments__) 39 | super().__init__() 40 | 41 | def _execute_subcommand(self, args): 42 | args.application = self.application 43 | self.application.name = args.process_name 44 | self.application.configure(filename=args.config_file) 45 | self.application.initialize_orm() 46 | return super()._execute_subcommand(args) 47 | 48 | -------------------------------------------------------------------------------- /restfulpy/application/cli/migrate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from os.path import join 4 | 5 | from alembic.config import main as alembic_main 6 | from easycli import SubCommand, Argument 7 | 8 | 9 | class MigrateSubCommand(SubCommand): 10 | __command__ = 'migrate' 11 | __help__ = 'Executes the alembic command' 12 | __arguments__ = [ 13 | Argument( 14 | 'alembic_args', 15 | nargs=argparse.REMAINDER, 16 | help='For more information, please see `alembic --help`', 17 | ), 18 | ] 19 | 20 | def __call__(self, args): 21 | current_directory = os.curdir 22 | try: 23 | os.chdir(args.application.path) 24 | alembic_ini = join(args.application.path, 'alembic.ini') 25 | alembic_main(argv=['--config', alembic_ini] + args.alembic_args) 26 | 27 | finally: 28 | os.chdir(current_directory) 29 | 30 | -------------------------------------------------------------------------------- /restfulpy/application/cli/worker.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import sys 3 | import threading 4 | 5 | from easycli import SubCommand, Argument 6 | from nanohttp import settings 7 | 8 | 9 | class StartSubSubCommand(SubCommand): 10 | __command__ = 'start' 11 | __help__ = 'Starts the background worker.' 12 | __arguments__ = [ 13 | Argument( 14 | '-g', 15 | '--gap', 16 | type=int, 17 | default=None, 18 | help='Gap between run next task.', 19 | ), 20 | Argument( 21 | '-s', 22 | '--status', 23 | default=[], 24 | action='append', 25 | help='Task status to process', 26 | ), 27 | Argument( 28 | '-n', 29 | '--number-of-threads', 30 | type=int, 31 | default=None, 32 | help='Number of working threads', 33 | ), 34 | ] 35 | 36 | def __call__(self, args): 37 | from restfulpy.taskqueue import worker 38 | 39 | signal.signal(signal.SIGINT, self.kill_signal_handler) 40 | signal.signal(signal.SIGTERM, self.kill_signal_handler) 41 | 42 | if not args.status: 43 | args.status = {'new'} 44 | 45 | if args.gap is not None: 46 | settings.worker.merge({'gap': args.gap}) 47 | 48 | print( 49 | f'The following task types would be processed with gap of ' 50 | f'{settings.worker.gap}s:' 51 | ) 52 | print('Tracking task status(es): %s' % ','.join(args.status)) 53 | 54 | number_of_threads = \ 55 | args.number_of_threads or settings.worker.number_of_threads 56 | for i in range(number_of_threads): 57 | t = threading.Thread( 58 | target=worker, 59 | name='restfulpy-worker-thread-%s' % i, 60 | daemon=True, 61 | kwargs=dict( 62 | statuses=args.status, 63 | filters=args.filter 64 | ) 65 | ) 66 | t.start() 67 | 68 | print('Worker started with %d threads' % number_of_threads) 69 | print('Press Ctrl+C to terminate worker') 70 | signal.pause() 71 | 72 | @staticmethod 73 | def kill_signal_handler(signal_number, frame): 74 | print('Terminating') 75 | sys.stdin.close() 76 | sys.stderr.close() 77 | sys.stdout.close() 78 | sys.exit(signal_number) 79 | 80 | 81 | class CleanupSubSubCommand(SubCommand): 82 | __command__ = 'cleanup' 83 | __help__ = 'Clean database before starting worker processes' 84 | 85 | def __call__(self, args): 86 | from restfulpy.orm import DBSession 87 | from restfulpy.taskqueue import RestfulpyTask 88 | 89 | RestfulpyTask.cleanup(DBSession, filters=args.filter) 90 | DBSession.commit() 91 | 92 | 93 | class WorkerSubCommand(SubCommand): 94 | __command__ = 'worker' 95 | __help__ = 'Task queue administration' 96 | __arguments__ = [ 97 | Argument( 98 | '-f', 99 | '--filter', 100 | default=None, 101 | type=str, 102 | action='store', 103 | help='Custom SQL filter for tasks', 104 | ), 105 | StartSubSubCommand, 106 | CleanupSubSubCommand, 107 | ] 108 | 109 | -------------------------------------------------------------------------------- /restfulpy/authorization.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from nanohttp import context, HTTPUnauthorized 4 | 5 | 6 | def authorize(*roles): 7 | 8 | def decorator(func): 9 | 10 | @functools.wraps(func) 11 | def wrapper(*args, **kwargs): 12 | 13 | identity = context.identity 14 | 15 | if not identity: 16 | raise HTTPUnauthorized() 17 | 18 | identity.assert_roles(*roles) 19 | 20 | return func(*args, **kwargs) 21 | 22 | return wrapper 23 | 24 | if roles and callable(roles[0]): 25 | f = roles[0] 26 | roles = [] 27 | return decorator(f) 28 | else: 29 | return decorator 30 | -------------------------------------------------------------------------------- /restfulpy/cli.py: -------------------------------------------------------------------------------- 1 | from easycli import Root, Argument 2 | 3 | 4 | class Restfulpy(Root): 5 | __help__ = 'Restfylpy command line interface' 6 | __completion__ = True 7 | __arguments__ = [ 8 | Argument( 9 | '-V', 10 | '--version', 11 | action='store_true', 12 | help='Show version' 13 | ), 14 | ] 15 | 16 | def __call__(self, args): 17 | if args.version: 18 | from restfulpy import __version__ 19 | print(__version__) 20 | return 21 | 22 | return super().__call__(args) 23 | 24 | 25 | def main(): 26 | return Restfulpy().main() 27 | 28 | -------------------------------------------------------------------------------- /restfulpy/configuration.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from nanohttp import configure as nanohttp_configure, settings 4 | 5 | 6 | __builtin_config = """ 7 | 8 | debug: true 9 | timestamp: false 10 | # Default timezone. 11 | # empty for local time 12 | # 0, utc, UTC, z or Z for the UTC, 13 | # UTC±HH:MM for specify timezone. ie. +3:30 for tehran 14 | # An instance of datetime.tzinfo is also acceptable 15 | # Example: 16 | # timezone: !!python/object/apply:datetime.timezone 17 | # - !!python/object/apply:datetime.timedelta [0, 7200, 0] 18 | # - myzone 19 | timezone: 20 | 21 | db: 22 | # The main uri 23 | url: postgresql://postgres:postgres@localhost/%(dbname)s 24 | 25 | # Will be used to create and drop database(s). 26 | administrative_url: postgresql://postgres:postgres@localhost/postgres 27 | 28 | # Will be used to run tests 29 | test_url: postgresql://postgres:postgres@localhost/%(dbname)s_test 30 | 31 | # Redirect all SQL Queries to std-out 32 | echo: false 33 | 34 | migration: 35 | directory: migration 36 | ini: alembic.ini 37 | 38 | jwt: 39 | secret: JWT-SECRET 40 | algorithm: HS256 41 | max_age: 86400 # 24 Hours 42 | refresh_token: 43 | secret: JWT-REFRESH-SECRET 44 | algorithm: HS256 45 | max_age: 2678400 # 30 Days 46 | secure: true 47 | httponly: false 48 | # path: optional 49 | #path: / 50 | 51 | messaging: 52 | # default_messenger: restfulpy.messaging.providers.SMTPProvider 53 | default_messenger: restfulpy.messaging.ConsoleMessenger 54 | default_sender: restfulpy 55 | mako_modules_directory: 56 | template_directories: 57 | 58 | templates: 59 | directories: [] 60 | 61 | authentication: 62 | redis: 63 | host: localhost 64 | port: 6379 65 | password: ~ 66 | db: 0 67 | 68 | worker: 69 | gap: .5 70 | number_of_threads: 1 71 | 72 | jobs: 73 | interval: .5 # Seconds 74 | number_of_threads: 1 75 | 76 | smtp: 77 | host: smtp.example.com 78 | port: 587 79 | username: user@example.com 80 | password: password 81 | local_hostname: localhost 82 | tls: true 83 | auth: true 84 | ssl: false 85 | 86 | """ 87 | 88 | 89 | def configure(context=None, force=False): 90 | 91 | context = context or {} 92 | context['restfulpy_root'] = path.dirname(__file__) 93 | 94 | nanohttp_configure( 95 | context=context, 96 | force=force 97 | ) 98 | settings.merge(__builtin_config) 99 | 100 | -------------------------------------------------------------------------------- /restfulpy/controllers.py: -------------------------------------------------------------------------------- 1 | from nanohttp import Controller, context, json, RestController, action 2 | 3 | from restfulpy.helpers import split_url 4 | from restfulpy.orm import DBSession 5 | 6 | 7 | class RootController(Controller): 8 | 9 | def __call__(self, *remaining_paths): 10 | 11 | if context.method == 'options': 12 | context.response_encoding = 'utf-8' 13 | context.response_headers.add_header( 14 | 'Cache-Control', 15 | 'no-cache,no-store' 16 | ) 17 | return b'' 18 | 19 | return super().__call__(*remaining_paths) 20 | 21 | 22 | class ModelRestController(RestController): 23 | __model__ = None 24 | 25 | @json 26 | def metadata(self): 27 | return self.__model__.json_metadata() 28 | 29 | 30 | 31 | class JSONPatchControllerMixin: 32 | 33 | @action(content_type='application/json', prevent_empty_form=True) 34 | def patch(self: Controller): 35 | """ 36 | Set context.method 37 | Set context.form 38 | :return: 39 | """ 40 | # Preserve patches 41 | patches = context.form 42 | results = [] 43 | context.jsonpatch = True 44 | 45 | try: 46 | for patch in patches: 47 | context.form = patch.get('value', {}) 48 | path, context.query = split_url(patch['path']) 49 | context.method = patch['op'].lower() 50 | context.request_content_length = \ 51 | len(context.form) if context.form else 0 52 | 53 | remaining_paths = path.split('/') 54 | if remaining_paths and not remaining_paths[0]: 55 | return_data = self() 56 | else: 57 | return_data = self(*remaining_paths) 58 | 59 | results.append(return_data) 60 | 61 | DBSession.flush() 62 | context.query = {} 63 | 64 | DBSession.commit() 65 | return '[%s]' % ',\n'.join(results) 66 | except: 67 | if DBSession.is_active: 68 | DBSession.rollback() 69 | raise 70 | finally: 71 | del context.jsonpatch 72 | -------------------------------------------------------------------------------- /restfulpy/datetimehelpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime, tzinfo, date 3 | 4 | from dateutil.parser import parse as dateutil_parse 5 | from dateutil.tz import tzutc, tzstr, tzlocal 6 | 7 | from .configuration import settings 8 | from .helpers import noneifnone 9 | 10 | 11 | POSIX_TIME_PATTERN = re.compile('^\d+(\.\d+)?$') 12 | 13 | 14 | def localtimezone(): 15 | return tzlocal() 16 | 17 | 18 | def configuredtimezone(): 19 | timezone = settings.timezone 20 | if timezone in ('', None): 21 | return None 22 | 23 | if isinstance(timezone, tzinfo): 24 | return timezone 25 | 26 | if timezone in (0, 'utc', 'UTC', 'Z', 'z'): 27 | return tzutc() 28 | 29 | return tzstr(timezone) 30 | 31 | 32 | def localnow(): 33 | timezone = configuredtimezone() 34 | return datetime.now(timezone) 35 | 36 | 37 | 38 | @noneifnone 39 | def parse_datetime(value) -> datetime: 40 | """Parses a string a a datetime object 41 | 42 | The reason of wrapping this functionality is to preserve compatibility 43 | and future exceptions handling. 44 | 45 | Another reason is to behave depend to the configuration when parsing date 46 | and time. 47 | """ 48 | timezone = configuredtimezone() 49 | 50 | # Parse and return if value is unix timestamp 51 | if isinstance(value, float) or POSIX_TIME_PATTERN.match(value): 52 | value = float(value) 53 | if timezone is None: 54 | return datetime.fromtimestamp(value, tzutc()) \ 55 | .replace(tzinfo=None) 56 | else: 57 | return datetime.fromtimestamp(value, timezone) 58 | 59 | 60 | parsed_value = dateutil_parse(value) 61 | if timezone is not None: 62 | # The application is configured to use UTC or another time zone: 63 | 64 | # Submit without timezone: Reject and tell the user to specify the 65 | # timezone. 66 | if parsed_value.tzinfo is None: 67 | raise ValueError('You have to specify the timezone') 68 | 69 | # The parsed value is a timezone aware object. 70 | # If ends with Z: accept and assume as the UTC 71 | # Then converting it to configured timezone and continue the 72 | # rest of process 73 | parsed_value = parsed_value.astimezone(timezone) 74 | 75 | elif parsed_value.tzinfo: 76 | # The application is configured to use system's local timezone 77 | # And the sumittd value has tzinfo. 78 | # So converting it to system's local and removing the tzinfo 79 | # to achieve a naive object. 80 | parsed_value = parsed_value\ 81 | .astimezone(localtimezone())\ 82 | .replace(tzinfo=None) 83 | 84 | return parsed_value 85 | 86 | 87 | @noneifnone 88 | def parse_date(value) -> date: 89 | """Parses a string as a date object 90 | 91 | .. note: It ignores the time part of the value 92 | 93 | The reason of wrapping this functionality is to preserve compatibility 94 | and future exceptions handling. 95 | """ 96 | 97 | # Parse and return if value is unix timestamp 98 | if isinstance(value, float) or POSIX_TIME_PATTERN.match(value): 99 | value = float(value) 100 | return date.fromtimestamp(value) 101 | 102 | return dateutil_parse(value).date() 103 | 104 | 105 | @noneifnone 106 | def parse_time(value) -> date: 107 | """Parses a string as a time object 108 | 109 | .. note: It ignores the date part of the value 110 | 111 | The reason of wrapping this functionality is to preserve compatibility 112 | and future exceptions handling. 113 | """ 114 | 115 | if isinstance(value, float): 116 | return datetime.utcfromtimestamp(value).time() 117 | 118 | # Parse and return if value is unix timestamp 119 | if POSIX_TIME_PATTERN.match(value): 120 | value = float(value) 121 | return datetime.utcfromtimestamp(value).time() 122 | 123 | return dateutil_parse(value).time() 124 | 125 | 126 | def format_datetime(value): 127 | timezone = configuredtimezone() 128 | if not isinstance(value, datetime) and isinstance(value, date): 129 | value = datetime(value.year, value.month, value.day, tzinfo=timezone) 130 | 131 | if timezone is None and value.tzinfo is not None: 132 | # The output shoudn't have a timezone specifier. 133 | # So, converting it to system's local time 134 | value = value.astimezone(localtimezone()).replace(tzinfo=None) 135 | 136 | elif timezone is not None: 137 | if value.tzinfo is None: 138 | raise ValueError('The value must have a timezone specifier') 139 | 140 | elif value.utcoffset() != timezone.utcoffset(value): 141 | value = value.astimezone(timezone) 142 | 143 | result = value.isoformat() 144 | 145 | return result 146 | 147 | 148 | def format_date(value): 149 | if isinstance(value, datetime): 150 | value = value.date() 151 | 152 | return value.isoformat() 153 | 154 | 155 | def format_time(value): 156 | if isinstance(value, datetime): 157 | value = value.time() 158 | 159 | return value.isoformat() 160 | 161 | -------------------------------------------------------------------------------- /restfulpy/db.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from urllib.parse import urlparse 3 | 4 | import psycopg2 5 | from nanohttp import settings 6 | from sqlalchemy import create_engine 7 | 8 | 9 | class PostgreSQLManager: 10 | connection = None 11 | 12 | def __init__(self, url=None): 13 | self.db_url = url or settings.db.url 14 | self.db_name = urlparse(self.db_url).path.lstrip('/') 15 | self.admin_url = settings.db.administrative_url 16 | self.admin_db_name = \ 17 | urlparse(settings.db.administrative_url).path.lstrip('/') 18 | 19 | def __enter__(self): 20 | self.admin_engine = create_engine(self.admin_url) 21 | self.connection = self.admin_engine.connect() 22 | self.connection.execute('commit') 23 | return self 24 | 25 | def __exit__(self, exc_type, exc_val, exc_tb): 26 | self.connection.close() 27 | self.admin_engine.dispose() 28 | 29 | def database_exists(self): 30 | r = self.connection.execute( 31 | f'SELECT 1 FROM pg_database WHERE datname = \'{self.db_name}\'' 32 | ) 33 | try: 34 | ret = r.cursor.fetchall() 35 | return ret 36 | finally: 37 | r.cursor.close() 38 | 39 | def create_database(self, exist_ok=False): 40 | if exist_ok and self.database_exists(): 41 | return 42 | self.connection.execute(f'CREATE DATABASE {self.db_name}') 43 | self.connection.execute(f'COMMIT') 44 | 45 | def drop_database(self): 46 | self.connection.execute(f'DROP DATABASE IF EXISTS {self.db_name}') 47 | self.connection.execute(f'COMMIT') 48 | 49 | @contextlib.contextmanager 50 | def cursor(self, query=None, args=None): 51 | connection = psycopg2.connect(self.db_url) 52 | cursor = connection.cursor() 53 | if query: 54 | cursor.execute(query, args) 55 | 56 | yield cursor 57 | cursor.close() 58 | connection.close() 59 | 60 | def table_exists(self, name): 61 | with self.cursor( 62 | f'select to_regclass(%s)', 63 | (f'public.{name}',) 64 | ) as c: 65 | return c.fetchone()[0] is not None 66 | -------------------------------------------------------------------------------- /restfulpy/helpers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import importlib.util 3 | import io 4 | import os 5 | import re 6 | import sys 7 | import functools 8 | from hashlib import md5 9 | from mimetypes import guess_type 10 | from os.path import dirname, abspath, split 11 | from urllib.parse import parse_qs 12 | 13 | 14 | def import_python_module_by_filename(name, module_filename): 15 | """ 16 | Import's a file as a python module, with specified name. 17 | 18 | Don't ask about the `name` argument, it's required. 19 | 20 | :param name: The name of the module to override upon imported filename. 21 | :param module_filename: The filename to import as a python module. 22 | :return: The newly imported python module. 23 | """ 24 | 25 | sys.path.append(abspath(dirname(module_filename))) 26 | spec = importlib.util.spec_from_file_location( 27 | name, 28 | location=module_filename) 29 | imported_module = importlib.util.module_from_spec(spec) 30 | spec.loader.exec_module(imported_module) 31 | return imported_module 32 | 33 | 34 | def construct_class_by_name(name, *args, **kwargs): 35 | """ 36 | Construct a class by module path name using *args and **kwargs 37 | 38 | Don't ask about the `name` argument, it's required. 39 | 40 | :param name: class name 41 | :return: The newly imported python module. 42 | """ 43 | parts = name.split('.') 44 | module_name, class_name = '.'.join(parts[:-1]), parts[-1] 45 | module = importlib.import_module(module_name) 46 | return getattr(module, class_name)(*args, **kwargs) 47 | 48 | 49 | def to_camel_case(text): 50 | return re.sub(r'(_\w)', lambda x: x.group(1)[1:].upper(), text) 51 | 52 | 53 | def copy_stream(source, target, *, chunk_size: int= 16 * 1024) -> int: 54 | length = 0 55 | while 1: 56 | buf = source.read(chunk_size) 57 | if not buf: 58 | break 59 | length += len(buf) 60 | target.write(buf) 61 | return length 62 | 63 | 64 | def md5sum(f): 65 | if isinstance(f, str): 66 | file_obj = open(f, 'rb') 67 | else: 68 | file_obj = f 69 | 70 | try: 71 | checksum = md5() 72 | while True: 73 | d = file_obj.read(1024) 74 | if not d: 75 | break 76 | checksum.update(d) 77 | return checksum.digest() 78 | finally: 79 | if file_obj is not f: 80 | file_obj.close() 81 | 82 | 83 | def split_url(url): 84 | if '?' in url: 85 | path, query = url.split('?') 86 | else: 87 | path, query = url, '' 88 | 89 | return path, {k: v[0] if len(v) == 1 else v for k, v in parse_qs( 90 | query, 91 | keep_blank_values=True, 92 | strict_parsing=False 93 | ).items()} 94 | 95 | 96 | def encode_multipart_data(fields, files, boundary=None): 97 | boundary = boundary or ''.join([ 98 | '-----', 99 | base64.urlsafe_b64encode(os.urandom(27)).decode() 100 | ]) 101 | crlf = b'\r\n' 102 | lines = [] 103 | 104 | if fields: 105 | for key, value in fields.items(): 106 | lines.append('--' + boundary) 107 | lines.append('Content-Disposition: form-data; name="%s"' % key) 108 | lines.append('') 109 | lines.append(value) 110 | 111 | if files: 112 | for key, file_path in files.items(): 113 | filename = split(file_path)[1] 114 | lines.append('--' + boundary) 115 | lines.append( 116 | 'Content-Disposition: form-data; name="%s"; filename="%s"' % 117 | (key, filename)) 118 | lines.append( 119 | 'Content-Type: %s' % 120 | (guess_type(filename)[0] or 'application/octet-stream')) 121 | lines.append('') 122 | lines.append(open(file_path, 'rb').read()) 123 | 124 | lines.append('--' + boundary + '--') 125 | lines.append('') 126 | 127 | body = io.BytesIO() 128 | length = 0 129 | for l in lines: 130 | line = (l if isinstance(l, bytes) else l.encode()) + crlf 131 | length += len(line) 132 | body.write(line) 133 | body.seek(0) 134 | content_type = 'multipart/form-data; boundary=%s' % boundary 135 | return content_type, body, length 136 | 137 | 138 | def noneifnone(func): 139 | 140 | @functools.wraps(func) 141 | def wrapper(value): 142 | return func(value) if value is not None else None 143 | 144 | return wrapper 145 | 146 | -------------------------------------------------------------------------------- /restfulpy/logger.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import sys 3 | import traceback 4 | 5 | 6 | DEBUG = 1 7 | INFO = 2 8 | WARNING = 3 9 | ERROR = 4 10 | 11 | level = DEBUG 12 | 13 | 14 | def error(ex): 15 | message = None 16 | traceback_ = None 17 | if isinstance(ex, str): 18 | message = ex 19 | type_, ex, traceback_ = sys.exc_info() 20 | else: 21 | type_ = type(ex) 22 | 23 | traceback.print_exception(type_, ex, traceback_, file=sys.stderr) 24 | if message: 25 | print(message, file=sys.stderr) 26 | 27 | 28 | def log(severity, message): 29 | if level <= severity: 30 | print(message) 31 | 32 | 33 | debug = functools.partial(log, DEBUG) 34 | info = functools.partial(log, INFO) 35 | warning = functools.partial(log, WARNING) 36 | 37 | -------------------------------------------------------------------------------- /restfulpy/messaging/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .models import Email 3 | from .providers import Messenger, create_messenger, SMTPProvider, \ 4 | ConsoleMessenger 5 | -------------------------------------------------------------------------------- /restfulpy/messaging/models.py: -------------------------------------------------------------------------------- 1 | from nanohttp import settings 2 | from sqlalchemy import Integer, ForeignKey, Unicode 3 | from sqlalchemy.ext.declarative import declared_attr 4 | 5 | from ..orm import Field, FakeJSON,synonym 6 | from ..taskqueue import RestfulpyTask 7 | from .providers import create_messenger 8 | 9 | 10 | class Email(RestfulpyTask): 11 | __tablename__ = 'email' 12 | 13 | 14 | template_filename = Field(Unicode(200), nullable=True) 15 | to = Field(Unicode(254), json='to') 16 | subject = Field(Unicode(254), json='subject') 17 | cc = Field(Unicode(254), nullable=True, json='cc') 18 | bcc = Field(Unicode(254), nullable=True, json='bcc') 19 | _body = Field('body', FakeJSON) 20 | 21 | from_ = Field( 22 | Unicode(254), 23 | json='from', 24 | default=lambda: settings.messaging.default_sender 25 | ) 26 | 27 | __mapper_args__ = { 28 | 'polymorphic_identity': __tablename__ 29 | } 30 | 31 | def _set_body(self, body): 32 | self._body = body 33 | 34 | def _get_body(self): 35 | return self._body 36 | 37 | @declared_attr 38 | def body(cls): 39 | return synonym( 40 | '_body', 41 | descriptor=property(cls._get_body, cls._set_body) 42 | ) 43 | 44 | @declared_attr 45 | def id(cls): 46 | return Field( 47 | Integer, 48 | ForeignKey('restfulpy_task.id'), 49 | primary_key=True, json='id' 50 | ) 51 | 52 | def do_(self, context, attachments=None): 53 | messenger = create_messenger() 54 | messenger.send( 55 | self.to, 56 | self.subject, 57 | self.body, 58 | cc=self.cc, 59 | bcc=self.bcc, 60 | template_filename=self.template_filename, 61 | from_=self.from_, 62 | attachments=attachments 63 | ) 64 | 65 | print('%s is sent to %s', self.subject, self.to) 66 | 67 | -------------------------------------------------------------------------------- /restfulpy/messaging/providers.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email.mime.application import MIMEApplication 3 | from email.mime.multipart import MIMEMultipart 4 | from email.mime.text import MIMEText 5 | from os.path import basename 6 | 7 | from mako.lookup import TemplateLookup 8 | from nanohttp import settings, LazyAttribute 9 | 10 | from restfulpy.helpers import construct_class_by_name 11 | 12 | 13 | class Messenger(object): 14 | """ 15 | The abstract base class for everyone messaging operations 16 | """ 17 | 18 | def render_body(self, body, template_filename=None): 19 | if template_filename: 20 | mako_template = self.lookup.get_template(template_filename) 21 | assert mako_template is not None, \ 22 | 'Cannot find template file: {template_filename}.' 23 | return mako_template.render(**body) 24 | 25 | return body 26 | 27 | @LazyAttribute 28 | def lookup(self): 29 | template_directories = settings.messaging.template_directories 30 | if not template_directories: 31 | raise ValueError( 32 | 'Please provide some mako template directoris via ' 33 | 'configuration: settings.messaging.template_directories' 34 | ) 35 | return TemplateLookup( 36 | module_directory=settings.messaging.mako_modules_directory, 37 | directories=settings.messaging.template_directories, 38 | input_encoding='utf8' 39 | ) 40 | 41 | def send(self, to, subject, body, cc=None, bcc=None, 42 | template_filename=None, from_=None, attachments=None): 43 | raise NotImplementedError 44 | 45 | 46 | class SMTPProvider(Messenger): 47 | 48 | def send(self, to, subject, body, cc=None, bcc=None, 49 | template_filename=None, from_=None, attachments=None): 50 | """ 51 | Sending messages with SMTP server 52 | """ 53 | 54 | body = self.render_body(body, template_filename) 55 | 56 | smtp_config = settings.smtp 57 | smtp_server = (smtplib.SMTP_SSL if smtp_config.ssl else smtplib.SMTP)( 58 | host=smtp_config.host, 59 | port=smtp_config.port, 60 | local_hostname=smtp_config.local_hostname 61 | ) 62 | if smtp_config.tls: # pragma: no cover 63 | smtp_server.starttls() 64 | 65 | if smtp_config.auth: # pragma: no cover 66 | smtp_server.login(smtp_config.username, smtp_config.password) 67 | 68 | from_ = from_ or smtp_config.username 69 | 70 | msg = MIMEMultipart() 71 | msg['Subject'] = subject 72 | msg['From'] = from_ 73 | msg['To'] = to 74 | if cc: 75 | msg['Cc'] = cc 76 | if bcc: 77 | msg['Bcc'] = bcc 78 | 79 | html_part = MIMEText(body, 'html') 80 | msg.attach(html_part) 81 | if attachments: 82 | for attachment in attachments: 83 | assert hasattr(attachment, 'name') 84 | attachment_part = MIMEApplication( 85 | attachment.read(), 86 | Name=basename(attachment.name) 87 | ) 88 | attachment_part['Content-Disposition'] = \ 89 | f'attachment; filename="{basename(attachment.name)}"' 90 | msg.attach(attachment_part) 91 | 92 | smtp_server.send_message(msg) 93 | smtp_server.quit() 94 | 95 | 96 | class ConsoleMessenger(Messenger): 97 | def send(self, to, subject, body, cc=None, bcc=None, 98 | template_filename=None, from_=None, attachments=None): 99 | """ 100 | Sending messages by email 101 | """ 102 | 103 | body = self.render_body(body, template_filename) 104 | print(body) 105 | 106 | 107 | def create_messenger() -> Messenger: 108 | return construct_class_by_name(settings.messaging.default_messenger) 109 | -------------------------------------------------------------------------------- /restfulpy/mockup.py: -------------------------------------------------------------------------------- 1 | import asyncore 2 | import contextlib 3 | import smtpd 4 | import threading 5 | 6 | from . import datetimehelpers 7 | from .application import Application 8 | from .messaging import Messenger 9 | 10 | 11 | @contextlib.contextmanager 12 | def mockup_smtp_server(bind=('localhost', 0)): 13 | SMTP_SERVER_LOCK = threading.Event() 14 | class MockupSMTPServer(smtpd.SMTPServer): 15 | def __init__(self, bind): 16 | super().__init__(bind, None, decode_data=False) 17 | self.server_address = self.socket.getsockname()[:2] 18 | SMTP_SERVER_LOCK.set() 19 | 20 | def process_message(*args, **kwargs): 21 | pass 22 | 23 | 24 | server = MockupSMTPServer(bind) 25 | thread = threading.Thread(target=asyncore.loop, daemon=True) 26 | thread.start() 27 | SMTP_SERVER_LOCK.wait() 28 | yield server, server.server_address 29 | asyncore.close_all() 30 | 31 | 32 | class MockupMessenger(Messenger): 33 | _last_message = None 34 | 35 | @property 36 | def last_message(self): 37 | return self.__class__._last_message 38 | 39 | @last_message.setter 40 | def last_message(self, value): 41 | self.__class__._last_message = value 42 | 43 | def send( 44 | self, 45 | to, subject, body, 46 | cc=None, 47 | bcc=None, 48 | template_string=None, 49 | template_filename=None, 50 | from_=None, 51 | attachments=None 52 | ): 53 | self.last_message = { 54 | 'to': to, 55 | 'body': body, 56 | 'subject': subject 57 | } 58 | 59 | 60 | @contextlib.contextmanager 61 | def mockup_localtimezone(timezone): 62 | backup = datetimehelpers.localtimezone 63 | datetimehelpers.localtimezone = timezone if callable(timezone) \ 64 | else lambda: timezone 65 | 66 | yield 67 | 68 | datetimehelpers.localtimezone = backup 69 | 70 | -------------------------------------------------------------------------------- /restfulpy/orm/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import functools 3 | from os.path import exists 4 | 5 | from nanohttp import settings, context 6 | from sqlalchemy import create_engine as sa_create_engine 7 | from sqlalchemy.orm import scoped_session, sessionmaker 8 | from sqlalchemy.sql.schema import MetaData 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from alembic import config, command 11 | 12 | from .field import Field, relationship, composite, synonym 13 | from .metadata import MetadataField 14 | from .mixins import ModifiedMixin, SoftDeleteMixin, TimestampMixin, \ 15 | ActivationMixin, PaginationMixin, FilteringMixin, OrderingMixin, \ 16 | ApproveRequiredMixin, FullTextSearchMixin, AutoActivationMixin, \ 17 | DeactivationMixin 18 | from .models import BaseModel 19 | from .fulltext_search import to_tsvector, fts_escape 20 | from .types import FakeJSON 21 | 22 | # Global session manager: DBSession() returns the Thread-local 23 | # session object appropriate for the current web request. 24 | session_factory = sessionmaker( 25 | autoflush=False, 26 | autocommit=False, 27 | expire_on_commit=True, 28 | twophase=False) 29 | 30 | DBSession = scoped_session(session_factory) 31 | 32 | # Global metadata. 33 | metadata = MetaData() 34 | 35 | DeclarativeBase = declarative_base(cls=BaseModel, metadata=metadata) 36 | 37 | 38 | def create_engine(url=None, echo=None): 39 | return sa_create_engine(url or settings.db.url, echo=echo or settings.db.echo) 40 | 41 | 42 | def init_model(engine): 43 | """ 44 | Call me before using any of the tables or classes in the model. 45 | :param engine: SqlAlchemy engine to bind the session 46 | :return: 47 | """ 48 | DBSession.remove() 49 | DBSession.configure(bind=engine) 50 | 51 | 52 | def setup_schema(session=None): 53 | session = session or DBSession 54 | engine = session.bind 55 | metadata.create_all(bind=engine) 56 | 57 | if hasattr(settings, 'migration') and exists(settings.migration.directory): # pragma: no cover 58 | alembic_cfg = config.Config() 59 | alembic_cfg.set_main_option("script_location", settings.migration.directory) 60 | alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url)) 61 | alembic_cfg.config_file_name = settings.migration.ini 62 | command.stamp(alembic_cfg, "head") 63 | 64 | 65 | def create_thread_unsafe_session(): 66 | return session_factory() 67 | 68 | 69 | def commit(func): 70 | 71 | @functools.wraps(func) 72 | def wrapper(*args, **kwargs): 73 | 74 | try: 75 | if hasattr(context, 'jsonpatch'): 76 | result = func(*args, **kwargs) 77 | DBSession.flush() 78 | return result 79 | 80 | result = func(*args, **kwargs) 81 | DBSession.commit() 82 | return result 83 | 84 | except Exception as ex: 85 | # Actualy 200 <= status <= 399 is not an exception and commit must 86 | # be occure. 87 | if hasattr(ex, 'status') and '200' <= ex.status < '400': 88 | DBSession.commit() 89 | raise 90 | if DBSession.is_active: 91 | DBSession.rollback() 92 | raise 93 | 94 | return wrapper 95 | 96 | -------------------------------------------------------------------------------- /restfulpy/orm/field.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Unicode, String 2 | from sqlalchemy.orm import relationship as sa_relationship, \ 3 | composite as sa_composite, synonym as sa_synonym 4 | 5 | 6 | class Field(Column): 7 | 8 | def __init__(self, *args, json=None, readonly=None, max_length=None, 9 | min_length=None, maximum=None, minimum=None, protected=None, 10 | pattern=None, pattern_description=None, watermark=None, 11 | not_none=None, nullable=False, required=None, label=None, 12 | example=None, default=None, python_type=None, message=None, 13 | **kwargs): 14 | 15 | info = { 16 | 'json': json, 17 | 'readonly': readonly, 18 | 'protected': protected, 19 | 'watermark': watermark, 20 | 'message': message, 21 | 'label': label, 22 | 'min_length': min_length, 23 | 'maximum': maximum, 24 | 'minimum': minimum, 25 | 'pattern': pattern, 26 | 'pattern_description': pattern_description, 27 | 'example': example, 28 | 'not_none': not_none, 29 | 'default': default, 30 | 'type': python_type, 31 | } 32 | 33 | if max_length is None and args \ 34 | and isinstance(args[0], (Unicode, String)): 35 | info['max_length'] = args[0].length 36 | else: 37 | info['max_length'] = max_length 38 | 39 | if required is not None: 40 | info['required'] = required 41 | 42 | if not_none: 43 | nullable = False 44 | 45 | super(Field, self).__init__( 46 | *args, 47 | info=info, 48 | nullable=nullable, 49 | default=default, 50 | **kwargs 51 | ) 52 | 53 | 54 | def relationship(*args, json=None, protected=True, readonly=True, **kwargs): 55 | info = { 56 | 'json': json, 57 | 'protected': protected, 58 | 'readonly': readonly, 59 | } 60 | 61 | return sa_relationship(*args, info=info, **kwargs) 62 | 63 | 64 | def composite(*args, json=None, protected=None, readonly=None, **kwargs): 65 | info = { 66 | 'json': json, 67 | 'protected': protected, 68 | 'readonly': readonly, 69 | } 70 | 71 | return sa_composite(*args, info=info, **kwargs) 72 | 73 | 74 | def synonym(*args, json=None, protected=None, readonly=None, **kwargs): 75 | info = { 76 | 'json': json, 77 | 'protected': protected, 78 | 'readonly': readonly, 79 | } 80 | 81 | return sa_synonym(*args, info=info, **kwargs) 82 | 83 | -------------------------------------------------------------------------------- /restfulpy/orm/fulltext_search.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql import func 2 | 3 | 4 | escaping_map = str.maketrans({ 5 | '&': r'\&', 6 | '%': r'\%', 7 | '!': r'\!', 8 | '^': r'\^', 9 | '$': r'\$', 10 | '*': r'\*', 11 | '[': r'\[', 12 | ']': r'\]', 13 | '(': r'\(', 14 | ')': r'\)', 15 | '{': r'\{', 16 | '}': r'\}', 17 | '\\': r'\\', 18 | '\'': '\'\'' 19 | }) 20 | 21 | 22 | def fts_escape(expression): 23 | return expression.translate(escaping_map) 24 | 25 | 26 | def to_tsvector(*args): 27 | exp = args[0] 28 | for i in args[1:]: 29 | exp += ' ' + i 30 | return func.to_tsvector('english', exp) 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /restfulpy/orm/metadata.py: -------------------------------------------------------------------------------- 1 | from ..helpers import to_camel_case 2 | 3 | 4 | class FieldInfo: 5 | def __init__(self, type_=str, max_length=None, min_length=None, 6 | minimum=None, maximum=None, default=None, readonly=False, 7 | pattern=None, pattern_description=None, protected=False, 8 | not_none=None, required=None): 9 | self.type_ = type_ 10 | self.pattern = pattern 11 | self.pattern_description = pattern_description 12 | self.max_length = max_length 13 | self.min_length = min_length 14 | self.minimum = minimum 15 | self.maximum = maximum 16 | self.readonly = readonly 17 | self.protected = protected 18 | self.not_none = not_none 19 | self.required = required 20 | self.default = default 21 | 22 | def to_json(self): 23 | result = {} 24 | 25 | for k,v in vars(self).items(): 26 | json_key = to_camel_case(k) 27 | json_value = v[0] if isinstance(v, tuple) else v 28 | if isinstance(json_value, type): 29 | json_value = json_value.__name__ 30 | 31 | result[json_key] = json_value 32 | 33 | result['type'] = result.pop('type_') 34 | return result 35 | 36 | # Commented because not used 37 | # def __copy__(self): 38 | # new_one = type(self)() 39 | # new_one.__dict__.update(self.__dict__) 40 | # return new_one 41 | # 42 | # def to_dict(self): 43 | # return { 44 | # k: v for k, v in self.__dict__.items() if not k.startswith('_') 45 | # } 46 | 47 | 48 | class MetadataField(FieldInfo): 49 | def __init__(self, name, key, primary_key=False, label=None, 50 | watermark=None, message=None, example=None, **kwargs): 51 | super().__init__(**kwargs) 52 | self.name = name 53 | self.key = key[1:] if key.startswith('_') else key 54 | self.primary_key = primary_key 55 | self.label = label 56 | self.watermark = watermark 57 | self.example = example 58 | self.message = message 59 | 60 | @classmethod 61 | def from_column(cls, c, info=None): 62 | info = info or c.info 63 | 64 | json_name = info.get('json') or to_camel_case(c.key) 65 | result = [] 66 | 67 | key = c.key 68 | 69 | if hasattr(c, 'default') \ 70 | and c.default \ 71 | and hasattr(c.default, 'is_scalar'): 72 | default = c.default.arg if c.default.is_scalar else None 73 | else: 74 | default = None 75 | 76 | result.append(cls( 77 | json_name, 78 | key, 79 | default=default, 80 | type_=info.get('type'), 81 | not_none=info.get('not_none'), 82 | required=info.get('required'), 83 | pattern=info.get('pattern'), 84 | pattern_description=info.get('pattern_description'), 85 | max_length=info.get('max_length'), 86 | min_length=info.get('min_length'), 87 | minimum=info.get('minimum'), 88 | maximum=info.get('maximum'), 89 | watermark=info.get('watermark', None), 90 | label=info.get('label', None), 91 | example=info.get('example', None), 92 | primary_key=hasattr(c.expression, 'primary_key') \ 93 | and c.expression.primary_key, 94 | readonly=info.get('readonly', False), 95 | protected=info.get('protected', False), 96 | message=info.get('message') 97 | )) 98 | 99 | return result 100 | 101 | def to_json(self): 102 | result = super().to_json() 103 | result.update( 104 | name=self.name, 105 | example=self.example, 106 | watermark=self.watermark, 107 | label=self.label, 108 | message=self.message, 109 | key=self.key, 110 | primaryKey=self.primary_key, 111 | ) 112 | return result 113 | 114 | -------------------------------------------------------------------------------- /restfulpy/orm/models.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import uuid 3 | from datetime import datetime, date, time 4 | from decimal import Decimal 5 | 6 | from nanohttp import context, HTTPNotFound, HTTPBadRequest, validate 7 | from sqlalchemy import Column 8 | from sqlalchemy.ext.associationproxy import ASSOCIATION_PROXY 9 | from sqlalchemy.ext.hybrid import HYBRID_PROPERTY 10 | from sqlalchemy.inspection import inspect 11 | from sqlalchemy.orm import Query, CompositeProperty, \ 12 | RelationshipProperty 13 | from sqlalchemy.orm.attributes import InstrumentedAttribute 14 | 15 | from ..datetimehelpers import parse_datetime, parse_date, parse_time, \ 16 | format_date, format_time, format_datetime 17 | from ..helpers import to_camel_case 18 | from .metadata import MetadataField 19 | from .mixins import PaginationMixin, FilteringMixin, OrderingMixin 20 | 21 | 22 | class BaseModel(object): 23 | 24 | @classmethod 25 | def get_column(cls, column): 26 | # Commented-out by vahid, because I cannot reach here by tests, 27 | # I think it's not necessary at all. 28 | #if isinstance(column, str): 29 | # mapper = inspect(cls) 30 | # return mapper.columns[column] 31 | # if isinstance(column, SynonymProperty): 32 | # return column.parent.columns[column.name] 33 | return column 34 | 35 | @classmethod 36 | def import_value(cls, column, v): 37 | c = cls.get_column(column) 38 | if isinstance(c, Column) or isinstance(c, InstrumentedAttribute): 39 | if c.type.python_type is bool and not isinstance(v, bool): 40 | return str(v).lower() == 'true' 41 | return v 42 | 43 | @classmethod 44 | def get_column_info(cls, column): 45 | # Use original property for proxies 46 | if hasattr(column, 'original_property') and column.original_property: 47 | info = column.info.copy() 48 | info.update(column.original_property.info) 49 | else: 50 | info = column.info 51 | 52 | if not info.get('json'): 53 | info['json'] = to_camel_case(column.key) 54 | 55 | return info 56 | 57 | @classmethod 58 | def prepare_for_export(cls, column, v): 59 | info = cls.get_column_info(column) 60 | param_name = info.get('json') 61 | 62 | if hasattr(column, 'property') \ 63 | and isinstance(column.property, RelationshipProperty) \ 64 | and column.property.uselist: 65 | result = [c.to_dict() for c in v] 66 | 67 | elif hasattr(column, 'property') \ 68 | and isinstance(column.property, CompositeProperty): 69 | result = v.__composite_values__() 70 | 71 | elif v is None: 72 | result = v 73 | 74 | elif isinstance(v, datetime): 75 | result = format_datetime(v) 76 | 77 | elif isinstance(v, date): 78 | result = format_date(v) 79 | 80 | elif isinstance(v, time): 81 | result = format_time(v) 82 | 83 | elif hasattr(v, 'to_dict'): 84 | result = v.to_dict() 85 | 86 | elif isinstance(v, Decimal): 87 | result = str(v) 88 | 89 | elif isinstance(v, uuid.UUID): 90 | result = v.hex 91 | 92 | else: 93 | result = v 94 | 95 | return param_name, result 96 | 97 | @classmethod 98 | def iter_metadata_fields(cls): 99 | for c in cls.iter_json_columns( 100 | relationships=True, 101 | include_readonly_columns=True, 102 | include_protected_columns=True 103 | ): 104 | yield from MetadataField.from_column( 105 | cls.get_column(c), 106 | info=cls.get_column_info(c) 107 | ) 108 | 109 | @classmethod 110 | def json_metadata(cls): 111 | fields = {f.name: f.to_json() for f in cls.iter_metadata_fields()} 112 | mapper = inspect(cls) 113 | return { 114 | 'name': cls.__name__, 115 | 'primaryKeys': [c.key for c in mapper.primary_key], 116 | 'fields': fields 117 | } 118 | 119 | def update_from_request(self): 120 | for column, value in self.extract_data_from_request(): 121 | setattr( 122 | self, 123 | column.key[1:] if column.key.startswith('_') else column.key, 124 | self.import_value(column, value) 125 | ) 126 | 127 | @classmethod 128 | def iter_columns(cls, relationships=True, synonyms=True, composites=True, 129 | hybrids=True): 130 | mapper = inspect(cls) 131 | for k, c in mapper.all_orm_descriptors.items(): 132 | 133 | if k == '__mapper__' or c.extension_type == ASSOCIATION_PROXY: 134 | continue 135 | 136 | if (not hybrids and c.extension_type == HYBRID_PROPERTY) \ 137 | or (not relationships and k in mapper.relationships) \ 138 | or (not synonyms and k in mapper.synonyms) \ 139 | or (not composites and k in mapper.composites): 140 | continue 141 | 142 | yield getattr(cls, k) 143 | 144 | @classmethod 145 | def iter_json_columns(cls, include_readonly_columns=True, 146 | include_protected_columns=False, **kw): 147 | for c in cls.iter_columns(**kw): 148 | 149 | info = cls.get_column_info(c) 150 | if (not include_protected_columns and info.get('protected')) or \ 151 | (not include_readonly_columns and info.get('readonly')): 152 | continue 153 | 154 | yield c 155 | 156 | @classmethod 157 | def extract_data_from_request(cls): 158 | for c in cls.iter_json_columns( 159 | include_protected_columns=True, 160 | include_readonly_columns=False 161 | ): 162 | info = cls.get_column_info(c) 163 | param_name = info.get('json') 164 | 165 | if param_name in context.form: 166 | 167 | if hasattr(c, 'property') and hasattr(c.property, 'mapper'): 168 | raise HTTPBadRequest('Invalid attribute') 169 | 170 | value = context.form[param_name] 171 | 172 | # Ensuring the python type, and ignoring silently if the 173 | # python type is not specified 174 | try: 175 | type_ = c.type.python_type 176 | except NotImplementedError: 177 | yield c, value 178 | continue 179 | 180 | # Parsing date and or time if required. 181 | if type_ in (datetime, date, time): 182 | try: 183 | if type_ == time: 184 | yield c, parse_time(value) 185 | 186 | elif type_ == datetime: 187 | yield c, parse_datetime(value) 188 | 189 | elif type_ == date: 190 | yield c, parse_date(value) 191 | 192 | except ValueError: 193 | raise HTTPBadRequest(f'Invalid date or time: {value}') 194 | 195 | else: 196 | yield c, value 197 | 198 | def to_dict(self): 199 | result = {} 200 | for c in self.iter_json_columns(): 201 | result.setdefault( 202 | *self.prepare_for_export(c, getattr(self, c.key)) 203 | ) 204 | return result 205 | 206 | @classmethod 207 | def create_sort_criteria(cls, sort_columns): 208 | criteria = [] 209 | columns = { 210 | cls.get_column_info(c).get('json'): c 211 | for c in cls.iter_json_columns() 212 | } 213 | for column_name, option in sort_columns: 214 | if column_name in columns: 215 | criteria.append((columns[column_name], option == 'desc')) 216 | return criteria 217 | 218 | @classmethod 219 | def filter_paginate_sort_query_by_request(cls, query=None): 220 | query = query or cls.query 221 | 222 | if issubclass(cls, FilteringMixin): 223 | query = cls.filter_by_request(query) 224 | 225 | if issubclass(cls, OrderingMixin): 226 | query = cls.sort_by_request(query) 227 | 228 | if issubclass(cls, PaginationMixin): 229 | query = cls.paginate_by_request(query=query) 230 | 231 | return query 232 | 233 | @classmethod 234 | def dump_query(cls, query=None): 235 | result = [] 236 | for o in cls.filter_paginate_sort_query_by_request(query): 237 | result.append(o.to_dict()) 238 | return result 239 | 240 | @classmethod 241 | def expose(cls, func): 242 | 243 | @functools.wraps(func) 244 | def wrapper(*args, **kwargs): 245 | result = func(*args, **kwargs) 246 | if result is None: 247 | raise HTTPNotFound() 248 | if isinstance(result, Query): 249 | return cls.dump_query(result) 250 | return result 251 | 252 | return wrapper 253 | 254 | @classmethod 255 | def create_validation_rules(cls, strict=False, ignore=None): 256 | fields = {} 257 | for f in cls.iter_metadata_fields(): 258 | if ignore and f.name in ignore: 259 | continue 260 | 261 | fields[f.name] = field = dict( 262 | required=f.required, 263 | type_=f.type_, 264 | minimum=f.minimum, 265 | maximum=f.maximum, 266 | pattern=f.pattern, 267 | min_length=f.min_length, 268 | max_length=f.max_length, 269 | not_none=f.not_none, 270 | readonly=f.readonly 271 | ) 272 | 273 | if not strict and 'required' in field: 274 | del field['required'] 275 | return fields 276 | 277 | @classmethod 278 | def validate(cls, strict=False, fields=None, ignore=None): 279 | if callable(strict): 280 | # Decorator is used without any parameter and call parentesis. 281 | func = strict 282 | strict = False 283 | else: 284 | func = None 285 | 286 | rules = cls.create_validation_rules(strict, ignore) 287 | if fields: 288 | for k, v in fields.items(): 289 | d = rules.setdefault(k, {}).update(v) 290 | 291 | decorator = validate(**rules) 292 | 293 | if func: 294 | return decorator(func) 295 | 296 | return decorator 297 | 298 | -------------------------------------------------------------------------------- /restfulpy/orm/types.py: -------------------------------------------------------------------------------- 1 | import ujson 2 | from sqlalchemy import Unicode, TypeDecorator 3 | 4 | 5 | class FakeJSON(TypeDecorator): 6 | impl = Unicode 7 | 8 | def process_bind_param(self, value, engine): 9 | return ujson.dumps(value) 10 | 11 | def process_result_value(self, value, engine): 12 | if value is None: 13 | return None 14 | 15 | return ujson.loads(value) 16 | -------------------------------------------------------------------------------- /restfulpy/principal.py: -------------------------------------------------------------------------------- 1 | from itsdangerous import TimedJSONWebSignatureSerializer, \ 2 | JSONWebSignatureSerializer 3 | from nanohttp import settings, context, HTTPForbidden 4 | 5 | 6 | class BaseJWTPrincipal: 7 | def __init__(self, payload): 8 | self.payload = payload 9 | 10 | @classmethod 11 | def create_serializer(cls, force=False, max_age=None): 12 | config = cls.get_config() 13 | 14 | if force: 15 | return JSONWebSignatureSerializer( 16 | config['secret'], 17 | algorithm_name=config['algorithm'] 18 | ) 19 | else: 20 | return TimedJSONWebSignatureSerializer( 21 | config['secret'], 22 | expires_in=max_age or config['max_age'], 23 | algorithm_name=config['algorithm'] 24 | ) 25 | 26 | def dump(self, max_age=None): 27 | return self.create_serializer(max_age=max_age).dumps(self.payload) 28 | 29 | @classmethod 30 | def load(cls, encoded, force=False): 31 | if encoded.startswith('Bearer '): 32 | encoded = encoded[7:] 33 | payload = cls.create_serializer(force=force).loads(encoded) 34 | return cls(payload) 35 | 36 | @classmethod 37 | def get_config(cls): 38 | raise NotImplementedError() 39 | 40 | 41 | class JWTPrincipal(BaseJWTPrincipal): 42 | def is_in_roles(self, *roles): 43 | if 'roles' in self.payload: 44 | if set(self.payload['roles']).intersection(roles): 45 | return True 46 | return False 47 | 48 | def assert_roles(self, *roles): 49 | """ 50 | .. versionadded:: 0.29 51 | 52 | :param roles: 53 | :return: 54 | """ 55 | if roles and not self.is_in_roles(*roles): 56 | raise HTTPForbidden() 57 | 58 | @property 59 | def email(self): 60 | return self.payload.get('email') 61 | 62 | @property 63 | def session_id(self): 64 | return self.payload.get('sessionId') 65 | 66 | @property 67 | def id(self): 68 | return self.payload.get('id') 69 | 70 | @property 71 | def roles(self): 72 | return self.payload.get('roles', []) 73 | 74 | @classmethod 75 | def get_config(cls): 76 | """ 77 | Warning! Returned value is a dict, so it's mutable. If you modify this 78 | value, default config of the whole project will be changed and it may 79 | cause unpredictable problems. 80 | """ 81 | return settings.jwt 82 | 83 | 84 | class JWTRefreshToken: 85 | def __init__(self, payload): 86 | self.payload = payload 87 | 88 | @classmethod 89 | def create_serializer(cls): 90 | return TimedJSONWebSignatureSerializer( 91 | settings.jwt.refresh_token.secret, 92 | expires_in=settings.jwt.refresh_token.max_age, 93 | algorithm_name=settings.jwt.refresh_token.algorithm 94 | ) 95 | 96 | def dump(self): 97 | return self.create_serializer().dumps(self.payload) 98 | 99 | @classmethod 100 | def load(cls, encoded): 101 | payload = cls.create_serializer().loads(encoded) 102 | return cls(payload) 103 | 104 | @property 105 | def id(self): 106 | return self.payload.get('id') 107 | 108 | 109 | class DummyIdentity(JWTPrincipal): 110 | def __init__(self, *roles): 111 | super().__init__({'roles': list(roles)}) 112 | 113 | 114 | class ImpersonateAs: 115 | backup_identity = None 116 | 117 | def __init__(self, principal): 118 | self.principal = principal 119 | 120 | def __enter__(self): 121 | if hasattr(context, 'identity'): 122 | self.backup_identity = context.identity 123 | context.identity = self.principal 124 | 125 | def __exit__(self, exc_type, exc_val, exc_tb): 126 | context.identity = self.backup_identity 127 | -------------------------------------------------------------------------------- /restfulpy/taskqueue.py: -------------------------------------------------------------------------------- 1 | import time 2 | import traceback 3 | from datetime import datetime 4 | 5 | from nanohttp import settings 6 | from sqlalchemy import Integer, Enum, Unicode, DateTime 7 | from sqlalchemy.sql.expression import text 8 | 9 | from . import logger 10 | from .exceptions import RestfulException 11 | from .orm import TimestampMixin, DeclarativeBase, Field, DBSession, \ 12 | create_thread_unsafe_session 13 | 14 | 15 | class TaskPopError(RestfulException): 16 | pass 17 | 18 | 19 | class RestfulpyTask(TimestampMixin, DeclarativeBase): 20 | __tablename__ = 'restfulpy_task' 21 | 22 | id = Field(Integer, primary_key=True, json='id') 23 | priority = Field(Integer, nullable=False, default=50, json='priority') 24 | status = Field( 25 | Enum( 26 | 'new', 27 | 'success', 28 | 'in-progress', 29 | 'failed', 30 | name='task_status_enum' 31 | ), 32 | default='new', 33 | nullable=True, json='status' 34 | ) 35 | fail_reason = Field(Unicode(4096), nullable=True, json='reason') 36 | started_at = Field(DateTime, nullable=True, json='startedAt') 37 | terminated_at = Field(DateTime, nullable=True, json='terminatedAt') 38 | type = Field(Unicode(50)) 39 | 40 | __mapper_args__ = { 41 | 'polymorphic_identity': __tablename__, 42 | 'polymorphic_on': type 43 | } 44 | 45 | def do_(self): 46 | raise NotImplementedError 47 | 48 | @classmethod 49 | def pop(cls, statuses={'new'}, filters=None, session=DBSession): 50 | 51 | find_query = session.query( 52 | cls.id.label('id'), 53 | cls.created_at, 54 | cls.status, 55 | cls.type, 56 | cls.priority 57 | ) 58 | if filters is not None: 59 | find_query = find_query.filter( 60 | text(filters) if isinstance(filters, str) else filters 61 | ) 62 | 63 | find_query = find_query \ 64 | .filter(cls.status.in_(statuses)) \ 65 | .order_by(cls.priority.desc()) \ 66 | .order_by(cls.created_at) \ 67 | .limit(1) \ 68 | .with_for_update() 69 | 70 | cte = find_query.cte('find_query') 71 | 72 | update_query = RestfulpyTask.__table__.update() \ 73 | .where(RestfulpyTask.id == cte.c.id) \ 74 | .values(status='in-progress') \ 75 | .returning(RestfulpyTask.__table__.c.id) 76 | 77 | task_id = session.execute(update_query).fetchone() 78 | session.commit() 79 | if not task_id: 80 | raise TaskPopError('There is no task to pop') 81 | task_id = task_id[0] 82 | task = session.query(cls).filter(cls.id == task_id).one() 83 | return task 84 | 85 | def execute(self, context, session=DBSession): 86 | try: 87 | isolated_task = session \ 88 | .query(RestfulpyTask) \ 89 | .filter(RestfulpyTask.id == self.id) \ 90 | .one() 91 | isolated_task.do_(context) 92 | session.commit() 93 | except: 94 | session.rollback() 95 | raise 96 | 97 | @classmethod 98 | def cleanup(cls, session=DBSession, filters=None, statuses=['in-progress']): 99 | cleanup_query = session.query(RestfulpyTask) \ 100 | .filter(RestfulpyTask.status.in_(statuses)) 101 | 102 | if filters is not None: 103 | cleanup_query = cleanup_query.filter( 104 | text(filters) if isinstance(filters, str) else filters 105 | ) 106 | 107 | cleanup_query.with_for_update() \ 108 | .update({ 109 | 'status': 'new', 110 | 'started_at': None, 111 | 'terminated_at': None 112 | }, synchronize_session='fetch') 113 | 114 | @classmethod 115 | def reset_status(cls, task_id, session=DBSession, 116 | statuses=['in-progress']): 117 | session.query(RestfulpyTask) \ 118 | .filter(RestfulpyTask.status.in_(statuses)) \ 119 | .filter(RestfulpyTask.id == task_id) \ 120 | .with_for_update() \ 121 | .update({ 122 | 'status': 'new', 123 | 'started_at': None, 124 | 'terminated_at': None 125 | }, synchronize_session='fetch') 126 | 127 | 128 | def worker(statuses={'new'}, filters=None, tries=-1): 129 | isolated_session = create_thread_unsafe_session() 130 | context = {'counter': 0} 131 | tasks = [] 132 | 133 | while True: 134 | context['counter'] += 1 135 | logger.debug('Trying to pop a task, Counter: %s' % context['counter']) 136 | try: 137 | task = RestfulpyTask.pop( 138 | statuses=statuses, 139 | filters=filters, 140 | session=isolated_session 141 | ) 142 | assert task is not None 143 | 144 | except TaskPopError as ex: 145 | logger.debug('No task to pop: %s' % ex.to_json()) 146 | isolated_session.rollback() 147 | if tries > -1: 148 | tries -= 1 149 | if tries <= 0: 150 | return tasks 151 | time.sleep(settings.worker.gap) 152 | continue 153 | except: 154 | logger.error('Error when popping task.') 155 | raise 156 | 157 | try: 158 | task.execute(context) 159 | 160 | # Task success 161 | task.status = 'success' 162 | task.terminated_at = datetime.utcnow() 163 | 164 | except: 165 | logger.error('Error when executing task: %s' % task.id) 166 | task.status = 'failed' 167 | task.fail_reason = traceback.format_exc()[-4096:] 168 | 169 | finally: 170 | if isolated_session.is_active: 171 | isolated_session.commit() 172 | tasks.append((task.id, task.status)) 173 | 174 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os.path import join, dirname 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | # reading package version (without reloading it) 8 | with open(join(dirname(__file__), 'restfulpy', '__init__.py')) as v_file: 9 | package_version = re.compile(r".*__version__ = '(.*?)'", re.S) \ 10 | .match(v_file.read()) \ 11 | .group(1) 12 | 13 | 14 | dependencies = [ 15 | 'pymlconf >= 2, < 3', 16 | 'nanohttp >= 1.11.10, < 2', 17 | 'easycli >= 1.5, < 2', 18 | 'argcomplete', 19 | 'ujson', 20 | 'sqlalchemy', 21 | 'alembic', 22 | 'itsdangerous', 23 | 'psycopg2-binary', 24 | 'redis', 25 | 'python-dateutil', 26 | 27 | # Testing 28 | 'pytest', 29 | 'bddrest >= 2.5.7, < 3', 30 | 'bddcli >= 2.5, < 3' 31 | ] 32 | 33 | 34 | setup( 35 | name='restfulpy', 36 | version=package_version, 37 | description='A toolchain for developing REST APIs', 38 | author='Vahid Mardani', 39 | author_email='vahid.mardani@gmail.com', 40 | install_requires=dependencies, 41 | packages=find_packages(exclude=['tests']), 42 | include_package_data=True, 43 | license='MIT', 44 | entry_points={ 45 | 'console_scripts': [ 46 | 'restfulpy = restfulpy.cli:main' 47 | ] 48 | }, 49 | ) 50 | -------------------------------------------------------------------------------- /sphinx/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/restfulpy.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/restfulpy.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/restfulpy" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/restfulpy" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /sphinx/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pylover/restfulpy/21472af0415fffc23f8003b6074afc2de2e0b414/sphinx/_static/.keep -------------------------------------------------------------------------------- /sphinx/_templates/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pylover/restfulpy/21472af0415fffc23f8003b6074afc2de2e0b414/sphinx/_templates/.keep -------------------------------------------------------------------------------- /sphinx/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # restfulpy documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Feb 2 15:53:29 2019. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.doctest', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.todo', 36 | 'sphinx.ext.coverage', 37 | 'sphinx.ext.ifconfig', 38 | 'sphinx.ext.viewcode', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = u'restfulpy' 57 | copyright = u'2019, Vahid Mardani' 58 | author = u'Vahid Mardani' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = u'2.6.13' 66 | # The full version, including alpha/beta/rc tags. 67 | release = u'2.6.13' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # There are two options for replacing |today|: either, you set today to some 77 | # non-false value, then it is used: 78 | #today = '' 79 | # Else, today_fmt is used as the format for a strftime call. 80 | #today_fmt = '%B %d, %Y' 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | exclude_patterns = ['_build'] 85 | 86 | # The reST default role (used for this markup: `text`) to use for all 87 | # documents. 88 | #default_role = None 89 | 90 | # If true, '()' will be appended to :func: etc. cross-reference text. 91 | #add_function_parentheses = True 92 | 93 | # If true, the current module name will be prepended to all description 94 | # unit titles (such as .. function::). 95 | #add_module_names = True 96 | 97 | # If true, sectionauthor and moduleauthor directives will be shown in the 98 | # output. They are ignored by default. 99 | #show_authors = False 100 | 101 | # The name of the Pygments (syntax highlighting) style to use. 102 | pygments_style = 'sphinx' 103 | 104 | # A list of ignored prefixes for module index sorting. 105 | #modindex_common_prefix = [] 106 | 107 | # If true, keep warnings as "system message" paragraphs in the built documents. 108 | #keep_warnings = False 109 | 110 | # If true, `todo` and `todoList` produce output, else they produce nothing. 111 | todo_include_todos = True 112 | 113 | 114 | # -- Options for HTML output ---------------------------------------------- 115 | 116 | # The theme to use for HTML and HTML Help pages. See the documentation for 117 | # a list of builtin themes. 118 | html_theme = 'alabaster' 119 | 120 | # Theme options are theme-specific and customize the look and feel of a theme 121 | # further. For a list of options available for each theme, see the 122 | # documentation. 123 | #html_theme_options = {} 124 | 125 | # Add any paths that contain custom themes here, relative to this directory. 126 | #html_theme_path = [] 127 | 128 | # The name for this set of Sphinx documents. If None, it defaults to 129 | # " v documentation". 130 | #html_title = None 131 | 132 | # A shorter title for the navigation bar. Default is the same as html_title. 133 | #html_short_title = None 134 | 135 | # The name of an image file (relative to this directory) to place at the top 136 | # of the sidebar. 137 | #html_logo = None 138 | 139 | # The name of an image file (relative to this directory) to use as a favicon of 140 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 141 | # pixels large. 142 | #html_favicon = None 143 | 144 | # Add any paths that contain custom static files (such as style sheets) here, 145 | # relative to this directory. They are copied after the builtin static files, 146 | # so a file named "default.css" will overwrite the builtin "default.css". 147 | html_static_path = ['_static'] 148 | 149 | # Add any extra paths that contain custom files (such as robots.txt or 150 | # .htaccess) here, relative to this directory. These files are copied 151 | # directly to the root of the documentation. 152 | #html_extra_path = [] 153 | 154 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 155 | # using the given strftime format. 156 | #html_last_updated_fmt = '%b %d, %Y' 157 | 158 | # If true, SmartyPants will be used to convert quotes and dashes to 159 | # typographically correct entities. 160 | #html_use_smartypants = True 161 | 162 | # Custom sidebar templates, maps document names to template names. 163 | #html_sidebars = {} 164 | 165 | # Additional templates that should be rendered to pages, maps page names to 166 | # template names. 167 | #html_additional_pages = {} 168 | 169 | # If false, no module index is generated. 170 | #html_domain_indices = True 171 | 172 | # If false, no index is generated. 173 | #html_use_index = True 174 | 175 | # If true, the index is split into individual pages for each letter. 176 | #html_split_index = False 177 | 178 | # If true, links to the reST sources are added to the pages. 179 | #html_show_sourcelink = True 180 | 181 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 182 | #html_show_sphinx = True 183 | 184 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 185 | #html_show_copyright = True 186 | 187 | # If true, an OpenSearch description file will be output, and all pages will 188 | # contain a tag referring to it. The value of this option must be the 189 | # base URL from which the finished HTML is served. 190 | #html_use_opensearch = '' 191 | 192 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 193 | #html_file_suffix = None 194 | 195 | # Language to be used for generating the HTML full-text search index. 196 | # Sphinx supports the following languages: 197 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 198 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 199 | #html_search_language = 'en' 200 | 201 | # A dictionary with options for the search language support, empty by default. 202 | # Now only 'ja' uses this config value 203 | #html_search_options = {'type': 'default'} 204 | 205 | # The name of a javascript file (relative to the configuration directory) that 206 | # implements a search results scorer. If empty, the default will be used. 207 | #html_search_scorer = 'scorer.js' 208 | 209 | # Output file base name for HTML help builder. 210 | htmlhelp_basename = 'restfulpydoc' 211 | 212 | # -- Options for LaTeX output --------------------------------------------- 213 | 214 | latex_elements = { 215 | # The paper size ('letterpaper' or 'a4paper'). 216 | #'papersize': 'letterpaper', 217 | 218 | # The font size ('10pt', '11pt' or '12pt'). 219 | #'pointsize': '10pt', 220 | 221 | # Additional stuff for the LaTeX preamble. 222 | #'preamble': '', 223 | 224 | # Latex figure (float) alignment 225 | #'figure_align': 'htbp', 226 | } 227 | 228 | # Grouping the document tree into LaTeX files. List of tuples 229 | # (source start file, target name, title, 230 | # author, documentclass [howto, manual, or own class]). 231 | latex_documents = [ 232 | (master_doc, 'restfulpy.tex', u'restfulpy Documentation', 233 | u'Vahid Mardani', 'manual'), 234 | ] 235 | 236 | # The name of an image file (relative to this directory) to place at the top of 237 | # the title page. 238 | #latex_logo = None 239 | 240 | # For "manual" documents, if this is true, then toplevel headings are parts, 241 | # not chapters. 242 | #latex_use_parts = False 243 | 244 | # If true, show page references after internal links. 245 | #latex_show_pagerefs = False 246 | 247 | # If true, show URL addresses after external links. 248 | #latex_show_urls = False 249 | 250 | # Documents to append as an appendix to all manuals. 251 | #latex_appendices = [] 252 | 253 | # If false, no module index is generated. 254 | #latex_domain_indices = True 255 | 256 | 257 | # -- Options for manual page output --------------------------------------- 258 | 259 | # One entry per manual page. List of tuples 260 | # (source start file, name, description, authors, manual section). 261 | man_pages = [ 262 | (master_doc, 'restfulpy', u'restfulpy Documentation', 263 | [author], 1) 264 | ] 265 | 266 | # If true, show URL addresses after external links. 267 | #man_show_urls = False 268 | 269 | 270 | # -- Options for Texinfo output ------------------------------------------- 271 | 272 | # Grouping the document tree into Texinfo files. List of tuples 273 | # (source start file, target name, title, author, 274 | # dir menu entry, description, category) 275 | texinfo_documents = [ 276 | (master_doc, 'restfulpy', u'restfulpy Documentation', 277 | author, 'restfulpy', 'One line description of project.', 278 | 'Miscellaneous'), 279 | ] 280 | 281 | # Documents to append as an appendix to all manuals. 282 | #texinfo_appendices = [] 283 | 284 | # If false, no module index is generated. 285 | #texinfo_domain_indices = True 286 | 287 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 288 | #texinfo_show_urls = 'footnote' 289 | 290 | # If true, do not generate a @detailmenu in the "Top" node's menu. 291 | #texinfo_no_detailmenu = False 292 | 293 | 294 | # Example configuration for intersphinx: refer to the Python standard library. 295 | intersphinx_mapping = {'https://docs.python.org/': None} 296 | -------------------------------------------------------------------------------- /sphinx/index.rst: -------------------------------------------------------------------------------- 1 | .. restfulpy documentation master file, created by 2 | sphinx-quickstart on Sat Feb 2 15:53:29 2019. 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 restfulpy's documentation! 7 | ===================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pylover/restfulpy/21472af0415fffc23f8003b6074afc2de2e0b414/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from restfulpy.testing import db 2 | 3 | -------------------------------------------------------------------------------- /tests/templates/test-email-template.mako: -------------------------------------------------------------------------------- 1 | 2 | test template 3 | -------------------------------------------------------------------------------- /tests/test_appcli_configuration.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from os import path 3 | 4 | from bddcli import Given, stderr, Application, status, stdout, when, given 5 | 6 | from restfulpy import Application as RestfulpyApplication 7 | 8 | 9 | foo = RestfulpyApplication(name='Foo') 10 | app = Application('foo', 'tests.test_appcli_configuration:foo.cli_main') 11 | 12 | 13 | def test_configuration_dump(): 14 | 15 | with Given(app, 'configuration dump'): 16 | assert stderr == '' 17 | assert status == 0 18 | assert len(stdout.proxied_object) > 100 19 | 20 | filename = tempfile.mktemp() 21 | when(given + filename) 22 | assert stderr == '' 23 | assert status == 0 24 | assert path.exists(filename) 25 | 26 | -------------------------------------------------------------------------------- /tests/test_appcli_db.py: -------------------------------------------------------------------------------- 1 | from bddcli import Given, stderr, Application, when, given, status 2 | from nanohttp import settings 3 | from sqlalchemy import Integer, Unicode 4 | 5 | from restfulpy import Application as RestfulpyApplication 6 | from restfulpy.db import PostgreSQLManager as DBManager 7 | from restfulpy.orm import DeclarativeBase, Field, DBSession 8 | 9 | 10 | DBURL = 'postgresql://postgres:postgres@localhost/restfulpy_db_test' 11 | 12 | 13 | class FooModel(DeclarativeBase): 14 | __tablename__ = 'foo_model' 15 | 16 | id = Field(Integer, primary_key=True) 17 | title = Field(Unicode(50)) 18 | 19 | 20 | class FooApplication(RestfulpyApplication): 21 | __configuration__ = f''' 22 | db: 23 | url: {DBURL} 24 | ''' 25 | 26 | def insert_basedata(self, *args): 27 | DBSession.add(FooModel(title='FooBase')) 28 | DBSession.commit() 29 | 30 | def insert_mockup(self, *args): 31 | DBSession.add(FooModel(title='FooMock')) 32 | DBSession.commit() 33 | 34 | 35 | foo = FooApplication(name='Foo') 36 | app = Application('foo', 'tests.test_appcli_db:foo.cli_main') 37 | 38 | 39 | class TestDatabaseAdministrationCommandLine: 40 | db = None 41 | 42 | @classmethod 43 | def setup_class(cls): 44 | foo.configure(force=True) 45 | cls.db = DBManager(DBURL) 46 | cls.db.__enter__() 47 | 48 | @classmethod 49 | def teardown_class(cls): 50 | cls.db.__exit__(None, None, None) 51 | 52 | def test_database_create(self): 53 | 54 | self.db.drop_database() 55 | assert not self.db.database_exists() 56 | 57 | with Given(app, ['db', 'create']): 58 | assert stderr == '' 59 | assert status == 0 60 | assert self.db.database_exists() 61 | assert not self.db.table_exists(FooModel.__tablename__) 62 | 63 | when(given + '--drop --schema') 64 | assert stderr == '' 65 | assert status == 0 66 | assert self.db.database_exists() 67 | assert self.db.table_exists(FooModel.__tablename__) 68 | 69 | when(given + '--drop --mockup') 70 | assert stderr == '' 71 | assert status == 0 72 | with self.db.cursor( 73 | f'SELECT count(*) FROM foo_model WHERE title = %s', 74 | ('FooMock', ) 75 | ) as c: 76 | assert c.fetchone()[0] == 1 77 | 78 | when(given + '--drop --basedata') 79 | assert stderr == '' 80 | assert status == 0 81 | with self.db.cursor( 82 | f'SELECT count(*) FROM foo_model WHERE title = %s', 83 | ('FooBase', ) 84 | ) as c: 85 | assert c.fetchone()[0] == 1 86 | 87 | def test_database_drop(self): 88 | 89 | self.db.create_database(exist_ok=True) 90 | assert self.db.database_exists() 91 | 92 | with Given(app, ['db', 'drop']): 93 | assert stderr == '' 94 | assert status == 0 95 | assert not self.db.database_exists() 96 | 97 | def test_database_schema_basedata_mockup(self): 98 | 99 | self.db.drop_database() 100 | self.db.create_database() 101 | assert self.db.database_exists() 102 | 103 | with Given(app, ['db', 'schema']): 104 | assert stderr == '' 105 | assert status == 0 106 | assert self.db.table_exists(FooModel.__tablename__) 107 | 108 | when(['db', 'basedata']) 109 | assert stderr == '' 110 | assert status == 0 111 | with self.db.cursor( 112 | f'SELECT count(*) FROM foo_model WHERE title = %s', 113 | ('FooBase', ) 114 | ) as c: 115 | assert c.fetchone()[0] == 1 116 | 117 | when(['db', 'mockup']) 118 | assert stderr == '' 119 | assert status == 0 120 | with self.db.cursor( 121 | f'SELECT count(*) FROM foo_model WHERE title = %s', 122 | ('FooMock', ) 123 | ) as c: 124 | assert c.fetchone()[0] == 1 125 | 126 | 127 | if __name__ == '__main__': # pragma: no cover 128 | # Use for debugging 129 | foo.cli_main(['db', 'create']) 130 | -------------------------------------------------------------------------------- /tests/test_appcli_jwt.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | from bddcli import Given, given, when, stdout, stderr, Application, status 5 | 6 | from restfulpy import Application as RestfulpyApplication 7 | 8 | 9 | foo = RestfulpyApplication(name='jwt') 10 | app = Application('foo', 'tests.test_appcli_jwt:foo.cli_main') 11 | 12 | 13 | def test_jwt(): 14 | with Given(app, 'jwt create'): 15 | assert stderr == '' 16 | assert status == 0 17 | assert len(stdout) > 10 18 | 19 | when(given + '\'{"foo": 1}\'') 20 | assert stderr == '' 21 | assert status == 0 22 | header, payload, signature = stdout.encode().split(b'.') 23 | payload = base64.urlsafe_b64decode(payload) 24 | assert json.loads(payload) == {'foo': 1} 25 | 26 | 27 | if __name__ == '__main__': # pragma: no cover 28 | foo.cli_main(['jwt', 'create', '{"foo": 1}']) 29 | 30 | -------------------------------------------------------------------------------- /tests/test_appcli_migrate.py: -------------------------------------------------------------------------------- 1 | from bddcli import Given, stderr, Application, status 2 | from restfulpy import Application as RestfulpyApplication 3 | 4 | 5 | foo = RestfulpyApplication(name='migration') 6 | app = Application('foo', 'tests.test_appcli_migrate:foo.cli_main') 7 | 8 | 9 | def test_migrate(): 10 | with Given(app, 'migrate'): 11 | assert stderr.startswith('usage: foo') 12 | assert status == 2 13 | 14 | 15 | if __name__ == '__main__': # pragma: no cover 16 | foo.cli_main(['migrate', '--help']) 17 | 18 | -------------------------------------------------------------------------------- /tests/test_appcli_root.py: -------------------------------------------------------------------------------- 1 | from bddcli import Given, given, when, stdout, stderr, Application, status 2 | 3 | from restfulpy import Application as RestfulpyApplication 4 | 5 | 6 | foo = RestfulpyApplication(name='Foo') 7 | app = Application('foo', 'tests.test_appcli_jwt:foo.cli_main') 8 | 9 | 10 | def test_appcli_root(): 11 | with Given(app): 12 | assert stderr == '' 13 | assert status == 0 14 | assert stdout == EXPECTED_HELP 15 | 16 | 17 | EXPECTED_HELP = '''\ 18 | usage: foo [-h] [-p PREFIX] [-c FILE] 19 | {configuration,db,jwt,migrate,worker,completion} ... 20 | 21 | optional arguments: 22 | -h, --help show this help message and exit 23 | -p PREFIX, --process-name PREFIX 24 | A string indicates the name for this process. 25 | -c FILE, --config-file FILE 26 | Configuration file, Default: none 27 | 28 | Sub commands: 29 | {configuration,db,jwt,migrate,worker,completion} 30 | configuration Configuration tools 31 | db Database administrationn 32 | jwt JWT management 33 | migrate Executes the alembic command 34 | worker Task queue administration 35 | completion Bash auto completion using argcomplete python package. 36 | ''' 37 | 38 | 39 | if __name__ == '__main__': # pragma: no cover 40 | foo.cli_main([]) 41 | 42 | -------------------------------------------------------------------------------- /tests/test_appcli_worker.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from bddcli import Given, stderr, Application, status, when, story, \ 4 | given 5 | 6 | from restfulpy import Application as RestfulpyApplication 7 | from restfulpy.taskqueue import RestfulpyTask 8 | 9 | 10 | DBURL = 'postgresql://postgres:postgres@localhost/restfulpy_test' 11 | 12 | 13 | class WorkerTask(RestfulpyTask): 14 | 15 | __mapper_args__ = { 16 | 'polymorphic_identity': 'worker_task' 17 | } 18 | 19 | def do_(self, context): 20 | _task_done.set() 21 | 22 | 23 | class FooApplication(RestfulpyApplication): 24 | __configuration__ = f''' 25 | db: 26 | url: {DBURL} 27 | ''' 28 | 29 | 30 | foo = FooApplication(name='Foo') 31 | app = Application('foo', 'tests.test_appcli_worker:foo.cli_main') 32 | 33 | 34 | def test_appcli_worker_cleanup(db): 35 | with Given(app, 'worker cleanup'): 36 | assert stderr == '' 37 | assert status == 0 38 | 39 | 40 | def test_appcli_worker_start(db): 41 | session = db() 42 | task = WorkerTask() 43 | session.add(task) 44 | session.commit() 45 | 46 | with Given(app, 'worker start', nowait=True): 47 | time.sleep(2) 48 | story.kill() 49 | story.wait() 50 | assert status == -15 51 | 52 | when(given + '--gap 1') 53 | time.sleep(2) 54 | story.kill() 55 | story.wait() 56 | assert status == -15 57 | 58 | 59 | if __name__ == '__main__': # pragma: no cover 60 | foo.cli_main(['migrate', '--help']) 61 | 62 | -------------------------------------------------------------------------------- /tests/test_application.py: -------------------------------------------------------------------------------- 1 | from tempfile import mktemp 2 | 3 | from bddrest.authoring import response 4 | from nanohttp import action, settings 5 | 6 | from restfulpy.controllers import RootController 7 | from restfulpy.testing import ApplicableTestCase 8 | from restfulpy import Application as RestfulpyApplication 9 | 10 | 11 | class Root(RootController): 12 | 13 | @action 14 | def index(self): 15 | return 'Index' 16 | 17 | 18 | foo = RestfulpyApplication('Foo', Root()) 19 | 20 | 21 | class TestApplication(ApplicableTestCase): 22 | __application__ = foo 23 | 24 | def test_index(self): 25 | with self.given('Test application root', '/', 'GET'): 26 | assert response.body == b'Index' 27 | assert response.status == '200 OK' 28 | 29 | def test_options(self): 30 | with self.given('Test OPTIONS verb', '/', 'OPTIONS'): 31 | assert response.headers['Cache-Control'] == 'no-cache,no-store' 32 | 33 | def test_application_configure(self): 34 | foo.configure(context={'a': 1}, force=True) 35 | assert settings.debug == True 36 | 37 | def test_application_configure_file(self): 38 | filename = mktemp() 39 | content = b'foo:\n bar: baz\n' 40 | 41 | with open(filename, 'wb') as f: 42 | f.write(content) 43 | 44 | foo.configure(filename=filename, force=True) 45 | assert settings.foo.bar == 'baz' 46 | 47 | -------------------------------------------------------------------------------- /tests/test_authenticator.py: -------------------------------------------------------------------------------- 1 | from freezegun import freeze_time 2 | from bddrest.authoring import response, when, status 3 | from nanohttp import json, Controller, context, HTTPBadRequest, settings 4 | 5 | from restfulpy.application import Application 6 | from restfulpy.authentication import Authenticator 7 | from restfulpy.authorization import authorize 8 | from restfulpy.principal import JWTPrincipal, JWTRefreshToken 9 | from restfulpy.testing import ApplicableTestCase 10 | 11 | 12 | token_expired = False 13 | 14 | 15 | class MockupMember: 16 | def __init__(self, **kwargs): 17 | self.__dict__.update(kwargs) 18 | 19 | 20 | class MockupStatelessAuthenticator(Authenticator): 21 | def validate_credentials(self, credentials): 22 | email, password = credentials 23 | if password == 'test': 24 | return MockupMember(id=1, email=email, roles=['admin', 'test']) 25 | 26 | def create_refresh_principal(self, member_id=None): 27 | return JWTRefreshToken(dict( 28 | id=member_id 29 | )) 30 | 31 | def create_principal(self, member_id=None, session_id=None): 32 | return JWTPrincipal(dict( 33 | id=1, 34 | email='test@example.com', 35 | roles=['admin', 'test'], 36 | sessionId='1') 37 | ) 38 | 39 | 40 | class Root(Controller): 41 | @json 42 | def index(self): 43 | return context.form 44 | 45 | @json(verbs='post') 46 | def login(self): 47 | principal = context.application.__authenticator__.login( 48 | (context.form['email'], context.form['password']) 49 | ) 50 | if principal: 51 | return dict(token=principal.dump()) 52 | raise HTTPBadRequest() 53 | 54 | @json 55 | @authorize 56 | def me(self): 57 | return context.identity.payload 58 | 59 | @json() 60 | @authorize 61 | def logout(self): 62 | context.application.__authenticator__.logout() 63 | return {} 64 | 65 | @json 66 | @authorize('god') 67 | def kill(self): # pragma: no cover 68 | context.application.__authenticator__.logout() 69 | return {} 70 | 71 | 72 | class TestAuthenticator(ApplicableTestCase): 73 | __application__ = Application( 74 | 'Authenticator', 75 | Root(), 76 | authenticator=MockupStatelessAuthenticator() 77 | ) 78 | 79 | __configuration__ = (''' 80 | jwt: 81 | max_age: 10 82 | refresh_token: 83 | max_age: 20 84 | secure: false 85 | path: / 86 | ''') 87 | 88 | def test_login(self): 89 | logintime = freeze_time("2000-01-01T01:01:00") 90 | latetime = freeze_time("2000-01-01T01:01:12") 91 | with logintime, self.given( 92 | 'Loggin in to get a token', 93 | '/login', 94 | 'POST', 95 | form=dict(email='test@example.com', password='test') 96 | ): 97 | assert status == '200 OK' 98 | assert response.headers['X-Identity'] == '1' 99 | assert 'token' in response.json 100 | token = response.json['token'] 101 | 102 | when( 103 | 'Password is incorrect', 104 | form=dict(email='test@example.com', password='invalid') 105 | ) 106 | assert status == '400 Bad Request' 107 | 108 | with logintime, self.given( 109 | 'Trying to access a protected resource with the token', 110 | '/me', 111 | authorization=token 112 | ): 113 | assert response.headers['X-Identity'] == '1' 114 | 115 | when('Token is broken', authorization='bad') 116 | assert status == 400 117 | 118 | when('Token is empty', url='/me', authorization='') 119 | assert status == 401 120 | 121 | when('Token is blank', url='/me', authorization=' ') 122 | assert status == 401 123 | 124 | with latetime, self.given( 125 | 'Try to access a protected resource when session is expired', 126 | '/me', 127 | form=dict(a='a', b='b'), 128 | authorization=token 129 | ): 130 | assert status == 401 131 | 132 | 133 | def test_logout(self): 134 | self.login( 135 | dict( 136 | email='test@example.com', 137 | password='test' 138 | ), 139 | url='/login', 140 | verb='POST' 141 | ) 142 | 143 | with self.given( 144 | 'Logging out', 145 | '/logout', 146 | ): 147 | assert status == '200 OK' 148 | assert 'X-Identity' not in response.headers 149 | 150 | def test_refresh_token(self): 151 | logintime = freeze_time("2000-01-01T01:01:00") 152 | latetime = freeze_time("2000-01-01T01:01:12") 153 | verylatetime = freeze_time("2000-01-01T01:02:00") 154 | self.logout() 155 | with logintime, self.given( 156 | 'Loggin in to get a token', 157 | '/login', 158 | 'POST', 159 | form=dict(email='test@example.com', password='test') 160 | ): 161 | refresh_token = response.headers['Set-Cookie'].split('; ')[0] 162 | assert 'token' in response.json 163 | assert refresh_token.startswith('refresh-token=') 164 | token = response.json['token'] 165 | 166 | 167 | # Request a protected resource after the token has been expired, 168 | # with broken cookies 169 | with latetime, self.given( 170 | 'Refresh token is broken', 171 | '/me', 172 | authorization=token, 173 | headers={ 174 | 'Cookie': 'refresh-token=broken-data' 175 | } 176 | ): 177 | assert status == 400 178 | 179 | # Request a protected resource after the token has been expired, 180 | # with empty cookies 181 | when( 182 | 'Refresh token is empty', 183 | headers={ 184 | 'Cookie': 'refresh-token' 185 | } 186 | ) 187 | assert status == 401 188 | 189 | # Request a protected resource after the token has been expired, 190 | # without the cookies 191 | when( 192 | 'Without the cookies', 193 | headers=None 194 | ) 195 | assert status == 401 196 | 197 | # Request a protected resource after the token has been expired, 198 | # with appropriate cookies. 199 | when( 200 | 'With appropriate cookies', 201 | headers={ 202 | 'Cookie': refresh_token 203 | } 204 | ) 205 | assert 'X-New-JWT-Token' in response.headers 206 | assert response.headers['X-New-JWT-Token'] is not None 207 | 208 | when( 209 | 'With invalid refresh token', 210 | headers={ 211 | 'Cookie': 'refresh-token=InvalidToken' 212 | } 213 | ) 214 | assert status == 400 215 | 216 | when( 217 | 'With empty refresh token', 218 | headers={ 219 | 'Cookie': 'refresh-token=' 220 | } 221 | ) 222 | assert status == 401 223 | 224 | with verylatetime, self.given( 225 | 'When refresh token is expired', 226 | '/me', 227 | authorization=token, 228 | headers={ 229 | 'Cookie': refresh_token 230 | } 231 | ): 232 | 233 | assert status == 401 234 | 235 | def test_authorization(self): 236 | self.login( 237 | dict( 238 | email='test@example.com', 239 | password='test' 240 | ), 241 | url='/login' 242 | ) 243 | 244 | with self.given( 245 | 'Access forbidden', 246 | url='/kill', 247 | verb='GET' 248 | ): 249 | assert status == 403 250 | 251 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from bddcli import Given, stderr, Application, status, when, story, \ 4 | given, stdout 5 | 6 | import restfulpy 7 | from restfulpy import Application as RestfulpyApplication 8 | from restfulpy.taskqueue import RestfulpyTask 9 | 10 | 11 | app = Application('restfulpy', 'restfulpy.cli:main') 12 | 13 | 14 | def test_restfulpy_cli(db): 15 | with Given(app): 16 | assert stdout.startswith('usage') 17 | assert status == 0 18 | 19 | when(given + '--version') 20 | assert stdout == f'{restfulpy.__version__}\n' 21 | assert status == 0 22 | 23 | 24 | if __name__ == '__main__': # pragma: no cover 25 | foo.cli_main(['migrate', '--help']) 26 | 27 | -------------------------------------------------------------------------------- /tests/test_commit_decorator.py: -------------------------------------------------------------------------------- 1 | from bddrest import response, when 2 | from nanohttp import json, RestController, context, HTTPStatus 3 | from sqlalchemy import Unicode, Integer 4 | 5 | from restfulpy.controllers import JSONPatchControllerMixin 6 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 7 | from restfulpy.testing import ApplicableTestCase 8 | 9 | 10 | class CommitCheckingModel(DeclarativeBase): 11 | __tablename__ = 'commit_checking_model' 12 | id = Field(Integer, primary_key=True) 13 | title = Field(Unicode(50), unique=True) 14 | 15 | 16 | class Root(JSONPatchControllerMixin, RestController): 17 | 18 | @json 19 | @commit 20 | def post(self): 21 | m = CommitCheckingModel() 22 | m.title = context.form['title'] 23 | DBSession.add(m) 24 | return m 25 | 26 | @json 27 | @commit 28 | def success(self): 29 | m = CommitCheckingModel() 30 | m.title = context.form['title'] 31 | DBSession.add(m) 32 | raise HTTPStatus('200 OK') 33 | 34 | @json 35 | @commit 36 | def redirect(self): 37 | m = CommitCheckingModel() 38 | m.title = context.form['title'] 39 | DBSession.add(m) 40 | raise HTTPStatus('300 Redirect') 41 | 42 | @json 43 | def get(self, title: str=None): 44 | m = DBSession.query(CommitCheckingModel).\ 45 | filter(CommitCheckingModel.title == title).one() 46 | return m 47 | 48 | @json 49 | @commit 50 | def error(self): 51 | m = CommitCheckingModel() 52 | m.title = 'Error' 53 | DBSession.add(m) 54 | raise Exception() 55 | 56 | 57 | class TestCommitDecorator(ApplicableTestCase): 58 | __controller_factory__ = Root 59 | 60 | def test_commit_decorator(self): 61 | with self.given( 62 | 'Testing the operation of commit decorator', 63 | verb='POST', 64 | url='/', 65 | form=dict(title='first') 66 | ): 67 | when('Geting the result of appling commit decorator', 68 | verb='GET', 69 | url='/first' 70 | ) 71 | assert response.json['title'] == 'first' 72 | assert response.json['id'] == 1 73 | 74 | def test_commit_decorator_and_json_patch(self): 75 | with self.given( 76 | 'The commit decorator should not to do anything if the request\ 77 | is a jsonpatch.', 78 | verb='PATCH', 79 | url='/', 80 | json=[ 81 | dict(op='post', path='', value=dict(title='second')), 82 | dict(op='post', path='', value=dict(title='third')) 83 | ]): 84 | when('Inset form parameter to body', verb='GET', url='/third') 85 | assert response.json['title'] == 'third' 86 | 87 | def test_rollback(self): 88 | with self.given('Raise exception', verb='ERROR', url='/'): 89 | assert response.status == 500 90 | 91 | def test_commit_on_raise_http_success(self): 92 | with self.given( 93 | 'Testing the operation of commit decorator on raise 2xx', 94 | verb='SUCCESS', 95 | url='/', 96 | form=dict(title='HTTPSuccess') 97 | ): 98 | when( 99 | 'Geting the result of appling commit decorator', 100 | verb='GET', 101 | url='/HTTPSuccess' 102 | ) 103 | assert response.json['title'] == 'HTTPSuccess' 104 | 105 | def test_commit_on_raise_http_redirect(self): 106 | with self.given( 107 | 'Testing the operation of commit decorator on raise 3xx', 108 | verb='REDIRECT', 109 | url='/', 110 | form=dict(title='HTTPRedirect') 111 | ): 112 | when( 113 | 'Geting the result of appling commit decorator', 114 | verb='GET', 115 | url='/HTTPRedirect' 116 | ) 117 | assert response.json['title'] == 'HTTPRedirect' 118 | 119 | -------------------------------------------------------------------------------- /tests/test_console_messenger.py: -------------------------------------------------------------------------------- 1 | import io 2 | from os.path import dirname, abspath, join 3 | 4 | import pytest 5 | from nanohttp import configure, settings 6 | 7 | from restfulpy.messaging.providers import ConsoleMessenger 8 | 9 | 10 | HERE = abspath(dirname(__file__)) 11 | 12 | 13 | class TestSMTPProvider: 14 | __configuration__ = f''' 15 | messaging: 16 | mako_modules_directory: {join(HERE, '../../data', 'mako_modules')} 17 | template_directories: 18 | - {join(HERE, 'templates')} 19 | ''' 20 | 21 | @classmethod 22 | def setup_class(cls): 23 | configure(force=True) 24 | settings.merge(cls.__configuration__) 25 | 26 | def test_console_messenger(self): 27 | # Without templates 28 | ConsoleMessenger().send( 29 | 'test@example.com', 30 | 'test@example.com', 31 | 'Simple test body', 32 | cc='test@example.com', 33 | bcc='test@example.com' 34 | ) 35 | 36 | # With template 37 | ConsoleMessenger().send( 38 | 'test@example.com', 39 | 'test@example.com', 40 | {}, 41 | template_filename='test-email-template.mako' 42 | ) 43 | 44 | # With attachments 45 | attachment = io.BytesIO(b'This is test attachment file') 46 | attachment.name = 'path/to/file.txt' 47 | ConsoleMessenger().send( 48 | 'test@example.com', 49 | 'test@example.com', 50 | 'email body with Attachment', 51 | attachments=[attachment] 52 | ) 53 | 54 | settings.messaging.template_directories = None 55 | with pytest.raises(ValueError): 56 | ConsoleMessenger().send( 57 | 'test@example.com', 58 | 'test@example.com', 59 | {}, 60 | template_filename='test-email-template.mako' 61 | ) 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /tests/test_date.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | import pytest 4 | from bddrest import response, when, status 5 | from dateutil.tz import tzoffset 6 | from nanohttp import json 7 | from sqlalchemy import Integer, Date 8 | 9 | from restfulpy.configuration import settings 10 | from restfulpy.controllers import JSONPatchControllerMixin, ModelRestController 11 | from restfulpy.datetimehelpers import parse_datetime, format_datetime, \ 12 | parse_date, format_date 13 | from restfulpy.mockup import mockup_localtimezone 14 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 15 | from restfulpy.testing import ApplicableTestCase 16 | 17 | 18 | class Party(DeclarativeBase): 19 | __tablename__ = 'party' 20 | id = Field(Integer, primary_key=True) 21 | when = Field(Date) 22 | 23 | 24 | class Root(JSONPatchControllerMixin, ModelRestController): 25 | __model__ = 'party' 26 | 27 | @json 28 | @commit 29 | def post(self): 30 | m = Party() 31 | m.update_from_request() 32 | DBSession.add(m) 33 | return m.when.isoformat() 34 | 35 | 36 | class TestDate(ApplicableTestCase): 37 | __controller_factory__ = Root 38 | 39 | def test_update_from_request(self): 40 | with self.given( 41 | 'Posting a date in form', 42 | verb='POST', 43 | form=dict( 44 | when='2001-01-01', 45 | ) 46 | ): 47 | assert status == 200 48 | assert response.json == '2001-01-01' 49 | 50 | when( 51 | 'Posix time format', 52 | form=dict( 53 | when='1513434403' 54 | ) 55 | ) 56 | assert status == 200 57 | assert response.json == '2017-12-16' 58 | 59 | when( 60 | 'Posting a datetime instead of date', 61 | form=dict( 62 | when='2001-01-01T00:01:00.123456' 63 | ) 64 | ) 65 | assert status == 200 66 | assert response.json == '2001-01-01' 67 | 68 | when( 69 | 'Posting an invalid datetime', 70 | form=dict( 71 | when='2001-00-01' 72 | ) 73 | ) 74 | assert status == '400 Invalid date or time: 2001-00-01' 75 | 76 | def test_date_parsing(self): 77 | assert date(1970, 1, 1) == parse_date('1970-01-01') 78 | assert date(1970, 1, 1) == parse_date('1970-01-01T00:00:00.001') 79 | assert date(1970, 1, 1) == parse_date('1970-01-01T00:00:00.001000') 80 | 81 | def test_date_formatting(self): 82 | assert '1970-01-01' == format_date(date(1970, 1, 1)) 83 | assert '1970-01-01' == format_date(datetime(1970, 1, 1)) 84 | 85 | def test_unix_timestamp(self): 86 | assert date(1970, 1, 1) == parse_date('1.3343') 87 | 88 | -------------------------------------------------------------------------------- /tests/test_datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, time, date 2 | 3 | import pytest 4 | from bddrest import response, when, status 5 | from dateutil.tz import tzoffset, tzstr 6 | from nanohttp import json 7 | from sqlalchemy import Integer, DateTime 8 | 9 | from freezegun import freeze_time 10 | from restfulpy.configuration import settings 11 | from restfulpy.controllers import JSONPatchControllerMixin, ModelRestController 12 | from restfulpy.datetimehelpers import parse_datetime, format_datetime, \ 13 | localnow, parse_time, localtimezone 14 | from restfulpy.mockup import mockup_localtimezone 15 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 16 | from restfulpy.testing import ApplicableTestCase 17 | 18 | 19 | class Metting(DeclarativeBase): 20 | __tablename__ = 'metting' 21 | id = Field(Integer, primary_key=True) 22 | when = Field(DateTime) 23 | 24 | 25 | class Root(JSONPatchControllerMixin, ModelRestController): 26 | __model__ = 'metting' 27 | 28 | @json 29 | @commit 30 | def post(self): 31 | m = Metting() 32 | m.update_from_request() 33 | DBSession.add(m) 34 | return m.when.isoformat() 35 | 36 | 37 | class TestDateTime(ApplicableTestCase): 38 | __controller_factory__ = Root 39 | 40 | def test_update_from_request(self): 41 | with self.given( 42 | 'Posting a datetime in form', 43 | verb='POST', 44 | form=dict( 45 | when='2001-01-01T00:01', 46 | ) 47 | ): 48 | assert status == 200 49 | assert response.json == '2001-01-01T00:01:00' 50 | 51 | when( 52 | 'Posting a date instead of datetime', 53 | form=dict( 54 | when='2001-01-01' 55 | ) 56 | ) 57 | 58 | assert status == 200 59 | assert response.json == '2001-01-01T00:00:00' 60 | 61 | when( 62 | 'Posting an invalid datetime', 63 | form=dict( 64 | when='2001-00-01' 65 | ) 66 | ) 67 | 68 | assert status == '400 Invalid date or time: 2001-00-01' 69 | 70 | def test_naive_datetime_parsing(self): 71 | # The application is configured to use system's local date and time. 72 | settings.timezone = None 73 | 74 | # Submit without timezone: accept and assume the local date and time. 75 | assert datetime(1970, 1, 1) == parse_datetime('1970-01-01') 76 | assert datetime(1970, 1, 1) == parse_datetime('1970-01-01T00:00:00') 77 | assert datetime(1970, 1, 1, microsecond=1000) == \ 78 | parse_datetime('1970-01-01T00:00:00.001') 79 | assert datetime(1970, 1, 1, microsecond=1000) == \ 80 | parse_datetime('1970-01-01T00:00:00.001000') 81 | 82 | # Timezone aware 83 | # Submit with 'Z' and or '+3:30': 84 | # accept and assume as the UTC, so we have to convert 85 | # it to local date and time before continuing the rest of process 86 | with mockup_localtimezone(tzoffset(None, 3600)): 87 | assert datetime(1970, 1, 1, 1) == \ 88 | parse_datetime('1970-01-01T00:00:00Z') 89 | assert datetime(1970, 1, 1, 1, 30) == \ 90 | parse_datetime('1970-01-01T00:00:00-0:30') 91 | 92 | def test_timezone_aware_datetime_parsing(self): 93 | # The application is configured to use a specific timezone 94 | settings.timezone = tzoffset('Tehran', 12600) 95 | with pytest.raises(ValueError): 96 | parse_datetime('1970-01-01T00:00:00') 97 | 98 | assert datetime(1970, 1, 1, 3, 30, tzinfo=tzoffset( 99 | 'Tehran', 12600)) == parse_datetime('1970-01-01T00:00:00Z') 100 | assert datetime(1970, 1, 1, 4, 30, tzinfo=tzoffset( 101 | 'Tehran', 12600)) == parse_datetime('1970-01-01T00:00:00-1:00') 102 | 103 | def test_naive_datetime_formatting(self): 104 | # The application is configured to use system's local date and time. 105 | settings.timezone = None 106 | 107 | assert '1970-01-01T00:00:00' == format_datetime(datetime(1970, 1, 1)) 108 | assert '1970-01-01T00:00:00.000001' == \ 109 | format_datetime(datetime(1970, 1, 1, 0, 0, 0, 1)) 110 | 111 | with mockup_localtimezone(tzoffset(None, 3600)): 112 | assert '1970-01-01T00:00:00' == format_datetime( 113 | datetime(1970, 1, 1, tzinfo=tzoffset(None, 3600)) 114 | ) 115 | 116 | assert '1970-01-01T00:00:00' == format_datetime(date(1970, 1, 1)) 117 | 118 | def test_timezone_aware_datetime_formatting(self): 119 | # The application is configured to use a specific timezone as the 120 | # default 121 | settings.timezone = tzoffset('Tehran', 12600) 122 | 123 | with pytest.raises(ValueError): 124 | format_datetime(datetime(1970, 1, 1)) 125 | 126 | assert '1970-01-01T00:00:00+03:30' == \ 127 | format_datetime(datetime(1970, 1, 1, tzinfo=tzoffset(None, 12600))) 128 | 129 | assert '1970-01-01T00:00:00+03:30' == format_datetime(date(1970, 1, 1)) 130 | 131 | def test_different_timezone_formatting(self): 132 | settings.timezone = tzoffset('Tehran', 12600) 133 | assert '1970-01-01T06:31:00+03:30' == \ 134 | format_datetime(datetime(1970, 1, 1, 1, 1, tzinfo=tzstr('GMT-2'))) 135 | 136 | def test_naive_unix_timestamp(self): 137 | # The application is configured to use system's local date and time. 138 | settings.timezone = None 139 | 140 | assert datetime(1970,1,1,0,0,1,334300) == \ 141 | parse_datetime('1.3343') 142 | assert datetime(1970,1,1,0,0,1,334300) == \ 143 | parse_datetime('1.3343') 144 | 145 | def test_timezone_aware_unix_timestamp(self): 146 | # The application is configured to use a specific timezone as the 147 | # default 148 | settings.timezone = tzoffset('Tehran', 12600) 149 | dt = parse_datetime('1.3343') 150 | assert dt == datetime( 151 | 1970, 1, 1, 3, 30, 1, 334300, tzinfo=tzoffset('Tehran', 12600) 152 | ) 153 | assert dt.utcoffset() == timedelta(0, 12600) 154 | 155 | def test_timezone_named(self): 156 | # The application is configured to use a named timzone 157 | settings.timezone = 'utc' 158 | dt = parse_datetime('1.3343') 159 | assert dt == datetime( 160 | 1970, 1, 1, 3, 30, 1, 334300, tzinfo=tzoffset('Tehran', 12600) 161 | ) 162 | assert dt.utcoffset() == timedelta(0) 163 | 164 | def test_timezone_string(self): 165 | # The application is configured to use a named timzone 166 | settings.timezone = 'GMT+3' 167 | dt = parse_datetime('1.3343') 168 | assert dt == datetime( 169 | 1970, 1, 1, 3, 30, 1, 334300, tzinfo=tzoffset('Tehran', 12600) 170 | ) 171 | assert dt.utcoffset() == timedelta(0, 10800) 172 | 173 | def test_local_datetime(self): 174 | # The application is configured to use a named timzone 175 | settings.timezone = 'GMT+3' 176 | with freeze_time('2000-01-01T01:01:00'): 177 | now = localnow() 178 | assert now == datetime(2000, 1, 1, 4, 1, tzinfo=tzstr('GMT+3')) 179 | assert now.utcoffset() == timedelta(0, 10800) 180 | 181 | def test_parse_time_posix_timestamp(self): 182 | assert parse_time(1000000.11) == time(13, 46, 40, 110000) 183 | assert parse_time('1000000.11') == time(13, 46, 40, 110000) 184 | 185 | def test_localtimezone(self): 186 | assert localtimezone() is not None 187 | 188 | -------------------------------------------------------------------------------- /tests/test_documentaion.py: -------------------------------------------------------------------------------- 1 | from os import path, mkdir 2 | 3 | from bddrest.authoring import response, status 4 | from nanohttp import action, RegexRouteController 5 | 6 | from restfulpy.controllers import RootController 7 | from restfulpy.testing import ApplicableTestCase 8 | 9 | 10 | HERE = path.abspath(path.dirname(__file__)) 11 | DATA_DIRECTORY = path.abspath(path.join(HERE, '../data')) 12 | 13 | 14 | class Root(RegexRouteController): 15 | 16 | def __init__(self): 17 | return super().__init__([ 18 | ('/apiv1/documents', self.get), ('/', self.index) 19 | ]) 20 | 21 | @action 22 | def get(self): 23 | return 'Index' 24 | 25 | @action 26 | def index(self): 27 | return 'Index' 28 | 29 | 30 | class TestAutoDocumentation(ApplicableTestCase): 31 | __controller_factory__ = Root 32 | __story_directory__ = path.join(DATA_DIRECTORY, 'stories') 33 | __api_documentation_directory__ = path.join(DATA_DIRECTORY, 'markdown') 34 | __metadata__ = { 35 | '/': dict(a=dict(not_none=True, required=True)) 36 | } 37 | 38 | def test_nested_url(self): 39 | with self.given( 40 | 'There is a / in the title', 41 | '/apiv1/documents', 42 | 'GET' 43 | ): 44 | assert status == 200 45 | assert response.body == b'Index' 46 | 47 | def test_root_request(self): 48 | with self.given( 49 | 'Requesting on the root controller', 50 | '/', 51 | 'INDEX', 52 | form=dict(a=1) 53 | ): 54 | assert status == 200 55 | 56 | def test_with_url_parameters(self): 57 | with self.given( 58 | 'There is a url parameter', 59 | '/apiv1/documents/id: 2', 60 | 'GET' 61 | ): 62 | assert status == 200 63 | assert response.body == b'Index' 64 | 65 | with self.given( 66 | 'There is a url parameter and nested resource', 67 | '/apiv1/documents/id: 2/authors', 68 | 'GET' 69 | ): 70 | assert status == 200 71 | assert response.body == b'Index' 72 | 73 | 74 | -------------------------------------------------------------------------------- /tests/test_fulltext_search.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Unicode 2 | 3 | from restfulpy.orm import DeclarativeBase, Field, FullTextSearchMixin, \ 4 | fts_escape, to_tsvector 5 | 6 | 7 | class FullTextSearchObject(FullTextSearchMixin, DeclarativeBase): 8 | __tablename__ = 'fulltext_search_object' 9 | 10 | id = Field(Integer, primary_key=True) 11 | title = Field(Unicode(50)) 12 | 13 | __ts_vector__ = to_tsvector( 14 | title 15 | ) 16 | 17 | 18 | def test_fts_escape(db): 19 | result = fts_escape('&%!^$*[](){}\\') 20 | assert result == r'\&\%\!\^\$\*\[\]\(\)\{\}\\' 21 | 22 | 23 | def test_to_tsvector(db): 24 | result = to_tsvector('a', 'b', 'c') 25 | assert str(result) == 'to_tsvector(:to_tsvector_1, :to_tsvector_2)' 26 | 27 | 28 | def test_activation_mixin(db): 29 | session = db() 30 | query = session.query(FullTextSearchObject) 31 | query = FullTextSearchObject.search('a', query) 32 | query_str = str(query) 33 | 34 | assert str(query) == \ 35 | 'SELECT fulltext_search_object.id AS fulltext_search_object_id, '\ 36 | 'fulltext_search_object.title AS fulltext_search_object_title \nFROM '\ 37 | 'fulltext_search_object \nWHERE to_tsvector(%(to_tsvector_1)s, '\ 38 | 'fulltext_search_object.title) @@ to_tsquery(%(to_tsvector_2)s)' 39 | 40 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import io 2 | from tempfile import mktemp 3 | from os import mkdir 4 | from os.path import dirname, abspath, join, exists 5 | 6 | from restfulpy.helpers import import_python_module_by_filename, \ 7 | construct_class_by_name, copy_stream, md5sum, to_camel_case, \ 8 | encode_multipart_data, split_url, noneifnone 9 | 10 | 11 | HERE = abspath(dirname(__file__)) 12 | DATA_DIR = join(HERE, 'data') 13 | 14 | 15 | if not exists(DATA_DIR): 16 | mkdir(DATA_DIR) 17 | 18 | 19 | class MyClassToConstructByName: 20 | def __init__(self, a): 21 | self.a = a 22 | 23 | 24 | def test_import_python_module_by_filename(): 25 | filename = join(DATA_DIR, 'a.py') 26 | with open(filename, mode='w') as f: 27 | f.write('b = 123\n') 28 | 29 | module_ = import_python_module_by_filename('a', filename) 30 | assert module_.b == 123 31 | 32 | 33 | def test_construct_class_by_name(): 34 | obj = construct_class_by_name( 35 | 'tests.test_helpers.MyClassToConstructByName', 36 | 1 37 | ) 38 | assert obj.a == 1 39 | assert obj is not None 40 | 41 | 42 | def test_copy_stream(): 43 | content = b'This is the initial source file' 44 | source = io.BytesIO(content) 45 | target = io.BytesIO() 46 | copy_stream(source, target) 47 | target.seek(0) 48 | assert target.read() == content 49 | 50 | 51 | def test_md5sum(): 52 | content = b'This is the initial source file' 53 | source = io.BytesIO(content) 54 | filename = join(DATA_DIR, 'a.txt') 55 | with open(filename, mode='wb') as f: 56 | f.write(content) 57 | 58 | assert md5sum(source) == md5sum(filename) 59 | 60 | 61 | def test_to_camel_case(): 62 | assert to_camel_case('foo_bar_baz') == 'fooBarBaz' 63 | 64 | 65 | def test_encode_multipart(): 66 | filename = f'{mktemp()}.txt' 67 | with open(filename, 'w') as f: 68 | f.write('abcdefgh\n') 69 | 70 | contenttype, body, length = encode_multipart_data( 71 | dict(foo='bar'), 72 | files=dict(bar=filename), 73 | boundary='MAGIC' 74 | ) 75 | assert contenttype.startswith('multipart/form') 76 | assert body.read().decode() == \ 77 | f'--MAGIC\r\nContent-Disposition: form-data; ' \ 78 | f'name="foo"\r\n\r\nbar\r\n--MAGIC\r\nContent-Disposition: ' \ 79 | f'form-data; name="bar"; filename="{filename.split("/")[-1]}"' \ 80 | f'\r\nContent-Type: text/plain\r\n\r\nabcdefgh\n\r\n--MAGIC--\r\n\r\n' 81 | assert length == 193 82 | 83 | 84 | def test_split_url(): 85 | url = 'https://www.example.com/id/1?a=1&b=2' 86 | path, query = split_url(url) 87 | 88 | assert path == 'https://www.example.com/id/1' 89 | assert query == dict(a='1', b='2') 90 | 91 | 92 | @noneifnone 93 | def func(name): 94 | return name 95 | 96 | 97 | def test_noneifnone(): 98 | assert func('test') == 'test' 99 | assert func(None) == None 100 | 101 | -------------------------------------------------------------------------------- /tests/test_impersonation.py: -------------------------------------------------------------------------------- 1 | from nanohttp.contexts import context, Context 2 | 3 | from restfulpy.principal import ImpersonateAs, DummyIdentity 4 | 5 | 6 | def test_impersonation(): 7 | with Context({}) as ctx: 8 | role1 = 'role-1' 9 | role2 = 'role-2' 10 | role3 = 'role-3' 11 | role4 = 'role-4' 12 | 13 | # Simple test 14 | role_1_principal = DummyIdentity(role1) 15 | with ImpersonateAs(role_1_principal): 16 | assert context.identity == role_1_principal 17 | assert context.identity.is_in_roles(role1) 18 | 19 | # Now we change the role 20 | role_2_principal = DummyIdentity(role2) 21 | with ImpersonateAs(role_2_principal): 22 | assert not context.identity.is_in_roles(role1) 23 | assert context.identity.is_in_roles(role2) 24 | 25 | # Multiple roles 26 | role_3_4_principal = DummyIdentity(role3, role4) 27 | with ImpersonateAs(role_3_4_principal): 28 | assert context.identity.is_in_roles(role3) 29 | assert context.identity.is_in_roles(role4) 30 | 31 | assert not context.identity.is_in_roles(role1) 32 | assert not context.identity.is_in_roles(role2) 33 | 34 | -------------------------------------------------------------------------------- /tests/test_json_payload.py: -------------------------------------------------------------------------------- 1 | from bddrest.authoring import response 2 | from nanohttp import json, Controller, context 3 | 4 | from restfulpy.testing import ApplicableTestCase 5 | 6 | 7 | class Root(Controller): 8 | 9 | @json 10 | def index(self): 11 | return context.form 12 | 13 | 14 | class TestJSONPayload(ApplicableTestCase): 15 | __controller_factory__ = Root 16 | 17 | def test_index(self): 18 | payload = dict( 19 | a=1, 20 | b=2 21 | ) 22 | 23 | with self.given( 24 | 'Testing json pyload', 25 | json=payload, 26 | ): 27 | assert response.json == payload 28 | 29 | -------------------------------------------------------------------------------- /tests/test_jsonpatch.py: -------------------------------------------------------------------------------- 1 | from bddrest import response, when, status 2 | from nanohttp import context, json, RestController, HTTPNotFound, HTTPStatus 3 | from sqlalchemy import Unicode, Integer 4 | 5 | from restfulpy.controllers import JSONPatchControllerMixin 6 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 7 | from restfulpy.testing import ApplicableTestCase 8 | 9 | 10 | class Person(DeclarativeBase): 11 | __tablename__ = 'person' 12 | 13 | id = Field(Integer, primary_key=True) 14 | title = Field( 15 | Unicode(50), 16 | unique=True, 17 | index=True, 18 | min_length=2, 19 | watermark='Title', 20 | label='Title' 21 | ) 22 | 23 | 24 | class Root(JSONPatchControllerMixin, RestController): 25 | __model__ = Person 26 | 27 | @json(prevent_empty_form=True) 28 | @commit 29 | def create(self): 30 | title = context.form.get('title') 31 | 32 | if DBSession.query(Person).filter(Person.title == title).count(): 33 | raise HTTPStatus('600 Already person has existed') 34 | 35 | person = Person( 36 | title=context.form.get('title') 37 | ) 38 | DBSession.add(person) 39 | return person 40 | 41 | @json(prevent_form=True) 42 | def get(self, title: str): 43 | person = DBSession.query(Person) \ 44 | .filter(Person.title == title) \ 45 | .one_or_none() 46 | 47 | if person is None: 48 | raise HTTPNotFound() 49 | 50 | return person 51 | 52 | @json 53 | @Person.expose 54 | def list(self): 55 | return DBSession.query(Person) 56 | 57 | 58 | class TestJsonPatchMixin(ApplicableTestCase): 59 | __controller_factory__ = Root 60 | 61 | @classmethod 62 | def mockup(cls): 63 | session = cls.create_session() 64 | cls.person = Person( 65 | title='already_added', 66 | ) 67 | session.add(cls.person) 68 | session.commit() 69 | 70 | def test_jsonpatch(self): 71 | with self.given( 72 | 'Testing the patch method', 73 | verb='PATCH', 74 | url='/', 75 | json=[ 76 | dict(op='CREATE', path='', value=dict(title='first')), 77 | dict(op='CREATE', path='', value=dict(title='second')) 78 | ] 79 | ): 80 | assert status == 200 81 | assert len(response.json) == 2 82 | assert response.json[0]['id'] is not None 83 | assert response.json[1]['id'] is not None 84 | 85 | when( 86 | 'Testing the list method using patch', 87 | json=[dict(op='LIST', path='')] 88 | ) 89 | assert len(response.json) == 1 90 | assert len(response.json[0]) == 3 91 | 92 | when( 93 | 'Trying to pass without value', 94 | json=[ 95 | dict(op='CREATE', path='', value=dict(title='third')), 96 | dict(op='CREATE', path=''), 97 | ] 98 | ) 99 | assert status == 400 100 | 101 | when('Trying to pass with empty form', json={}) 102 | assert status == '400 Empty Form' 103 | 104 | id = self.person.id 105 | when( 106 | 'Prevent Form', 107 | json=[ 108 | dict(op='GET', path=f'{id}', value=dict(form='form1')), 109 | dict(op='GET', path=f'{id}') 110 | ] 111 | ) 112 | assert status == '400 Form Not Allowed' 113 | 114 | 115 | def test_jsonpatch_rollback(self): 116 | with self.given( 117 | 'Testing rollback scenario', 118 | verb='PATCH', 119 | url='/', 120 | json=[ 121 | dict(op='CREATE', path='', value=dict(title='third')), 122 | dict(op='CREATE', path='', value=dict(title='already_added')) 123 | ] 124 | ): 125 | assert status == '600 Already person has existed' 126 | 127 | when( 128 | 'Trying to get the person that not exist', 129 | verb='GET', 130 | url='/third', 131 | json=None, 132 | ) 133 | assert status == 404 134 | 135 | -------------------------------------------------------------------------------- /tests/test_messaging_models.py: -------------------------------------------------------------------------------- 1 | from nanohttp import settings 2 | 3 | from restfulpy.messaging import Email, create_messenger 4 | 5 | 6 | def test_messaging_model(db): 7 | __configuration__ = ''' 8 | messaging: 9 | default_sender: test@example.com 10 | default_messenger: restfulpy.mockup.MockupMessenger 11 | ''' 12 | 13 | settings.merge(__configuration__) 14 | session = db() 15 | 16 | mockup_messenger = create_messenger() 17 | 18 | message = Email( 19 | to='test@example.com', 20 | subject='Test Subject', 21 | body={'msg': 'Hello'} 22 | ) 23 | 24 | session.add(message) 25 | session.commit() 26 | 27 | message.do_({'counter': 1}, {}) 28 | 29 | assert mockup_messenger.last_message == { 30 | 'body': {'msg': 'Hello'}, 31 | 'subject': 'Test Subject', 32 | 'to': 'test@example.com' 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/test_messenger_factory.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, abspath 2 | 3 | from nanohttp import settings, configure 4 | 5 | from restfulpy.messaging.providers import create_messenger, ConsoleMessenger,\ 6 | SMTPProvider 7 | 8 | 9 | HERE = abspath(dirname(__file__)) 10 | 11 | def test_messenger_factory(): 12 | 13 | __configuration__ = ''' 14 | messaging: 15 | default_messenger: restfulpy.messaging.ConsoleMessenger 16 | ''' 17 | configure(force=True) 18 | settings.merge(__configuration__) 19 | 20 | console_messenger = create_messenger() 21 | assert isinstance(console_messenger, ConsoleMessenger) 22 | 23 | settings.messaging.default_messenger =\ 24 | 'restfulpy.messaging.providers.SMTPProvider' 25 | smtp_messenger = create_messenger() 26 | assert isinstance(smtp_messenger, SMTPProvider) 27 | 28 | -------------------------------------------------------------------------------- /tests/test_mixin_activation.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Unicode 2 | 3 | from restfulpy.orm import DeclarativeBase, Field, ActivationMixin, \ 4 | AutoActivationMixin 5 | 6 | 7 | class ActiveObject(ActivationMixin, DeclarativeBase): 8 | __tablename__ = 'active_object' 9 | 10 | id = Field(Integer, primary_key=True) 11 | title = Field(Unicode(50)) 12 | 13 | 14 | class AutoActiveObject(AutoActivationMixin, DeclarativeBase): 15 | __tablename__ = 'auto_active_object' 16 | 17 | id = Field(Integer, primary_key=True) 18 | title = Field(Unicode(50)) 19 | 20 | 21 | def test_activation_mixin(db): 22 | session = db(expire_on_commit=False) 23 | 24 | object1 = ActiveObject( 25 | title='object 1', 26 | ) 27 | 28 | session.add(object1) 29 | session.commit() 30 | assert not object1.is_active 31 | assert session.query(ActiveObject)\ 32 | .filter(ActiveObject.is_active)\ 33 | .count() == 0 34 | 35 | object1.is_active = True 36 | assert object1.is_active 37 | session.commit() 38 | 39 | object1 = session.query(ActiveObject).one() 40 | assert object1.is_active 41 | 42 | assert 'isActive' in object1.to_dict() 43 | 44 | assert session.query(ActiveObject)\ 45 | .filter(ActiveObject.is_active)\ 46 | .count() == 1 47 | assert ActiveObject.filter_activated(session.query(ActiveObject)).count()\ 48 | == 1 49 | 50 | assert not ActiveObject.import_value(ActiveObject.is_active, 'false') 51 | assert not ActiveObject.import_value(ActiveObject.is_active, 'FALSE') 52 | assert not ActiveObject.import_value(ActiveObject.is_active, 'False') 53 | assert ActiveObject.import_value(ActiveObject.is_active, 'true') 54 | assert ActiveObject.import_value(ActiveObject.is_active, 'TRUE') 55 | assert ActiveObject.import_value(ActiveObject.is_active, 'True') 56 | assert ActiveObject.import_value(ActiveObject.title, 'title') == 'title' 57 | 58 | 59 | def test_auto_activation(db): 60 | session = db() 61 | object1 = AutoActiveObject( 62 | title='object 1', 63 | ) 64 | 65 | session.add(object1) 66 | session.commit() 67 | assert object1.is_active 68 | assert session.query(AutoActiveObject)\ 69 | .filter(AutoActiveObject.is_active)\ 70 | .count() == 1 71 | 72 | 73 | def test_metadata(): 74 | # Metadata 75 | object_metadata = ActiveObject.json_metadata() 76 | fields = object_metadata['fields'] 77 | 78 | assert 'id' in fields 79 | assert 'title' in fields 80 | assert 'isActive' in fields 81 | 82 | -------------------------------------------------------------------------------- /tests/test_mixin_approverequired.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Unicode 2 | 3 | from restfulpy.orm import DeclarativeBase, Field, ApproveRequiredMixin 4 | 5 | 6 | class ApproveRequiredObject(ApproveRequiredMixin, DeclarativeBase): 7 | __tablename__ = 'approve_required_object' 8 | 9 | id = Field(Integer, primary_key=True) 10 | title = Field(Unicode(50)) 11 | 12 | 13 | def test_approve_required_mixin(db): 14 | session = db(expire_on_commit=True) 15 | 16 | object1 = ApproveRequiredObject( 17 | title='object 1', 18 | ) 19 | 20 | session.add(object1) 21 | session.commit() 22 | assert not object1.is_approved 23 | assert session.query(ApproveRequiredObject)\ 24 | .filter(ApproveRequiredObject.is_approved).count() == 0 25 | 26 | object1.is_approved = True 27 | assert object1.is_approved 28 | session.commit() 29 | 30 | object1 = session.query(ApproveRequiredObject).one() 31 | assert object1.is_approved 32 | 33 | json = object1.to_dict() 34 | assert 'isApproved' in json 35 | 36 | assert session.query(ApproveRequiredObject)\ 37 | .filter(ApproveRequiredObject.is_approved).count() == 1 38 | 39 | assert ApproveRequiredObject.filter_approved(session=session).count() == 1 40 | 41 | assert not ApproveRequiredObject.import_value( 42 | ApproveRequiredObject.is_approved, 'false' 43 | ) 44 | assert not ApproveRequiredObject.import_value( 45 | ApproveRequiredObject.is_approved, 'FALSE' 46 | ) 47 | assert not ApproveRequiredObject.import_value( 48 | ApproveRequiredObject.is_approved, 'False' 49 | ) 50 | assert ApproveRequiredObject.import_value( 51 | ApproveRequiredObject.is_approved, 'true' 52 | ) 53 | assert ApproveRequiredObject.import_value( 54 | ApproveRequiredObject.is_approved, 'TRUE' 55 | ) 56 | assert ApproveRequiredObject.import_value( 57 | ApproveRequiredObject.is_approved, 'True' 58 | ) 59 | 60 | assert ApproveRequiredObject.import_value( 61 | ApproveRequiredObject.title, 'title' 62 | ) == 'title' 63 | 64 | -------------------------------------------------------------------------------- /tests/test_mixin_deactivation.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Unicode 2 | 3 | from restfulpy.orm import DeclarativeBase, Field, DeactivationMixin 4 | 5 | 6 | class DeactiveObject(DeactivationMixin, DeclarativeBase): 7 | __tablename__ = 'deactive_object' 8 | 9 | id = Field(Integer, primary_key=True) 10 | title = Field(Unicode(50)) 11 | 12 | 13 | def test_deactivation_mixin(db): 14 | session = db() 15 | 16 | object1 = DeactiveObject( 17 | title='object 1', 18 | ) 19 | 20 | session.add(object1) 21 | session.commit() 22 | 23 | assert object1.is_active == False 24 | assert session.query(DeactiveObject).filter(DeactiveObject.is_active)\ 25 | .count() == 0 26 | 27 | object1.is_active = True 28 | 29 | assert object1.is_active == True 30 | session.commit() 31 | object1 = session.query(DeactiveObject).one() 32 | assert object1.is_active == True 33 | assert object1.deactivated_at is None 34 | assert object1.activated_at is not None 35 | 36 | object1.is_active = False 37 | assert object1.is_active == False 38 | session.commit() 39 | object1 = session.query(DeactiveObject).one() 40 | 41 | assert object1.is_active == False 42 | assert object1.activated_at is None 43 | assert object1.deactivated_at is not None 44 | 45 | -------------------------------------------------------------------------------- /tests/test_mixin_filtering.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from nanohttp import HTTPBadRequest 3 | from nanohttp.contexts import Context 4 | from sqlalchemy import Integer, Unicode 5 | from sqlalchemy.ext.hybrid import hybrid_property 6 | 7 | from restfulpy.orm import DeclarativeBase, Field, FilteringMixin 8 | 9 | 10 | class FilteringObject(FilteringMixin, DeclarativeBase): 11 | __tablename__ = 'filtering_object' 12 | 13 | id = Field(Integer, primary_key=True) 14 | title = Field(Unicode(50)) 15 | 16 | 17 | class Interval(FilteringMixin, DeclarativeBase): 18 | __tablename__ = 'interval' 19 | 20 | id = Field(Integer, primary_key=True) 21 | start = Field(Integer) 22 | end = Field(Integer) 23 | 24 | @hybrid_property 25 | def length(self): 26 | return self.end - self.start 27 | 28 | @length.expression 29 | def length(cls): 30 | return cls.end - cls.start 31 | 32 | 33 | def test_filtering_mixin(db): 34 | session = db() 35 | 36 | for i in range(1, 6): 37 | session.add(FilteringObject( 38 | title='object %s' % i, 39 | )) 40 | 41 | session.add(FilteringObject( 42 | title='A simple title', 43 | )) 44 | session.commit() 45 | 46 | query = session.query(FilteringObject) 47 | 48 | with Context({'QUERY_STRING': 'id=1'}) as context, \ 49 | pytest.raises(HTTPBadRequest): 50 | context.query['id'] = 1 51 | FilteringObject.filter_by_request(query) 52 | 53 | # IN 54 | with Context({'QUERY_STRING': 'id=IN(1,2,3)'}): 55 | assert FilteringObject.filter_by_request(query).count() == 3 56 | 57 | # NOT IN 58 | with Context({'QUERY_STRING': 'id=!IN(1,2,3)'}): 59 | assert FilteringObject.filter_by_request(query).count() == 3 60 | 61 | # IN (error) 62 | with Context({'QUERY_STRING': 'id=IN()'}), \ 63 | pytest.raises(HTTPBadRequest): 64 | FilteringObject.filter_by_request(query) 65 | 66 | # Between 67 | with Context({'QUERY_STRING': 'id=BETWEEN(1,3)'}): 68 | assert FilteringObject.filter_by_request(query).count() == 3 69 | 70 | # Not Between 71 | with Context({'QUERY_STRING': 'id=!BETWEEN(1,5)'}): 72 | assert FilteringObject.filter_by_request(query).count() == 1 73 | 74 | # Bad Between 75 | with pytest.raises(HTTPBadRequest), \ 76 | Context({'QUERY_STRING': 'id=BETWEEN(1,)'}): 77 | FilteringObject.filter_by_request(query) 78 | 79 | # IS NULL 80 | with Context({'QUERY_STRING': 'title=%00'}): 81 | assert FilteringObject.filter_by_request(query).count() == 0 82 | 83 | # IS NOT NULL 84 | with Context({'QUERY_STRING': 'title=!%00'}): 85 | assert FilteringObject.filter_by_request(query).count() == 6 86 | 87 | # == 88 | with Context({'QUERY_STRING': 'id=1'}): 89 | assert FilteringObject.filter_by_request(query).count() == 1 90 | 91 | # != 92 | with Context({'QUERY_STRING': 'id=!1'}): 93 | assert FilteringObject.filter_by_request(query).count() == 5 94 | 95 | # >= 96 | with Context({'QUERY_STRING': 'id=>=2'}): 97 | assert FilteringObject.filter_by_request(query).count() == 5 98 | 99 | # > 100 | with Context({'QUERY_STRING': 'id=>2'}): 101 | assert FilteringObject.filter_by_request(query).count() == 4 102 | 103 | # <= 104 | with Context({'QUERY_STRING': 'id=<=3'}): 105 | FilteringObject.filter_by_request(query).count() == 3 106 | 107 | # < 108 | with Context({'QUERY_STRING': 'id=<3'}): 109 | assert FilteringObject.filter_by_request(query).count() == 2 110 | 111 | # LIKE 112 | with Context({'QUERY_STRING': 'title=%obj%'}): 113 | assert FilteringObject.filter_by_request(query).count() == 5 114 | 115 | with Context({'QUERY_STRING': 'title=%OBJ%'}): 116 | assert FilteringObject.filter_by_request(query).count() == 0 117 | 118 | # ILIKE 119 | with Context({'QUERY_STRING': 'title=~%obj%'}): 120 | assert FilteringObject.filter_by_request(query).count() == 5 121 | 122 | with Context({'QUERY_STRING': 'title=~%OBJ%'}): 123 | assert FilteringObject.filter_by_request(query).count() == 5 124 | 125 | with Context({'QUERY_STRING': 'title=A sim%'}): 126 | assert FilteringObject.filter_by_request(query).count() == 1 127 | 128 | with Context({'QUERY_STRING': 'title=%25ect 5'}): 129 | assert FilteringObject.filter_by_request(query).count() == 1 130 | 131 | with Context({'QUERY_STRING': 'title=%imple%'}): 132 | assert FilteringObject.filter_by_request(query).count() == 1 133 | 134 | with Context({'QUERY_STRING': 'title=~%IMPLE%'}): 135 | assert FilteringObject.filter_by_request(query).count() == 1 136 | 137 | session.add(Interval(start=1, end=4)) 138 | session.commit() 139 | 140 | query = session.query(Interval) 141 | 142 | # Filtering on hybrid property 143 | with Context({'QUERY_STRING': 'length=3'}): 144 | assert Interval.filter_by_request(query).count() == 1 145 | 146 | # Get sure filtering on hybrid property works correctly 147 | with Context({'QUERY_STRING': 'length=2'}): 148 | assert Interval.filter_by_request(query).count() == 0 149 | 150 | -------------------------------------------------------------------------------- /tests/test_mixin_modified.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Unicode, Integer 2 | 3 | from restfulpy.orm import DeclarativeBase, Field, ModifiedMixin 4 | 5 | 6 | class ModificationCheckingModel(ModifiedMixin, DeclarativeBase): 7 | __tablename__ = 'modification_checking_model' 8 | __exclude__ = {'age'} 9 | 10 | title = Field(Unicode(50), primary_key=True) 11 | age = Field(Integer) 12 | 13 | 14 | class ModificationExcludelessModel(ModifiedMixin, DeclarativeBase): 15 | __tablename__ = 'modification_checking_excludeless_model' 16 | 17 | title = Field(Unicode(50), primary_key=True) 18 | age = Field(Integer) 19 | 20 | 21 | def test_modified_mixin(db): 22 | session = db() 23 | 24 | instance = ModificationCheckingModel( 25 | title='test title', 26 | age=1, 27 | ) 28 | session.add(instance) 29 | 30 | excludeless_instance = ModificationExcludelessModel( 31 | title='test title', 32 | age=1, 33 | ) 34 | session.add(excludeless_instance) 35 | session.commit() 36 | 37 | assert instance.modified_at is None 38 | assert instance.created_at is not None 39 | assert instance.last_modification_time == instance.created_at 40 | 41 | instance = session.query(ModificationCheckingModel).one() 42 | assert instance.modified_at is None 43 | assert instance.created_at is not None 44 | assert instance.last_modification_time == instance.created_at 45 | 46 | instance.age = 2 47 | session.commit() 48 | assert instance.modified_at is None 49 | assert instance.created_at is not None 50 | assert instance.last_modification_time == instance.created_at 51 | 52 | instance.title = 'Edited title' 53 | session.commit() 54 | assert instance.modified_at is not None 55 | assert instance.created_at is not None 56 | assert instance.last_modification_time == instance.modified_at 57 | 58 | instance = session.query(ModificationCheckingModel).one() 59 | assert instance.modified_at is not None 60 | assert instance.created_at is not None 61 | assert instance.last_modification_time == instance.modified_at 62 | 63 | excludeless_instance.age = 3 64 | session.commit() 65 | assert excludeless_instance.modified_at is not None 66 | assert excludeless_instance.created_at is not None 67 | assert excludeless_instance.last_modification_time == \ 68 | excludeless_instance.modified_at 69 | 70 | -------------------------------------------------------------------------------- /tests/test_mixin_ordering.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from nanohttp.contexts import Context 4 | from sqlalchemy import Integer, Unicode, DateTime 5 | from sqlalchemy.orm import synonym 6 | 7 | from restfulpy.orm import DeclarativeBase, Field, OrderingMixin 8 | 9 | 10 | class OrderingObject(OrderingMixin, DeclarativeBase): 11 | __tablename__ = 'orderable_ordering_object' 12 | 13 | id = Field(Integer, primary_key=True) 14 | title = Field(Unicode(50)) 15 | _age = Field(Integer) 16 | created_at = Field( 17 | DateTime, 18 | nullable=False, 19 | json='createdAt', 20 | readonly=True, 21 | default=datetime.utcnow, 22 | ) 23 | 24 | def _set_age(self, age): 25 | self._age = age 26 | 27 | def _get_age(self): # pragma: no cover 28 | return self._age 29 | 30 | age = synonym('_age', descriptor=property(_get_age, _set_age)) 31 | 32 | 33 | def test_ordering_mixin(db): 34 | session =db() 35 | 36 | for i in range(1, 6): 37 | obj = OrderingObject( 38 | title=f'object {6-i//2}', 39 | age=i * 10, 40 | ) 41 | session.add(obj) 42 | 43 | session.commit() 44 | 45 | query = session.query(OrderingObject) 46 | 47 | # Ascending 48 | with Context({'QUERY_STRING': 'sort=id'}): 49 | result = OrderingObject.sort_by_request(query).all() 50 | assert result[0].id == 1 51 | assert result[-1].id == 5 52 | 53 | # Descending 54 | with Context({'QUERY_STRING': 'sort=-id'}): 55 | result = OrderingObject.sort_by_request(query).all() 56 | assert result[0].id == 5 57 | assert result[-1].id == 1 58 | 59 | # Sort by Synonym Property 60 | with Context({'QUERY_STRING': 'sort=age'}): 61 | result = OrderingObject.sort_by_request(query).all() 62 | assert result[0].id == 1 63 | assert result[-1].id == 5 64 | 65 | # Mutiple sort criteria 66 | with Context({'QUERY_STRING': 'sort=title,id'}): 67 | result = OrderingObject.sort_by_request(query).all() 68 | assert result[0].id == 4 69 | assert result[1].id == 5 70 | 71 | assert result[2].id == 2 72 | assert result[3].id == 3 73 | 74 | assert result[4].id == 1 75 | 76 | # Sort by date 77 | with Context({'QUERY_STRING': 'sort=-createdAt'}): 78 | result = OrderingObject.sort_by_request(query).all() 79 | assert result[0].id == 5 80 | assert result[1].id == 4 81 | assert result[2].id == 3 82 | assert result[3].id == 2 83 | assert result[4].id == 1 84 | 85 | -------------------------------------------------------------------------------- /tests/test_mixin_pagination.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from nanohttp import HTTPBadRequest 3 | from nanohttp.contexts import Context 4 | from sqlalchemy import Integer, Unicode 5 | 6 | from restfulpy.orm import DeclarativeBase, Field, PaginationMixin 7 | 8 | 9 | class PagingObject(PaginationMixin, DeclarativeBase): 10 | __tablename__ = 'paging_object' 11 | __max_take__ = 4 12 | 13 | id = Field(Integer, primary_key=True) 14 | title = Field(Unicode(50)) 15 | 16 | 17 | def test_pagination_mixin(db): 18 | session = db() 19 | 20 | for i in range(1, 6): 21 | obj = PagingObject( 22 | title='object %s' % i, 23 | ) 24 | session.add(obj) 25 | session.commit() 26 | 27 | query = session.query(PagingObject) 28 | 29 | with Context({'QUERY_STRING': 'take=2&skip=1'}) as context: 30 | assert PagingObject.paginate_by_request(query).count() == 2 31 | assert context.response_headers['X-Pagination-Take'] == '2' 32 | assert context.response_headers['X-Pagination-Skip'] == '1' 33 | assert context.response_headers['X-Pagination-Count'] == '5' 34 | 35 | with Context({'QUERY_STRING': 'take=two&skip=one'}), \ 36 | pytest.raises(HTTPBadRequest): 37 | PagingObject.paginate_by_request(query).count() 38 | 39 | with Context({'QUERY_STRING': 'take=5'}), pytest.raises(HTTPBadRequest): 40 | PagingObject.paginate_by_request(query) 41 | 42 | -------------------------------------------------------------------------------- /tests/test_mixin_softdelete.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import Unicode 3 | 4 | from restfulpy.orm import DeclarativeBase, Field, SoftDeleteMixin 5 | 6 | 7 | class SoftDeleteCheckingModel(SoftDeleteMixin, DeclarativeBase): 8 | __tablename__ = 'soft_delete_checking_model' 9 | 10 | title = Field(Unicode(50), primary_key=True) 11 | 12 | 13 | def test_soft_delete_mixin(db): 14 | session = db() 15 | 16 | instance = SoftDeleteCheckingModel( 17 | title='test title' 18 | ) 19 | session.add(instance) 20 | session.commit() 21 | instance.assert_is_not_deleted() 22 | with pytest.raises(ValueError): 23 | instance.assert_is_deleted() 24 | 25 | instance = session.query(SoftDeleteCheckingModel).one() 26 | instance.soft_delete() 27 | session.commit() 28 | instance.assert_is_deleted() 29 | with pytest.raises(ValueError): 30 | instance.assert_is_not_deleted() 31 | 32 | query = session.query(SoftDeleteCheckingModel) 33 | assert SoftDeleteCheckingModel.filter_deleted(query).count() == 1 34 | assert SoftDeleteCheckingModel.exclude_deleted(query).count() == 0 35 | 36 | instance.soft_undelete() 37 | session.commit() 38 | instance.assert_is_not_deleted() 39 | with pytest.raises(ValueError): 40 | instance.assert_is_deleted() 41 | 42 | assert SoftDeleteCheckingModel.filter_deleted(query).count() == 0 43 | assert SoftDeleteCheckingModel.exclude_deleted(query).count() == 1 44 | 45 | session.delete(instance) 46 | with pytest.raises(AssertionError): 47 | session.commit() 48 | 49 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Integer, Unicode, not_ 4 | from sqlalchemy.sql.expression import desc, asc 5 | 6 | from restfulpy.orm import DeclarativeBase, ActivationMixin, PaginationMixin,\ 7 | FilteringMixin, OrderingMixin, Field 8 | 9 | 10 | class Student( 11 | DeclarativeBase, 12 | ActivationMixin, 13 | PaginationMixin, 14 | FilteringMixin, 15 | OrderingMixin 16 | ): 17 | __tablename__ = 'student' 18 | id = Field(Integer, primary_key=True) 19 | name = Field(Unicode(100), max_length=90) 20 | 21 | 22 | def test_activation_mixin(db): 23 | """ 24 | This unittest is wrote to test the combination of mixins 25 | """ 26 | session = db() 27 | 28 | activated_student = Student() 29 | activated_student.name = 'activated-student' 30 | activated_student.activated_at = datetime.utcnow() 31 | session.add(activated_student) 32 | 33 | deactivated_student = Student() 34 | deactivated_student.name = 'deactivated-student' 35 | deactivated_student.activated_at = None 36 | session.add(deactivated_student) 37 | 38 | session.commit() 39 | 40 | # Test ordering: 41 | student_list = session.query(Student).\ 42 | order_by(desc(Student.is_active)).all() 43 | assert student_list[0].activated_at is not None 44 | assert student_list[-1].activated_at is None 45 | 46 | student_list = session.query(Student).\ 47 | order_by(asc(Student.is_active)).all() 48 | assert student_list[-1].activated_at is not None 49 | assert student_list[0].activated_at is None 50 | 51 | # Test filtering: 52 | student_list = session.query(Student).filter(Student.is_active).all() 53 | for student in student_list: 54 | assert student.activated_at is not None 55 | 56 | student_list = session.query(Student).filter(not_(Student.is_active)).all() 57 | for student in student_list: 58 | assert student.activated_at is None 59 | 60 | -------------------------------------------------------------------------------- /tests/test_orm.py: -------------------------------------------------------------------------------- 1 | from datetime import date, time 2 | 3 | import pytest 4 | from nanohttp import HTTPBadRequest, settings 5 | from nanohttp.contexts import Context 6 | from sqlalchemy import Integer, Unicode, ForeignKey, Boolean, Table, Date,\ 7 | Time, Float 8 | from sqlalchemy.orm import synonym 9 | 10 | from restfulpy.orm import DeclarativeBase, Field, relationship, composite, \ 11 | ModifiedMixin 12 | 13 | 14 | class FullName(object): # pragma: no cover 15 | def __init__(self, first_name, last_name): 16 | self.first_name = first_name 17 | self.last_name = last_name 18 | 19 | def __composite_values__(self): 20 | return '%s %s' % (self.first_name, self.last_name) 21 | 22 | def __repr__(self): 23 | return "FullName(%s %s)" % (self.first_name, self.last_name) 24 | 25 | def __eq__(self, other): 26 | return isinstance(other, FullName) and \ 27 | other.first_name == self.first_name and \ 28 | other.last_name == self.last_name 29 | 30 | def __ne__(self, other): 31 | return not self.__eq__(other) 32 | 33 | 34 | class Author(DeclarativeBase): 35 | __tablename__ = 'author' 36 | 37 | id = Field(Integer, primary_key=True) 38 | email = Field( 39 | Unicode(100), 40 | unique=True, 41 | index=True, 42 | json='email', 43 | pattern=r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)', 44 | watermark='Email', 45 | example="user@example.com" 46 | ) 47 | title = Field( 48 | Unicode(50), 49 | index=True, 50 | min_length=2, 51 | watermark='First Name' 52 | ) 53 | first_name = Field( 54 | Unicode(50), 55 | index=True, 56 | json='firstName', 57 | min_length=2, 58 | watermark='First Name' 59 | ) 60 | last_name = Field( 61 | Unicode(100), 62 | json='lastName', 63 | min_length=2, 64 | watermark='Last Name' 65 | ) 66 | phone = Field( 67 | Unicode(10), nullable=True, json='phone', min_length=10, 68 | pattern=\ 69 | r'\d{3}[-\.\s]??\d{3}[-\.\s]??\d{4}|\(\d{3}\)\s*\d{3}[-\.\s]??' 70 | r'\d{4}|\d{3}[-\.\s]??\d{4}', 71 | watermark='Phone' 72 | ) 73 | name = composite( 74 | FullName, 75 | first_name, 76 | last_name, 77 | readonly=True, 78 | json='fullName', 79 | protected=True 80 | ) 81 | _password = Field( 82 | 'password', 83 | Unicode(128), 84 | index=True, 85 | json='password', 86 | protected=True, 87 | min_length=6 88 | ) 89 | birth = Field(Date) 90 | weight = Field(Float(asdecimal=True)) 91 | age = Field(Integer, default=18, minimum=18, maximum=100) 92 | 93 | def _set_password(self, password): 94 | self._password = 'hashed:%s' % password 95 | 96 | def _get_password(self): # pragma: no cover 97 | return self._password 98 | 99 | password = synonym( 100 | '_password', 101 | descriptor=property(_get_password, _set_password), 102 | info=dict(protected=True) 103 | ) 104 | 105 | 106 | class Memo(DeclarativeBase): 107 | __tablename__ = 'memo' 108 | id = Field(Integer, primary_key=True) 109 | content = Field(Unicode(100), max_length=90) 110 | post_id = Field(ForeignKey('post.id'), json='postId') 111 | 112 | 113 | class Comment(DeclarativeBase): 114 | __tablename__ = 'comment' 115 | id = Field(Integer, primary_key=True) 116 | content = Field(Unicode(100), max_length=90) 117 | special = Field(Boolean, default=True) 118 | post_id = Field(ForeignKey('post.id'), json='postId') 119 | post = relationship('Post') 120 | 121 | 122 | post_tag_table = Table( 123 | 'post_tag', DeclarativeBase.metadata, 124 | Field('post_id', Integer, ForeignKey('post.id')), 125 | Field('tag_id', Integer, ForeignKey('tag.id')) 126 | ) 127 | 128 | 129 | class Tag(DeclarativeBase): 130 | __tablename__ = 'tag' 131 | id = Field(Integer, primary_key=True) 132 | title = Field(Unicode(50), watermark='title', label='title') 133 | posts = relationship( 134 | 'Post', 135 | secondary=post_tag_table, 136 | back_populates='tags' 137 | ) 138 | 139 | 140 | class Post(ModifiedMixin, DeclarativeBase): 141 | __tablename__ = 'post' 142 | 143 | id = Field(Integer, primary_key=True) 144 | title = Field(Unicode(50), watermark='title', label='title') 145 | author_id = Field(ForeignKey('author.id'), json='authorId') 146 | author = relationship(Author, protected=False) 147 | memos = relationship(Memo, protected=True, json='privateMemos') 148 | comments = relationship(Comment, protected=False) 149 | tags = relationship( 150 | Tag, 151 | secondary=post_tag_table, 152 | back_populates='posts', 153 | protected=False 154 | ) 155 | tag_time = Field(Time) 156 | 157 | 158 | def test_model(db): 159 | session = db() 160 | 161 | __configuration__ = ''' 162 | timezone: 163 | ''' 164 | 165 | settings.merge(__configuration__) 166 | 167 | with Context({}): 168 | author1 = Author( 169 | title='author1', 170 | email='author1@example.org', 171 | first_name='author 1 first name', 172 | last_name='author 1 last name', 173 | phone=None, 174 | password='123456', 175 | birth=date(1, 1, 1), 176 | weight=1.1 177 | ) 178 | 179 | post1 = Post( 180 | title='First post', 181 | author=author1, 182 | tag_time=time(1, 1, 1) 183 | ) 184 | session.add(post1) 185 | session.commit() 186 | 187 | assert post1.id == 1 188 | 189 | post1_dict = post1.to_dict() 190 | assert { 191 | 'author': { 192 | 'email': 'author1@example.org', 193 | 'firstName': 'author 1 first name', 194 | 'id': 1, 195 | 'lastName': 'author 1 last name', 196 | 'phone': None, 197 | 'title': 'author1', 198 | 'birth': '0001-01-01', 199 | 'weight': 1.100, 200 | 'age': 18 201 | }, 202 | 'authorId': 1, 203 | 'comments': [], 204 | 'id': 1, 205 | 'tags': [], 206 | 'title': 'First post', 207 | 'tagTime': '01:01:01', 208 | }.items() < post1_dict.items() 209 | 210 | assert 'createdAt' in post1_dict 211 | assert 'modifiedAt' in post1_dict 212 | 213 | author1_dict = author1.to_dict() 214 | assert 'fullName' not in author1_dict 215 | 216 | 217 | def test_metadata(db): 218 | # Metadata 219 | author_metadata = Author.json_metadata() 220 | assert 'id' in author_metadata['fields'] 221 | assert'email' in author_metadata['fields'] 222 | assert author_metadata['fields']['fullName']['protected'] == True 223 | assert author_metadata['fields']['password']['protected'] == True 224 | 225 | post_metadata = Post.json_metadata() 226 | assert'author' in post_metadata['fields'] 227 | 228 | comment_metadata = Comment.json_metadata() 229 | assert 'post' in comment_metadata['fields'] 230 | 231 | tag_metadata = Tag.json_metadata() 232 | assert 'posts' in tag_metadata['fields'] 233 | 234 | assert Comment.import_value(Comment.__table__.c.special, 'TRUE') ==\ 235 | True 236 | 237 | -------------------------------------------------------------------------------- /tests/test_principal.py: -------------------------------------------------------------------------------- 1 | from nanohttp import configure, settings 2 | 3 | from restfulpy.principal import JWTPrincipal 4 | 5 | 6 | def test_principal(): 7 | __configuration__ = ''' 8 | jwt: 9 | secret: JWT-SECRET 10 | algorithm: HS256 11 | max_age: 86400 # 24 Hours 12 | refresh_token: 13 | secret: JWT-REFRESH-SECRET 14 | algorithm: HS256 15 | max_age: 2678400 # 30 Days 16 | ''' 17 | configure(force=True) 18 | settings.merge(__configuration__) 19 | 20 | principal = JWTPrincipal(dict( 21 | email='test@example.com', 22 | id=1, 23 | sessionId=1, 24 | roles=['admin'] 25 | )) 26 | 27 | assert principal.email == 'test@example.com' 28 | assert principal.id == 1 29 | assert principal.session_id == 1 30 | assert principal.roles == ['admin'] 31 | assert principal.is_in_roles('admin') is True 32 | assert principal.is_in_roles('admin', 'god') is True 33 | 34 | encoded = principal.dump() 35 | 36 | principal = JWTPrincipal.load(encoded.decode()) 37 | assert principal.email == 'test@example.com' 38 | assert principal.id == 1 39 | assert principal.session_id == 1 40 | assert principal.roles == ['admin'] 41 | assert principal.is_in_roles('admin') is True 42 | assert principal.is_in_roles('admin', 'god') is True 43 | 44 | principal = JWTPrincipal.load(encoded.decode(), force=True) 45 | assert principal.email == 'test@example.com' 46 | assert principal.id == 1 47 | assert principal.session_id == 1 48 | assert principal.roles == ['admin'] 49 | assert principal.is_in_roles('admin') is True 50 | assert principal.is_in_roles('admin', 'god') is True 51 | 52 | principal =\ 53 | JWTPrincipal.load((b'Bearer %s' % encoded).decode(), force=True) 54 | assert principal.email == 'test@example.com' 55 | assert principal.id == 1 56 | assert principal.session_id == 1 57 | assert principal.roles == ['admin'] 58 | assert principal.is_in_roles('admin') is True 59 | assert principal.is_in_roles('admin', 'god') is True 60 | 61 | -------------------------------------------------------------------------------- /tests/test_pytest.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, String 2 | 3 | from restfulpy.orm import DeclarativeBase, Field 4 | 5 | 6 | class Member(DeclarativeBase): 7 | __tablename__ = 'members' 8 | 9 | id = Field(Integer, primary_key=True, autoincrement=True) 10 | username = Field(String(50)) 11 | 12 | 13 | def test_db(db): 14 | session = db() 15 | assert session.query(Member).count() == 0 16 | 17 | -------------------------------------------------------------------------------- /tests/test_refreshtoken_without_ssl.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from bddrest.authoring import response 4 | 5 | from restfulpy.application import Application 6 | from restfulpy.authentication import StatefulAuthenticator 7 | from restfulpy.principal import JWTPrincipal, JWTRefreshToken 8 | from restfulpy.testing import ApplicableTestCase 9 | 10 | 11 | roles = ['admin', 'test'] 12 | 13 | 14 | class MockupAuthenticator(StatefulAuthenticator): 15 | def validate_credentials(self, credentials): 16 | raise NotImplementedError() 17 | 18 | def create_refresh_principal(self, member_id=None): 19 | return JWTRefreshToken(dict( 20 | id=member_id 21 | )) 22 | 23 | def create_principal(self, member_id=None, session_id=None, **kwargs): 24 | return JWTPrincipal( 25 | dict(id=1, email='test@example.com', roles=roles, sessionId='1') 26 | ) 27 | 28 | 29 | class TestRefreshTokenWithoutSSl(ApplicableTestCase): 30 | __application__ = Application( 31 | 'Application', 32 | None, 33 | authenticator=MockupAuthenticator() 34 | ) 35 | 36 | __configuration__ = (''' 37 | jwt: 38 | max_age: .3 39 | refresh_token: 40 | max_age: 3 41 | secure: true 42 | ''') 43 | 44 | def test_refresh_token_security(self): 45 | principal = self.__application__.__authenticator__.create_principal() 46 | 47 | token = principal.dump().decode("utf-8") 48 | refresh_principal = self.__application__.__authenticator__.\ 49 | create_refresh_principal() 50 | refresh_token = 'refresh-token=' + refresh_principal.dump().\ 51 | decode("utf-8") 52 | assert refresh_token.startswith('refresh-token=') is True 53 | self._authentication_token = token 54 | 55 | time.sleep(1) 56 | 57 | with self.given( 58 | 'Refresh tokn can not be set in not secure connections', 59 | headers={'Cookie': refresh_token}, 60 | ): 61 | assert response.status == 400 62 | 63 | -------------------------------------------------------------------------------- /tests/test_server_timestamp.py: -------------------------------------------------------------------------------- 1 | from bddrest import response, status, when 2 | from nanohttp import json, Controller, settings 3 | 4 | from restfulpy.testing import ApplicableTestCase 5 | 6 | 7 | class Root(Controller): 8 | 9 | @json 10 | def index(self): 11 | return 'index' 12 | 13 | 14 | class TestServerTimestamp(ApplicableTestCase): 15 | __controller_factory__ = Root 16 | 17 | @classmethod 18 | def configure_application(self): 19 | super().configure_application() 20 | settings.merge('timestamp: true') 21 | 22 | def test_server_timestamp_header(self): 23 | with self.given('Geting server\'s timestamp'): 24 | assert status == 200 25 | assert 'X-Server-Timestamp' in response.headers 26 | 27 | settings.merge('timestamp: false') 28 | when('With default configuration') 29 | assert status == 200 30 | assert 'X-Server-Timestamp' not in response.headers 31 | 32 | -------------------------------------------------------------------------------- /tests/test_smtp_provider.py: -------------------------------------------------------------------------------- 1 | import io 2 | from os.path import dirname, abspath, join 3 | 4 | from nanohttp import settings, configure 5 | 6 | from restfulpy.messaging.providers import SMTPProvider 7 | from restfulpy.mockup import mockup_smtp_server 8 | 9 | 10 | HERE = abspath(dirname(__file__)) 11 | 12 | 13 | def test_smtp_provider(): 14 | configure(force=True) 15 | settings.merge(f''' 16 | smtp: 17 | host: smtp.example.com 18 | port: 587 19 | username: user@example.com 20 | password: password 21 | local_hostname: localhost 22 | tls: false 23 | auth: false 24 | ssl: false 25 | messaging: 26 | mako_modules_directory: {join(HERE, '../../data', 'mako_modules')} 27 | template_directories: 28 | - {join(HERE, 'templates')} 29 | ''', 30 | ) 31 | 32 | with mockup_smtp_server() as (server, bind): 33 | settings.smtp.host = bind[0] 34 | settings.smtp.port = bind[1] 35 | 36 | # Without templates 37 | SMTPProvider().send( 38 | 'test@example.com', 39 | 'test@example.com', 40 | 'Simple test body', 41 | cc='test@example.com', 42 | bcc='test@example.com' 43 | ) 44 | 45 | # With template 46 | SMTPProvider().send( 47 | 'test@example.com', 48 | 'test@example.com', 49 | {}, 50 | template_filename='test-email-template.mako' 51 | ) 52 | 53 | # With attachments 54 | attachment = io.BytesIO(b'This is test attachment file') 55 | attachment.name = 'path/to/file.txt' 56 | SMTPProvider().send( 57 | 'test@example.com', 58 | 'test@example.com', 59 | 'email body with Attachment', 60 | attachments=[attachment] 61 | ) 62 | 63 | -------------------------------------------------------------------------------- /tests/test_sql_exceptions.py: -------------------------------------------------------------------------------- 1 | from bddrest import response, when, status 2 | from nanohttp import json 3 | from sqlalchemy import Unicode, Integer 4 | 5 | from restfulpy.controllers import JSONPatchControllerMixin, ModelRestController 6 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession, \ 7 | FilteringMixin, PaginationMixin, OrderingMixin, ModifiedMixin 8 | from restfulpy.testing import ApplicableTestCase 9 | from restfulpy.exceptions import SQLError 10 | 11 | 12 | class SQLErrorCheckingModel( 13 | ModifiedMixin, 14 | FilteringMixin, 15 | PaginationMixin, 16 | OrderingMixin, 17 | DeclarativeBase 18 | ): 19 | __tablename__ = 'sql_error_checking_model' 20 | 21 | id = Field(Integer, primary_key=True) 22 | title = Field(Unicode(50), unique=True, nullable=False) 23 | 24 | 25 | class Root(ModelRestController): 26 | __model__ = SQLErrorCheckingModel 27 | 28 | @json 29 | @commit 30 | def post(self): 31 | m = SQLErrorCheckingModel() 32 | m.update_from_request() 33 | DBSession.add(m) 34 | return m 35 | 36 | @json 37 | @SQLErrorCheckingModel.expose 38 | def get(self, title: str=None): 39 | query = SQLErrorCheckingModel.query 40 | if title: 41 | return query.filter(SQLErrorCheckingModel.title == title)\ 42 | .one_or_none() 43 | return query 44 | 45 | 46 | class TestSqlExceptions(ApplicableTestCase): 47 | __controller_factory__ = Root 48 | 49 | def test_sql_errors(self): 50 | with self.given( 51 | 'Testing SQL exceptions', 52 | '/', 53 | 'POST', 54 | form=dict(title='test') 55 | ): 56 | assert response.json['title'] == 'test' 57 | 58 | when('Posting gain to raise a unique_violation sql error') 59 | assert status == 409 60 | 61 | def test_invalid_sql_error(self): 62 | assert '500 Internal server error' == SQLError.map_exception(ValueError()) 63 | 64 | -------------------------------------------------------------------------------- /tests/test_stateful_authenticator.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from bddrest import status, response, when 4 | from freezegun import freeze_time 5 | from nanohttp import json, Controller, context 6 | from nanohttp.contexts import Context 7 | 8 | from restfulpy.application import Application 9 | from restfulpy.authentication import StatefulAuthenticator 10 | from restfulpy.authorization import authorize 11 | from restfulpy.principal import JWTPrincipal, JWTRefreshToken 12 | from restfulpy.testing import ApplicableTestCase 13 | 14 | 15 | class MockupMember: 16 | def __init__(self, **kwargs): 17 | self.__dict__.update(kwargs) 18 | 19 | 20 | roles = ['admin', 'test'] 21 | 22 | 23 | class MockupStatefulAuthenticator(StatefulAuthenticator): 24 | def validate_credentials(self, credentials): 25 | email, password = credentials 26 | if password == 'test': 27 | return MockupMember(id=1, email=email, roles=['admin', 'test']) 28 | 29 | def create_refresh_principal(self, member_id=None): 30 | return JWTRefreshToken(dict( 31 | id=member_id 32 | )) 33 | 34 | def create_principal(self, member_id=None, session_id=None): 35 | return JWTPrincipal(dict( 36 | id=1, 37 | email='test@example.com', 38 | roles=roles, 39 | sessionId='1' 40 | )) 41 | 42 | 43 | class Root(Controller): 44 | @json 45 | def login(self): 46 | principal = context.application.__authenticator__.login( 47 | (context.form['email'], context.form['password']) 48 | ) 49 | return dict(token=principal.dump()) 50 | 51 | @json 52 | @authorize 53 | def me(self): 54 | return context.identity.payload 55 | 56 | @json 57 | @authorize 58 | def invalidate_token(self): 59 | context.application.__authenticator__.invalidate_member(1) 60 | return context.identity.payload 61 | 62 | @json(verbs='delete') 63 | @authorize 64 | def logout(self): 65 | context.application.__authenticator__.logout() 66 | return {} 67 | 68 | 69 | class TestStatefulAuthenticator(ApplicableTestCase): 70 | __application__ = Application( 71 | 'statefulauthenticatorapplication', 72 | Root(), 73 | authenticator=MockupStatefulAuthenticator() 74 | ) 75 | 76 | __configuration__ = ''' 77 | jwt: 78 | max_age: 10 79 | refresh_token: 80 | max_age: 20 81 | secure: false 82 | ''' 83 | 84 | def test_invalidate_token(self): 85 | with self.given( 86 | 'Log in to get a token and refresh token cookie', 87 | '/login', 88 | 'POST', 89 | form=dict(email='test@example.com', password='test') 90 | ): 91 | assert status == 200 92 | assert 'token' in response.json 93 | refresh_token = response.headers['Set-Cookie'].split('; ')[0] 94 | assert refresh_token.startswith('refresh-token=') 95 | 96 | # Store token to use it for future requests 97 | token = response.json['token'] 98 | self._authentication_token = token 99 | 100 | with self.given( 101 | 'Request a protected resource to ensure authenticator is ' 102 | 'working well', 103 | '/me', 104 | headers={'Cookie': refresh_token} 105 | ): 106 | assert status == 200 107 | assert response.json['roles'] == roles 108 | 109 | roles.append('god') 110 | when( 111 | 'Invalidating the token by server', 112 | '/invalidate_token' 113 | ) 114 | assert response.json['roles'] == roles 115 | assert 'X-New-JWT-Token' in response.headers 116 | 117 | when( 118 | 'Invalidating the token by server after the token has ' 119 | 'been expired, with appropriate cookies.', 120 | '/invalidate_token', 121 | ) 122 | time.sleep(1) 123 | assert 'X-New-JWT-Token' in response.headers 124 | assert response.headers['X-New-JWT-Token'] is not None 125 | self._authentication_token = response.headers['X-New-JWT-Token'] 126 | 127 | when( 128 | 'Requesting a resource with new token', 129 | '/me', 130 | ) 131 | assert status == 200 132 | 133 | def test_logout(self): 134 | with self.given( 135 | 'Log in to get a token and refresh token cookie', 136 | '/login', 137 | 'POST', 138 | form=dict(email='test@example.com', password='test') 139 | ): 140 | assert status == 200 141 | assert 'token' in response.json 142 | assert response.headers['X-Identity'] == '1' 143 | self._authentication_token = response.json['token'] 144 | 145 | with self.given( 146 | 'Logging out', 147 | '/logout', 148 | 'DELETE' 149 | ): 150 | assert status == 200 151 | assert 'X-Identity' not in response.headers 152 | 153 | def test_session_member(self): 154 | with Context(environ={}, application=self.__application__): 155 | authenticator = self.__application__.__authenticator__ 156 | principal = authenticator.login( 157 | ('test@example.com', 'test') 158 | ) 159 | assert authenticator.get_member_id_by_session( 160 | principal.session_id 161 | ) == 1 162 | 163 | @freeze_time("2017-07-13T13:11:44", tz_offset=-4) 164 | def test_isonline(self): 165 | with Context(environ={}, application=self.__application__): 166 | authenticator = self.__application__.__authenticator__ 167 | principal = authenticator.login( 168 | ('test@example.com', 'test') 169 | ) 170 | assert authenticator.isonline(principal.session_id) == True 171 | 172 | authenticator.logout() 173 | assert authenticator.isonline(principal.session_id) == False 174 | 175 | 176 | session_info_test_cases = [ 177 | { 178 | 'environment': { 179 | 'REMOTE_ADDR': '127.0.0.1', 180 | 'HTTP_USER_AGENT': \ 181 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 5_1 like Mac OS X) ' 182 | 'AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 ' 183 | 'Mobile/9B179 Safari/7534.48.3 RestfulpyClient-js/1.2.3 (My ' 184 | 'App; test-name; 1.4.5-beta78; fa-IR; some; extra; info)' 185 | }, 186 | 'expected_remote_address': '127.0.0.1', 187 | 'expected_machine': 'iPhone', 188 | 'expected_os': 'iOS 5.1', 189 | 'expected_agent': 'Mobile Safari 5.1', 190 | 'expected_last_activity': '2017-07-13T13:11:44', 191 | }, 192 | { 193 | 'environment': { 194 | 'REMOTE_ADDR': '185.87.34.23', 195 | 'HTTP_USER_AGENT': \ 196 | 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; ' 197 | 'Trident/5.0) RestfulpyClient-custom/4.5.6 (A; B; C)' 198 | }, 199 | 'expected_remote_address': '185.87.34.23', 200 | 'expected_machine': 'PC', 201 | 'expected_os': 'Windows 7', 202 | 'expected_agent': 'IE 9.0', 203 | 'expected_last_activity': '2017-07-13T13:11:44', 204 | }, 205 | { 206 | 'environment': { 207 | 'REMOTE_ADDR': '172.16.0.111', 208 | 'HTTP_USER_AGENT': '' 209 | }, 210 | 'expected_remote_address': '172.16.0.111', 211 | 'expected_machine': 'Other', 212 | 'expected_os': 'Other', 213 | 'expected_agent': 'Other', 214 | 'expected_last_activity': '2017-07-13T13:11:44', 215 | }, 216 | { 217 | 'environment': {}, 218 | 'expected_remote_address': '127.0.0.1', 219 | 'expected_machine': 'Other', 220 | 'expected_os': 'Other', 221 | 'expected_agent': 'Other', 222 | 'expected_last_activity': '2017-07-13T13:11:44', 223 | } 224 | ] 225 | -------------------------------------------------------------------------------- /tests/test_string_encoding.py: -------------------------------------------------------------------------------- 1 | from bddrest import response, status 2 | from nanohttp import json 3 | from sqlalchemy import Unicode, Integer 4 | 5 | from restfulpy.controllers import ModelRestController 6 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 7 | from restfulpy.testing import ApplicableTestCase 8 | 9 | 10 | class Foo(DeclarativeBase): 11 | __tablename__ = 'foo' 12 | id = Field(Integer, primary_key=True) 13 | title = Field(Unicode(10)) 14 | 15 | 16 | class Root(ModelRestController): 17 | __model__ = Foo 18 | 19 | @json 20 | @commit 21 | @Foo.expose 22 | def post(self): 23 | m = Foo() 24 | m.update_from_request() 25 | DBSession.add(m) 26 | return m 27 | 28 | @json 29 | @Foo.expose 30 | def get(self): 31 | return DBSession.query(Foo).first() 32 | 33 | 34 | class TestStringEncoding(ApplicableTestCase): 35 | __controller_factory__ = Root 36 | 37 | def test_string_codec(self): 38 | with self.given( 39 | 'Title Has a backslash', 40 | verb='POST', 41 | form=dict(title='\\'), 42 | ): 43 | assert status == 200 44 | assert response.json['title'] == '\\' 45 | 46 | with self.given( 47 | 'Getting foo', 48 | verb='GET', 49 | ): 50 | title = response.json['title'] 51 | assert status == 200 52 | assert title == '\\' 53 | assert len(title) == 1 54 | -------------------------------------------------------------------------------- /tests/test_taskqueue.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from restfulpy.taskqueue import RestfulpyTask, worker 4 | 5 | 6 | awesome_task_done = threading.Event() 7 | another_task_done = threading.Event() 8 | 9 | 10 | class AwesomeTask(RestfulpyTask): 11 | 12 | __mapper_args__ = { 13 | 'polymorphic_identity': 'awesome_task' 14 | } 15 | 16 | def do_(self, context): 17 | awesome_task_done.set() 18 | 19 | 20 | class AnotherTask(RestfulpyTask): 21 | 22 | __mapper_args__ = { 23 | 'polymorphic_identity': 'another_task' 24 | } 25 | 26 | def do_(self, context): 27 | another_task_done.set() 28 | 29 | 30 | class BadTask(RestfulpyTask): 31 | 32 | __mapper_args__ = { 33 | 'polymorphic_identity': 'bad_task' 34 | } 35 | 36 | def do_(self, context): 37 | raise Exception() 38 | 39 | 40 | def test_worker(db): 41 | session = db() 42 | awesome_task = AwesomeTask() 43 | session.add(awesome_task) 44 | 45 | another_task = AnotherTask() 46 | session.add(another_task) 47 | 48 | bad_task = BadTask() 49 | session.add(bad_task) 50 | 51 | session.commit() 52 | 53 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'awesome_task') 54 | assert len(tasks) == 1 55 | 56 | assert awesome_task_done.is_set() == True 57 | assert another_task_done.is_set() == False 58 | 59 | session.refresh(awesome_task) 60 | assert awesome_task.status == 'success' 61 | 62 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'bad_task') 63 | assert len(tasks) == 1 64 | bad_task_id = tasks[0][0] 65 | session.refresh(bad_task) 66 | assert bad_task.status == 'failed' 67 | 68 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'bad_task') 69 | assert len(tasks) == 0 70 | 71 | # Reset the status of one task 72 | session.refresh(bad_task) 73 | bad_task.status = 'in-progress' 74 | session.commit() 75 | session.refresh(bad_task) 76 | 77 | RestfulpyTask.reset_status(bad_task_id, session) 78 | session.commit() 79 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'bad_task') 80 | assert len(tasks) == 1 81 | 82 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'bad_task') 83 | assert len(tasks) == 0 84 | 85 | # Cleanup all tasks 86 | RestfulpyTask.cleanup(session, statuses=('in-progress', 'failed')) 87 | session.commit() 88 | 89 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'bad_task') 90 | assert len(tasks) == 1 91 | 92 | tasks = worker(tries=0, filters=RestfulpyTask.type == 'bad_task') 93 | assert len(tasks) == 0 94 | 95 | # Doing all remaining tasks 96 | tasks = worker(tries=0) 97 | assert len(tasks) == 1 98 | 99 | 100 | -------------------------------------------------------------------------------- /tests/test_time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, time 2 | 3 | import pytest 4 | from bddrest import response, when, status 5 | from dateutil.tz import tzoffset 6 | from nanohttp import json 7 | from sqlalchemy import Integer, Time 8 | 9 | from restfulpy.configuration import settings 10 | from restfulpy.controllers import JSONPatchControllerMixin, ModelRestController 11 | from restfulpy.datetimehelpers import parse_datetime, format_datetime, \ 12 | format_time 13 | from restfulpy.mockup import mockup_localtimezone 14 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 15 | from restfulpy.testing import ApplicableTestCase 16 | 17 | 18 | class Azan(DeclarativeBase): 19 | __tablename__ = 'azan' 20 | id = Field(Integer, primary_key=True) 21 | when = Field(Time) 22 | 23 | 24 | class Root(JSONPatchControllerMixin, ModelRestController): 25 | __model__ = 'azan' 26 | 27 | @json 28 | @commit 29 | def post(self): 30 | m = Azan() 31 | m.update_from_request() 32 | DBSession.add(m) 33 | return m.when.isoformat() 34 | 35 | 36 | class TestTime(ApplicableTestCase): 37 | __controller_factory__ = Root 38 | 39 | def test_update_from_request(self): 40 | with self.given( 41 | 'Posting a time in form', 42 | verb='POST', 43 | form=dict( 44 | when='00:01', 45 | ) 46 | ): 47 | assert status == 200 48 | assert response.json == '00:01:00' 49 | 50 | when( 51 | 'Posting a date instead of time', 52 | form=dict( 53 | when='2001-01-01' 54 | ) 55 | ) 56 | assert status == 200 57 | assert response.json == '00:00:00' 58 | 59 | when( 60 | 'Posting a datetime instead of time', 61 | form=dict( 62 | when='2001-01-01T00:01:00.123456' 63 | ) 64 | ) 65 | assert status == 200 66 | assert response.json == '00:01:00.123456' 67 | 68 | when( 69 | 'Posting an invalid time', 70 | form=dict( 71 | when='' 72 | ) 73 | ) 74 | assert status == '400 Invalid date or time: ' 75 | 76 | when( 77 | 'Posting another invalid time', 78 | form=dict( 79 | when='invalid' 80 | ) 81 | ) 82 | assert status == '400 Invalid date or time: invalid' 83 | 84 | def test_format_time(self): 85 | assert '01:02:03' == format_time(time(1, 2, 3)) 86 | assert '01:02:03' == format_time(datetime(1970, 1, 1, 1, 2, 3)) 87 | 88 | -------------------------------------------------------------------------------- /tests/test_to_json_field_info.py: -------------------------------------------------------------------------------- 1 | from restfulpy.orm.metadata import FieldInfo 2 | 3 | 4 | def test_to_json_fieldinfo(): 5 | age = FieldInfo( 6 | type_=(str, 400), 7 | pattern=('\\d+', 400), 8 | max_length=(2, 400), 9 | min_length=(1, 400), 10 | minimum=(1, 400), 11 | maximum=(99, 400), 12 | readonly=(True, 400), 13 | protected=(True, 400), 14 | not_none=(True, 400), 15 | required=(True, 400), 16 | ) 17 | age_json_field_info = age.to_json() 18 | 19 | assert age_json_field_info['type'] == 'str' 20 | assert age_json_field_info['pattern'] == '\\d+' 21 | assert age_json_field_info['maxLength'] == 2 22 | assert age_json_field_info['minLength'] == 1 23 | assert age_json_field_info['minimum'] == 1 24 | assert age_json_field_info['maximum'] == 99 25 | assert age_json_field_info['readonly'] is True 26 | assert age_json_field_info['protected'] is True 27 | assert age_json_field_info['notNone'] is True 28 | assert age_json_field_info['required'] is True 29 | -------------------------------------------------------------------------------- /tests/test_uuidfield.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from bddrest import response 4 | from nanohttp import json 5 | from sqlalchemy.dialects.postgresql import UUID 6 | 7 | from restfulpy.controllers import JSONPatchControllerMixin, ModelRestController 8 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 9 | from restfulpy.testing import ApplicableTestCase, UUID1Freeze 10 | 11 | 12 | def new_uuid(): 13 | return uuid.uuid1() 14 | 15 | 16 | class Uuid1Model(DeclarativeBase): 17 | 18 | __tablename__ = 'uuid1' 19 | 20 | id = Field(UUID(as_uuid=True), primary_key=True, default=new_uuid) 21 | 22 | 23 | class Root(JSONPatchControllerMixin, ModelRestController): 24 | __model__ = Uuid1Model 25 | 26 | @json 27 | @commit 28 | def get(self): 29 | u = Uuid1Model() 30 | DBSession.add(u) 31 | return u 32 | 33 | 34 | class TestUuidField(ApplicableTestCase): 35 | __controller_factory__ = Root 36 | 37 | def test_uuid1(self): 38 | frozen_uuid_str = 'ce52b1ee602a11e9a721b06ebfbfaee7' 39 | frozen_uuid = uuid.UUID(frozen_uuid_str) 40 | with UUID1Freeze(frozen_uuid), self.given('testing uuid'): 41 | assert response.json['id'] == frozen_uuid_str 42 | 43 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | from datetime import date, time 2 | 3 | import pytest 4 | from nanohttp import HTTPBadRequest, settings, HTTPStatus, RequestValidator 5 | from nanohttp.contexts import Context 6 | from sqlalchemy import Integer, Unicode, ForeignKey, Boolean, Table, Date,\ 7 | Time, Float 8 | from sqlalchemy.orm import synonym 9 | 10 | from restfulpy.orm import DeclarativeBase, Field, relationship, composite, \ 11 | ModifiedMixin 12 | 13 | 14 | class Actor(DeclarativeBase): 15 | __tablename__ = 'actor' 16 | 17 | id = Field( 18 | Integer, 19 | primary_key=True, 20 | readonly='709 id is readonly' 21 | ) 22 | email = Field( 23 | Unicode(100), 24 | not_none='701 email cannot be null', 25 | required='702 email required', 26 | pattern=( 27 | r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)', 28 | '707 Invalid email address' 29 | ), 30 | watermark='Email', 31 | example="user@example.com" 32 | ) 33 | title = Field( 34 | Unicode(50), 35 | index=True, 36 | min_length=(4, '703 title must be at least 4 characters'), 37 | max_length=(50, '704 Maximum allowed length for title is 50'), 38 | watermark='First Name', 39 | nullable=True, 40 | ) 41 | age = Field( 42 | Integer, 43 | nullable=True, 44 | python_type=(int, '705 age must be integer'), 45 | minimum=(18, '706 age must be greater than 17'), 46 | maximum=(99, '706 age must be less than 100') 47 | ) 48 | 49 | 50 | 51 | def test_validation(db): 52 | session = db() 53 | email = 'user@example.com' 54 | 55 | validate = RequestValidator(Actor.create_validation_rules(strict=True)) 56 | values, querystring = validate(dict( 57 | email=email, 58 | )) 59 | assert values['email'] == email 60 | 61 | # Not None 62 | with pytest.raises(HTTPStatus) as ctx: 63 | validate(dict(email=None)) 64 | assert issubclass(ctx.type, HTTPStatus) 65 | assert isinstance(ctx.value, HTTPStatus) 66 | assert str(ctx.value) == '701 email cannot be null' 67 | 68 | # Required 69 | with pytest.raises(HTTPStatus) as ctx: 70 | validate(dict(title='required-test-case')) 71 | assert issubclass(ctx.type, HTTPStatus) 72 | assert isinstance(ctx.value, HTTPStatus) 73 | assert str(ctx.value) == '702 email required' 74 | 75 | # Minimum length 76 | with pytest.raises(HTTPStatus) as ctx: 77 | validate(dict(email=email, title='abc')) 78 | assert issubclass(ctx.type, HTTPStatus) 79 | assert isinstance(ctx.value, HTTPStatus) 80 | assert str(ctx.value) == '703 title must be at least 4 characters' 81 | 82 | # Mamimum length 83 | with pytest.raises(HTTPStatus) as ctx: 84 | validate(dict(email=email, title='a'*(50+1))) 85 | assert issubclass(ctx.type, HTTPStatus) 86 | assert isinstance(ctx.value, HTTPStatus) 87 | assert str(ctx.value) == '704 Maximum allowed length for title is 50' 88 | 89 | # Type 90 | with pytest.raises(HTTPStatus) as ctx: 91 | validate(dict(email=email, title='abcd', age='a')) 92 | assert issubclass(ctx.type, HTTPStatus) 93 | assert isinstance(ctx.value, HTTPStatus) 94 | assert str(ctx.value) == '705 age must be integer' 95 | 96 | # Minimum 97 | with pytest.raises(HTTPStatus) as ctx: 98 | validate(dict(email=email, title='abcd', age=18-1)) 99 | assert issubclass(ctx.type, HTTPStatus) 100 | assert isinstance(ctx.value, HTTPStatus) 101 | assert str(ctx.value) == '706 age must be greater than 17' 102 | 103 | # Maximum 104 | with pytest.raises(HTTPStatus) as ctx: 105 | validate(dict(email=email, title='abcd', age=99+1)) 106 | assert issubclass(ctx.type, HTTPStatus) 107 | assert isinstance(ctx.value, HTTPStatus) 108 | assert str(ctx.value) == '706 age must be less than 100' 109 | 110 | # Pattern 111 | with pytest.raises(HTTPStatus) as ctx: 112 | validate(dict(email='invalidemail')) 113 | assert issubclass(ctx.type, HTTPStatus) 114 | assert isinstance(ctx.value, HTTPStatus) 115 | assert str(ctx.value) == '707 Invalid email address' 116 | 117 | # Readonly 118 | with pytest.raises(HTTPStatus) as ctx: 119 | validate(dict(email=email, id=22)) 120 | assert issubclass(ctx.type, HTTPStatus) 121 | assert isinstance(ctx.value, HTTPStatus) 122 | assert str(ctx.value) == '709 id is readonly' 123 | 124 | -------------------------------------------------------------------------------- /tests/test_validation_decorator.py: -------------------------------------------------------------------------------- 1 | from bddrest import response, status, when, given 2 | from nanohttp import json 3 | from sqlalchemy import Unicode, Integer 4 | 5 | from restfulpy.controllers import ModelRestController 6 | from restfulpy.orm import commit, DeclarativeBase, Field, DBSession 7 | from restfulpy.testing import ApplicableTestCase 8 | 9 | 10 | class Supervisor(DeclarativeBase): 11 | __tablename__ = 'supervisor' 12 | 13 | id = Field( 14 | Integer, 15 | primary_key=True, 16 | readonly='709 id is readonly' 17 | ) 18 | email = Field( 19 | Unicode(100), 20 | not_none='701 email cannot be null', 21 | required='702 email required', 22 | pattern=( 23 | r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)', 24 | '707 Invalid email address' 25 | ), 26 | watermark='Email', 27 | example="user@example.com" 28 | ) 29 | title = Field( 30 | Unicode(50), 31 | index=True, 32 | min_length=(4, '703 title must be at least 4 characters'), 33 | max_length=(50, '704 Maximum allowed length for title is 50'), 34 | watermark='First Name', 35 | nullable=True, 36 | ) 37 | age = Field( 38 | Integer, 39 | nullable=True, 40 | python_type=(int, '705 age must be integer'), 41 | minimum=(18, '706 age must be greater than 17'), 42 | maximum=(99, '706 age must be less than 100') 43 | ) 44 | garbage = Field( 45 | Unicode(50), 46 | nullable=True, 47 | ) 48 | 49 | 50 | class Root(ModelRestController): 51 | __model__ = Supervisor 52 | 53 | @json 54 | @commit 55 | @Supervisor.validate(strict=True) 56 | @Supervisor.expose 57 | def post(self): 58 | m = Supervisor() 59 | m.update_from_request() 60 | DBSession.add(m) 61 | return m 62 | 63 | @json 64 | @commit 65 | @Supervisor.validate 66 | @Supervisor.expose 67 | def put(self): 68 | m = Supervisor() 69 | m.update_from_request() 70 | DBSession.add(m) 71 | return m 72 | 73 | 74 | @json 75 | @Supervisor.expose 76 | def get(self): 77 | return DBSession.query(Supervisor).first() 78 | 79 | @json 80 | @commit 81 | @Supervisor.validate(strict=True, fields=dict( 82 | email=dict( 83 | not_none=False, 84 | required=False 85 | ) 86 | ), ignore=['garbage']) 87 | def extra(self): 88 | m = Supervisor() 89 | m.update_from_request() 90 | DBSession.add(m) 91 | return m 92 | 93 | 94 | class TestModelValidationDecorator(ApplicableTestCase): 95 | __controller_factory__ = Root 96 | 97 | def test_string_codec(self): 98 | with self.given( 99 | 'Whitebox', 100 | verb='POST', 101 | form=dict( 102 | title='white', 103 | email='user@example.com', 104 | ), 105 | ): 106 | assert status == 200 107 | assert response.json['title'] == 'white' 108 | 109 | when( 110 | 'Not strict', 111 | verb='PUT', 112 | form=dict( 113 | email='userput@example.com' 114 | ) 115 | ) 116 | assert status == 200 117 | 118 | 119 | when( 120 | 'Invalid email format', 121 | form=dict( 122 | title='black1', 123 | email='inavlidformat' 124 | ) 125 | ) 126 | assert status == 707 127 | 128 | when( 129 | 'Passing readonly field', 130 | form=dict( 131 | id=22, 132 | title='black2', 133 | email='user@example.com' 134 | ) 135 | ) 136 | assert status == 709 137 | 138 | when( 139 | 'Testing extra validation rules', 140 | verb='EXTRA', 141 | form=given | dict(email='InvalidEmail') 142 | ) 143 | assert status == 707 144 | 145 | --------------------------------------------------------------------------------