From 4e6ea8b0f9b2942bb5ef4e498834d2fbd8d77081 Mon Sep 17 00:00:00 2001 From: Diogo Albuquerque Date: Fri, 23 Dec 2016 20:31:47 -0200 Subject: [PATCH] WIP --- .coveragerc | 6 + .gitignore | 3 + .travis.yml | 13 + LICENSE | 2 +- MANIFEST.in | 6 + bin/bash_profile | 28 + docker/Dockerfile | 9 + docker/docker-compose.yml | 24 + docker/run_tests.sh | 5 + pytest-docker-vars.json | 14 + pytest-docker.ini | 2 + pytest-travis-vars.json | 14 + pytest-travis.ini | 2 + pytest-vars.json | 14 + pytest.ini | 2 + requirements-dev.txt | 6 + requirements.txt | 6 + setup.py | 70 + swaggerit/__init__.py | 0 swaggerit/api.py | 210 +++ swaggerit/constants.py | 40 + swaggerit/exceptions.py | 33 + swaggerit/json_builder.py | 120 ++ swaggerit/method.py | 184 ++ swaggerit/models/__init__.py | 0 swaggerit/models/base.py | 78 + swaggerit/models/orm/__init__.py | 0 swaggerit/models/orm/factories.py | 74 + swaggerit/models/orm/jobs.py | 104 ++ swaggerit/models/orm/redis.py | 135 ++ swaggerit/models/orm/redis_base.py | 80 + swaggerit/models/orm/session.py | 144 ++ swaggerit/models/orm/sqlalchemy_redis.py | 510 ++++++ swaggerit/models/orm/swaggerit.py | 97 + swaggerit/models/swaggerit.py | 89 + swaggerit/request.py | 47 + swaggerit/response.py | 37 + swaggerit/sanic_api.py | 87 + swaggerit/swagger_schema_extended.json | 1624 +++++++++++++++++ swaggerit/swagger_template.json | 5 + swaggerit/utils.py | 70 + tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/conftest.py | 95 + tests/integration/models/__init__.py | 0 tests/integration/models/orm/__init__.py | 0 .../integration/models/orm/models_fixtures.py | 370 ++++ .../models/orm/test_jobs_integration.py | 121 ++ .../models/orm/test_redis_integration.py | 66 + .../models/orm/test_session_integration.py | 245 +++ .../test_session_without_redis_integration.py | 68 + .../test_sqlalchemy_redis_get_integration.py | 124 ++ ...qlalchemy_redis_get_related_integration.py | 174 ++ .../orm/test_sqlalchemy_redis_integration.py | 718 ++++++++ tests/integration/test_api_errors.py | 219 +++ tests/integration/test_sanic_api.py | 144 ++ tests/unit/__init__.py | 0 tests/unit/test_json_builder.py | 51 + version.py | 2 + 59 files changed, 6390 insertions(+), 1 deletion(-) create mode 100644 .coveragerc create mode 100644 .travis.yml create mode 100644 MANIFEST.in create mode 100644 bin/bash_profile create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100755 docker/run_tests.sh create mode 100644 pytest-docker-vars.json create mode 100644 pytest-docker.ini create mode 100644 pytest-travis-vars.json create mode 100644 pytest-travis.ini create mode 100644 pytest-vars.json create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 swaggerit/__init__.py create mode 100644 swaggerit/api.py create mode 100644 swaggerit/constants.py create mode 100644 swaggerit/exceptions.py create mode 100644 swaggerit/json_builder.py create mode 100644 swaggerit/method.py create mode 100644 swaggerit/models/__init__.py create mode 100644 swaggerit/models/base.py create mode 100644 swaggerit/models/orm/__init__.py create mode 100644 swaggerit/models/orm/factories.py create mode 100644 swaggerit/models/orm/jobs.py create mode 100644 swaggerit/models/orm/redis.py create mode 100644 swaggerit/models/orm/redis_base.py create mode 100644 swaggerit/models/orm/session.py create mode 100644 swaggerit/models/orm/sqlalchemy_redis.py create mode 100644 swaggerit/models/orm/swaggerit.py create mode 100644 swaggerit/models/swaggerit.py create mode 100644 swaggerit/request.py create mode 100644 swaggerit/response.py create mode 100644 swaggerit/sanic_api.py create mode 100644 swaggerit/swagger_schema_extended.json create mode 100644 swaggerit/swagger_template.json create mode 100644 swaggerit/utils.py create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/models/__init__.py create mode 100644 tests/integration/models/orm/__init__.py create mode 100644 tests/integration/models/orm/models_fixtures.py create mode 100644 tests/integration/models/orm/test_jobs_integration.py create mode 100644 tests/integration/models/orm/test_redis_integration.py create mode 100644 tests/integration/models/orm/test_session_integration.py create mode 100644 tests/integration/models/orm/test_session_without_redis_integration.py create mode 100644 tests/integration/models/orm/test_sqlalchemy_redis_get_integration.py create mode 100644 tests/integration/models/orm/test_sqlalchemy_redis_get_related_integration.py create mode 100644 tests/integration/models/orm/test_sqlalchemy_redis_integration.py create mode 100644 tests/integration/test_api_errors.py create mode 100644 tests/integration/test_sanic_api.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_json_builder.py create mode 100644 version.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7273b72 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +omit = + version.py + *__init__* + +branch = True diff --git a/.gitignore b/.gitignore index 72364f9..0341f92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# sublime-text +*.sublime-* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4ca8069 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +python: + - "3.5" + - "3.6" +services: + - mysql + - redis +script: py.test -c pytest-travis.ini +install: + - pip install -r requirements.txt -r requirements-dev.txt + - pip install coveralls +after_success: + - coveralls diff --git a/LICENSE b/LICENSE index bb81146..43f1377 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2016 Diogo Dutra +Copyright (c) 2016 Diogo Dutra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b33e48d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include swaggerit/swagger_schema_extended.json +include swaggerit/swagger_template.json +include README.md +include requirements.txt +include requirements-dev.txt +include version.py diff --git a/bin/bash_profile b/bin/bash_profile new file mode 100644 index 0000000..85c14b9 --- /dev/null +++ b/bin/bash_profile @@ -0,0 +1,28 @@ +# Sets the SWAGGERIT_HOME variable to your working directory +# export SWAGGERIT_HOME=$HOME/dev/swaggerit + + +function docker-compose-wrapper { + docker-compose -f $SWAGGERIT_HOME/docker/docker-compose.yml "${@}" +} + + +function swaggerit-exec { + docker-compose-wrapper exec swaggerit "${@}" +} + + +function swaggerit-up { + docker-compose-wrapper up -d "${@}" +} + + +function swaggerit-kill { + docker-compose-wrapper kill "${@}" +} + + +function swaggerit-tests { + swaggerit-up &>/dev/null + docker-compose-wrapper exec swaggerit run-tests "${@}" +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c3f6955 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,9 @@ +FROM python:alpine + +MAINTAINER Diogo Dutra +RUN apk update && apk add bash alpine-sdk +RUN git clone http://github.com/dutradda/swaggerit --branch forking_from_falcon_swagger /tmp/swaggerit +RUN pip install -r /tmp/swaggerit/requirements-dev.txt -r /tmp/swaggerit/requirements.txt +RUN rm -rf /tmp/swaggerit +ADD run_tests.sh /usr/bin/run-tests +ENTRYPOINT /bin/bash \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..87dd50d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,24 @@ +version: "2" +services: + mysql: + image: wangxian/alpine-mysql + container_name: swaggerit-mysql + environment: + MYSQL_ROOT_PASSWORD: root + redis: + image: redis:alpine + container_name: swaggerit-redis + swaggerit: + build: . + image: swaggerit + container_name: swaggerit + stdin_open: true + tty: true + depends_on: + - mysql + - redis + links: + - mysql:swaggerit-mysql + - redis:swaggerit-redis + volumes: + - $SWAGGERIT_HOME:/swaggerit \ No newline at end of file diff --git a/docker/run_tests.sh b/docker/run_tests.sh new file mode 100755 index 0000000..980875e --- /dev/null +++ b/docker/run_tests.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +cd /swaggerit +pip install -r requirements-dev.txt -r requirements.txt +py.test -c pytest-docker.ini $@ diff --git a/pytest-docker-vars.json b/pytest-docker-vars.json new file mode 100644 index 0000000..e87de22 --- /dev/null +++ b/pytest-docker-vars.json @@ -0,0 +1,14 @@ +{ + "database": { + "host": "swaggerit-mysql", + "port": 3306, + "user": "root", + "password": "root", + "database": "swaggerit_test" + }, + "redis": { + "host": "swaggerit-redis", + "port": 6379, + "db": 0 + } +} diff --git a/pytest-docker.ini b/pytest-docker.ini new file mode 100644 index 0000000..8e4330b --- /dev/null +++ b/pytest-docker.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --cov=swaggerit --no-cov-on-fail --exitfirst --variables pytest-docker-vars.json diff --git a/pytest-travis-vars.json b/pytest-travis-vars.json new file mode 100644 index 0000000..e152e5c --- /dev/null +++ b/pytest-travis-vars.json @@ -0,0 +1,14 @@ +{ + "database": { + "host": "localhost", + "port": 3306, + "user": "root", + "password": null, + "database": "swaggerit_test" + }, + "redis": { + "host": "localhost", + "port": 6379, + "db": 0 + } +} diff --git a/pytest-travis.ini b/pytest-travis.ini new file mode 100644 index 0000000..034e305 --- /dev/null +++ b/pytest-travis.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --cov=swaggerit --no-cov-on-fail --exitfirst --variables pytest-travis-vars.json diff --git a/pytest-vars.json b/pytest-vars.json new file mode 100644 index 0000000..1602cfd --- /dev/null +++ b/pytest-vars.json @@ -0,0 +1,14 @@ +{ + "database": { + "host": "localhost", + "port": 3306, + "user": "root", + "password": "root", + "database": "swaggerit_test" + }, + "redis": { + "host": "localhost", + "port": 6379, + "db": 5 + } +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..82ee4ef --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --cov=swaggerit --cov-report=html --no-cov-on-fail --exitfirst --variables pytest-vars.json diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..734de33 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +pytest==2.9.2 +pytest-cov==2.3.1 +pytest-variables[hjson]==1.4 +ipython==5.1.0 +PyMySQL==0.7.9 +aiohttp==1.2.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc94c8c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +SQLAlchemy==1.1.1 +redis==2.10.5 +hiredis==0.2.0 +jsonschema==2.5.1 +ujson==1.35 +https://github.com/dutradda/sanic/archive/master.tar.gz diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2c350f5 --- /dev/null +++ b/setup.py @@ -0,0 +1,70 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from version import VERSION +from setuptools import setup, find_packages + + +long_description = '' + + +install_requires = [] +with open('requirements.txt') as requirements: + install_requires = requirements.readlines() + + +tests_require = [] +with open('requirements-dev.txt') as requirements_dev: + tests_require = requirements_dev.readlines() + + +setup( + name='swaggerit', + packages=find_packages('.'), + include_package_data=True, + version=VERSION, + description='A Framework featuring Swagger, SQLAlchemy and Redis', + long_description=long_description, + author='Diogo Dutra', + author_email='dutradda@gmail.com', + url='https://github.com/dutradda/swaggerit', + download_url='http://github.com/dutradda/swaggerit/archive/master.zip', + license='MIT', + keywords='framework swagger openapi sqlalchemy redis crud', + setup_requires=[ + 'pytest-runner==2.9', + 'setuptools==28.3.0' + ], + tests_require=tests_require, + install_requires=install_requires, + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Programming Language :: Python :: 3.5', + 'Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware', + 'Topic :: Database :: Front-Ends', + 'Topic :: Software Development :: Libraries :: Python Modules' + ] +) diff --git a/swaggerit/__init__.py b/swaggerit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swaggerit/api.py b/swaggerit/api.py new file mode 100644 index 0000000..2ffcb43 --- /dev/null +++ b/swaggerit/api.py @@ -0,0 +1,210 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.method import SwaggerMethod +from swaggerit.response import SwaggerResponse +from swaggerit.models.orm.session import Session +from swaggerit.exceptions import SwaggerItAPIError +from swaggerit.constants import SWAGGER_TEMPLATE, SWAGGER_SCHEMA, HTTP_METHODS +from swaggerit.utils import set_logger +from collections import namedtuple, defaultdict +from jsonschema import Draft4Validator, ValidationError +from copy import deepcopy +import ujson +import re + + +class SwaggerAPI(object): + + def __init__(self, models, sqlalchemy_bind=None, redis_bind=None, + swagger_json_template=None, title=None, version='1.0.0', + authorizer=None, get_swagger_req_auth=True): + set_logger(self) + self._authorizer = authorizer + self._sqlalchemy_bind = sqlalchemy_bind + self._redis_bind = redis_bind + self._get_swagger_req_auth = get_swagger_req_auth + + self._set_swagger_json(swagger_json_template, title, version) + self._set_models(models) + self._set_path_method_map() + + def _set_swagger_json(self, swagger_json_template, title, version): + self._validate_metadata(swagger_json_template, title, version) + if swagger_json_template is None: + swagger_json_template = deepcopy(SWAGGER_TEMPLATE) + swagger_json_template['info']['title'] = title + swagger_json_template['info']['version'] = version + + if swagger_json_template['paths']: + raise SwaggerItAPIError("The Swagger Json 'paths' property will be populated " + "by the 'models' contents. This property must be empty.") + + Draft4Validator(SWAGGER_SCHEMA).validate(swagger_json_template) + + self.swagger_json = deepcopy(swagger_json_template) + definitions = self.swagger_json.get('definitions', {}) + self.swagger_json['definitions'] = definitions + + def _validate_metadata(self, swagger_json_template, title, version): + if bool(title is None) == bool(swagger_json_template is None): + raise SwaggerItAPIError("One of 'title' or 'swagger_json_template' " + "arguments must be setted.") + + if version != '1.0.0' and swagger_json_template is not None: + raise SwaggerItAPIError("'version' argument can't be setted when " + "'swagger_json_template' was setted.") + + def _set_models(self, models): + self._models = set() + for model in models: + if hasattr(model, '__schema__'): + if model.__api__ is not None: + raise SwaggerItAPIError( + "Model '{}' was already registered for '{}' API".format( + model.__name__, model.__api__.__class__.__name__ + )) + + self._models.add(model) + model.__api__ = self + model_paths, definitions = self._get_model_paths_and_definitions(model) + self.swagger_json['paths'].update(model_paths) + self.swagger_json['definitions'].update(definitions) + + if not self.swagger_json['definitions']: + self.swagger_json.pop('definitions') + + def _get_model_paths_and_definitions(self, model): + model_paths = deepcopy(model.__schema__) + self._validate_model_paths(model_paths, model) + + definitions = {} + for definition, values in model_paths.pop('definitions', {}).items(): + definitions['{}.{}'.format(model.__name__, definition)] = values + + self._format_operations_names(model_paths, model) + model_paths = self._format_definitions_names(model_paths, model) + + return model_paths, definitions + + def _validate_model_paths(self, model_paths, model): + for path in model_paths: + if path in self.swagger_json['paths']: + raise SwaggerItAPIError("Duplicated path '{}' for models '{}' and '{}'".format( + path, model.__name__, self._get_duplicated_path_model_name(path))) + + def _format_operations_names(self, model_paths, model): + for path_name, path in model_paths.items(): + for method_name, method in path.items(): + if not isinstance(method, list): + opId = method['operationId'] + method['operationId'] = '{}.{}'.format(model.__name__, opId) + + def _format_definitions_names(self, model_paths, model): + json_paths = ujson.dumps(model_paths) + json_paths = re.sub(r'"#/definitions/([a-zA-Z0-9_]+)"', + r'"#/definitions/{}.\1"'.format(model.__name__), + json_paths) + return ujson.loads(json_paths) + + def _set_path_method_map(self): + self._path_method_map = defaultdict(dict) + base_path = self._get_base_path() + + for model in self._models: + for path, path_schema in model.__schema__.items(): + all_methods_parameters = path_schema.get('parameters', []) + self._validate_authorizer(all_methods_parameters) + path = base_path + path.rstrip('/') + + for method_name in HTTP_METHODS: + method_schema = path_schema.get(method_name) + + if method_schema is not None: + method_schema = deepcopy(method_schema) + definitions = model.__schema__.get('definitions') + parameters = method_schema.get('parameters', []) + self._validate_authorizer(parameters) + parameters.extend(all_methods_parameters) + + method_schema['parameters'] = parameters + operation = getattr(model, method_schema['operationId']) + method = SwaggerMethod(operation, method_schema, + definitions, model.__schema_dir__) + self._path_method_map[path][method_name] = method + + def _get_base_path(self): + return self.swagger_json.get('basePath', '').rstrip('/') + + def _validate_authorizer(self, parameters): + for param in parameters: + if param['in'] == 'header' and param['name'] == 'Authorization' and self._authorizer is None: + raise SwaggerItAPIError( + "'authorizer' attribute must be setted with 'Authorization' header setted") + + def get_response(self, req): + path = req.path.rstrip('/') + method = self._path_method_map[path].get(req.method) + authorization = req.headers.get('Authorization') + response_headers = {'Content-Type': 'application/json'} + + if method is None: + headers = {'Allow': ', '.join(self._path_method_map[path].keys())} + return SwaggerResponse(405, headers) + + session = self._build_session() + + if method.auth_required or (self._authorizer and authorization is not None): + response = self._authorizer(req, session) + if response is not None: + self._destroy_session(session) + return response + + try: + response = method(req, session) + + except ValidationError as error: + body = { + 'message': error.message + } + if isinstance(error.schema, dict) and len(error.schema): + body['schema'] = error.schema + if error.instance: + body['instance'] = error.instance + response = SwaggerResponse(400, body=ujson.dumps(body), headers=response_headers) + + except Exception as error: + body = ujson.dumps({'message': 'Something unexpected happened'}) + self._logger.exception('ERROR Unexpected') + response = SwaggerResponse(500, body=body, headers=response_headers) + + self._destroy_session(session) + return response + + def _build_session(self): + if self._sqlalchemy_bind is not None or self._redis_bind is not None: + return Session(bind=self._sqlalchemy_bind, redis_bind=self._redis_bind) + + def _destroy_session(self, session): + if hasattr(session, 'close'): + session.close() diff --git a/swaggerit/constants.py b/swaggerit/constants.py new file mode 100644 index 0000000..03e0d74 --- /dev/null +++ b/swaggerit/constants.py @@ -0,0 +1,40 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.utils import build_validator, get_model_schema, get_dir_path +import os.path + + +HTTP_METHODS = ('delete', 'get', 'head', 'options', 'patch', 'post', 'put') + + +SWAGGER_TEMPLATE = get_model_schema(__file__, 'swagger_template.json') + + +SWAGGER_SCHEMA = get_model_schema(__file__, 'swagger_schema_extended.json') + + +SWAGGER_VALIDATOR = build_validator( + {'$ref': 'swagger_schema_extended.json#/definitions/paths'}, + get_dir_path(__file__) +) diff --git a/swaggerit/exceptions.py b/swaggerit/exceptions.py new file mode 100644 index 0000000..35acdc3 --- /dev/null +++ b/swaggerit/exceptions.py @@ -0,0 +1,33 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +class SwaggerItError(Exception): + pass + + +class SwaggerItAPIError(SwaggerItError): + pass + + +class SwaggerItModelError(SwaggerItError): + pass diff --git a/swaggerit/json_builder.py b/swaggerit/json_builder.py new file mode 100644 index 0000000..5b0d8eb --- /dev/null +++ b/swaggerit/json_builder.py @@ -0,0 +1,120 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from jsonschema import ValidationError +from copy import deepcopy +import ujson + + +class JsonBuilderMeta(type): + + def _type_builder(cls, type_): + return getattr(cls, '_build_' + type_) + + def _build_string(cls, value): + return str(value) + + def _build_number(cls, value): + return float(value) + + def _build_boolean(cls, value): + value = ujson.loads(value) + if not isinstance(value, bool): + raise ValueError(value) + return value + + def _build_integer(cls, value): + return int(value) + + def _build_array(cls, values, schema, nested_types, input_): + if 'array' in nested_types: + raise ValidationError('nested array was not allowed', instance=input_) + + if isinstance(values, list): + new_values = [] + [new_values.extend(value.split(',')) for value in values] + values = new_values + else: + values = values.split(',') + + items_schema = schema.get('items') + if items_schema: + nested_types.add('array') + + if isinstance(items_schema, dict): + values = [cls._build_value( + value, items_schema, nested_types, input_) for value in values] + + elif isinstance(items_schema, list): + if len(items_schema) != len(values): + raise ValidationError( + "size mismatch for items array '{}'".format(', '.join(values)), + instance=input_, schema=items_schema) + values = [cls._build_value(value, schema, nested_types, input_) \ + for value, schema in zip(values, items_schema)] + + return values + + def _build_value(cls, value, schema, nested_types, input_): + type_ = schema['type'] + exception = ValidationError("invalid value '{}' for type '{}'".format(value, type_), + instance=input_, schema=schema) + if type_ == 'array' or type_ == 'object': + try: + return cls._type_builder(type_)(value, schema, nested_types, input_) + except ValueError: + raise exception + + try: + return cls._type_builder(type_)(value) + except ValueError: + raise exception + + def _build_object(cls, value, schema, nested_types, input_): + if 'object' in nested_types: + raise ValidationError('nested object was not allowed', instance=input_) + + properties = value.split('|') + dict_obj = dict() + nested_types.add('object') + for prop in properties: + key, value = prop.split(':') + prop_schema = schema['properties'].get(key) + if prop_schema is None: + raise ValidationError("Invalid property '{}'".format(key), + instance=input_, schema=schema) + + dict_obj[key] = \ + cls._build_value(value, prop_schema, nested_types, input_) + + nested_types.discard('object') + return dict_obj + + +class JsonBuilder(metaclass=JsonBuilderMeta): + + @classmethod + def build(cls, json_value, schema): + nested_types = set() + input_ = deepcopy(json_value) + return cls._build_value(json_value, schema, nested_types, input_) diff --git a/swaggerit/method.py b/swaggerit/method.py new file mode 100644 index 0000000..9e779a0 --- /dev/null +++ b/swaggerit/method.py @@ -0,0 +1,184 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.json_builder import JsonBuilder +from swaggerit.utils import build_validator +from swaggerit.request import SwaggerRequest +from swaggerit.response import SwaggerResponse +from jsonschema import ValidationError +from copy import deepcopy +import ujson + + +class SwaggerMethod(object): + + def __init__(self, operation, schema, definitions, schema_dir): + self._operation = operation + self._body_validator = None + self._path_validator = None + self._query_validator = None + self._headers_validator = None + self._schema_dir = schema_dir + self._body_required = False + self._has_body_parameter = False + self.auth_required = False + + query_schema = self._build_default_schema() + path_schema = self._build_default_schema() + headers_schema = self._build_default_schema() + + for parameter in schema.get('parameters', []): + if parameter['in'] == 'body': + if definitions: + body_schema = deepcopy(parameter['schema']) + body_schema.update({'definitions': definitions}) + else: + body_schema = parameter['schema'] + + self._body_validator = build_validator(body_schema, self._schema_dir) + self._body_required = parameter.get('required', False) + self._has_body_parameter = True + + elif parameter['in'] == 'path': + self._set_parameter_on_schema(parameter, path_schema) + + elif parameter['in'] == 'query': + self._set_parameter_on_schema(parameter, query_schema) + + elif parameter['in'] == 'header': + self._set_parameter_on_schema(parameter, headers_schema) + + if path_schema['properties']: + self._path_validator = build_validator(path_schema, self._schema_dir) + + if query_schema['properties']: + self._query_validator = build_validator(query_schema, self._schema_dir) + + if headers_schema['properties']: + has_auth = ('Authorization' in headers_schema['properties']) + + self.auth_required = (has_auth + and ('Authorization' in headers_schema.get('required', []))) + + self._headers_validator = build_validator(headers_schema, self._schema_dir) + + def _build_default_schema(self): + return {'type': 'object', 'required': [], 'properties': {}} + + def _set_parameter_on_schema(self, parameter, schema): + name = parameter['name'] + property_ = {'type': parameter['type']} + + if parameter['type'] == 'array': + items = parameter.get('items', {}) + if items: + property_['items'] = items + + if parameter['type'] == 'object': + obj_schema = parameter.get('schema', {}) + if obj_schema: + property_.update(obj_schema) + + if parameter.get('required'): + schema['required'].append(name) + + schema['properties'][name] = property_ + + def __call__(self, req, session): + try: + body_params = self._build_body_params(req) + query_params = self._build_non_body_params(self._query_validator, req.query) + path_params = self._build_non_body_params(self._path_validator, + req.path_params) + headers_params = self._build_non_body_params(self._headers_validator, dict(req.headers)) + except ValidationError as error: + body = { + 'message': error.message + } + if isinstance(error.schema, dict) and len(error.schema): + body['schema'] = error.schema + if error.instance: + body['instance'] = error.instance + return SwaggerResponse(400, body=ujson.dumps(body)) + + req = SwaggerRequest( + path=req.path, + method=req.method, + url=req.url, + path_params=path_params, + query=query_params, + headers=headers_params, + body=body_params, + body_schema=self._body_validator.schema if self._body_validator else None, + context=req.context + ) + + if session is None: + return self._operation(req) + else: + return self._operation(req, session) + + def _build_body_params(self, req): + content_type = req.headers.get('Content-Type') + if content_type is None and req.body: + raise ValidationError('Request content_type is missing', instance=req.body) + + elif self._body_required and not req.body: + raise ValidationError('Request body is missing', instance=req.body) + + elif content_type is not None and 'application/json' in content_type: + if not self._has_body_parameter: + # try to format the body output + try: + body = ujson.loads(req.body) + except: + body = req.body + raise ValidationError('Request body is not acceptable', instance=body) + + try: + body = ujson.loads(req.body) + except (ValueError, TypeError) as error: + raise ValidationError(*error.args, instance=req.body) + + if self._body_validator: + self._body_validator.validate(body) + + return body + + elif self._body_required: + raise ValidationError('Invalid Content-Type header', instance=content_type) + + else: + return req.body + + def _build_non_body_params(self, validator, params): + if validator: + for param_name, prop in validator.schema['properties'].items(): + param = params.get(param_name) + + if param is not None: + params[param_name] = JsonBuilder.build(param, prop) + + validator.validate(params) + + return params diff --git a/swaggerit/models/__init__.py b/swaggerit/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swaggerit/models/base.py b/swaggerit/models/base.py new file mode 100644 index 0000000..3b98e4c --- /dev/null +++ b/swaggerit/models/base.py @@ -0,0 +1,78 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.exceptions import SwaggerItModelError +from swaggerit.utils import set_logger +from types import MethodType +import ujson +import re + + +def _init(obj, name): + all_models = type(obj).__all_models__ + name = name.replace('Model', '') + key = obj.__key__ = getattr(obj, '__key__', _camel_case_convert(name)) + + if key in all_models: + raise SwaggerItModelError("The model '{}' was already registered with name '{}'." + .format(all_models[key].__name__, key)) + + all_models[key] = obj + set_logger(obj) + +def _camel_case_convert(name): + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + +def _get_model(obj, name): + return type(obj).__all_models__[name] + +def _unpack_obj(obj): + return ujson.loads(obj) + +def _pack_obj(obj): + return ujson.dumps(obj) + + +class ModelBaseMeta(type): + __all_models__ = dict() + + def __init__(cls, name, bases_classes, attributes): + _init(cls, name) + cls.get_model = MethodType(_get_model, cls) + cls._unpack_obj = _unpack_obj + cls._pack_obj = _pack_obj + + def get_key(cls, sufix=None): + if not sufix or sufix == cls.__key__: + return cls.__key__ + + return '{}_{}'.format(cls.__key__, sufix) + + +class ModelBase(object): + __all_models__ = dict() + + def __init__(self): + _init(self, type(self).__name__) + self.get_model = MethodType(_get_model, self) diff --git a/swaggerit/models/orm/__init__.py b/swaggerit/models/orm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swaggerit/models/orm/factories.py b/swaggerit/models/orm/factories.py new file mode 100644 index 0000000..ca7ec43 --- /dev/null +++ b/swaggerit/models/orm/factories.py @@ -0,0 +1,74 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.orm.redis import ModelRedisMeta +from swaggerit.models.orm.sqlalchemy_redis import ( + ModelSQLAlchemyRedisBaseMeta, ModelSQLAlchemyRedisBaseSuper) +from sqlalchemy.ext.declarative import declarative_base +from types import MethodType + + +class ModelRedisFactory(object): + + @staticmethod + def make(class_name, id_names, key=None, schema=None, key_separator=None, metaclass=None): + attributes = { + '__id_names__': sorted(tuple(id_names)) + } + + if key is not None: + attributes['__key__'] = key + + if schema is not None: + attributes['__schema__'] = schema + + if key_separator is not None: + attributes['__key_separator__'] = key_separator + + if metaclass is None: + metaclass = ModelRedisMeta + + class_ = metaclass(class_name, (dict,), attributes) + class_.update = MethodType(metaclass.update, class_) + class_.get = MethodType(metaclass.get, class_) + return class_ + + +class ModelSQLAlchemyRedisBaseFactory(object): + + @staticmethod + def make(name='ModelSQLAlchemyRedisBase', + bind=None, metadata=None, + mapper=None, key_separator=None, + metaclass=ModelSQLAlchemyRedisBaseMeta, + cls=ModelSQLAlchemyRedisBaseSuper, + constructor=ModelSQLAlchemyRedisBaseSuper.__init__): + base = declarative_base( + name=name, metaclass=metaclass, + cls=cls, bind=bind, metadata=metadata, + mapper=mapper, constructor=constructor) + + if key_separator is not None: + base.__key_separator__ = key_separator + + return base diff --git a/swaggerit/models/orm/jobs.py b/swaggerit/models/orm/jobs.py new file mode 100644 index 0000000..26e9998 --- /dev/null +++ b/swaggerit/models/orm/jobs.py @@ -0,0 +1,104 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.swaggerit import ModelSwaggerItMeta +from swaggerit.response import SwaggerResponse +from concurrent.futures import ThreadPoolExecutor +from collections import defaultdict +from datetime import datetime +import random + + +class ModelJobsMeta(ModelSwaggerItMeta): + + def _create_job(cls, func, jobs_id, req, session, *arg, **kwargs): + executor = ThreadPoolExecutor(2) + + job_hash = '{:x}'.format(random.getrandbits(128)) + job = executor.submit(func, req, session, *arg, **kwargs) + executor.submit(cls._job_watcher, jobs_id, job_hash, job, session) + return cls._build_response(201, body=cls._pack_obj({'job_hash': job_hash})) + + def _build_response(cls, status_code, body=None): + return SwaggerResponse(status_code, {}, body) + + def _job_watcher(cls, jobs_id, job_hash, job, session): + cls._set_job(jobs_id, job_hash, {'status': 'running'}, session) + start_time = datetime.now() + + try: + result = job.result() + except Exception as error: + result = {'name': error.__class__.__name__, 'message': str(error)} + status = 'error' + cls._logger.exception('ERROR from job {}:{}'.format(jobs_id, job_hash)) + + else: + status = 'done' + + end_time = datetime.now() + time_info = { + 'start': str(start_time)[:-3], + 'end': str(end_time)[:-3], + 'elapsed': str(end_time - start_time)[:-3] + } + job_obj = {'status': status, 'result': result, 'time_info': time_info} + + cls._set_job(jobs_id, job_hash, job_obj, session) + session.bind.close() + session.close() + + def _set_job(cls, jobs_id, job_hash, job_obj, session): + key = cls._build_jobs_key(jobs_id) + session.redis_bind.hset(key, job_hash, cls._pack_obj(job_obj)) + if session.redis_bind.ttl(key) < 0: + session.redis_bind.expire(key, 7*24*60*60) + + def _build_jobs_key(cls, jobs_id): + return jobs_id + '_jobs' + + def _get_job(cls, jobs_id, req, session): + job_obj = session.redis_bind.hget(cls._build_jobs_key(jobs_id), req.query['job_hash']) + if job_obj is None: + return cls._build_response(404) + else: + return cls._build_response(200, job_obj.decode()) + + def _get_all_jobs(cls, jobs_id, req, session): + jobs = session.redis_bind.hgetall(cls._build_jobs_key(jobs_id)) + + if not jobs: + return cls._build_response(404) + + else: + jobs = cls._unpack_obj(jobs) + all_jobs = defaultdict(dict) + + for job_id, job in jobs.items(): + all_jobs[job['status']][job_id] = job + + return cls._build_response(200, cls._pack_obj(all_jobs)) + + def _copy_session(cls, session): + return type(session)(bind=session.bind.engine.connect(), + redis_bind=session.redis_bind) \ No newline at end of file diff --git a/swaggerit/models/orm/redis.py b/swaggerit/models/orm/redis.py new file mode 100644 index 0000000..dc8b659 --- /dev/null +++ b/swaggerit/models/orm/redis.py @@ -0,0 +1,135 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.orm.redis_base import ModelRedisBaseMeta +from collections import OrderedDict +from copy import deepcopy + + +class ModelRedisMeta(ModelRedisBaseMeta): + CHUNKS = 100 + + def insert(cls, session, objs, **kwargs): + input_ = deepcopy(objs) + objs = cls._to_list(objs) + ids_objs_map = dict() + counter = 0 + + for obj in objs: + obj_key = cls.get_instance_key(obj) + ids_objs_map[obj_key] = cls._pack_obj(obj) + counter += 1 + + if counter == cls.CHUNKS: + session.redis_bind.hmset(cls.__key__, ids_objs_map) + ids_objs_map = dict() + counter = 0 + + if ids_objs_map: + session.redis_bind.hmset(cls.__key__, ids_objs_map) + + return objs + + def update(cls, session, objs, ids=None, **kwargs): + input_ = deepcopy(objs) + + objs = cls._to_list(objs) + if ids: + keys_objs_map = cls._build_keys_objs_map_with_ids(objs, ids) + else: + keys_objs_map = OrderedDict([(cls.get_instance_key(obj), obj) for obj in objs]) + + keys = set(keys_objs_map.keys()) + keys.difference_update(set(session.redis_bind.hkeys(cls.__key__))) + keys.intersection_update(keys) + invalid_keys = keys + + for key in invalid_keys: + keys_objs_map.pop(key, None) + + keys_objs_to_del = dict() + + if keys_objs_map: + set_map = OrderedDict() + counter = 0 + for key in set(keys_objs_map.keys()): + obj = keys_objs_map[key] + if obj.get('_operation') == 'delete': + keys_objs_to_del[key] = obj + keys_objs_map.pop(key) + continue + + set_map[key] = cls._pack_obj(obj) + counter += 1 + + if counter == cls.CHUNKS: + session.redis_bind.hmset(cls.__key__, set_map) + set_map = OrderedDict() + counter = 0 + + if set_map: + session.redis_bind.hmset(cls.__key__, set_map) + + if keys_objs_to_del: + session.redis_bind.hdel(cls.__key__, *keys_objs_to_del.keys()) + + return list(keys_objs_map.values()) or list(keys_objs_to_del.values()) + + def _build_keys_objs_map_with_ids(cls, objs, ids): + ids = cls._to_list(ids) + keys_objs_map = OrderedDict() + + for obj in objs: + obj_ids = cls.get_instance_ids_map(obj) + if obj_ids in ids: + keys_objs_map[cls._build_key_by_id(obj_ids)] = obj + + for id_, obj in zip(ids, objs): + keys_objs_map[cls.get_instance_key(id_, id_.keys())] = obj + + return keys_objs_map + + def _build_key_by_id(cls, id_): + return cls.get_instance_key(id_, id_.keys()) + + def delete(cls, session, ids, **kwargs): + keys = [cls._build_key_by_id(id_) for id_ in cls._to_list(ids)] + if keys: + return session.redis_bind.hdel(cls.__key__, *keys) + + def get(cls, session, ids=None, limit=None, offset=None, **kwargs): + if limit is not None and offset is not None: + limit += offset + + elif ids is None and limit is None and offset is None: + return cls._unpack_objs(session.redis_bind.hgetall(cls.__key__)) + + if ids is None: + keys = [k for k in session.redis_bind.hkeys(cls.__key__)][offset:limit] + if keys: + return cls._unpack_objs(session.redis_bind.hmget(cls.__key__, *keys)) + else: + return [] + else: + ids = [cls._build_key_by_id(id_) for id_ in cls._to_list(ids)] + return cls._unpack_objs(session.redis_bind.hmget(cls.__key__, *ids[offset:limit])) diff --git a/swaggerit/models/orm/redis_base.py b/swaggerit/models/orm/redis_base.py new file mode 100644 index 0000000..f656558 --- /dev/null +++ b/swaggerit/models/orm/redis_base.py @@ -0,0 +1,80 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.base import ModelBaseMeta +from swaggerit.models.orm.swaggerit import ModelSwaggerItOrmMeta +from swaggerit.exceptions import SwaggerItModelError + + +class ModelRedisBaseMeta(ModelSwaggerItOrmMeta): + + def __init__(cls, name, bases_classes, attributes): + if hasattr(cls, '__schema__'): + ModelSwaggerItOrmMeta.__init__(cls, name, bases_classes, attributes) + else: + ModelBaseMeta.__init__(cls, name, bases_classes, attributes) + + if not getattr(cls, '__id_names__', None): + raise SwaggerItModelError("Class attribute '__id_names__' must be setted " + "and must contains at least one name.") + + cls.__key_separator__ = getattr(cls, '__key_separator__', b'|') + + def _to_list(cls, objs): + return objs if isinstance(objs, list) else [objs] + + def get_instance_key(cls, instance, id_names=None): + ids_ = [str(v) for v in cls.get_instance_ids_values(instance, id_names)] + return cls.__key_separator__.decode().join(ids_).encode() + + def get_instance_ids_values(cls, instance, keys=None): + if keys is None: + keys = cls.__id_names__ + + if isinstance(instance, dict): + return tuple([instance[key] for key in sorted(keys)]) + else: + return tuple([getattr(instance, key) for key in sorted(keys)]) + + def set_instance_ids(cls, instance, key, keys=None): + if keys is None: + keys = sorted(cls.__id_names__) + + values = key.split(cls.__key_separator__) + for key, value in zip(keys, values): + instance[key] = value.decode() + + def get_instance_ids_map(cls, instance, keys=None): + if keys is None: + keys = cls.__id_names__ + + keys = sorted(keys) + if isinstance(instance, dict): + return {key: instance[key] for key in keys} + else: + return {key: getattr(instance, key) for key in keys} + + def _unpack_objs(cls, objs): + if isinstance(objs, dict): + objs = objs.values() + return [cls._unpack_obj(obj) for obj in objs if obj is not None] diff --git a/swaggerit/models/orm/session.py b/swaggerit/models/orm/session.py new file mode 100644 index 0000000..d8ffd60 --- /dev/null +++ b/swaggerit/models/orm/session.py @@ -0,0 +1,144 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from sqlalchemy.orm import sessionmaker, Session as SessionSA +from sqlalchemy.orm.query import Query +from sqlalchemy import event +from collections import defaultdict +import ujson + + +class _SessionBase(SessionSA): + + def __init__( + self, bind=None, autoflush=True, + expire_on_commit=True, _enable_transaction_accounting=True, + autocommit=False, twophase=False, weak_identity_map=True, + binds=None, extension=None, info=None, query_cls=Query, redis_bind=None): + self.redis_bind = redis_bind + self.user = None + self._clean_redis_sets() + SessionSA.__init__( + self, bind=bind, autoflush=autoflush, expire_on_commit=expire_on_commit, + _enable_transaction_accounting=_enable_transaction_accounting, + autocommit=autocommit, twophase=twophase, weak_identity_map=weak_identity_map, + binds=binds, extension=extension, info=info, query_cls=query_cls) + + def _clean_redis_sets(self): + self._insts_to_hdel = set() + self._insts_to_hmset = set() + + def commit(self): + try: + SessionSA.commit(self) + if self.redis_bind is not None: + self._exec_hdel(self._insts_to_hdel) + self._update_objects_on_redis() + finally: + self._clean_redis_sets() + + def delete(self, instance): + self._insts_to_hmset.update(instance.get_related(self)) + return SessionSA.delete(self, instance) + + def _update_objects_on_redis(self): + insts_to_hmset = set.union(self._insts_to_hdel, self._insts_to_hmset) + insts_to_hmset_count = 0 + + while len(insts_to_hmset) != insts_to_hmset_count: + insts_to_hmset_count = len(insts_to_hmset) + insts_to_hmset_copy = insts_to_hmset.copy() + [insts_to_hmset.update(inst.get_related(self)) for inst in insts_to_hmset_copy] + + insts_to_hmset.difference_update(self._insts_to_hdel) + self._exec_hmset(insts_to_hmset) + + def _exec_hdel(self, insts): + models_keys_insts_keys_map = defaultdict(set) + + for inst in insts: + model = type(inst) + if not model.__use_redis__: + continue + + filters_names_set = self._get_filters_names_set(inst) + for filters_names in filters_names_set: + model_redis_key = model.get_key(filters_names.decode()) + inst_redis_key = model.get_instance_key(inst) + models_keys_insts_keys_map[model_redis_key].add(inst_redis_key) + + for model_key, insts_keys in models_keys_insts_keys_map.items(): + self.redis_bind.hdel(model_key, *insts_keys) + + def _get_filters_names_set(self, inst): + filters_names_key = type(inst).get_filters_names_key() + filters_names = self.redis_bind.smembers(filters_names_key) + filters_names.add(type(inst).__key__.encode()) + return filters_names + + def _exec_hmset(self, insts): + models_keys_insts_keys_insts_map = defaultdict(dict) + models_keys_insts_keys_map = defaultdict(set) + + for inst in insts: + model = type(inst) + if not model.__use_redis__: + continue + + filters_names_set = self._get_filters_names_set(inst) + for filters_names in filters_names_set: + model_redis_key = model.get_key(filters_names.decode()) + inst_redis_key = model.get_instance_key(inst) + + inst_old_redis_key = getattr(inst, 'old_redis_key', None) + if inst_old_redis_key is not None and inst_old_redis_key != inst_redis_key: + models_keys_insts_keys_map[model_redis_key].add(inst_old_redis_key) + + models_keys_insts_keys_insts_map[model_redis_key][inst_redis_key] = ujson.dumps(inst.todict()) + + for model_key, insts_keys_insts_map in models_keys_insts_keys_insts_map.items(): + self.redis_bind.hmset(model_key, insts_keys_insts_map) + + for model_key, insts_keys in models_keys_insts_keys_map.items(): + self.redis_bind.hdel(model_key, *insts_keys) + + def mark_for_hdel(self, inst): + self._insts_to_hdel.add(inst) + + def mark_for_hmset(self, inst): + self._insts_to_hmset.add(inst) + + +Session = sessionmaker(class_=_SessionBase) + + +@event.listens_for(Session, 'persistent_to_deleted') +def deleted_from_database(session, instance): + if session.redis_bind is not None and instance is not None: + session.mark_for_hdel(instance) + + +@event.listens_for(Session, 'pending_to_persistent') +def added_to_database(session, instance): + if session.redis_bind is not None and instance is not None: + session.mark_for_hmset(instance) diff --git a/swaggerit/models/orm/sqlalchemy_redis.py b/swaggerit/models/orm/sqlalchemy_redis.py new file mode 100644 index 0000000..d5f3192 --- /dev/null +++ b/swaggerit/models/orm/sqlalchemy_redis.py @@ -0,0 +1,510 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.orm.redis_base import ModelRedisBaseMeta +from swaggerit.exceptions import SwaggerItModelError +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.ext.declarative.clsregistry import _class_resolver +from sqlalchemy.orm.properties import RelationshipProperty, ColumnProperty +from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy import or_, and_ +from collections import OrderedDict +from copy import deepcopy +import ujson + + +class _ModelSQLAlchemyRedisBaseInitMetaMixin(DeclarativeMeta, ModelRedisBaseMeta): + + def __init__(cls, name, bases_classes, attributes): + DeclarativeMeta.__init__(cls, name, bases_classes, attributes) + + if hasattr(cls, '__baseclass_name__'): + cls._set_primaries_keys() + cls.__id_names__ = sorted(cls.__primaries_keys__.keys()) + cls.__key__ = str(cls.__table__.name) + + ModelRedisBaseMeta.__init__(cls, name, bases_classes, attributes) + cls._validate_base_class(bases_classes) + + cls.__backrefs__ = set() + cls.__relationships__ = dict() + cls.__columns__ = set(cls.__table__.c) + cls.__use_redis__ = getattr(cls, '__use_redis__', True) + cls.__todict_schema__ = {} + cls._set_backrefs_for_all_models(type(cls).__all_models__.values()) + + else: + cls.__baseclass_name__= name + cls.__all_models__ = dict() + + def _set_primaries_keys(cls): + primaries_keys = {} + + for attr_name in cls.__dict__: + primary_key = cls._get_primary_key(attr_name) + if primary_key: + primaries_keys[attr_name] = primary_key + + cls.__primaries_keys__ = OrderedDict(sorted(primaries_keys.items())) + + def _get_primary_key(cls, attr_name): + attr = getattr(cls, attr_name) + if isinstance(attr, InstrumentedAttribute) \ + and isinstance(attr.prop, ColumnProperty) \ + and [col for col in attr.prop.columns if col.primary_key]: + return attr + + def _validate_base_class(cls, bases_classes): + for base in bases_classes: + if base.__name__ == cls.__baseclass_name__: + return base + else: + raise SwaggerItModelError("'{}' class must inherit from '{}'".format( + name, cls.__baseclass_name__)) + + def _set_backrefs_for_all_models(cls, all_models): + all_relationships = set() + + for model in all_models: + model._set_relationships() + all_relationships.update(model.__relationships__.values()) + + for model in all_models: + for relationship in all_relationships: + if model != cls.get_model_from_rel(relationship, all_models, parent=True) and \ + model == cls.get_model_from_rel(relationship, all_models): + model.__backrefs__.add(relationship) + + def _set_relationships(cls): + if cls.__relationships__: + return + + for attr_name in cls.__dict__: + relationship = cls._get_relationship(attr_name) + if relationship: + cls.__relationships__[attr_name] = relationship + + def _get_relationship(cls, attr_name): + attr = getattr(cls, attr_name) + if isinstance(attr, InstrumentedAttribute): + if isinstance(attr.prop, RelationshipProperty): + return attr + + +class _ModelSQLAlchemyRedisBaseInsertMetaMixin(type): + + def insert(cls, session, objs, commit=True, todict=True, **kwargs): + input_ = deepcopy(objs) + objs = cls._to_list(objs) + new_insts = set() + + for obj in objs: + instance = cls(session, input_, **obj) + new_insts.add(instance) + + session.add_all(new_insts) + + if commit: + session.commit() + + return cls._build_todict_list(new_insts) if todict else list(new_insts) + + +class _ModelSQLAlchemyRedisBaseUpdateMetaMixin(type): + + def update(cls, session, objs, commit=True, todict=True, ids=None, **kwargs): + input_ = deepcopy(objs) + objs = cls._to_list(objs) + ids = [cls.get_ids_from_values( + obj) for obj in objs] if not ids else cls._to_list(ids) + + insts = cls.get(session, ids, todict=False) + + for inst in insts: + inst.old_redis_key = cls.get_instance_key(inst) + + id_insts_zip = [(cls.get_instance_ids_map(inst), inst) for inst in insts] + + for id_, inst in id_insts_zip: + inst.__init__(session, input_, **objs[ids.index(id_)]) + + if commit: + session.commit() + + return cls._build_todict_list(insts) if todict else insts + + +class _ModelSQLAlchemyRedisBaseDeleteMetaMixin(type): + + def delete(cls, session, ids, commit=True, **kwargs): + ids = cls._to_list(ids) + filters = cls.build_filters_by_ids(ids) + instances = cls._build_query(session).filter(filters).all() + [session.delete(inst) for inst in instances] + + if commit: + session.commit() + + +class _ModelSQLAlchemyRedisBaseGetMetaMixin(type): + + def get(cls, session, ids=None, limit=None, offset=None, todict=True, **kwargs): + if ids is None: + query = cls._build_query(session, kwargs) + + if limit is not None: + query = query.limit(limit) + + if offset is not None: + query = query.offset(offset) + + return cls._build_todict_list(query.all()) if todict else query.all() + + if limit is not None and offset is not None: + limit += offset + + ids = cls._to_list(ids) + return cls._get_many(session, ids[offset:limit], todict, kwargs) + + def _build_query(cls, session, kwargs=None): + query = session.query(cls) + + if kwargs: + for prop_name, value in kwargs.items(): + if isinstance(value, dict): + query, filters = \ + cls._get_query_and_filters_by_relationship(prop_name, value, kwargs, query) + + elif isinstance(value, list): + if value and isinstance(value[0], dict): + query, filters = cls._get_query_and_filters_by_relationship( + prop_name, value, kwargs, query) + else: + filters = cls.build_filters_by_ids([{prop_name: v} for v in value]) + + else: + filters = cls.build_filters_by_ids([{prop_name: value}]) + + query = query.filter(filters) + + return query + + def _get_query_and_filters_by_relationship(cls, prop_name, value, kwargs, query): + relationship = cls.__relationships__.get(prop_name) + if not relationship: + raise SwaggerItModelError("invalid model '{}'".format(prop_name), kwargs) + + secondary = relationship.prop.secondary + model = cls.get_model_from_rel(relationship) + join = secondary if secondary is not None else model + + ids = cls._to_list(value) + filters = model.build_filters_by_ids(ids) + query = query.join(join) + + return query, filters + + def _get_many(cls, session, ids, todict, kwargs): + if not todict or session.redis_bind is None: + filters = cls.build_filters_by_ids(ids) + insts = cls._build_query(session, kwargs).filter(filters).all() + + if todict: + return [inst.todict() for inst in insts] + else: + return insts + + model_redis_key = type(cls).get_key(cls, '_'.join(kwargs.keys())) + ids_redis_keys = [cls.get_instance_key(id_, id_.keys()) for id_ in ids] + objs = session.redis_bind.hmget(model_redis_key, ids_redis_keys) + ids_not_cached = [id_ for i, (id_, obj) in enumerate(zip(ids, objs)) if obj is None] + objs = [ujson.loads(obj) for obj in objs if obj is not None] + + if ids_not_cached: + session.redis_bind.sadd(cls.get_filters_names_key(), model_redis_key) + filters = cls.build_filters_by_ids(ids_not_cached) + instances = cls._build_query(session).filter(filters).all() + if instances: + items_to_set = { + cls.get_instance_key(inst): ujson.dumps(inst.todict()) for inst in instances} + session.redis_bind.hmset(model_redis_key, items_to_set) + + for inst in instances: + inst_ids = cls.get_instance_ids_map(inst) + index = ids_not_cached.index(inst_ids) + objs.insert(index, inst.todict()) + + return objs + + def get_filters_names_key(cls): + return cls.get_key('_filters_names') + + +class ModelSQLAlchemyRedisBaseMeta( + _ModelSQLAlchemyRedisBaseInitMetaMixin, + _ModelSQLAlchemyRedisBaseInsertMetaMixin, + _ModelSQLAlchemyRedisBaseUpdateMetaMixin, + _ModelSQLAlchemyRedisBaseDeleteMetaMixin, + _ModelSQLAlchemyRedisBaseGetMetaMixin): + + def get_model_from_rel(cls, relationship, all_models=None, parent=False): + if parent: + return relationship.prop.parent.class_ + + if isinstance(relationship.prop.argument, _class_resolver): + if all_models is None: + return relationship.prop.argument() + + for model in all_models: + if model.__name__ == relationship.prop.argument.arg: + return model + + return + + return relationship.prop.argument + + def _build_todict_list(cls, insts): + return [inst.todict() for inst in insts] + + def get_ids_from_values(cls, values): + cast = lambda id_name, value: getattr(cls, id_name).type.python_type(value) \ + if value is not None else None + return {id_name: cast(id_name, values.get(id_name)) for id_name in cls.__primaries_keys__} + + def build_filters_by_ids(cls, ids): + if len(ids) == 1: + return cls._get_obj_i_comparison(ids[0]) + + or_clause_args = [] + for i in range(0, len(ids)): + comparison = cls._get_obj_i_comparison(ids[i]) + or_clause_args.append(comparison) + + return or_(*or_clause_args) + + def _get_obj_i_comparison(cls, attributes): + if len(attributes) == 1: + attr_name = [i for i in attributes.keys()][0] + return cls._build_attribute_comparison(attr_name, attributes) + + and_clause_args = [] + for attr_name in attributes: + comparison = cls._build_attribute_comparison( + attr_name, attributes) + and_clause_args.append(comparison) + + return and_(*and_clause_args) + + def _build_attribute_comparison(cls, attr_name, attributes): + return getattr(cls, attr_name) == attributes[attr_name] + + +class ModelSQLAlchemyRedisBaseSuper(object): + + def __init__(self, session, input_=None, **kwargs): + if input_ is None: + input_ = deepcopy(kwargs) + + for key, value in kwargs.items(): + self._setattr(key, value, session, input_) + + self._validate() + + def _setattr(self, attr_name, value, session, input_): + cls = type(self) + if not hasattr(cls, attr_name): + raise TypeError("{} is an invalid keyword argument for {}".format(attr_name, cls.__name__)) + + relationship = cls._get_relationship(attr_name) + + if relationship is not None: + self._set_relationship(relationship, attr_name, value, session, input_) + else: + setattr(self, attr_name, value) + + def _set_relationship(self, relationship, attr_name, values_list, session, input_): + cls = type(self) + rel_model = cls.get_model_from_rel(relationship) + + if relationship.prop.uselist is not True: + if isinstance(values_list, list): + raise SwaggerItModelError("Relationship '{}' don't use lists.".format(attr_name), input_) + values_list = [values_list] + + rel_insts = self._get_instances_from_values(session, rel_model, values_list) + + for rel_values, rel_inst in zip(values_list, rel_insts): + self._do_nested_operation(rel_values, rel_inst, + attr_name, relationship, session, input_) + + def _get_instances_from_values(self, session, rel_model, rels_values): + ids_to_get = self._get_ids_from_rels_values(rel_model, rels_values) + if not ids_to_get: + return [] + + # attribution made just to keep the reference on session identity_map + instances = rel_model.get(session, ids_to_get, todict=False) + + rels_ints = [] + for rel_ids in ids_to_get: + rel_inst = rel_model.get(session, rel_ids, todict=False) + rel_inst = rel_inst[0] if rel_inst else None + rels_ints.append(rel_inst) + + return rels_ints + + def _get_ids_from_rels_values(self, rel_model, rels_values): + ids = [] + for rel_value in rels_values: + ids_values = rel_model.get_ids_from_values(rel_value) + ids.append(ids_values) + + return ids + + def _do_nested_operation(self, rel_values, rel_inst, + attr_name, relationship, session, input_): + operation = rel_values.pop('_operation', 'get') + + if rel_inst is None and operation != 'insert': + raise SwaggerItModelError( + "Can't execute nested '{}' operation".format(operation), input_) + + if operation == 'get': + self._do_get(attr_name, relationship, rel_inst) + + elif operation == 'update': + self._do_get(attr_name, relationship, rel_inst) + rel_inst.__init__(session, input_, **rel_values) + + elif operation == 'delete': + rel_model = type(rel_inst) + rel_model.delete(session, rel_inst.get_ids_map(), commit=False) + + elif operation == 'remove': + self._do_remove(attr_name, relationship, rel_inst, input_) + + elif operation == 'insert': + self._do_insert(session, attr_name, relationship, rel_values) + + def _do_get(self, attr_name, relationship, rel_inst): + if relationship.prop.uselist is True: + if rel_inst not in getattr(self, attr_name): + getattr(self, attr_name).append(rel_inst) + + else: + setattr(self, attr_name, rel_inst) + + def get_ids_map(self, keys=None): + if keys is None: + keys = type(self).__primaries_keys__.keys() + + pk_names = sorted(keys) + return {id_name: getattr(self, id_name) for id_name in pk_names} + + def _do_remove(self, attr_name, relationship, rel_inst, input_): + rel_model = type(rel_inst) + if relationship.prop.uselist is True: + if rel_inst in getattr(self, attr_name): + getattr(self, attr_name).remove(rel_inst) + else: + columns_str = ', '.join(rel_model.__primaries_keys__) + ids_str = ', '.join([str(id_) for id_ in type(rel_inst).get_instance_ids_values(rel_inst)]) + error_message = "can't remove model '{}' on column(s) '{}' with value(s) {}" + error_message = error_message.format(rel_model.__key__, columns_str, ids_str) + raise SwaggerItModelError(error_message, input_) + else: + setattr(self, attr_name, None) + + def _do_insert(self, session, attr_name, relationship, rel_values): + rel_model = type(self).get_model_from_rel(relationship) + rel_inst = rel_model(session, **rel_values) + + if relationship.prop.uselist is not True: + setattr(self, attr_name, rel_inst) + else: + if rel_inst not in getattr(self, attr_name): + getattr(self, attr_name).append(rel_inst) + + def _validate(self): + pass + + def get_related(self, session): + related = set() + cls = type(self) + for relationship in cls.__backrefs__: + related_model_insts = self._get_related_model_insts( + session, relationship, parent=True) + related.update(related_model_insts) + + return related + + def _get_related_model_insts(self, session, relationship, parent=False): + cls = type(self) + rel_model = cls.get_model_from_rel(relationship, parent=parent) + + filters = cls.build_filters_by_ids([self.get_ids_map()]) + result = set(rel_model._build_query(session).join( + relationship).filter(filters).all()) + return result + + def todict(self, schema=None): + if schema is None: + schema = type(self).__todict_schema__ + + dict_inst = self._todict(schema) + self._format_output_json(dict_inst, schema) + return dict_inst + + def _todict(self, schema=None): + dict_inst = dict() + self._todict_columns(dict_inst, schema) + self._todict_relationships(dict_inst, schema) + + return dict_inst + + def _format_output_json(self, dict_inst, schema): + pass + + def _todict_columns(self, dict_inst, schema): + for col in type(self).__columns__: + col_name = str(col.name) + if self._attribute_in_schema(col_name, schema): + dict_inst[col_name] = getattr(self, col_name) + + def _attribute_in_schema(self, attr_name, schema): + return (attr_name in schema and schema[attr_name]) or (not attr_name in schema) + + def _todict_relationships(self, dict_inst, schema): + for rel_name, rel in type(self).__relationships__.items(): + if self._attribute_in_schema(rel_name, schema): + rel_schema = schema.get(rel_name) + rel_schema = rel_schema if isinstance( + rel_schema, dict) else None + attr = getattr(self, rel_name) + relationships = None + if rel.prop.uselist is True: + relationships = [rel.todict(rel_schema) for rel in attr] + elif attr is not None: + relationships = attr.todict(rel_schema) + + dict_inst[rel_name] = relationships diff --git a/swaggerit/models/orm/swaggerit.py b/swaggerit/models/orm/swaggerit.py new file mode 100644 index 0000000..6dd4663 --- /dev/null +++ b/swaggerit/models/orm/swaggerit.py @@ -0,0 +1,97 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.response import SwaggerResponse +from swaggerit.exceptions import SwaggerItModelError +from swaggerit.models.orm.jobs import ModelJobsMeta +from sqlalchemy.exc import IntegrityError +from functools import partial + + +class ModelSwaggerItOrmMeta(ModelJobsMeta): + + def _execute_operation(cls, operation, status_code, has_404=True, pack_first=False): + try: + objs = operation() + + except SwaggerItModelError as error: + error_obj = { + 'message': error.args[0] + } + if len(error.args) > 1: + error_obj['instance'] = error.args[1] + return SwaggerResponse(400, body=cls._pack_obj(error_obj)) + + except IntegrityError as error: + error_obj = { + 'params': error.params, + 'database message': { + 'code': error.orig.args[0], + 'message': error.orig.args[1] + } + } + if len(error.detail): + error_obj['details'] = error.detail + return SwaggerResponse(400, body=cls._pack_obj(error_obj)) + + else: + if has_404 and not objs: + return SwaggerResponse(404) + else: + if pack_first: + body = cls._pack_obj(objs[0]) + else: + body = cls._pack_obj(objs) + return SwaggerResponse(status_code, body=body) + + def swagger_insert(cls, req, session): + operation = partial(cls.insert, session, req.body, **req.query) + return cls._execute_operation(operation, 201, False) + + def swagger_update(cls, req, session): + operation = partial(cls.update, session, [req.body], ids=[req.path_params], **req.query) + return cls._execute_operation(operation, 200, pack_first=True) + + def swagger_update_many(cls, req, session): + operation = partial(cls.update, session, req.body, **req.query) + return cls._execute_operation(operation, 200) + + def swagger_delete(cls, req, session): + operation = partial(cls.delete, session, ids=[req.path_params], **req.query) + return cls._execute_operation(operation, 204, False) + + def swagger_delete_many(cls, req, session): + operation = partial(cls.delete, session, ids=req.body, **req.query) + return cls._execute_operation(operation, 204, False) + + def swagger_get(cls, req, session): + operation = partial(cls.get, session, ids=[req.path_params], **req.query) + return cls._execute_operation(operation, 200, pack_first=True) + + def swagger_get_many(cls, req, session): + operation = partial(cls.get, session, **req.query) + return cls._execute_operation(operation, 200) + + def swagger_get_all(cls, req, session): + operation = partial(cls.get, session, **req.query) + return cls._execute_operation(operation, 200) diff --git a/swaggerit/models/swaggerit.py b/swaggerit/models/swaggerit.py new file mode 100644 index 0000000..4a871c7 --- /dev/null +++ b/swaggerit/models/swaggerit.py @@ -0,0 +1,89 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.base import ModelBaseMeta, ModelBase +from swaggerit.constants import SWAGGER_VALIDATOR +from swaggerit.exceptions import SwaggerItModelError +from swaggerit.utils import get_module_path +from swaggerit.response import SwaggerResponse +import re + + +def _init(obj): + SWAGGER_VALIDATOR.validate(obj.__schema__) + _validate_operation(obj) + obj.__api__ = getattr(obj, '__api__', None) + obj.__schema_dir__ = getattr(obj, '__schema_dir__', get_module_path(obj)) + _set_default_options(obj) + + +def _validate_operation(obj): + for path in obj.__schema__: + if path != 'definitions': + for key, method_schema in obj.__schema__[path].items(): + if key != 'parameters' and key != 'definitions': + operation_id = method_schema['operationId'] + if not hasattr(obj, operation_id): + raise SwaggerItModelError( + "'operationId' '{}' was not found".format(operation_id)) + + +def _set_default_options(obj): + for path, schema in obj.__schema__.items(): + if not 'options' in schema: + valid_methods = [k.upper() for k in schema.keys()] + headers = {'Allow': ', '.join(valid_methods)} + options_operation = lambda req, sess: SwaggerResponse(200, headers, None, None) + path_norm = path.strip('/').replace('/', '_') + path_norm = re.sub(r'\{([a-zA-Z_0-9-]+)\}', r'\1', path_norm) + options_operation_name = '{}_{}'.format('options', path_norm) \ + if path_norm else 'options' + + setattr(obj, options_operation_name, options_operation) + schema['options'] = _build_options_schema(options_operation_name) + + +def _build_options_schema(options_operation_name): + return { + 'operationId': options_operation_name, + 'responses': { + '204': { + 'description': 'No Content', + 'headers': {'Allow': {'type': 'string'}} + } + } + } + + +class ModelSwaggerItMeta(ModelBaseMeta): + + def __init__(cls, name, bases_classes, attributes): + ModelBaseMeta.__init__(cls, name, bases_classes, attributes) + _init(cls) + + +class ModelSwaggerIt(ModelBase): + + def __init__(self): + ModelBase.__init__(self) + _init(self) diff --git a/swaggerit/request.py b/swaggerit/request.py new file mode 100644 index 0000000..5bea766 --- /dev/null +++ b/swaggerit/request.py @@ -0,0 +1,47 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from collections import namedtuple + + +_SwaggerRequest = namedtuple('SwaggerRequest', [ + 'path', 'method', 'url', 'path_params', + 'query', 'headers', 'body', 'body_schema', 'context' +]) + + +class SwaggerRequest(_SwaggerRequest): + + def __new__(cls, path, method, url=None, path_params=None, query=None, + headers=None, body=None, body_schema=None, context=None): + return _SwaggerRequest.__new__( + cls, path=path, + method=method, + url=path if url is None else url, + path_params={} if path_params is None else path_params, + query={} if query is None else query, + headers={} if headers is None else headers, + body=body, + body_schema=body_schema, + context=context + ) diff --git a/swaggerit/response.py b/swaggerit/response.py new file mode 100644 index 0000000..6357fee --- /dev/null +++ b/swaggerit/response.py @@ -0,0 +1,37 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from collections import namedtuple + + +_SwaggerResponse = namedtuple('SwaggerResponse', ['status_code', 'headers', 'body']) + + +class SwaggerResponse(_SwaggerResponse): + + def __new__(cls, status_code, headers=None, body=None): + return _SwaggerResponse.__new__( + cls, status_code=status_code, + headers={} if headers is None else headers, + body=body + ) diff --git a/swaggerit/sanic_api.py b/swaggerit/sanic_api.py new file mode 100644 index 0000000..72de0e5 --- /dev/null +++ b/swaggerit/sanic_api.py @@ -0,0 +1,87 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.api import SwaggerAPI +from swaggerit.request import SwaggerRequest +from sanic.response import HTTPResponse +from sanic import Sanic +from urllib.parse import parse_qs +import ujson + + +class SanicAPI(SwaggerAPI, Sanic): + + def __init__(self, models, sqlalchemy_bind=None, redis_bind=None, + swagger_json_template=None, title=None, version='1.0.0', + authorizer=None, get_swagger_req_auth=True): + SwaggerAPI.__init__(self, models, sqlalchemy_bind, redis_bind, swagger_json_template, + title, version, authorizer, get_swagger_req_auth) + Sanic.__init__(self, type(self).__name__) + self._set_routes() + + def _set_routes(self): + for path_name in self._path_method_map: + self.add_route(self._get_sanic_response, path_name) + if path_name: + self.add_route(self._get_sanic_response, path_name + '/') + + self.add_route(self._get_swagger_json, self._get_base_path() + '/swagger.json') + + async def _get_sanic_response(self, req, **kwargs): + try: + resp = SwaggerAPI.get_response(self, self._cast_request(req, kwargs)) + return self._cast_response(resp) + + except Exception as error: + body = ujson.dumps({'message': 'Something unexpected happened'}) + self._logger.exception('ERROR Unexpected') + return HTTPResponse(body=body, status=500, content_type='application/json') + + def _cast_request(self, req, kwargs=None): + query = {k: ','.join(v) for k, v in parse_qs(req.query_string).items()} + return SwaggerRequest(req.uri.rstrip('/'), req.method.lower(), req.url, + kwargs, query, req.headers, req.body) + + def _cast_response(self, resp): + return HTTPResponse(body=resp.body, status=resp.status_code, + headers=resp.headers, + content_type=resp.headers.get('Content-Type', '')) + + def _get_swagger_json(self, req): + if self._authorizer and self._get_swagger_req_auth: + session = self._build_session() + try: + response = self._authorizer(self._cast_request(req), session) + + except Exception as error: + raise error + + else: + if response is not None: + return self._cast_response(resp) + + finally: + self._destroy_session(session) + + return HTTPResponse(status=200, content_type='application/json', + body=ujson.dumps(self.swagger_json)) diff --git a/swaggerit/swagger_schema_extended.json b/swaggerit/swagger_schema_extended.json new file mode 100644 index 0000000..ab1163c --- /dev/null +++ b/swaggerit/swagger_schema_extended.json @@ -0,0 +1,1624 @@ +{ + "title": "A Extended JSON Schema for Swagger 2.0 API.", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "required": [ + "swagger", + "info", + "paths" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "swagger": { + "type": "string", + "enum": [ + "2.0" + ], + "description": "The Swagger version of this document." + }, + "info": { + "$ref": "#/definitions/info" + }, + "host": { + "type": "string", + "pattern": "^[^{}/ :\\\\]+(?::\\d+)?$", + "description": "The host (name or ip) of the API. Example: 'swagger.io'" + }, + "basePath": { + "type": "string", + "pattern": "^/", + "description": "The base path to the API. Example: '/api'." + }, + "schemes": { + "$ref": "#/definitions/schemesList" + }, + "consumes": { + "description": "A list of MIME types accepted by the API.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "produces": { + "description": "A list of MIME types the API can produce.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "paths": { + "$ref": "#/definitions/paths" + }, + "definitions": { + "$ref": "#/definitions/definitions" + }, + "parameters": { + "$ref": "#/definitions/parameterDefinitions" + }, + "responses": { + "$ref": "#/definitions/responseDefinitions" + }, + "security": { + "$ref": "#/definitions/security" + }, + "securityDefinitions": { + "$ref": "#/definitions/securityDefinitions" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "definitions": { + "info": { + "type": "object", + "description": "General information about the API.", + "required": [ + "version", + "title" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "title": { + "type": "string", + "description": "A unique and precise title of the API." + }, + "version": { + "type": "string", + "description": "A semantic version number of the API." + }, + "description": { + "type": "string", + "description": "A longer description of the API. Should be different from the title. GitHub Flavored Markdown is allowed." + }, + "termsOfService": { + "type": "string", + "description": "The terms of service for the API." + }, + "contact": { + "$ref": "#/definitions/contact" + }, + "license": { + "$ref": "#/definitions/license" + } + } + }, + "contact": { + "type": "object", + "description": "Contact information for the owners of the API.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization." + }, + "url": { + "type": "string", + "description": "The URL pointing to the contact information.", + "format": "uri" + }, + "email": { + "type": "string", + "description": "The email address of the contact person/organization.", + "format": "email" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "license": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the license type. It's encouraged to use an OSI compatible license." + }, + "url": { + "type": "string", + "description": "The URL pointing to the license.", + "format": "uri" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "paths": { + "type": "object", + "description": "Relative paths to the individual endpoints. They must be relative to the 'basePath'.", + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + }, + "^/": { + "$ref": "#/definitions/pathItem" + }, + "definitions": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/definitions" + } + }, + "additionalProperties": false + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "description": "One or more JSON objects describing the schemas being consumed and produced by the API." + }, + "parameterDefinitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/parameter" + }, + "description": "One or more JSON representations for parameters" + }, + "responseDefinitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/response" + }, + "description": "One or more JSON representations for parameters" + }, + "externalDocs": { + "type": "object", + "additionalProperties": false, + "description": "information about external documentation", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "examples": { + "type": "object", + "additionalProperties": true + }, + "mimeType": { + "type": "string", + "description": "The MIME type of the HTTP message." + }, + "operation": { + "type": "object", + "required": [ + "responses", + "operationId" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the operation." + }, + "description": { + "type": "string", + "description": "A longer description of the operation, GitHub Flavored Markdown is allowed." + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "operationId": { + "type": "string", + "description": "A unique identifier of the operation." + }, + "produces": { + "description": "A list of MIME types the API can produce.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "consumes": { + "description": "A list of MIME types the API can consume.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "parameters": { + "$ref": "#/definitions/parametersList" + }, + "responses": { + "$ref": "#/definitions/responses" + }, + "schemes": { + "$ref": "#/definitions/schemesList" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "security": { + "$ref": "#/definitions/security" + } + } + }, + "pathItem": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "$ref": { + "type": "string" + }, + "get": { + "$ref": "#/definitions/operation" + }, + "put": { + "$ref": "#/definitions/operation" + }, + "post": { + "$ref": "#/definitions/operation" + }, + "delete": { + "$ref": "#/definitions/operation" + }, + "options": { + "$ref": "#/definitions/operation" + }, + "head": { + "$ref": "#/definitions/operation" + }, + "patch": { + "$ref": "#/definitions/operation" + }, + "parameters": { + "$ref": "#/definitions/parametersList" + } + } + }, + "responses": { + "type": "object", + "description": "Response objects names can either be any valid HTTP status code or 'default'.", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^([0-9]{3})$|^(default)$": { + "$ref": "#/definitions/responseValue" + }, + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "not": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + } + }, + "responseValue": { + "oneOf": [ + { + "$ref": "#/definitions/response" + }, + { + "$ref": "#/definitions/jsonReference" + } + ] + }, + "response": { + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string" + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/fileSchema" + } + ] + }, + "headers": { + "$ref": "#/definitions/headers" + }, + "examples": { + "$ref": "#/definitions/examples" + } + }, + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/header" + } + }, + "header": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "number", + "integer", + "boolean", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "vendorExtension": { + "description": "Any property starting with x- is valid.", + "additionalProperties": true, + "additionalItems": true + }, + "bodyParameter": { + "type": "object", + "required": [ + "name", + "in", + "schema" + ], + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "body" + ] + }, + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "schema": { + "$ref": "#/definitions/schema" + } + }, + "additionalProperties": false + }, + "headerParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "header" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "queryParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "query" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "allowEmptyValue": { + "type": "boolean", + "default": false, + "description": "allows sending a parameter by name only or with an empty value." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array", + "object" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "http://json-schema.org/draft-04/schema#" + }, + "schema": { + "$ref": "http://json-schema.org/draft-04/schema#" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormatWithMulti" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "formDataParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "formData" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "allowEmptyValue": { + "type": "boolean", + "default": false, + "description": "allows sending a parameter by name only or with an empty value." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array", + "file" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormatWithMulti" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "pathParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "required": [ + "required" + ], + "properties": { + "required": { + "type": "boolean", + "enum": [ + true + ], + "description": "Determines whether or not this parameter is required or optional." + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "path" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "nonBodyParameter": { + "type": "object", + "required": [ + "name", + "in", + "type" + ], + "oneOf": [ + { + "$ref": "#/definitions/headerParameterSubSchema" + }, + { + "$ref": "#/definitions/formDataParameterSubSchema" + }, + { + "$ref": "#/definitions/queryParameterSubSchema" + }, + { + "$ref": "#/definitions/pathParameterSubSchema" + } + ] + }, + "parameter": { + "oneOf": [ + { + "$ref": "#/definitions/bodyParameter" + }, + { + "$ref": "#/definitions/nonBodyParameter" + } + ] + }, + "schema": { + "type": "object", + "description": "A deterministic version of a JSON Schema object.", + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "id_names": {}, + "$ref": { + "type": "string" + }, + "format": { + "type": "string" + }, + "title": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/title" + }, + "description": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/description" + }, + "default": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/default" + }, + "multipleOf": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/multipleOf" + }, + "maximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/maximum" + }, + "exclusiveMaximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMaximum" + }, + "minimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/minimum" + }, + "exclusiveMinimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMinimum" + }, + "maxLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "pattern": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/pattern" + }, + "maxItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "uniqueItems": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/uniqueItems" + }, + "maxProperties": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minProperties": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "required": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/stringArray" + }, + "enum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/enum" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "boolean" + } + ], + "default": {} + }, + "type": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/type" + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + } + ], + "default": {} + }, + "allOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "discriminator": { + "type": "string" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/xml" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "example": {}, + "anyOf": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/anyOf" + }, + "oneOf": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/oneOf" + }, + "patternProperties": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/patternProperties" + } + }, + "additionalProperties": false + }, + "fileSchema": { + "type": "object", + "description": "A deterministic version of a JSON Schema object.", + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "required": [ + "type" + ], + "properties": { + "format": { + "type": "string" + }, + "title": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/title" + }, + "description": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/description" + }, + "default": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/default" + }, + "required": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/stringArray" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "example": {} + }, + "additionalProperties": false + }, + "primitivesItems": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "number", + "integer", + "boolean", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/securityRequirement" + }, + "uniqueItems": true + }, + "securityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "xml": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "tag": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "securityDefinitions": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/basicAuthenticationSecurity" + }, + { + "$ref": "#/definitions/apiKeySecurity" + }, + { + "$ref": "#/definitions/oauth2ImplicitSecurity" + }, + { + "$ref": "#/definitions/oauth2PasswordSecurity" + }, + { + "$ref": "#/definitions/oauth2ApplicationSecurity" + }, + { + "$ref": "#/definitions/oauth2AccessCodeSecurity" + } + ] + } + }, + "basicAuthenticationSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "basic" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "apiKeySecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2ImplicitSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "authorizationUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "implicit" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2PasswordSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "tokenUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "password" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2ApplicationSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "tokenUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "application" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2AccessCodeSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "authorizationUrl", + "tokenUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "accessCode" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2Scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "mediaTypeList": { + "type": "array", + "items": { + "$ref": "#/definitions/mimeType" + }, + "uniqueItems": true + }, + "parametersList": { + "type": "array", + "description": "The parameters needed to send a valid API call.", + "additionalItems": false, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/parameter" + }, + { + "$ref": "#/definitions/jsonReference" + } + ] + }, + "uniqueItems": true + }, + "schemesList": { + "type": "array", + "description": "The transfer protocol of the API.", + "items": { + "type": "string", + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "uniqueItems": true + }, + "collectionFormat": { + "type": "string", + "enum": [ + "csv", + "ssv", + "tsv", + "pipes" + ], + "default": "csv" + }, + "collectionFormatWithMulti": { + "type": "string", + "enum": [ + "csv", + "ssv", + "tsv", + "pipes", + "multi" + ], + "default": "csv" + }, + "title": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/title" + }, + "description": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/description" + }, + "default": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/default" + }, + "multipleOf": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/multipleOf" + }, + "maximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/maximum" + }, + "exclusiveMaximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMaximum" + }, + "minimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/minimum" + }, + "exclusiveMinimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMinimum" + }, + "maxLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "pattern": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/pattern" + }, + "maxItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "uniqueItems": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/uniqueItems" + }, + "enum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/enum" + }, + "jsonReference": { + "type": "object", + "required": [ + "$ref" + ], + "additionalProperties": false, + "properties": { + "$ref": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/swaggerit/swagger_template.json b/swaggerit/swagger_template.json new file mode 100644 index 0000000..7335f06 --- /dev/null +++ b/swaggerit/swagger_template.json @@ -0,0 +1,5 @@ +{ + "swagger":"2.0", + "info":{}, + "paths": {} +} \ No newline at end of file diff --git a/swaggerit/utils.py b/swaggerit/utils.py new file mode 100644 index 0000000..6a2ae15 --- /dev/null +++ b/swaggerit/utils.py @@ -0,0 +1,70 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from jsonschema import Draft4Validator, RefResolver +import os.path +import logging +import ujson +import sys + + +def build_validator(schema, path): + handlers = {'': _URISchemaHandler(path)} + resolver = RefResolver.from_schema(schema, handlers=handlers) + return Draft4Validator(schema, resolver=resolver) + + +class _URISchemaHandler(object): + + def __init__(self, schemas_path): + self._schemas_path = schemas_path + + def __call__(self, uri): + schema_filename = os.path.join(self._schemas_path, uri.lstrip('/')) + with open(schema_filename) as json_schema_file: + return ujson.load(json_schema_file) + + +def get_dir_path(filename): + return os.path.dirname(os.path.abspath(filename)) + + +def get_model_schema(filename, schema_name='schema.json'): + return ujson.load(open(os.path.join(get_dir_path(filename), schema_name))) + + +def get_module_path(cls): + module_filename = sys.modules[cls.__module__].__file__ + return get_dir_path(module_filename) + + +def set_logger(obj, name_sufix=None): + if isinstance(obj, type): + module_name = obj.__module__ + name = obj.__name__ + else: + module_name = type(obj).__module__ + name = type(obj).__name__ + + name = '.'.join([part for part in (module_name, name, name_sufix) if part is not None]) + obj._logger = logging.getLogger(name) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..a2f6c3b --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,95 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.orm.session import Session +from tests.integration.models.orm.models_fixtures import ModelSQLAlchemyRedisBase +from sqlalchemy import create_engine +from redis import StrictRedis +import pytest +import pymysql + + +@pytest.fixture +def redis(request, variables): + r = StrictRedis(**variables['redis']) + request.addfinalizer(lambda: r.flushdb()) + return r + + +@pytest.fixture(scope='session') +def pymysql_conn(variables): + database = variables['database'].pop('database') + conn = pymysql.connect(**variables['database']) + + with conn.cursor() as cursor: + try: + cursor.execute('drop database {};'.format(database)) + except: + pass + cursor.execute('create database {};'.format(database)) + cursor.execute('use {};'.format(database)) + conn.commit() + variables['database']['database'] = database + + return conn + + +@pytest.fixture(scope='session') +def engine(variables, pymysql_conn): + if variables['database']['password']: + url = 'mysql+pymysql://{user}:{password}'\ + '@{host}:{port}/{database}'.format(**variables['database']) + else: + variables['database'].pop('password') + url = 'mysql+pymysql://{user}'\ + '@{host}:{port}/{database}'.format(**variables['database']) + variables['database']['password'] = None + + return create_engine(url) + + +@pytest.fixture +def session(request, variables, redis, engine, pymysql_conn): + ModelSQLAlchemyRedisBase.metadata.bind = engine + ModelSQLAlchemyRedisBase.metadata.create_all() + + with pymysql_conn.cursor() as cursor: + cursor.execute('SET FOREIGN_KEY_CHECKS = 0;') + for table in ModelSQLAlchemyRedisBase.metadata.tables.values(): + cursor.execute('delete from {};'.format(table)) + + try: + cursor.execute('alter table {} auto_increment=1;'.format(table)) + except: + pass + cursor.execute('SET FOREIGN_KEY_CHECKS = 1;') + + pymysql_conn.commit() + + session = Session(bind=engine, redis_bind=redis) + + def tear_down(): + session.close() + + request.addfinalizer(tear_down) + return session diff --git a/tests/integration/models/__init__.py b/tests/integration/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/models/orm/__init__.py b/tests/integration/models/orm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/models/orm/models_fixtures.py b/tests/integration/models/orm/models_fixtures.py new file mode 100644 index 0000000..64e1c6b --- /dev/null +++ b/tests/integration/models/orm/models_fixtures.py @@ -0,0 +1,370 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.orm.factories import ModelSQLAlchemyRedisBaseFactory +import sqlalchemy as sa + + +ModelSQLAlchemyRedisBase = ModelSQLAlchemyRedisBaseFactory.make() + + +mtm_table = sa.Table( + 'model1_model4', ModelSQLAlchemyRedisBase.metadata, + sa.Column('model1_id', sa.Integer, sa.ForeignKey('model1.id', ondelete='CASCADE')), + sa.Column('model4_id', sa.Integer, sa.ForeignKey('model4.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model1(ModelSQLAlchemyRedisBase): + __tablename__ = 'model1' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model2_id = sa.Column(sa.ForeignKey('model2.id')) + + model2 = sa.orm.relationship('Model2') # one-to-many + model3 = sa.orm.relationship('Model3', uselist=True) # many-to-one + model4 = sa.orm.relationship('Model4', uselist=True, secondary='model1_model4') # many-to-many + + +mtm_table = sa.Table( + 'model2_model5', ModelSQLAlchemyRedisBase.metadata, + sa.Column('model2_id', sa.Integer, sa.ForeignKey('model2.id', ondelete='CASCADE')), + sa.Column('model5_id', sa.Integer, sa.ForeignKey('model5.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model2(ModelSQLAlchemyRedisBase): + __tablename__ = 'model2' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model3_id = sa.Column(sa.ForeignKey('model3.id')) + + model3 = sa.orm.relationship('Model3') # one-to-many + model4 = sa.orm.relationship('Model4', uselist=True) # many-to-one + model5 = sa.orm.relationship('Model5', uselist=True, secondary='model2_model5') # many-to-many + + +mtm_table = sa.Table( + 'model3_model6', ModelSQLAlchemyRedisBase.metadata, + sa.Column('model3_id', sa.Integer, sa.ForeignKey('model3.id', ondelete='CASCADE')), + sa.Column('model6_id', sa.Integer, sa.ForeignKey('model6.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model3(ModelSQLAlchemyRedisBase): + __tablename__ = 'model3' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model4_id = sa.Column(sa.ForeignKey('model4.id')) + model1_id = sa.Column(sa.ForeignKey('model1.id')) + + model4 = sa.orm.relationship('Model4') # one-to-many + model5 = sa.orm.relationship('Model5', uselist=True) # many-to-one + model6 = sa.orm.relationship('Model6', uselist=True, secondary='model3_model6') # many-to-many + + +mtm_table = sa.Table( + 'model4_model7', ModelSQLAlchemyRedisBase.metadata, + sa.Column('model4_id', sa.Integer, sa.ForeignKey('model4.id', ondelete='CASCADE')), + sa.Column('model7_id', sa.Integer, sa.ForeignKey('model7.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model4(ModelSQLAlchemyRedisBase): + __tablename__ = 'model4' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model5_id = sa.Column(sa.ForeignKey('model5.id')) + model2_id = sa.Column(sa.ForeignKey('model2.id')) + + model5 = sa.orm.relationship('Model5') # one-to-many + model6 = sa.orm.relationship('Model6', uselist=True) # many-to-one + model7 = sa.orm.relationship('Model7', uselist=True, secondary='model4_model7') # many-to-many + + +mtm_table = sa.Table( + 'model5_model8', ModelSQLAlchemyRedisBase.metadata, + sa.Column('model5_id', sa.Integer, sa.ForeignKey('model5.id', ondelete='CASCADE')), + sa.Column('model8_id', sa.Integer, sa.ForeignKey('model8.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model5(ModelSQLAlchemyRedisBase): + __tablename__ = 'model5' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model6_id = sa.Column(sa.ForeignKey('model6.id')) + model3_id = sa.Column(sa.ForeignKey('model3.id')) + + model6 = sa.orm.relationship('Model6') # one-to-many + model7 = sa.orm.relationship('Model7', uselist=True) # many-to-one + model8 = sa.orm.relationship('Model8', uselist=True, secondary='model5_model8') # many-to-many + + +mtm_table = sa.Table( + 'model6_model9', ModelSQLAlchemyRedisBase.metadata, + sa.Column('model6_id', sa.Integer, sa.ForeignKey('model6.id', ondelete='CASCADE')), + sa.Column('model9_id', sa.Integer, sa.ForeignKey('model9.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model6(ModelSQLAlchemyRedisBase): + __tablename__ = 'model6' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model7_id = sa.Column(sa.ForeignKey('model7.id')) + model4_id = sa.Column(sa.ForeignKey('model4.id')) + + model7 = sa.orm.relationship('Model7') # one-to-many + model8 = sa.orm.relationship('Model8', uselist=True) # many-to-one + model9 = sa.orm.relationship('Model9', uselist=True, secondary='model6_model9') # many-to-many + + +class Model7(ModelSQLAlchemyRedisBase): + __tablename__ = 'model7' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model8_id = sa.Column(sa.ForeignKey('model8.id')) + model5_id = sa.Column(sa.ForeignKey('model5.id')) + + model8 = sa.orm.relationship('Model8') # one-to-many + model9 = sa.orm.relationship('Model9', uselist=True) # many-to-one + + +class Model8(ModelSQLAlchemyRedisBase): + __tablename__ = 'model8' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model9_id = sa.Column(sa.ForeignKey('model9.id')) + model6_id = sa.Column(sa.ForeignKey('model6.id')) + + model9 = sa.orm.relationship('Model9') # one-to-many + + +class Model9(ModelSQLAlchemyRedisBase): + __tablename__ = 'model9' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + model7_id = sa.Column(sa.ForeignKey('model7.id')) + + +class Model10(ModelSQLAlchemyRedisBase): + __tablename__ = 'model10' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + +class Model11(ModelSQLAlchemyRedisBase): + __tablename__ = 'model11' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + +class Model12(ModelSQLAlchemyRedisBase): + __tablename__ = 'model_no_redis' + __table_args__ = {'mysql_engine':'innodb'} + __use_redis__ = False + + id = sa.Column(sa.Integer, primary_key=True) + + +class Model13(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model13' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + +class Model13_two_ids(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model13_two_ids' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + id2 = sa.Column(sa.Integer, primary_key=True) + + +class Model13_three_ids(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model13_three_ids' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + id2 = sa.Column(sa.Integer, primary_key=True) + id3 = sa.Column(sa.Integer, primary_key=True) + + +class Model14(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13.id')) + Model13 = sa.orm.relationship('Model13') + + +class Model15(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model15' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13.id', ondelete='CASCADE')) + Model14_id = sa.Column(sa.ForeignKey('Model14.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship('Model13') + Model14 = sa.orm.relationship('Model14') + + +mtm_table = sa.Table( + 'mtm', ModelSQLAlchemyRedisBase.metadata, + sa.Column('Model13_id', sa.Integer, sa.ForeignKey('Model13.id', ondelete='CASCADE')), + sa.Column('Model14_id', sa.Integer, sa.ForeignKey('Model14_mtm.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model14_mtm(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14_mtm' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13 = sa.orm.relationship( + 'Model13', secondary='mtm', uselist=True) + + +class Model15_mtm(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model15_mtm' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13.id', ondelete='CASCADE')) + Model14_id = sa.Column(sa.ForeignKey('Model14_mtm.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship(Model13) + Model14 = sa.orm.relationship(Model14_mtm) + + +class Model14_primary_join(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14_primary_join' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + id2 = sa.Column(sa.Integer) + Model13_id = sa.Column(sa.ForeignKey('Model13.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship( + Model13, + primaryjoin='and_(Model14_primary_join.Model13_id==Model13.id, Model14_primary_join.id2==Model13.id)') + + +class Model13_mto(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model13_mto' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + Model14 = sa.orm.relationship('Model14_mto', uselist=True) + + +class Model14_mto(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14_mto' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_mto.id', ondelete='CASCADE')) + + +class Model15_mto(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model15_mto' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_mto.id', ondelete='CASCADE')) + Model14_id = sa.Column(sa.ForeignKey('Model14_mto.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship('Model13_mto') + Model14 = sa.orm.relationship('Model14_mto') + + +class Model13_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model13_nested' + id = sa.Column(sa.Integer, primary_key=True) + test = sa.Column(sa.String(100)) + + +class Model14_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_nested.id')) + Model13 = sa.orm.relationship('Model13_nested') + + +class Model15_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model15_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_nested.id', ondelete='CASCADE')) + Model14_id = sa.Column(sa.ForeignKey('Model14_nested.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship('Model13_nested') + Model14 = sa.orm.relationship('Model14_nested') + + +mtm_table = sa.Table( + 'mtm_nested', ModelSQLAlchemyRedisBase.metadata, + sa.Column('Model13_id', sa.Integer, sa.ForeignKey('Model13_nested.id', ondelete='CASCADE')), + sa.Column('Model14_id', sa.Integer, sa.ForeignKey('Model14_mtm_nested.id', ondelete='CASCADE')), + mysql_engine='innodb' +) + + +class Model14_mtm_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14_mtm_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13 = sa.orm.relationship('Model13_nested', secondary='mtm_nested', uselist=True) + + +class Model15_mtm_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model15_mtm_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_nested.id', ondelete='CASCADE')) + Model14_id = sa.Column(sa.ForeignKey('Model14_mtm_nested.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship('Model13_nested') + Model14 = sa.orm.relationship('Model14_mtm_nested') + + +class Model13_mto_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model13_mto_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + Model14 = sa.orm.relationship('Model14_mto_nested', uselist=True) + + +class Model14_mto_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model14_mto_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_mto_nested.id', ondelete='CASCADE')) + test = sa.Column(sa.String(100)) + + +class Model15_mto_nested(ModelSQLAlchemyRedisBase): + __tablename__ = 'Model15_mto_nested' + __table_args__ = {'mysql_engine':'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + Model13_id = sa.Column(sa.ForeignKey('Model13_mto_nested.id', ondelete='CASCADE')) + Model14_id = sa.Column(sa.ForeignKey('Model14_mto_nested.id', ondelete='CASCADE')) + Model13 = sa.orm.relationship('Model13_mto_nested') + Model14 = sa.orm.relationship('Model14_mto_nested') diff --git a/tests/integration/models/orm/test_jobs_integration.py b/tests/integration/models/orm/test_jobs_integration.py new file mode 100644 index 0000000..402e58d --- /dev/null +++ b/tests/integration/models/orm/test_jobs_integration.py @@ -0,0 +1,121 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.models.orm.jobs import ModelJobsMeta +from swaggerit.sanic_api import SanicAPI +from sanic.utils import sanic_endpoint_test +from unittest import mock +import pytest +import random +import ujson + + +class ModelSanicJobs(metaclass=ModelJobsMeta): + __schema__ = { + '/': { + 'post': { + 'operationId': 'post_job', + 'responses': { + '201': {'description': 'Created'} + } + }, + 'get': { + 'parameters': [{ + 'name': 'job_hash', + 'in': 'query', + 'type': 'string', + 'required': True + }], + 'operationId': 'get_job', + 'responses': { + '200': {'description': 'Got'} + } + } + } + } + + @classmethod + def post_job(cls, req, session): + session = cls._copy_session(session) + return cls._create_job(cls._test, 'test', req, session, test='test') + + @classmethod + def _test(cls, req, session, test): + return test + + @classmethod + def get_job(cls, req, session): + resp = cls._get_job('test', req, session) + return resp + + +@pytest.fixture +def api(session, request): + def fin(): + ModelSanicJobs.__api__ = None + + request.addfinalizer(fin) + return SanicAPI([ModelSanicJobs], session.bind, session.redis_bind, title='Test API') + + +class TestModelSanicJobs(object): + + def test_post(self, api): + random.seed(0) + _, resp = sanic_endpoint_test(api, 'post', '/', debug=True) + assert resp.status == 201 + assert resp.body == b'{"job_hash":"e3e70682c2094cac629f6fbed82c07cd"}' + + def test_get_success(self, api): + random.seed(0) + sanic_endpoint_test(api, 'post', '/', debug=True) + + query = {'job_hash': 'e3e70682c2094cac629f6fbed82c07cd'} + while True: + _, resp = sanic_endpoint_test(api, 'get', '/', params=query, debug=True) + if ujson.loads(resp.body)['status'] != 'running': + break + + assert resp.status == 200 + assert ujson.loads(resp.body)['result'] == 'test' + assert ujson.loads(resp.body)['status'] == 'done' + + def test_get_error(self, api, request): + test_func = ModelSanicJobs._test + def fin(): + ModelSanicJobs._test = test_func + request.addfinalizer(fin) + + ModelSanicJobs._test = mock.MagicMock(side_effect=Exception('test')) + random.seed(0) + sanic_endpoint_test(api, 'post', '/', debug=True) + + query = {'job_hash': 'e3e70682c2094cac629f6fbed82c07cd'} + while True: + _, resp = sanic_endpoint_test(api, 'get', '/', params=query, debug=True) + if ujson.loads(resp.body)['status'] != 'running': + break + + assert resp.status == 200 + assert ujson.loads(resp.body)['result'] == {'message': 'test', 'name': 'Exception'} + assert ujson.loads(resp.body)['status'] == 'error' diff --git a/tests/integration/models/orm/test_redis_integration.py b/tests/integration/models/orm/test_redis_integration.py new file mode 100644 index 0000000..2957548 --- /dev/null +++ b/tests/integration/models/orm/test_redis_integration.py @@ -0,0 +1,66 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + + +from swaggerit.models.orm.factories import ModelRedisFactory +import pytest + + +TestModel = ModelRedisFactory.make('TestModel', ['id']) + + +@pytest.fixture +def obj(): + return { + 'id': 1, + 'field1': 'test', + 'field2': { + 'fid': '1' + } + } + + +class TestModelRedisPost(object): + def test_insert(self, obj, session): + assert TestModel.insert(session, obj) == [obj] + + def test_insert_with_list(self, obj, session): + assert TestModel.insert(session, [obj]) == [obj] + + def test_update(self, obj, session): + TestModel.insert(session, obj) + obj['field1'] = 'testing' + assert TestModel.update(session, obj) == [obj] + + def test_update_with_ids(self, obj, session): + TestModel.insert(session, obj) + obj['id'] = 2 + assert TestModel.update(session, obj, {'id': 1}) == [obj] + + def test_delete(self, obj, session): + TestModel.insert(session, obj) + assert TestModel.delete(session, {'id': 1}) == 1 + + def test_get(self, obj, session): + TestModel.insert(session, obj) + assert TestModel.get(session, {'id': 1}) == [obj] diff --git a/tests/integration/models/orm/test_session_integration.py b/tests/integration/models/orm/test_session_integration.py new file mode 100644 index 0000000..9cdae7f --- /dev/null +++ b/tests/integration/models/orm/test_session_integration.py @@ -0,0 +1,245 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from tests.integration.models.orm.models_fixtures import * +from unittest import mock +import pytest +import ujson + + +class TestSessionCommitRedisSet(object): + def test_if_instance_is_seted_on_redis(self, session, redis): + session.add(Model10(session, id=1)) + session.commit() + + + assert redis.hgetall(Model10.__key__) == {b'1': ujson.dumps({'id': 1}).encode()} + + def test_if_two_instance_are_seted_on_redis(self, session, redis): + session.add(Model10(session, id=1)) + session.add(Model10(session, id=2)) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({'id': 1}).encode(), + b'2': ujson.dumps({'id': 2}).encode() + } + + def test_if_two_commits_sets_redis_correctly(self, session, redis): + session.add(Model10(session, id=1)) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode() + } + + session.add(Model10(session, id=2)) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + def test_if_error_right_raised(self, session, redis): + class ExceptionTest(Exception): + pass + + session.add(Model10(session, id=1)) + redis.hmset = mock.MagicMock(side_effect=ExceptionTest) + with pytest.raises(ExceptionTest): + session.commit() + + def test_if_istances_are_seted_on_redis_with_two_models_correctly( + self, session, redis): + session.add(Model10(session, id=1)) + session.add(Model11(session, id=1)) + session.add(Model10(session, id=2)) + session.add(Model11(session, id=2)) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + assert redis.hgetall(Model11.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + def test_if_two_commits_sets_redis_with_two_models_correctly( + self, session, redis): + session.add(Model10(session, id=1)) + session.add(Model11(session, id=1)) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode() + } + assert redis.hgetall(Model11.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode() + } + + session.add(Model10(session, id=2)) + session.add(Model11(session, id=2)) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + assert redis.hgetall(Model11.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + +class TestSessionCommitRedisDelete(object): + def test_if_instance_is_deleted_from_redis(self, session, redis): + inst1 = Model10(session, id=1) + session.add(inst1) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode() + } + + session.delete(inst1) + session.commit() + + assert redis.hgetall(Model10.__key__) == {} + + def test_if_two_instance_are_deleted_from_redis(self, session, redis): + inst1 = Model10(session, id=1) + inst2 = Model10(session, id=2) + session.add_all([inst1, inst2]) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + session.delete(inst1) + session.delete(inst2) + session.commit() + + assert redis.hgetall(Model10.__key__) == {} + + def test_if_two_commits_delete_redis_correctly(self, session, redis): + inst1 = Model10(session, id=1) + inst2 = Model10(session, id=2) + session.add_all([inst1, inst2]) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + session.delete(inst1) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'2': ujson.dumps({b'id': 2}).encode() + } + + session.delete(inst2) + session.commit() + + assert redis.hgetall(Model10.__key__) == {} + + def test_if_error_right_raised(self, session, redis): + class ExceptionTest(Exception): + pass + + inst1 = Model10(session, id=1) + session.add(inst1) + session.commit() + session.delete(inst1) + redis.hdel = mock.MagicMock(side_effect=ExceptionTest) + with pytest.raises(ExceptionTest): + session.commit() + + def test_if_istances_are_seted_on_redis_with_two_models_correctly( + self, session, redis): + inst1 = Model10(session, id=1) + inst2 = Model10(session, id=2) + inst3 = Model11(session, id=1) + inst4 = Model11(session, id=2) + session.add_all([inst1, inst2, inst3, inst4]) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + assert redis.hgetall(Model11.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + session.delete(inst1) + session.delete(inst2) + session.delete(inst3) + session.delete(inst4) + session.commit() + + assert redis.hgetall(Model10.__key__) == {} + assert redis.hgetall(Model11.__key__) == {} + + def test_if_two_commits_delete_redis_with_two_models_correctly( + self, session, redis): + inst1 = Model10(session, id=1) + inst2 = Model10(session, id=2) + inst3 = Model11(session, id=1) + inst4 = Model11(session, id=2) + session.add_all([inst1, inst2, inst3, inst4]) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + assert redis.hgetall(Model11.__key__) == { + b'1': ujson.dumps({b'id': 1}).encode(), + b'2': ujson.dumps({b'id': 2}).encode() + } + + session.delete(inst1) + session.delete(inst3) + session.commit() + + assert redis.hgetall(Model10.__key__) == { + b'2': ujson.dumps({b'id': 2}).encode() + } + assert redis.hgetall(Model11.__key__) == { + b'2': ujson.dumps({b'id': 2}).encode() + } + + session.delete(inst2) + session.delete(inst4) + session.commit() + + assert redis.hgetall(Model10.__key__) == {} + assert redis.hgetall(Model11.__key__) == {} diff --git a/tests/integration/models/orm/test_session_without_redis_integration.py b/tests/integration/models/orm/test_session_without_redis_integration.py new file mode 100644 index 0000000..5e53567 --- /dev/null +++ b/tests/integration/models/orm/test_session_without_redis_integration.py @@ -0,0 +1,68 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from tests.integration.models.orm.models_fixtures import * +import pytest +from unittest import mock + + +@pytest.fixture +def redis(): + r = mock.MagicMock() + r.smembers.return_value = set() + return r + + +class TestSessionCommitWithoutRedis(object): + def test_set_without_redis(self, redis, session): + session.redis_bind = None + session.add(Model1(session, id=1)) + session.commit() + assert redis.hgetall.called == False + + def test_delete_without_redis(self, redis, session): + session.redis_bind = None + m1 = Model1(session, id=1) + session.add(m1) + session.commit() + session.delete(m1) + session.commit() + assert redis.hdel.called == False + + +class TestSessionCommitRedisWithoutUseRedisFlag(object): + def test_if_instance_is_not_setted_on_redis(self, session): + session.add(Model12(session, id=1)) + session.commit() + + assert session.redis_bind.hmset.call_args_list == [] + + def test_if_instance_is_not_deleted_from_redis(self, session, redis): + inst1 = Model12(session, id=1) + session.add(inst1) + session.commit() + + session.delete(inst1) + session.commit() + + assert session.redis_bind.hdel.call_args_list == [] diff --git a/tests/integration/models/orm/test_sqlalchemy_redis_get_integration.py b/tests/integration/models/orm/test_sqlalchemy_redis_get_integration.py new file mode 100644 index 0000000..115dc4e --- /dev/null +++ b/tests/integration/models/orm/test_sqlalchemy_redis_get_integration.py @@ -0,0 +1,124 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from tests.integration.models.orm.models_fixtures import Model13 +from unittest import mock +import pytest +import ujson + + +@pytest.fixture +def redis(): + return mock.MagicMock() + + +class TestModelBaseGet(object): + def test_if_query_get_calls_hmget_correctly(self, session, redis): + Model13.get(session, {'id': 1}) + assert redis.hmget.call_args_list == [mock.call('Model13', [b'1'])] + + def test_if_query_get_calls_hmget_correctly_with_two_ids(self, session, redis): + Model13.get(session, [{'id': 1}, {'id': 2}]) + assert redis.hmget.call_args_list == [mock.call('Model13', [b'1', b'2'])] + + def test_if_query_get_builds_redis_left_ids_correctly_with_result_found_on_redis_with_one_id( + self, session, redis): + session.add(Model13(session, id=1)) + session.commit() + redis.hmget.return_value = [None] + assert Model13.get(session, {'id': 1}) == [{'id': 1}] + + def test_if_query_get_builds_redis_left_ids_correctly_with_no_result_found_on_redis_with_two_ids( + self, session, redis): + session.add_all([Model13(session, id=1), Model13(session, id=2)]) + session.commit() + redis.hmget.return_value = [None, None] + assert Model13.get(session, [{'id': 1}, {'id': 2}]) == [{'id': 1}, {'id': 2}] + + def test_if_query_get_builds_redis_left_ids_correctly_with_no_result_found_on_redis_with_three_ids( + self, session, redis): + session.add_all([Model13(session, id=1), Model13(session, id=2), Model13(session, id=3)]) + session.commit() + redis.hmget.return_value = [None, None, None] + assert Model13.get(session, [{'id': 1}, {'id': 2}, {'id': 3}]) == \ + [{'id': 1}, {'id': 2}, {'id': 3}] + + def test_if_query_get_builds_redis_left_ids_correctly_with_no_result_found_on_redis_with_four_ids( + self, session, redis): + session.add_all([Model13(session, id=1), Model13(session, id=2), Model13(session, id=3), Model13(session, id=4)]) + session.commit() + redis.hmget.return_value = [None, None, None, None] + assert Model13.get(session, [{'id': 1}, {'id': 2}, {'id': 3}, {'id': 4}]) == \ + [{'id': 1}, {'id': 2}, {'id': 3}, {'id': 4}] + + def test_if_query_get_builds_redis_left_ids_correctly_with_one_not_found_on_redis( + self, session, redis): + session.add(Model13(session, id=1)) + session.commit() + redis.hmget.return_value = [None, ujson.dumps({'id': 2})] + assert Model13.get(session, [{'id': 1}, {'id': 2}]) == [{'id': 1}, {'id': 2}] + + def test_with_ids_and_limit(self, session, redis): + session.add_all([Model13(session, id=1), Model13(session, id=2), Model13(session, id=3)]) + session.commit() + Model13.get(session, [{'id': 1}, {'id': 2}, {'id': 3}, {'id': 4}], limit=2) + assert redis.hmget.call_args_list == [mock.call('Model13', [b'1', b'2'])] + + def test_with_ids_and_offset(self, session, redis): + session.add_all([Model13(session, id=1), Model13(session, id=2), Model13(session, id=3)]) + session.commit() + Model13.get(session, [{'id': 1}, {'id': 2}, {'id': 3}, {'id': 4}], offset=2) + assert redis.hmget.call_args_list == [mock.call('Model13', [b'3', b'4'])] + + def test_with_ids_and_limit_and_offset(self, session, redis): + session.add_all([Model13(session, id=1), Model13(session, id=2), Model13(session, id=3)]) + session.commit() + Model13.get(session, [{'id': 1}, {'id': 2}, {'id': 3}, {'id': 4}], limit=2, offset=1) + assert redis.hmget.call_args_list == [mock.call('Model13', [b'2', b'3'])] + + def test_with_missing_id(self, session, redis): + session.add(Model13(session, id=1)) + session.commit() + redis.hmget.return_value = [None, None] + assert Model13.get(session, [{'id': 1}, {'id': 2}]) == [{'id': 1}] + + def test_with_missing_all_ids(self, session, redis): + redis.hmget.return_value = [None, None] + assert Model13.get(session, [{'id': 1}, {'id': 2}]) == [] + + def test_without_ids(self, session, redis): + Model13.insert(session, {}) + assert Model13.get(session) == [{'id': 1}] + + def test_without_ids_and_with_limit(self, session, redis): + Model13.insert(session, [{}, {}, {}]) + assert Model13.get(session, limit=2) == [{'id': 1}, {'id': 2}] + + def test_without_ids_and_with_offset(self, session, redis): + Model13.insert(session, [{}, {}, {}]) + assert Model13.get(session, offset=1) == [{'id': 2}, {'id': 3}] + + def test_without_ids_and_with_limit_and_offset(self, session, redis): + Model13.insert(session, [{}, {}, {}]) + assert Model13.get(session, limit=1, offset=1) == [{'id': 2}] + diff --git a/tests/integration/models/orm/test_sqlalchemy_redis_get_related_integration.py b/tests/integration/models/orm/test_sqlalchemy_redis_get_related_integration.py new file mode 100644 index 0000000..226280a --- /dev/null +++ b/tests/integration/models/orm/test_sqlalchemy_redis_get_related_integration.py @@ -0,0 +1,174 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from tests.integration.models.orm.models_fixtures import * + + +class TestModelGetRelatedHard(object): + def test_get_related_with_all_models_related_each_other(self, session): + m11 = Model1(session, id=1) + m12 = Model1(session, id=2) + m13 = Model1(session, id=3) + + m21 = Model2(session, id=1) + m22 = Model2(session, id=2) + m23 = Model2(session, id=3) + + m31 = Model3(session, id=1) + m32 = Model3(session, id=2) + m33 = Model3(session, id=3) + + m41 = Model4(session, id=1) + m42 = Model4(session, id=2) + m43 = Model4(session, id=3) + + m51 = Model5(session, id=1) + m52 = Model5(session, id=2) + m53 = Model5(session, id=3) + + m61 = Model6(session, id=1) + m62 = Model6(session, id=2) + m63 = Model6(session, id=3) + + m71 = Model7(session, id=1) + m72 = Model7(session, id=2) + m73 = Model7(session, id=3) + + m81 = Model8(session, id=1) + m82 = Model8(session, id=2) + m83 = Model8(session, id=3) + + m91 = Model9(session, id=1) + m92 = Model9(session, id=2) + m93 = Model9(session, id=3) + + m11.model2 = m21 + m11.model3 = [m31, m32] + m11.model4 = [m42, m43] + + m12.model2 = m22 + m12.model3 = [m33] + m12.model4 = [m43, m41] + + m13.model2 = m23 + m13.model4 = [m41, m42] + + m21.model3 = m33 + m21.model4 = [m41, m42] + m21.model5 = [m52, m53] + + m22.model4 = [m43] + m22.model5 = [m51, m53] + + m23.model5 = [m51, m52] + + m31.model4 = m41 + m31.model5 = [m51, m52] + m31.model6 = [m62, m63] + + m32.model4 = m42 + m32.model5 = [m53] + m32.model6 = [m61, m63] + + m33.model4 = m43 + m33.model6 = [m61, m62] + + m41.model5 = m53 + m41.model6 = [m61, m62] + m41.model7 = [m72, m73] + + m42.model6 = [m63] + m42.model7 = [m71, m73] + + m43.model7 = [m71, m72] + + m51.model6 = m61 + m51.model7 = [m71, m72] + m51.model8 = [m82, m83] + + m52.model6 = m62 + m52.model7 = [m73] + m52.model8 = [m81, m83] + + m53.model6 = m63 + m53.model8 = [m81, m82] + + m61.model7 = m73 + m61.model8 = [m81, m82] + m61.model9 = [m92, m93] + + m62.model8 = [m83] + m62.model9 = [m91, m93] + + m63.model9 = [m91, m92] + + m71.model8 = m81 + m71.model9 = [m91, m92] + + m72.model8 = m82 + m72.model9 = [m93] + + m73.model8 = m83 + + m81.model9 = m93 + + session.add_all([ + m11, m12, m13, m21, m22, m33, + m31, m32, m33, m41, m42, m43, + m51, m52, m53, m61, m62, m63, + m71, m72, m73, m81, m82, m83, + m91, m92, m93 + ]) + session.commit() + + assert m21.get_related(session) == {m11} + assert m22.get_related(session) == {m12} + assert m23.get_related(session) == {m13} + + assert m31.get_related(session) == {m11} + assert m32.get_related(session) == {m11} + assert m33.get_related(session) == {m12, m21} + + assert m41.get_related(session) == {m12, m13, m21, m31} + assert m42.get_related(session) == {m11, m13, m21, m32} + assert m43.get_related(session) == {m11, m12, m22, m33} + + assert m51.get_related(session) == {m22, m23, m31} + assert m52.get_related(session) == {m21, m23, m31} + assert m53.get_related(session) == {m21, m22, m32, m41} + + assert m61.get_related(session) == {m32, m33, m41, m51} + assert m62.get_related(session) == {m31, m33, m41, m52} + assert m63.get_related(session) == {m31, m32, m42, m53} + + assert m71.get_related(session) == {m42, m43, m51} + assert m72.get_related(session) == {m41, m43, m51} + assert m73.get_related(session) == {m41, m42, m52, m61} + + assert m81.get_related(session) == {m52, m53, m61, m71} + assert m82.get_related(session) == {m51, m53, m61, m72} + assert m83.get_related(session) == {m51, m52, m62, m73} + + assert m91.get_related(session) == {m62, m63, m71} + assert m92.get_related(session) == {m61, m63, m71} + assert m93.get_related(session) == {m61, m62, m72, m81} diff --git a/tests/integration/models/orm/test_sqlalchemy_redis_integration.py b/tests/integration/models/orm/test_sqlalchemy_redis_integration.py new file mode 100644 index 0000000..172c309 --- /dev/null +++ b/tests/integration/models/orm/test_sqlalchemy_redis_integration.py @@ -0,0 +1,718 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from tests.integration.models.orm.models_fixtures import * +from swaggerit.exceptions import SwaggerItModelError +from copy import deepcopy +from unittest import mock +import pytest +import ujson +import sqlalchemy as sa + + +class TestModelBaseTodict(object): + def test_todict_after_get_from_database(self, session): + session.add(Model14(session, id=1, Model13={'id': 1, '_operation': 'insert'})) + session.commit() + expected = { + 'id': 1, + 'Model13_id': 1, + 'Model13': {'id': 1} + } + session.query(Model14).filter_by(id=1).one().todict() == expected + + def test_todict_after_get_from_database_with_mtm(self, session): + session.add(Model14_mtm(session, id=1, Model13=[{'id': 1, '_operation': 'insert'}])) + session.commit() + expected = { + 'id': 1, + 'Model13': [{'id': 1}] + } + session.query(Model14_mtm).filter_by(id=1).one().todict() == expected + + def test_todict_after_get_from_database_with_mtm_with_two_relations( + self, session): + session.add(Model14_mtm(session, id=1, Model13=[{'id': 1, '_operation': 'insert'}, {'id': 2, '_operation': 'insert'}])) + session.commit() + expected = { + 'id': 1, + 'Model13': [{'id': 1}, {'id': 2}] + } + session.query(Model14_mtm).filter_by(id=1).one().todict() == expected + + +class TestModelBaseGetRelated(object): + def test_get_related_with_one_model(self, session): + m11 = Model13(session, id=1) + m21 = Model14(session, id=1) + m21.Model13 = m11 + session.add_all([m11, m21]) + session.commit() + + assert m11.get_related(session) == {m21} + + def test_get_related_with_two_models(self, session): + m11 = Model13(session, id=1) + m21 = Model14(session, id=1) + m31 = Model15(session, id=1) + m31.Model13 = m11 + m31.Model14 = m21 + session.add_all([m11, m21, m31]) + session.commit() + + assert m11.get_related(session) == {m31} + assert m21.get_related(session) == {m31} + + def test_get_related_with_two_related(self, session): + m11 = Model13(session, id=1) + m21 = Model14(session, id=1) + m31 = Model15(session, id=1) + m31.Model13 = m11 + m21.Model13 = m11 + session.add_all([m11, m21, m31]) + session.commit() + + assert m11.get_related(session) == {m31, m21} + + def test_get_related_with_two_models_and_two_related(self, session): + m11 = Model13(session, id=1) + m21 = Model14(session, id=1) + m31 = Model15(session, id=1) + m31.Model13 = m11 + m21.Model13 = m11 + m22 = Model14(session, id=2) + m32 = Model15(session, id=2) + m32.Model13 = m11 + m22.Model13 = m11 + session.add_all([m11, m21, m31, m22, m32]) + session.commit() + + assert m11.get_related(session) == {m31, m21, m22, m32} + + def test_get_related_with_mtm( + self, session): + m11 = Model13(session, id=1) + m12 = Model13(session, id=2) + m21 = Model14_mtm(session, id=1) + m31 = Model15_mtm(session, id=1) + m31.Model13 = m11 + m21.Model13 = [m11, m12] + m22 = Model14_mtm(session, id=2) + m32 = Model15_mtm(session, id=2) + m32.Model13 = m11 + m22.Model13 = [m11, m12] + session.add_all([m11, m12, m21, m31, m22, m32]) + session.commit() + + assert m11.get_related(session) == {m31, m21, m22, m32} + assert m12.get_related(session) == {m21, m22} + + def test_get_related_with_primary_join( + self, session): + m11 = Model13(session, id=5) + m21 = Model14_primary_join(session, id=1, id2=5) + m21.Model13 = m11 + session.add_all([m11, m21]) + session.commit() + + assert m21.Model13 == m11 + assert m11.get_related(session) == {m21} + + def test_get_related_with_primary_join_get_no_result( + self, session): + m11 = Model13(session, id=1) + m21 = Model14_primary_join(session, id=1, id2=5) + m21.Model13 = m11 + session.add_all([m11, m21]) + session.commit() + + assert m21.Model13 == None + assert m11.get_related(session) == set() + assert m21.get_related(session) == set() + + def test_get_related_with_mto( + self, session): + m11 = Model13_mto(session, id=1) + m21 = Model14_mto(session, id=1) + m11.Model14 = [m21] + session.add_all([m11, m21]) + session.commit() + + assert m11.Model14 == [m21] + assert m21.get_related(session) == {m11} + + def test_get_related_with_mto_with_two_related( + self, session): + m11 = Model13_mto(session, id=1) + m21 = Model14_mto(session, id=1) + m22 = Model14_mto(session, id=2) + m11.Model14 = [m21, m22] + session.add(m11) + session.commit() + + assert m11.Model14 == [m21, m22] + assert m21.get_related(session) == {m11} + + +class TestModelBaseInsert(object): + def test_insert_with_one_object(self, session): + objs = Model13.insert(session, {'id': 1}) + assert objs == [{'id': 1}] + + def test_insert_without_todict(self, session): + objs = Model13.insert(session, {'id': 1}, todict=False) + assert [o.todict() for o in objs] == [{'id': 1}] + + def test_insert_with_two_objects(self, session): + objs = Model13.insert(session, [{'id': 1}, {'id': 2}]) + assert objs == [{'id': 1}, {'id': 2}] or objs == [{'id': 2}, {'id': 1}] + + def test_insert_with_two_nested_objects(self, session): + objs = Model14.insert(session, {'id': 1, 'Model13': {'id': 1, '_operation': 'insert'}}) + assert objs == [{'id': 1, 'Model13_id': 1, 'Model13': {'id': 1}}] + + def test_insert_with_three_nested_objects(self, session): + m1 = {'id': 1, '_operation': 'insert'} + m2 = {'id': 1, 'Model13': m1, '_operation': 'insert'} + objs = Model15.insert(session, {'id': 1, 'Model14': m2}) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1 + } + } + } + assert objs == [expected] + + def test_insert_with_nested_update(self, session): + Model13.insert(session, {'id': 1}) + Model14.insert(session, {'id': 1}) + + m3 = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13_id': 1 + } + } + objs = Model15.insert(session, m3) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1 + } + } + } + assert objs == [expected] + + def test_insert_with_nested_update_and_get(self, session): + Model13.insert(session, {'id': 1}) + Model14.insert(session, {'id': 1}) + + m3 = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13': {'id': 1, '_operation': 'get'} + } + } + objs = Model15.insert(session, m3) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1 + } + } + } + assert objs == [expected] + + def test_insert_with_two_nested_update(self, session): + Model13_nested.insert(session, {'id': 1}) + Model14_nested.insert(session, {'id': 1}) + + m3 = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13': { + 'id': 1, + '_operation': 'update', + 'test': 'test_updated' + } + } + } + objs = Model15_nested.insert(session, m3) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1, + 'test': 'test_updated' + } + } + } + assert objs == [expected] + + def test_insert_with_two_nested_update_with_mtm(self, session): + Model13_nested.insert(session, [{'id': 1}, {'id': 2}]) + Model14_mtm_nested.insert(session, {'id': 1}) + + m3 = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13': [ + { + 'id': 1, + '_operation': 'update', + 'test': 'test_updated' + }, { + 'id': 2, + '_operation': 'update', + 'test': 'test_updated2' + } + ] + } + } + objs = Model15_mtm_nested.insert(session, m3) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13': [ + { + 'id': 1, + 'test': 'test_updated' + },{ + 'id': 2, + 'test': 'test_updated2' + } + ] + } + } + assert objs == [expected] + + def test_insert_with_two_nested_update_with_mto(self, session): + Model13_mto_nested.insert(session, {'id': 1}) + Model14_mto_nested.insert(session, [{'id': 1}, {'id': 2}]) + + m3 = { + 'id': 1, + 'Model13': { + 'id': 1, + '_operation': 'update', + 'Model14': [ + { + 'id': 1, + '_operation': 'update', + 'test': 'test_updated' + }, { + 'id': 2, + '_operation': 'update', + 'test': 'test_updated2' + } + ] + } + } + objs = Model15_mto_nested.insert(session, m3) + + expected = { + 'id': 1, + 'Model14_id': None, + 'Model14': None, + 'Model13_id': 1, + 'Model13': { + 'id': 1, + 'Model14': [ + { + 'id': 1, + 'Model13_id': 1, + 'test': 'test_updated' + },{ + 'id': 2, + 'Model13_id': 1, + 'test': 'test_updated2' + } + ] + } + } + assert objs == [expected] + + def test_insert_with_mtm_update_and_delete(self, session): + m1 = {'id': 1, '_operation': 'insert'} + m2 = {'id': 1, 'Model13': [m1]} + Model14_mtm.insert(session, m2) + m3_insert = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13': [{ + 'id': 1, + '_operation': 'delete' + }] + } + } + objs = Model15_mtm.insert(session, m3_insert) + assert session.query(Model13).all() == [] + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13': [] + } + } + assert objs == [expected] + + def test_insert_with_mtm_update_and_remove(self, session): + m1 = {'id': 1, '_operation': 'insert'} + m2 = {'id': 1, 'Model13': [m1]} + Model14_mtm.insert(session, m2) + m3_insert = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13': [{ + 'id': 1, + '_operation': 'remove' + }] + } + } + objs = Model15_mtm.insert(session, m3_insert) + assert session.query(Model13).one().todict() == {'id': 1} + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13': [] + } + } + assert objs == [expected] + + def test_insert_with_mto_update_and_remove(self, session): + m2 = {'id': 1, '_operation': 'insert'} + m1 = {'id': 1, 'Model14': [m2]} + Model13_mto.insert(session, m1) + m3_insert = { + 'id': 1, + 'Model13': { + 'id': 1, + '_operation': 'update', + 'Model14': [{ + 'id': 1, + '_operation': 'remove' + }] + } + } + objs = Model15_mto.insert(session, m3_insert) + assert session.query(Model14_mto).one().todict() == {'id': 1, 'Model13_id': None} + + expected = { + 'id': 1, + 'Model13_id': 1, + 'Model14_id': None, + 'Model13': { + 'id': 1, + 'Model14': [] + }, + 'Model14': None + } + assert objs == [expected] + + def test_insert_nested_update_without_relationships(self, session): + with pytest.raises(SwaggerItModelError): + Model14.insert(session, {'Model13': {'id': 1, '_operation': 'update'}}) + + def test_insert_nested_remove_without_relationships(self, session): + with pytest.raises(SwaggerItModelError): + Model14.insert(session, {'Model13': {'id': 1, '_operation': 'remove'}}) + + def test_insert_nested_delete_without_relationships(self, session): + with pytest.raises(SwaggerItModelError): + Model14.insert(session, {'Model13': {'id': 1, '_operation': 'delete'}}) + + +class TestModelBaseUpdate(object): + def test_update_with_one_object(self, session): + Model13_nested.insert(session, {'id': 1}) + Model13_nested.update(session, {'id': 1, 'test': 'test_updated'}) + assert session.query(Model13_nested).one().todict() == {'id': 1, 'test': 'test_updated'} + + def test_update_with_two_objects(self, session): + Model13_nested.insert(session, [{'id': 1}, {'id': 2}]) + Model13_nested.update(session, [ + {'id': 1, 'test': 'test_updated'}, + {'id': 2, 'test': 'test_updated2'}]) + assert [o.todict() for o in session.query(Model13_nested).all()] == \ + [{'id': 1, 'test': 'test_updated'}, {'id': 2, 'test': 'test_updated2'}] + + def test_update_with_two_nested_objects(self, session): + Model13_nested.insert(session, {'id': 1}) + Model14_nested.insert(session, {'id': 1}) + Model14_nested.update(session, {'id': 1, 'Model13': {'id': 1, '_operation': 'update', 'test': 'test_updated'}}) + + assert session.query(Model14_nested).one().todict() == { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1, + 'test': 'test_updated' + } + } + + def test_update_with_three_nested_objects(self, session): + Model13_nested.insert(session, {'id': 1}) + Model14_nested.insert(session, {'id': 1}) + Model15_nested.insert(session, {'id': 1}) + m3_update = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'update', + 'Model13': { + 'id': 1, + '_operation': 'update', + 'test': 'test_updated' + } + } + } + Model15_nested.update(session, m3_update) + + assert session.query(Model15_nested).one().todict() == { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1, + 'test': 'test_updated' + } + } + } + + def test_update_with_nested_insert(self, session): + Model14.insert(session, {'id': 1}) + + m2_update = { + 'id': 1, + 'Model13': { + 'id': 1, + '_operation': 'insert' + } + } + Model14.update(session, m2_update) + + expected = { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1 + } + } + assert session.query(Model14).one().todict() == expected + + def test_update_with_two_nested_insert(self, session): + Model15.insert(session, {'id': 1}) + + m3_update = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'insert', + 'Model13': { + 'id': 1, + '_operation': 'insert' + } + } + } + Model15.update(session, m3_update) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1 + } + } + } + assert session.query(Model15).one().todict() == expected + + def test_update_with_two_nested_insert_with_mtm(self, session): + Model15_mtm.insert(session, {'id': 1}) + + m3_update = { + 'id': 1, + 'Model14': { + 'id': 1, + '_operation': 'insert', + 'Model13': [ + {'id': 1, '_operation': 'insert'}, + {'id': 2, '_operation': 'insert'} + ] + } + } + Model15_mtm.update(session, m3_update) + + expected = { + 'id': 1, + 'Model13_id': None, + 'Model13': None, + 'Model14_id': 1, + 'Model14': { + 'id': 1, + 'Model13': [ + {'id': 1}, + {'id': 2} + ] + } + } + assert session.query(Model15_mtm).one().todict() == expected + + def test_update_with_two_nested_insert_with_mto(self, session): + Model15_mto.insert(session, {'id': 1}) + + m3_update = { + 'id': 1, + 'Model13': { + 'id': 1, + '_operation': 'insert', + 'Model14': [ + {'id': 1, '_operation': 'insert'}, + {'id': 2, '_operation': 'insert'} + ] + } + } + Model15_mto.update(session, m3_update) + + expected = { + 'id': 1, + 'Model13_id': 1, + 'Model13': { + 'id': 1, + 'Model14': [ + {'id': 1, 'Model13_id': 1}, + {'id': 2, 'Model13_id': 1} + ] + }, + 'Model14_id': None, + 'Model14': None + } + assert session.query(Model15_mto).one().todict() == expected + + def test_update_with_missing_id(self, session): + session.add(Model13(session, id=1)) + session.commit() + assert Model13.update(session, [{'id': 1}, {'id': 2}]) == [{'id': 1}] + + def test_update_with_missing_all_ids(self, session): + assert Model13.update(session, [{'id': 1}, {'id': 2}]) == [] + + def test_update_with_nested_remove_without_uselist(self, session): + Model14.insert(session, {'Model13': {'_operation': 'insert'}}) + Model14.update(session, {'id': 1, 'Model13': {'id': 1, '_operation': 'remove'}}) + assert session.query(Model14).one().todict() == {'id': 1, 'Model13_id': None, 'Model13': None} + + def test_update_with_nested_remove_with_two_relationships(self, session): + Model14_mtm.insert(session, {'Model13': [{'_operation': 'insert'}, {'_operation': 'insert'}]}) + Model14_mtm.update(session, {'id': 1, 'Model13': [{'id': 2, '_operation': 'remove'}]}) + assert session.query(Model14_mtm).one().todict() == {'id': 1, 'Model13': [{'id': 1}]} + + +class TestModelBaseDelete(object): + def test_delete(self, session): + Model13.insert(session, {}) + assert Model13.get(session) == [{'id': 1}] + + Model13.delete(session, {'id': 1}) + assert Model13.get(session) == [] + + def test_delete_with_invalid_id(self, session): + Model13.insert(session, {}) + Model13.delete(session , [{'id': 2}, {'id': 3}]) + assert Model13.get(session) == [{'id': 1}] + + def test_delete_with_two_ids(self, session): + Model13_two_ids.insert(session, {'id2': 2}) + assert Model13_two_ids.get(session) == [{'id': 1, 'id2': 2}] + + Model13_two_ids.delete(session , {'id': 1, 'id2': 2}) + assert Model13_two_ids.get(session) == [] + + def test_delete_with_three_ids(self, session): + Model13_three_ids.insert(session, {'id2': 2, 'id3': 3}) + assert Model13_three_ids.get(session) == [{'id': 1, 'id2': 2, 'id3': 3}] + + Model13_three_ids.delete(session , {'id': 1, 'id2': 2, 'id3': 3}) + assert Model13_three_ids.get(session) == [] diff --git a/tests/integration/test_api_errors.py b/tests/integration/test_api_errors.py new file mode 100644 index 0000000..de05212 --- /dev/null +++ b/tests/integration/test_api_errors.py @@ -0,0 +1,219 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.api import SwaggerAPI +from swaggerit.request import SwaggerRequest +from tests.integration.models.orm.models_fixtures import ModelSQLAlchemyRedisBase +import pytest +import ujson +import sqlalchemy as sa + + +class Model2Swagger(ModelSQLAlchemyRedisBase): + __tablename__ = 'model2_swagger' + __table_args__ = {'mysql_engine': 'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + +class Model1Swagger(ModelSQLAlchemyRedisBase): + __tablename__ = 'model1_swagger' + __table_args__ = {'mysql_engine': 'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + m2_id = sa.Column(sa.ForeignKey('model2_swagger.id')) + model2_ = sa.orm.relationship('Model2Swagger') + + __schema__ = { + '/model1/': { + 'post': { + 'operationId': 'swagger_insert', + 'responses': {'200': {'description': 'test'}}, + 'parameters': [{ + 'name': 'body', + 'in': 'body', + 'schema': {'type': 'array'} + }] + } + }, + '/model1/{id}/': { + 'patch': { + 'operationId': 'swagger_update', + 'responses': {'200': {'description': 'test'}}, + 'parameters': [{ + 'name': 'body', + 'in': 'body', + 'schema': {'type': 'object'} + },{ + 'name': 'id', + 'in': 'path', + 'required': True, + 'type': 'integer' + }] + } + } + } + + +@pytest.fixture +def api(session, request): + def fin(): + Model1Swagger.__api__ = None + + request.addfinalizer(fin) + return SwaggerAPI([Model1Swagger], session.bind, session.redis_bind, title='Test API') + + +class TestSwaggerAPIErrorHandlingPOST(object): + + def test_integrity_error_handling_with_duplicated_key(self, api): + body = ujson.dumps([{'id': 1}, {'id': 1}]) + request = SwaggerRequest('/model1/', 'post', body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'params': [{'id': 1, 'm2_id': None}, {'id': 1, 'm2_id': None}], + 'database message': { + 'message': "Duplicate entry '1' for key 'PRIMARY'", + 'code': 1062 + } + } + + def test_integrity_error_handling_with_foreign_key(self, api): + body = ujson.dumps([{'m2_id': 1}]) + request = SwaggerRequest('/model1/', 'post', body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'params': {'m2_id': 1}, + 'database message': { + 'message': 'Cannot add or update a child row: ' + 'a foreign key constraint fails ' + '(`swaggerit_test`.`model1_swagger`, ' + 'CONSTRAINT `model1_swagger_ibfk_1` FOREIGN ' + 'KEY (`m2_id`) REFERENCES `model2_swagger` ' + '(`id`))', + 'code': 1452 + } + } + + def test_json_validation_error_handling(self, api): + request = SwaggerRequest('/model1/', 'post', body='"test"', headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': 'test', + 'message': "'test' is not of type 'array'", + 'schema': { + 'type': 'array' + } + } + + def test_json_error_handling(self, api): + request = SwaggerRequest('/model1/', 'post', body='test', headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': 'test', + 'message': "Unexpected character found when decoding 'true'" + } + + def test_model_base_error_handling_with_post_and_with_nested_delete(self, api): + body = ujson.dumps([{'model2_': {'id': 1, '_operation': 'delete'}}]) + request = SwaggerRequest('/model1/', 'post', body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': ujson.loads(body), + 'message': "Can't execute nested 'delete' operation" + } + + def test_model_base_error_handling_with_post_and_with_nested_remove(self, api): + body = ujson.dumps([{'model2_': {'id': 1, '_operation': 'remove'}}]) + request = SwaggerRequest('/model1/', 'post', body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': ujson.loads(body), + 'message': "Can't execute nested 'remove' operation" + } + + def test_model_base_error_handling_with_post_and_with_nested_update(self, api): + body = ujson.dumps([{'model2_': {'id': 1, '_operation': 'update'}}]) + request = SwaggerRequest('/model1/', 'post', body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': ujson.loads(body), + 'message': "Can't execute nested 'update' operation" + } + + +class TestSwaggerAPIErrorHandlingPATCH(object): + + def test_model_base_error_handling_with_patch_and_with_nested_delete(self, api): + request = SwaggerRequest('/model1/', 'post', body='[{}]', headers={'Content-Type': 'application/json'}) + api.get_response(request) + + body = ujson.dumps({'model2_': {'id': 1, '_operation': 'delete'}}) + request = SwaggerRequest('/model1/{id}', 'patch', path_params={'id': 1}, body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': [ujson.loads(body)], + 'message': "Can't execute nested 'delete' operation" + } + + def test_model_base_error_handling_with_patch_and_with_nested_remove(self, api): + request = SwaggerRequest('/model1/', 'post', body='[{}]', headers={'Content-Type': 'application/json'}) + api.get_response(request) + + body = ujson.dumps({'model2_': {'id': 1, '_operation': 'remove'}}) + request = SwaggerRequest('/model1/{id}', 'patch', path_params={'id': 1}, body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': [ujson.loads(body)], + 'message': "Can't execute nested 'remove' operation" + } + + def test_model_base_error_handling_with_patch_and_with_nested_update(self, api): + request = SwaggerRequest('/model1/', 'post', body='[{}]', headers={'Content-Type': 'application/json'}) + api.get_response(request) + + body = ujson.dumps({'model2_': {'id': 1, '_operation': 'update'}}) + request = SwaggerRequest('/model1/{id}', 'patch', path_params={'id': 1}, body=body, headers={'Content-Type': 'application/json'}) + resp = api.get_response(request) + + assert resp.status_code == 400 + assert ujson.loads(resp.body) == { + 'instance': [ujson.loads(body)], + 'message': "Can't execute nested 'update' operation" + } diff --git a/tests/integration/test_sanic_api.py b/tests/integration/test_sanic_api.py new file mode 100644 index 0000000..5b9ab70 --- /dev/null +++ b/tests/integration/test_sanic_api.py @@ -0,0 +1,144 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.sanic_api import SanicAPI +from tests.integration.models.orm.models_fixtures import ModelSQLAlchemyRedisBase +from sanic.utils import sanic_endpoint_test +import pytest +import ujson +import sqlalchemy as sa + + +class Model2Sanic(ModelSQLAlchemyRedisBase): + __tablename__ = 'model2_sanic' + __table_args__ = {'mysql_engine': 'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + + +class Model1Sanic(ModelSQLAlchemyRedisBase): + __tablename__ = 'model1_sanic' + __table_args__ = {'mysql_engine': 'innodb'} + id = sa.Column(sa.Integer, primary_key=True) + m2_id = sa.Column(sa.ForeignKey('model2_sanic.id')) + model2 = sa.orm.relationship('Model2Sanic') + + __schema__ = { + '/model1/': { + 'post': { + 'operationId': 'swagger_insert', + 'responses': {'200': {'description': 'test'}}, + 'parameters': [{ + 'name': 'body', + 'in': 'body', + 'schema': {'type': 'array'} + }] + } + }, + '/model1/{id}/': { + 'patch': { + 'operationId': 'swagger_update', + 'responses': {'200': {'description': 'test'}}, + 'parameters': [{ + 'name': 'body', + 'in': 'body', + 'schema': {'type': 'object'} + },{ + 'name': 'id', + 'in': 'path', + 'required': True, + 'type': 'integer' + }] + } + } + } + + +@pytest.fixture +def api(session, request): + def fin(): + Model1Sanic.__api__ = None + + request.addfinalizer(fin) + return SanicAPI([Model1Sanic], session.bind, session.redis_bind, title='Test API') + + +class TestSanicAPI(object): + + def test_insert(self, api): + headers = {'Content-Type': 'application/json'} + _, response = sanic_endpoint_test(api, 'post', '/model1', data=b'[{}]', headers=headers) + assert response.status == 201 + assert ujson.loads(response.body) == [{'id': 1, 'm2_id': None, 'model2': None}] + + def test_get_swagger_json(self, api): + _, response = sanic_endpoint_test(api, 'get', '/swagger.json') + assert response.status == 200 + assert ujson.loads(response.body) == { + 'swagger': '2.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': { + '/model1/{id}/': { + 'patch': { + 'parameters': [{ + 'name': 'body', + 'schema': {'type': 'object'}, + 'in': 'body' + },{ + 'name': 'id', + 'required': True, + 'in': 'path', + 'type': 'integer' + }], + 'responses': {'200': {'description': 'test'}}, + 'operationId': 'Model1Sanic.swagger_update' + }, + 'options': { + 'responses': { + '204': { + 'headers': {'Allow': {'type': 'string'}}, + 'description': 'No Content' + } + }, + 'operationId': 'Model1Sanic.options_model1_id' + } + }, + '/model1/': { + 'post': { + 'parameters': [{ + 'name': 'body', + 'schema': {'type': 'array'}, + 'in': 'body' + }], + 'responses': {'200': {'description': 'test'}}, + 'operationId': 'Model1Sanic.swagger_insert' + }, + 'options': { + 'responses': { + '204': { + 'headers': {'Allow': {'type': 'string'}}, + 'description': 'No Content'} + }, + 'operationId': 'Model1Sanic.options_model1'} + } + } + } diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_json_builder.py b/tests/unit/test_json_builder.py new file mode 100644 index 0000000..d91e38a --- /dev/null +++ b/tests/unit/test_json_builder.py @@ -0,0 +1,51 @@ +# MIT License + +# Copyright (c) 2016 Diogo Dutra + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from swaggerit.json_builder import JsonBuilder + + +class TestJsonBuilder(object): + + def test_build_boolean(self): + assert JsonBuilder.build('true', {'type': 'boolean'}) == True + + def test_build_integer(self): + assert JsonBuilder.build('1', {'type': 'integer'}) == 1 + + def test_build_number(self): + assert JsonBuilder.build('1.1', {'type': 'number'}) == 1.1 + + def test_build_string(self): + assert JsonBuilder.build('test', {'type': 'string'}) == 'test' + + def test_build_array(self): + assert JsonBuilder.build('1,2,3', {'type': 'array', 'items': {'type': 'integer'}}) == [1,2,3] + + def test_build_object(self): + schema = { + 'type': 'object', + 'properties': { + 'test': {'type': 'string'} + } + } + assert JsonBuilder.build('test:test', schema) == {'test': 'test'} diff --git a/version.py b/version.py new file mode 100644 index 0000000..009faa4 --- /dev/null +++ b/version.py @@ -0,0 +1,2 @@ +VERSION='0.1.0' +