diff --git a/.gitignore b/.gitignore index 72aa6fb..49e78eb 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ ENV/ # Pycharm .idea/ + +# pytest +.pytest_cache/ diff --git a/.travis.yml b/.travis.yml index 0b339e2..a07abbc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python sudo: false python: - - "3.4" - "3.5" - "3.6" @@ -30,4 +29,4 @@ deploy: # script: deploy/pypi.sh # skip_cleanup: true # on: -# branch: master \ No newline at end of file +# branch: master diff --git a/aiohttp_swagger/__init__.py b/aiohttp_swagger/__init__.py index cf04193..fd38601 100644 --- a/aiohttp_swagger/__init__.py +++ b/aiohttp_swagger/__init__.py @@ -1,17 +1,33 @@ import asyncio -from os.path import abspath, dirname, join +from os.path import ( + abspath, + dirname, + join, +) from types import FunctionType from aiohttp import web -from .helpers import (generate_doc_from_each_end_point, - load_doc_from_yaml_file, swagger_path) +from .helpers import ( + generate_doc_from_each_end_point, + load_doc_from_yaml_file, + load_doc_from_yaml_str, + swagger_path, + swagger_validation, + add_swagger_validation, +) try: import ujson as json except ImportError: import json +__all__ = ( + "setup_swagger", + "swagger_path", + "swagger_validation", +) + @asyncio.coroutine def _swagger_home(request): @@ -38,14 +54,18 @@ def _swagger_def(request): def setup_swagger(app: web.Application, *, swagger_from_file: str = None, + swagger_from_str: str = None, swagger_url: str = "/api/doc", api_base_url: str = "/", + swagger_validator_url: str = "", description: str = "Swagger API definition", api_version: str = "1.0.0", title: str = "Swagger API", contact: str = "", swagger_home_decor: FunctionType = None, swagger_def_decor: FunctionType = None, + swagger_merge_with_file: bool = False, + swagger_validate_schema: bool = False, swagger_info: dict = None): _swagger_url = ("/{}".format(swagger_url) if not swagger_url.startswith("/") @@ -53,17 +73,38 @@ def setup_swagger(app: web.Application, _base_swagger_url = _swagger_url.rstrip('/') _swagger_def_url = '{}/swagger.json'.format(_base_swagger_url) - # Build Swagget Info + # Build Swagger Info if swagger_info is None: - if swagger_from_file: - swagger_info = load_doc_from_yaml_file(swagger_from_file) + if swagger_from_file or swagger_from_str: + if swagger_from_file: + swagger_info = load_doc_from_yaml_file(swagger_from_file) + elif swagger_from_str: + swagger_info = load_doc_from_yaml_str(swagger_from_str) + if swagger_merge_with_file: + swagger_end_points_info = generate_doc_from_each_end_point( + app, api_base_url=api_base_url, description=description, + api_version=api_version, title=title, contact=contact + ) + paths = swagger_end_points_info.pop('paths', None) + swagger_info.update(swagger_end_points_info) + if paths is not None: + if 'paths' not in swagger_info: + swagger_info['paths'] = {} + for ph, description in paths.items(): + for method, desc in description.items(): + if ph not in swagger_info['paths']: + swagger_info['paths'][ph] = {} + swagger_info['paths'][ph][method] = desc else: swagger_info = generate_doc_from_each_end_point( app, api_base_url=api_base_url, description=description, api_version=api_version, title=title, contact=contact ) - else: - swagger_info = json.dumps(swagger_info) + + if swagger_validate_schema: + add_swagger_validation(app, swagger_info) + + swagger_info = json.dumps(swagger_info) _swagger_home_func = _swagger_home _swagger_def_func = _swagger_def @@ -91,11 +132,9 @@ def setup_swagger(app: web.Application, with open(join(STATIC_PATH, "index.html"), "r") as f: app["SWAGGER_TEMPLATE_CONTENT"] = ( f.read() - .replace("##SWAGGER_CONFIG##", '/{}{}'. + .replace("##SWAGGER_CONFIG##", '{}{}'. format(api_base_url.lstrip('/'), _swagger_def_url)) - .replace("##STATIC_PATH##", '/{}{}'. + .replace("##STATIC_PATH##", '{}{}'. format(api_base_url.lstrip('/'), statics_path)) + .replace("##SWAGGER_VALIDATOR_URL##", swagger_validator_url) ) - - -__all__ = ("setup_swagger", "swagger_path") diff --git a/aiohttp_swagger/helpers/__init__.py b/aiohttp_swagger/helpers/__init__.py index 89c27bb..5216a93 100644 --- a/aiohttp_swagger/helpers/__init__.py +++ b/aiohttp_swagger/helpers/__init__.py @@ -1,2 +1,3 @@ from .builders import * # noqa from .decorators import * # noqa +from .validation import * # noqa diff --git a/aiohttp_swagger/helpers/builders.py b/aiohttp_swagger/helpers/builders.py index e272094..7510762 100644 --- a/aiohttp_swagger/helpers/builders.py +++ b/aiohttp_swagger/helpers/builders.py @@ -1,22 +1,35 @@ +import logging +from typing import ( + MutableMapping, + Mapping, + TextIO, +) from collections import defaultdict -from os.path import abspath, dirname, join +from os.path import ( + abspath, + dirname, + join, +) import yaml from aiohttp import web from aiohttp.hdrs import METH_ANY, METH_ALL from jinja2 import Template - try: import ujson as json -except ImportError: # pragma: no cover +except ImportError: # pragma: no cover import json +from .validation import validate_decorator + SWAGGER_TEMPLATE = abspath(join(dirname(__file__), "..", "templates")) -def _extract_swagger_docs(end_point_doc, method="get"): - # Find Swagger start point in doc +def _extract_swagger_docs(end_point_doc: str) -> Mapping: + """ + Find Swagger start point in doc. + """ end_point_swagger_start = 0 for i, doc_line in enumerate(end_point_doc): if "---" in doc_line: @@ -34,7 +47,8 @@ def _extract_swagger_docs(end_point_doc, method="get"): "from docstring ⚠", "tags": ["Invalid Swagger"] } - return {method: end_point_swagger_doc} + return end_point_swagger_doc + def _build_doc_from_func_doc(route): @@ -42,23 +56,24 @@ def _build_doc_from_func_doc(route): if issubclass(route.handler, web.View) and route.method == METH_ANY: method_names = { - attr for attr in dir(route.handler) \ + attr for attr in dir(route.handler) if attr.upper() in METH_ALL } for method_name in method_names: method = getattr(route.handler, method_name) if method.__doc__ is not None and "---" in method.__doc__: end_point_doc = method.__doc__.splitlines() - out.update(_extract_swagger_docs(end_point_doc, method=method_name)) + out[method_name] = _extract_swagger_docs(end_point_doc) else: try: end_point_doc = route.handler.__doc__.splitlines() except AttributeError: return {} - out.update(_extract_swagger_docs(end_point_doc)) + out[route.method.lower()] = _extract_swagger_docs(end_point_doc) return out + def generate_doc_from_each_end_point( app: web.Application, *, @@ -66,7 +81,7 @@ def generate_doc_from_each_end_point( description: str = "Swagger API definition", api_version: str = "1.0.0", title: str = "Swagger API", - contact: str = ""): + contact: str = "") -> MutableMapping: # Clean description _start_desc = 0 for i, word in enumerate(description): @@ -92,8 +107,6 @@ def generate_doc_from_each_end_point( for route in app.router.routes(): - end_point_doc = None - # If route has a external link to doc, we use it, not function doc if getattr(route.handler, "swagger_file", False): try: @@ -133,13 +146,66 @@ def generate_doc_from_each_end_point( url = url_info.get("formatter") swagger["paths"][url].update(end_point_doc) + return swagger + - return json.dumps(swagger) +def load_doc_from_yaml_file(doc_path: str) -> MutableMapping: + return yaml.load(open(doc_path, "r").read()) -def load_doc_from_yaml_file(doc_path: str): - loaded_yaml = yaml.load(open(doc_path, "r").read()) - return json.dumps(loaded_yaml) +def load_doc_from_yaml_file_obj(doc: TextIO) -> MutableMapping: + return yaml.load(doc.read()) -__all__ = ("generate_doc_from_each_end_point", "load_doc_from_yaml_file") +def load_doc_from_yaml_str(doc: str) -> MutableMapping: + return yaml.load(doc) + + +def add_swagger_validation(app, swagger_info: Mapping): + for route in app.router.routes(): + method = route.method.lower() + handler = route.handler + url_info = route.get_info() + url = url_info.get('path') or url_info.get('formatter') + + if method != '*': + swagger_endpoint_info_for_method = \ + swagger_info['paths'].get(url, {}).get(method) + swagger_endpoint_info = \ + {method: swagger_endpoint_info_for_method} if \ + swagger_endpoint_info_for_method is not None else {} + else: + # all methods + swagger_endpoint_info = swagger_info['paths'].get(url, {}) + for method, info in swagger_endpoint_info.items(): + logging.debug( + 'Added validation for method: {}. Path: {}'. + format(method.upper(), url) + ) + if issubclass(handler, web.View) and route.method == METH_ANY: + # whole class validation + should_be_validated = getattr(handler, 'validation', False) + cls_method = getattr(handler, method, None) + if cls_method is not None: + if not should_be_validated: + # method validation + should_be_validated = \ + getattr(handler, 'validation', False) + if should_be_validated: + new_cls_method = \ + validate_decorator(swagger_info, info)(cls_method) + setattr(handler, method, new_cls_method) + else: + should_be_validated = getattr(handler, 'validation', False) + if should_be_validated: + route._handler = \ + validate_decorator(swagger_info, info)(handler) + + +__all__ = ( + "generate_doc_from_each_end_point", + "load_doc_from_yaml_file", + "load_doc_from_yaml_str", + "load_doc_from_yaml_file_obj", + "add_swagger_validation", +) diff --git a/aiohttp_swagger/helpers/decorators.py b/aiohttp_swagger/helpers/decorators.py index 55032f9..a42302a 100644 --- a/aiohttp_swagger/helpers/decorators.py +++ b/aiohttp_swagger/helpers/decorators.py @@ -1,7 +1,27 @@ -class swagger_path(object): +from functools import partial +from inspect import isfunction, isclass + +__all__ = ( + 'swagger_path', + 'swagger_validation', +) + + +class swagger_path: + def __init__(self, swagger_file): self.swagger_file = swagger_file def __call__(self, f): f.swagger_file = self.swagger_file return f + + +def swagger_validation(func=None, *, validation=True): + + if func is None or not (isfunction(func) or isclass(func)): + validation = func + return partial(swagger_validation, validation=validation) + + func.validation = validation + return func diff --git a/aiohttp_swagger/helpers/validation.py b/aiohttp_swagger/helpers/validation.py new file mode 100644 index 0000000..c3b4fdb --- /dev/null +++ b/aiohttp_swagger/helpers/validation.py @@ -0,0 +1,263 @@ +from copy import deepcopy +import sys +import json +import logging +from functools import ( + wraps, + reduce, +) +from traceback import format_exc +from itertools import groupby +from operator import itemgetter +from typing import ( + Mapping, + Iterable, + Any, +) + +from aiohttp import web +from aiohttp.web import ( + Request, + Response, + json_response, +) +from collections import MutableMapping +from jsonschema import ( + ValidationError, + FormatChecker, + Draft4Validator, + validators, +) + + +__all__ = ( + 'validate_decorator', +) + + +logger = logging.getLogger(__name__) + + +def serialize_error_response(message: str, code: int, padding='error', + traceback: bool=False, **kwargs): + obj = {padding: dict(message=message, code=code, **kwargs)} + if traceback and sys.exc_info()[0]: + obj[padding]['traceback'] = format_exc() + return json.dumps(obj, default=lambda x: str(x)) + + +def multi_dict_to_dict(mld: Mapping) -> Mapping: + return { + key: value[0] + if isinstance(value, (list, tuple)) and len(value) == 1 else value + for key, value in mld.items() + } + + +def extend_with_default(validator_class): + + validate_properties = validator_class.VALIDATORS["properties"] + + def set_defaults(validator, properties, instance, schema): + if isinstance(instance, MutableMapping): + for prop, sub_schema in properties.items(): + if "default" in sub_schema: + instance.setdefault(prop, sub_schema["default"]) + for error in validate_properties( + validator, properties, instance, schema): + yield error + + return validators.extend(validator_class, {"properties": set_defaults}) + + +json_schema_validator = extend_with_default(Draft4Validator) + + +def validate_schema(obj: Mapping, schema: Mapping) -> Mapping: + json_schema_validator(schema, format_checker=FormatChecker()).validate(obj) + return obj + + +def validate_multi_dict(obj, schema) -> Mapping: + _obj = multi_dict_to_dict(obj) + json_schema_validator( + schema, format_checker=FormatChecker()).validate(_obj) + return _obj + + +def validate_content_type(swagger: Mapping, content_type: str): + consumes = swagger.get('consumes') + if consumes and not any(content_type == consume for consume in consumes): + raise ValidationError( + message='Unsupported content type: {}'.format(content_type)) + + +async def validate_request( + request: Request, + parameter_groups: Mapping, + swagger: Mapping): + res = {} + validate_content_type(swagger, request.content_type) + for group_name, group_schema in parameter_groups.items(): + if group_name == 'header': + res['headers'] = validate_multi_dict(request.headers, group_schema) + if group_name == 'query': + res['query'] = validate_multi_dict(request.query, group_schema) + if group_name == 'formData': + try: + data = await request.post() + except ValueError: + data = None + res['formData'] = validate_multi_dict(data, group_schema) + if group_name == 'body': + if request.content_type == 'application/json': + try: + content = await request.json() + except json.JSONDecodeError: + content = None + elif request.content_type.startswith('text'): + content = await request.text() + else: + content = await request.read() + res['body'] = validate_schema(content, group_schema) + if group_name == 'path': + params = dict(request.match_info) + res['path'] = validate_schema(params, group_schema) + return res + + +def adjust_swagger_item_to_json_schemes(*schemes: Mapping) -> Mapping: + new_schema = { + 'type': 'object', + 'properties': {}, + } + required_fields = [] + for schema in schemes: + required = schema.get('required', False) + name = schema['name'] + _schema = schema.get('schema') + if _schema is not None: + new_schema['properties'][name] = _schema + else: + new_schema['properties'][name] = { + key: value for key, value in schema.items() + if key not in ('required',) + } + if required: + required_fields.append(name) + if required_fields: + new_schema['required'] = required_fields + validators.validator_for(new_schema).check_schema(new_schema) + return new_schema + + +def adjust_swagger_body_item_to_json_schema(schema: Mapping) -> Mapping: + required = schema.get('required', False) + _schema = schema.get('schema') + new_schema = deepcopy(_schema) + if not required: + new_schema = { + 'anyOf': [ + {'type': 'null'}, + new_schema, + ] + } + validators.validator_for(new_schema).check_schema(new_schema) + return new_schema + + +def adjust_swagger_to_json_schema(parameter_groups: Iterable) -> Mapping: + res = {} + for group_name, group_schemas in parameter_groups: + if group_name in ('query', 'header', 'path', 'formData'): + json_schema = adjust_swagger_item_to_json_schemes(*group_schemas) + res[group_name] = json_schema + else: + # only one possible schema for in: body + schema = list(group_schemas)[0] + json_schema = adjust_swagger_body_item_to_json_schema(schema) + res[group_name] = json_schema + return res + + +def validation_exc_to_dict(exc, code=400): + paths = list(exc.path) + field = str(paths[-1]) if paths else '' + value = exc.instance + validator = exc.validator + message = exc.message + try: + schema = dict(exc.schema) + except TypeError: + schema = {} + return { + 'message': message, + 'code': code, + 'description': { + 'validator': validator, + 'schema': schema, + 'field': field, + 'value': value, + } + } + + +def dereference_schema(swagger: Mapping, schema: Any, current_ref=None) -> Any: + + def get_ref(_ref: str): + path = filter(None, _ref.lstrip('#').split('/')) + return reduce(dict.__getitem__, path, swagger) + + if isinstance(schema, dict): + res = {} + for key, value in schema.items(): + if key == '$ref': + ref = value + if current_ref is not None and ref == current_ref: + raise ValidationError( + 'Cycle swagger reference in schema: {}'.format(ref)) + ref_data = get_ref(value) + ref_data_resolved = dereference_schema( + swagger, ref_data, current_ref=ref) + res.update(ref_data_resolved) + else: + res[key] = dereference_schema(swagger, value) + return res + elif isinstance(schema, list): + res = [] + for value in schema: + res.append(dereference_schema(swagger, value)) + return res + else: + return schema + + +def validate_decorator(swagger: Mapping, schema: Mapping): + + parameters = dereference_schema(swagger, schema).get('parameters', []) + parameter_groups = adjust_swagger_to_json_schema( + groupby(parameters, key=itemgetter('in')) + ) + + def _func_wrapper(func): + + @wraps(func) + async def _wrapper(*args, **kwargs) -> Response: + request = args[0].request \ + if isinstance(args[0], web.View) else args[0] + try: + validation = \ + await validate_request(request, parameter_groups, schema) + request.validation = validation + except ValidationError as exc: + logger.exception(exc) + exc_dict = validation_exc_to_dict(exc) + return json_response( + text=serialize_error_response(**exc_dict), + status=400 + ) + return await func(*args, **kwargs) + + return _wrapper + + return _func_wrapper diff --git a/aiohttp_swagger/swagger_ui/index.html b/aiohttp_swagger/swagger_ui/index.html index ae53c7e..0984cb6 100644 --- a/aiohttp_swagger/swagger_ui/index.html +++ b/aiohttp_swagger/swagger_ui/index.html @@ -50,6 +50,7 @@ } window.swaggerUi = new SwaggerUi({ url: url, + validatorUrl: "##SWAGGER_VALIDATOR_URL##" || null, dom_id: "swagger-ui-container", supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'], onComplete: function(swaggerApi, swaggerUi){ diff --git a/doc/source/customizing.rst b/doc/source/customizing.rst index 236caf7..7cb5249 100644 --- a/doc/source/customizing.rst +++ b/doc/source/customizing.rst @@ -168,6 +168,116 @@ Global Swagger YAML web.run_app(app, host="127.0.0.1") + +:samp:`aiohttp-swagger` also allow to build an external YAML Swagger file and merge swagger endpoint definitions to it: + +.. code-block:: python + + from aiohttp import web + from aiohttp_swagger import * + + async def ping(request): + """ + --- + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: examples.api.api.createUser + produces: + - application/json + parameters: + - in: body + name: body + description: Created user object + required: false + + responses: + "201": + description: successful operation + """ + return web.Response(text="pong") + + app = web.Application() + + app.router.add_route('GET', "/ping", ping) + + setup_swagger( + app, + swagger_from_file="example_swagger.yaml", # <-- Loaded Swagger from external YAML file + swagger_merge_with_file=True # <-- Merge + ) + + web.run_app(app, host="127.0.0.1") + + +:samp:`aiohttp-swagger` also allow to validate swagger schema against json schema: +Validated object would be added as **request.validation**. Default values also will be filled into object. + +.. code-block:: javascript + + { + 'query': {}, // validated request.query + 'path': {}, // validated request.path + 'body': {}, // validated request.json() + 'formData': {}, // validated post request.data() + 'headers': {}, // validated post request.headers + } + +.. code-block:: python + + from aiohttp import web + from aiohttp_swagger import * + + @swagger_validation # <-- Mark for validation + async def ping(request): + """ + --- + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: examples.api.api.createUser + consumes: + - application/json + produces: + - application/json + parameters: + - in: body + name: body + description: Created user object + required: false + schema: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + required: + - id + - username + responses: + "201": + description: successful operation + """ + return web.Response(text="pong") + + app = web.Application() + + app.router.add_route('GET', "/ping", ping) + + setup_swagger( + app, + swagger_from_file="example_swagger.yaml", # <-- Loaded Swagger from external YAML file + swagger_merge_with_file=True, # <-- Merge + swagger_validate_schema=True # <- Validate schema + ) + + web.run_app(app, host="127.0.0.1") + + Nested applications +++++++++++++++++++ @@ -195,4 +305,17 @@ In this case `api_base_url` argument of `setup_swagger` function should be the s app.add_subapp(prefix='/sub_app_prefix', subapp=sub_app) - web.run_app(app, host="127.0.0.1") \ No newline at end of file + web.run_app(app, host="127.0.0.1") + +Swagger content validation ++++++++++++++++++++++++++++ + +:samp:`aiohttp-swagger` allows to perform online swagger validation. By default this feature is turned off `(swagger_validator_url='')`: + + +.. code-block:: python + + setup_swagger(app, + api_base_url='/sub_app_prefix', + swagger_validator_url='//online.swagger.io/validator' + ) diff --git a/doc/source/faq.rst b/doc/source/faq.rst index 93f162d..7bb32e2 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -52,7 +52,7 @@ Swagger has a tag that uses to build the titles. The tag name is :samp:`tags`. T .. code-block:: yaml - tags: # <-- TAG USEF FOR THE TITLE + tags: # <-- TAG USED FOR THE TITLE - Health check description: This end-point allow to test that service is up. produces: diff --git a/requirements.txt b/requirements.txt index 3bbb231..39a751a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyyaml jinja2 -aiohttp \ No newline at end of file +aiohttp==3.0.5 +jsonschema diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d924925 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[aliases] +test=pytest + +[tool:pytest] +addopts = -q diff --git a/setup.py b/setup.py index 4c7ca06..b8ae1c1 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,6 @@ from os.path import dirname, join from setuptools import setup, find_packages -from setuptools.command.test import test as TestCommand with open(join(dirname(__file__), 'requirements.txt')) as f: @@ -44,15 +43,6 @@ long_description = f.read() -class PyTest(TestCommand): - user_options = [] - - def run(self): - import subprocess - import sys - errno = subprocess.call([sys.executable, '-m', 'pytest', 'tests']) - raise SystemExit(errno) - setup( name='aiohttp-swagger', version='1.0.5', @@ -82,5 +72,5 @@ def run(self): 'Topic :: Security', ], tests_require=['pytest', 'pytest-aiohttp'], - cmdclass=dict(test=PyTest) + setup_requires=['pytest-runner'], ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a363510 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +from os.path import ( + abspath, + dirname, + join, +) + +import yaml +import pytest + + +@pytest.fixture +def swagger_file(): + tests_path = abspath(join(dirname(__file__))) + return join(tests_path, "data", "example_swagger.yaml") + + +@pytest.fixture +def swagger_ref_file(): + tests_path = abspath(join(dirname(__file__))) + return join(tests_path, "data", "example_swagger_with_ref.yaml") + + +@pytest.fixture +def swagger_info(): + filename = abspath(join(dirname(__file__))) + "/data/example_swagger.yaml" + return yaml.load(open(filename).read()) + + diff --git a/tests/data/example_swagger_with_ref.yaml b/tests/data/example_swagger_with_ref.yaml new file mode 100644 index 0000000..46db6e8 --- /dev/null +++ b/tests/data/example_swagger_with_ref.yaml @@ -0,0 +1,28 @@ +swagger: "2.0" +info: + description: | + API Description + version: "1.0" + title: API Title + contact: + name: my@contact.com +basePath: / +schemes: + - http + - https +definitions: + UserData: + description: Schema for a user item + properties: + user_id: + description: User id + type: string + minLength: 1 + gender: + description: User gender + type: string + enum: + - male + - female + default: female + example: female diff --git a/tests/test_swagger.py b/tests/test_swagger.py index f0fe181..ceddfd1 100644 --- a/tests/test_swagger.py +++ b/tests/test_swagger.py @@ -1,15 +1,17 @@ import asyncio import json -import pytest -import yaml -from os.path import join, dirname, abspath +from os.path import ( + join, + dirname, + abspath, +) from aiohttp import web from aiohttp_swagger import * @asyncio.coroutine -def ping(request): +def ping(_): """ --- description: This end-point allow to test that service is up. @@ -27,7 +29,7 @@ def ping(request): @asyncio.coroutine -def undoc_ping(request): +def undoc_ping(_): return web.Response(text="pong") @@ -77,9 +79,49 @@ def patch(self): return web.Response(text="OK") +class ClassViewWithSwaggerDoc(web.View): + + def _irrelevant_method(self): + pass + + @asyncio.coroutine + def get(self): + """ + --- + description: Get resources + tags: + - Class View + produces: + - application/json + consumes: + - application/json + parameters: + - in: body + name: body + description: Created user object + required: false + schema: + type: object + properties: + id: + type: integer + format: int64 + username: + type: + - "string" + - "null" + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + @swagger_path(abspath(join(dirname(__file__))) + '/data/partial_swagger.yaml') @asyncio.coroutine -def ping_partial(request): +def ping_partial(_): return web.Response(text="pong") @@ -96,12 +138,9 @@ def test_ping(test_client, loop): @asyncio.coroutine -def test_swagger_file_url(test_client, loop): - TESTS_PATH = abspath(join(dirname(__file__))) - +def test_swagger_file_url(test_client, loop, swagger_file): app = web.Application(loop=loop) - setup_swagger(app, - swagger_from_file=TESTS_PATH + "/data/example_swagger.yaml") + setup_swagger(app, swagger_from_file=swagger_file) client = yield from test_client(app) resp1 = yield from client.get('/api/doc/swagger.json') @@ -192,17 +231,10 @@ def test_swagger_def_decorator(test_client, loop): assert 'Test Custom Title' in result['info']['title'] -@pytest.fixture -def swagger_info(): - filename = abspath(join(dirname(__file__))) + "/data/example_swagger.yaml" - return yaml.load(open(filename).read()) - - @asyncio.coroutine def test_swagger_info(test_client, loop, swagger_info): app = web.Application(loop=loop) app.router.add_route('GET', "/ping", ping) - description = "Test Custom Swagger" setup_swagger(app, swagger_url="/api/v1/doc", swagger_info=swagger_info) @@ -231,6 +263,7 @@ def test_undocumented_fn(test_client, loop): result = json.loads(text) assert not result['paths'] + @asyncio.coroutine def test_class_view(test_client, loop): app = web.Application(loop=loop) @@ -294,3 +327,50 @@ def test_sub_app(test_client, loop): assert "/class_view" in result['paths'] assert "get" in result['paths']["/class_view"] assert "post" in result['paths']["/class_view"] + + +@asyncio.coroutine +def test_class_merge_swagger_view(test_client, loop, swagger_file): + app = web.Application(loop=loop) + app.router.add_route('*', "/example2", ClassViewWithSwaggerDoc) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + resp = yield from client.get('/example2') + assert resp.status == 200 + text = yield from resp.text() + assert 'OK' in text + swagger_resp1 = yield from client.get('/api/doc/swagger.json') + assert swagger_resp1.status == 200 + text = yield from swagger_resp1.text() + result = json.loads(text) + assert "/example2" in result['paths'] + assert "get" in result['paths']["/example2"] + assert result['paths']["/example2"]["get"]["parameters"][0]["schema"][ + "properties"]["id"]["type"] == "integer" + + +@asyncio.coroutine +def test_class_no_merge_swagger_view(test_client, loop, swagger_file): + app = web.Application(loop=loop) + app.router.add_route('*', "/example2", ClassViewWithSwaggerDoc) + setup_swagger( + app, + swagger_merge_with_file=False, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + resp = yield from client.get('/example2') + assert resp.status == 200 + text = yield from resp.text() + assert 'OK' in text + swagger_resp1 = yield from client.get('/api/doc/swagger.json') + assert swagger_resp1.status == 200 + text = yield from swagger_resp1.text() + result = json.loads(text) + assert "/example2" in result['paths'] + assert "get" in result['paths']["/example2"] + assert "schema" not in result['paths']["/example2"]["get"]["parameters"][0] diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..f060032 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,661 @@ +import asyncio +import json + +import pytest +from aiohttp import web +from aiohttp_swagger import * + + +@swagger_validation +class ClassViewWithSwaggerDoc(web.View): + + def _irrelevant_method(self): + pass + + @asyncio.coroutine + def get(self, *args, **kwargs): + """ + --- + description: Get resources + tags: + - Class View + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: user_id + description: User ID + required: true + type: string + minLength: 1 + pattern: '^\d+$' + - in: query + name: user_name + description: User Name + required: false + type: string + minLength: 1 + pattern: '^[a-d]+$' + - in: query + name: user_sex + description: User Sex + required: false + type: string + minLength: 1 + enum: + - male + - female + - in: query + name: user_login + description: User Login + required: false + type: string + minLength: 5 + - in: body + name: body + description: Created user object + required: false + schema: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + allowEmptyValue: true + required: + - id + - username + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + @asyncio.coroutine + def post(self, *args, **kwargs): + """ + --- + description: Post resources + tags: + - Class View + produces: + - application/json + consumes: + - application/x-www-form-urlencoded + parameters: + - in: header + name: user_id + description: User ID + required: false + type: string + minLength: 1 + pattern: '^\d+$' + - in: path + name: user_id + description: User ID + required: true + type: string + minLength: 1 + pattern: '^\d+$' + - in: query + name: user_name + description: User Name + required: false + type: string + minLength: 1 + pattern: '^[a-d]+$' + - in: query + name: user_login + description: User Login + required: false + type: string + minLength: 5 + - in: formData + name: id + type: string + pattern: '^\d+' + minLength: 1 + require: true + - in: formData + name: username + type: string + minLength: 2 + require: false + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + +@asyncio.coroutine +@swagger_validation +def get(request, *args, **kwargs): + """ + --- + description: Get resources + tags: + - Function View + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: user_id + description: User ID + required: true + type: string + minLength: 1 + pattern: '^\d+$' + - in: query + name: user_name + description: User Name + required: false + type: string + minLength: 1 + pattern: '^[a-d]+$' + - in: query + name: user_login + description: User Login + required: false + type: string + minLength: 5 + - in: query + name: user_sex + description: User Sex + required: false + type: string + minLength: 1 + enum: + - male + - female + - in: body + name: body + description: Created user object + required: false + schema: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + required: + - id + - username + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + +@asyncio.coroutine +@swagger_validation +def post(request, *args, **kwargs): + """ + --- + description: Post resources + tags: + - Function View + produces: + - application/json + consumes: + - application/x-www-form-urlencoded + parameters: + - in: header + name: user_id + description: User ID + required: false + type: string + minLength: 1 + pattern: '^\d+$' + - in: path + name: user_id + description: User ID + required: true + type: string + minLength: 1 + pattern: '^\d+$' + - in: query + name: user_name + description: User Name + required: false + type: string + minLength: 1 + pattern: '^[a-d]+$' + - in: query + name: user_login + description: User Login + required: false + type: string + minLength: 5 + - in: formData + name: id + type: string + pattern: '^\d+' + minLength: 1 + require: true + - in: formData + name: username + type: string + minLength: 2 + require: false + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + +@asyncio.coroutine +@swagger_validation(True) +def get_turn_on_validation(request, *args, **kwargs): + """ + --- + description: Test validation + tags: + - Post + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: user_id + description: User ID + required: true + type: string + minLength: 1 + pattern: '^\d+$' + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + +@asyncio.coroutine +@swagger_validation(False) +def get_turn_off_validation(request, *args, **kwargs): + """ + --- + description: Test validation + tags: + - Post + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: user_id + description: User ID + required: true + type: string + minLength: 1 + pattern: '^\d+$' + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + +POST_METHOD_PARAMETERS = [ + # success + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2&username=12', + {'Content-Type': 'application/x-www-form-urlencoded'}, + 200 + ), + # success + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2', + {'Content-Type': 'application/x-www-form-urlencoded'}, + 200 + ), + # wrong. username too short + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2&username=1', + {'Content-Type': 'application/x-www-form-urlencoded'}, + 400 + ), + # success + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2&username=12', + {'Content-Type': 'application/x-www-form-urlencoded'}, + 200 + ), + # wrong user_id header + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2&username=12', + { + 'Content-Type': 'application/x-www-form-urlencoded', + 'user_id': 'aaa' + }, + 400 + ), + # correct user_id header + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2&username=12', + { + 'Content-Type': 'application/x-www-form-urlencoded', + 'user_id': '123' + }, + 200 + ), + # unsupported content-type + ( + 'post', + '/example2/122212?user_login=12232323a', + 'id=2&username=12', + { + 'Content-Type': 'application11', + 'user_id': '123' + }, + 400 + ), +] + +GET_METHOD_PARAMETERS = [ + # wrong user_id test12 + ( + 'get', + '/example2/test12', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 400 + ), + # wrong user_id test12 and body + ( + 'get', + '/example2/test12', + {'id': 1}, + {'Content-Type': 'application/json'}, + 400 + ), + # wrong user_name + ( + 'get', + '/example2/122212?user_name=123', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 400 + ), + # wrong blank body + ( + 'get', + '/example2/122212', + {}, + {'Content-Type': 'application/json'}, + 400 + ), + # wrong body. required username + ( + 'get', + '/example2/122212', + {'id': 2}, + {'Content-Type': 'application/json'}, + 400 + ), + # success. Not mandatory body + ( + 'get', + '/example2/122212', + None, + {'Content-Type': 'application/json'}, + 200 + ), + # success + ( + 'get', + '/example2/122212', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 200 + ), + # too short user_login + ( + 'get', + '/example2/122212?user_login=1', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 400 + ), + # success + ( + 'get', + '/example2/122212?user_login=12232323a', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 200 + ), + # wrong user sex + ( + 'get', + '/example2/122212?user_sex=aaa', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 400 + ), + # success user sex + ( + 'get', + '/example2/122212?user_sex=male', + {'id': 1, 'username': 'test'}, + {'Content-Type': 'application/json'}, + 200 + ), +] + +ALL_METHODS_PARAMETERS = GET_METHOD_PARAMETERS + POST_METHOD_PARAMETERS + + +@pytest.mark.parametrize("method,url,body,headers,response", + ALL_METHODS_PARAMETERS) +@asyncio.coroutine +def test_class_swagger_view_validation(test_client, loop, swagger_file, + method, url, body, headers, response): + app = web.Application(loop=loop) + app.router.add_route('*', "/example2/{user_id}", ClassViewWithSwaggerDoc) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + data = json.dumps(body) \ + if headers['Content-Type'] == 'application/json' else body + resp = yield from getattr(client, method)(url, data=data, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text + + +@pytest.mark.parametrize("method,url,body,headers,response", + GET_METHOD_PARAMETERS) +@asyncio.coroutine +def test_function_get_method_swagger_view_validation( + test_client, loop, swagger_file, method, url, body, headers, response): + app = web.Application(loop=loop) + app.router.add_get("/example2/{user_id}", get) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + data = json.dumps(body) \ + if headers['Content-Type'] == 'application/json' else body + resp = yield from getattr(client, method)(url, data=data, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text + + +@pytest.mark.parametrize("method,url,body,headers,response", + POST_METHOD_PARAMETERS) +@asyncio.coroutine +def test_function_post_method_swagger_view_validation( + test_client, loop, swagger_file, method, url, body, headers, response): + app = web.Application(loop=loop) + app.router.add_post("/example2/{user_id}", post) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + data = json.dumps(body) \ + if headers['Content-Type'] == 'application/json' else body + resp = yield from getattr(client, method)(url, data=data, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text + + +@pytest.mark.parametrize("method,url,headers,response", [ + # wrong user_id test12 + ( + 'get', + '/example2/test12', + {'Content-Type': 'application/json'}, + 400 + ), + ( + 'get', + '/example2/123123', + {'Content-Type': 'application/json'}, + 200 + ), + # wrong header + ( + 'get', + '/example2/123123', + {'Content-Type': 'application/oops'}, + 400 + ), +]) +@asyncio.coroutine +def test_function_get_turn_on_validation( + test_client, loop, swagger_file, method, url, headers, response): + app = web.Application(loop=loop) + app.router.add_get("/example2/{user_id}", get_turn_on_validation) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + resp = yield from getattr(client, method)(url, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text + + +@pytest.mark.parametrize("method,url,headers,response", [ + # wrong user_id test12 + ( + 'get', + '/example2/test12', + {'Content-Type': 'application/json'}, + 200 + ), + ( + 'get', + '/example2/123123', + {'Content-Type': 'application/json'}, + 200 + ), + # wrong header + ( + 'get', + '/example2/123123', + {'Content-Type': 'application/oops'}, + 200 + ), +]) +@asyncio.coroutine +def test_function_get_turn_off_validation( + test_client, loop, swagger_file, method, url, headers, response): + app = web.Application(loop=loop) + app.router.add_get("/example2/{user_id}", get_turn_off_validation) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + resp = yield from getattr(client, method)(url, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text + + +@asyncio.coroutine +def test_validate_swagger_ui(test_client, loop, swagger_file): + app = web.Application(loop=loop) + app.router.add_route('*', "/example2/{user_id}", ClassViewWithSwaggerDoc) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + swagger_validator_url='//online.swagger.io/validator', + ) + client = yield from test_client(app) + swagger_resp = yield from client.get('/api/doc') + assert swagger_resp.status == 200 + text = yield from swagger_resp.text() + assert 'online.swagger.io/validator' in text diff --git a/tests/test_validation_body.py b/tests/test_validation_body.py new file mode 100644 index 0000000..4030594 --- /dev/null +++ b/tests/test_validation_body.py @@ -0,0 +1,170 @@ +import asyncio +import json + +import pytest +from aiohttp import web +from aiohttp_swagger import * + + +@asyncio.coroutine +@swagger_validation +def post1(request, *args, **kwargs): + """ + --- + description: Post resources + tags: + - Function View + produces: + - application/json + consumes: + - application/json + parameters: + - in: body + name: body + required: true + schema: + type: object + properties: + test: + type: string + default: default + minLength: 2 + test1: + type: string + default: default1 + minLength: 2 + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.json_response(data=request.validation['body']) + + +@asyncio.coroutine +@swagger_validation +def post2(request, *args, **kwargs): + """ + --- + description: Post resources + tags: + - Function View + produces: + - text/plain + consumes: + - text/plain + parameters: + - in: body + name: body + required: true + schema: + type: string + default: default + minLength: 2 + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text=request.validation['body']) + + +POST1_METHOD_PARAMETERS = [ + # success + ( + 'post', + '/example12', + {'test': 'default'}, + {'Content-Type': 'application/json'}, + 200 + ), + # success + ( + 'post', + '/example12', + {}, + {'Content-Type': 'application/json'}, + 200 + ), + # error + ( + 'post', + '/example12', + None, + {'Content-Type': 'application/json'}, + 400 + ), +] + +POST2_METHOD_PARAMETERS = [ + # success + ( + 'post', + '/example12', + '1234', + {'Content-Type': 'text/plain'}, + 200 + ), + ( + 'post', + '/example12', + None, + {'Content-Type': 'text/plain'}, + 400 + ), +] + + +@pytest.mark.parametrize("method,url,body,headers,response", + POST1_METHOD_PARAMETERS) +@asyncio.coroutine +def test_function_post1_method_body_validation( + test_client, loop, swagger_file, method, url, body, headers, response): + app = web.Application(loop=loop) + app.router.add_post("/example12", post1) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + data = json.dumps(body) \ + if headers['Content-Type'] == 'application/json' else body + resp = yield from getattr(client, method)(url, data=data, headers=headers) + text = yield from resp.json() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text + assert 'test' in text + assert text['test'] == 'default' + assert text['test1'] == 'default1' + + +@pytest.mark.parametrize("method,url,body,headers,response", + POST2_METHOD_PARAMETERS) +@asyncio.coroutine +def test_function_post2_method_body_validation( + test_client, loop, swagger_file, method, url, body, headers, response): + app = web.Application(loop=loop) + app.router.add_post("/example12", post2) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + data = json.dumps(body) \ + if headers['Content-Type'] == 'application/json' else body + resp = yield from getattr(client, method)(url, data=data, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert isinstance(text, str) diff --git a/tests/test_validation_defaults.py b/tests/test_validation_defaults.py new file mode 100644 index 0000000..00659b6 --- /dev/null +++ b/tests/test_validation_defaults.py @@ -0,0 +1,83 @@ +import asyncio + +import pytest +from aiohttp import web +from aiohttp_swagger import * + + +@asyncio.coroutine +@swagger_validation +def post(request, *args, **kwargs): + """ + --- + description: Post User data + tags: + - Function View + produces: + - application/json + consumes: + - application/json + parameters: + - in: query + name: test + type: string + minLength: 3 + required: true + default: test + - in: query + name: test1 + type: string + minLength: 3 + required: true + default: test1 + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.json_response(data=request.validation) + + +METHOD_PARAMETERS = [ + # too short test + ( + 'post', + '/example2?test=1', + {'Content-Type': 'application/json'}, + 400 + ), + # without test + ( + 'post', + '/example2', + {'Content-Type': 'application/json'}, + 200 + ), +] + + +@pytest.mark.parametrize("method,url,headers,response", METHOD_PARAMETERS) +@asyncio.coroutine +def test_function_post_with_defaults( + test_client, loop, swagger_ref_file, + method, url, headers, response): + app = web.Application(loop=loop) + app.router.add_post("/example2", post) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_ref_file, + ) + client = yield from test_client(app) + resp = yield from getattr(client, method)(url, headers=headers) + data = yield from resp.json() + assert resp.status == response, data + if response != 200: + assert 'error' in data + else: + assert 'error' not in data + # both default parameters + assert data['query']['test1'] == 'test1' + assert data['query']['test'] == 'test' diff --git a/tests/test_validation_with_ref.py b/tests/test_validation_with_ref.py new file mode 100644 index 0000000..ff53782 --- /dev/null +++ b/tests/test_validation_with_ref.py @@ -0,0 +1,79 @@ +import asyncio +import json + +import pytest +from aiohttp import web +from aiohttp_swagger import * + + +@asyncio.coroutine +@swagger_validation +def post(request, *args, **kwargs): + """ + --- + description: Post User data + tags: + - Function View + produces: + - application/json + consumes: + - application/json + parameters: + - in: body + name: body + description: Created user object + required: false + schema: + $ref: '#/definitions/UserData' + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + +METHOD_PARAMETERS = [ + # wrong gender + ( + 'post', + '/example2', + {'user_id': '123', 'gender': 'aaa'}, + {'Content-Type': 'application/json'}, + 400 + ), + # success + ( + 'post', + '/example2', + {'user_id': '123', 'gender': 'male'}, + {'Content-Type': 'application/json'}, + 200 + ), +] + + +@pytest.mark.parametrize("method,url,body,headers,response", METHOD_PARAMETERS) +@asyncio.coroutine +def test_function_post_with_swagger_ref( + test_client, loop, swagger_ref_file, + method, url, body, headers, response): + app = web.Application(loop=loop) + app.router.add_post("/example2", post) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_validate_schema=True, + swagger_from_file=swagger_ref_file, + ) + client = yield from test_client(app) + data = json.dumps(body) \ + if headers['Content-Type'] == 'application/json' else body + resp = yield from getattr(client, method)(url, data=data, headers=headers) + text = yield from resp.text() + assert resp.status == response, text + if response != 200: + assert 'error' in text + else: + assert 'error' not in text diff --git a/tox.ini b/tox.ini index 6e547dc..9f3341d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] -envlist = py34, py35, py36 +envlist = py35, py36 [testenv] deps = -rrequirements-dev.txt -commands = py.test \ No newline at end of file +commands = py.test